Migrate to golangci-lint v2 config format and fix all lint issues: - errcheck: add explicit error handling for Close/Remove calls - gocritic: convert if-else chains to switch statements - gosec: tighten file permissions, add nolint for intentional cases - staticcheck: lowercase error strings, simplify boolean returns Also update Makefile to install golangci-lint v2 and update CLAUDE.md.
462 lines
12 KiB
Go
462 lines
12 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestSanitizeTemplateName(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"opencode", "opencode"},
|
|
{"my-app", "my-app"},
|
|
{"my_app", "my_app"},
|
|
{"my.app", "my.app"},
|
|
{"my app", "my_app"},
|
|
{"/usr/bin/opencode", "usr_bin_opencode"},
|
|
{"my@app!v2", "my_app_v2"},
|
|
{"", "unknown"},
|
|
{"///", "unknown"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := SanitizeTemplateName(tt.input)
|
|
if got != tt.expected {
|
|
t.Errorf("SanitizeTemplateName(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLearnedTemplatePath(t *testing.T) {
|
|
path := LearnedTemplatePath("opencode")
|
|
if !strings.HasSuffix(path, "/learned/opencode.json") {
|
|
t.Errorf("LearnedTemplatePath(\"opencode\") = %q, expected suffix /learned/opencode.json", path)
|
|
}
|
|
}
|
|
|
|
func TestFindApplicationDirectory(t *testing.T) {
|
|
home := "/home/testuser"
|
|
tests := []struct {
|
|
path string
|
|
expected string
|
|
}{
|
|
{"/home/testuser/.cache/opencode/db/main.sqlite", "/home/testuser/.cache/opencode"},
|
|
{"/home/testuser/.cache/opencode/version", "/home/testuser/.cache/opencode"},
|
|
{"/home/testuser/.config/opencode/settings.json", "/home/testuser/.config/opencode"},
|
|
{"/home/testuser/.local/share/myapp/data", "/home/testuser/.local/share/myapp"},
|
|
{"/home/testuser/.local/state/myapp/log", "/home/testuser/.local/state/myapp"},
|
|
// Not under a well-known parent
|
|
{"/home/testuser/documents/file.txt", ""},
|
|
{"/home/testuser/.cache", ""},
|
|
// Different home
|
|
{"/other/user/.cache/app/file", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
got := findApplicationDirectory(tt.path, home)
|
|
if got != tt.expected {
|
|
t.Errorf("findApplicationDirectory(%q, %q) = %q, want %q", tt.path, home, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCollapsePaths(t *testing.T) {
|
|
// Temporarily override home for testing
|
|
t.Setenv("HOME", "/home/testuser")
|
|
|
|
tests := []struct {
|
|
name string
|
|
paths []string
|
|
contains []string // paths that should be in the result
|
|
notContains []string // paths that must NOT be in the result
|
|
}{
|
|
{
|
|
name: "multiple paths under same app dir",
|
|
paths: []string{
|
|
"/home/testuser/.cache/opencode/db/main.sqlite",
|
|
"/home/testuser/.cache/opencode/version",
|
|
},
|
|
contains: []string{"/home/testuser/.cache/opencode"},
|
|
},
|
|
{
|
|
name: "empty input",
|
|
paths: nil,
|
|
contains: nil,
|
|
},
|
|
{
|
|
name: "single path uses parent dir",
|
|
paths: []string{
|
|
"/home/testuser/.cache/opencode/version",
|
|
},
|
|
contains: []string{"/home/testuser/.cache/opencode"},
|
|
},
|
|
{
|
|
name: "paths from different app dirs",
|
|
paths: []string{
|
|
"/home/testuser/.cache/opencode/db",
|
|
"/home/testuser/.cache/opencode/version",
|
|
"/home/testuser/.config/opencode/settings.json",
|
|
},
|
|
contains: []string{
|
|
"/home/testuser/.cache/opencode",
|
|
"/home/testuser/.config/opencode",
|
|
},
|
|
},
|
|
{
|
|
name: "files directly under home stay as exact paths",
|
|
paths: []string{
|
|
"/home/testuser/.gitignore",
|
|
"/home/testuser/.npmrc",
|
|
},
|
|
contains: []string{
|
|
"/home/testuser/.gitignore",
|
|
"/home/testuser/.npmrc",
|
|
},
|
|
notContains: []string{"/home/testuser"},
|
|
},
|
|
{
|
|
name: "mix of home files and app dir paths",
|
|
paths: []string{
|
|
"/home/testuser/.gitignore",
|
|
"/home/testuser/.cache/opencode/db/main.sqlite",
|
|
"/home/testuser/.cache/opencode/version",
|
|
"/home/testuser/.npmrc",
|
|
},
|
|
contains: []string{
|
|
"/home/testuser/.gitignore",
|
|
"/home/testuser/.npmrc",
|
|
"/home/testuser/.cache/opencode",
|
|
},
|
|
notContains: []string{"/home/testuser"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := CollapsePaths(tt.paths)
|
|
if tt.contains == nil {
|
|
if got != nil {
|
|
t.Errorf("CollapsePaths() = %v, want nil", got)
|
|
}
|
|
return
|
|
}
|
|
for _, want := range tt.contains {
|
|
found := false
|
|
for _, g := range got {
|
|
if g == want {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("CollapsePaths() = %v, missing expected path %q", got, want)
|
|
}
|
|
}
|
|
for _, bad := range tt.notContains {
|
|
for _, g := range got {
|
|
if g == bad {
|
|
t.Errorf("CollapsePaths() = %v, should NOT contain %q", got, bad)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDefaultWritablePath(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
expected bool
|
|
}{
|
|
{"/dev/null", true},
|
|
{"/dev/stdout", true},
|
|
{"/tmp/somefile", false}, // /tmp is tmpfs inside sandbox, not host /tmp
|
|
{"/home/user/file", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
got := isDefaultWritablePath(tt.path)
|
|
if got != tt.expected {
|
|
t.Errorf("isDefaultWritablePath(%q) = %v, want %v", tt.path, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsSensitivePath(t *testing.T) {
|
|
home := "/home/testuser"
|
|
tests := []struct {
|
|
path string
|
|
expected bool
|
|
}{
|
|
{"/home/testuser/.bashrc", true},
|
|
{"/home/testuser/.gitconfig", true},
|
|
{"/home/testuser/.ssh/id_rsa", true},
|
|
{"/home/testuser/.ssh/known_hosts", true},
|
|
{"/home/testuser/.gnupg/secring.gpg", true},
|
|
{"/home/testuser/.env", true},
|
|
{"/home/testuser/project/.env.local", true},
|
|
{"/home/testuser/.cache/opencode/db", false},
|
|
{"/home/testuser/documents/readme.txt", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
got := isSensitivePath(tt.path, home)
|
|
if got != tt.expected {
|
|
t.Errorf("isSensitivePath(%q, %q) = %v, want %v", tt.path, home, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeduplicateSubPaths(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
paths []string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "removes sub-paths",
|
|
paths: []string{"/home/user/.cache", "/home/user/.cache/opencode"},
|
|
expected: []string{"/home/user/.cache"},
|
|
},
|
|
{
|
|
name: "keeps independent paths",
|
|
paths: []string{"/home/user/.cache/opencode", "/home/user/.config/opencode"},
|
|
expected: []string{"/home/user/.cache/opencode", "/home/user/.config/opencode"},
|
|
},
|
|
{
|
|
name: "empty",
|
|
paths: nil,
|
|
expected: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := deduplicateSubPaths(tt.paths)
|
|
if len(got) != len(tt.expected) {
|
|
t.Errorf("deduplicateSubPaths(%v) = %v, want %v", tt.paths, got, tt.expected)
|
|
return
|
|
}
|
|
for i := range got {
|
|
if got[i] != tt.expected[i] {
|
|
t.Errorf("deduplicateSubPaths(%v)[%d] = %q, want %q", tt.paths, i, got[i], tt.expected[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetDangerousFilePatterns(t *testing.T) {
|
|
patterns := getDangerousFilePatterns()
|
|
if len(patterns) == 0 {
|
|
t.Error("getDangerousFilePatterns() returned empty list")
|
|
}
|
|
// Check some expected patterns
|
|
found := false
|
|
for _, p := range patterns {
|
|
if p == "~/.bashrc" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("getDangerousFilePatterns() missing ~/.bashrc")
|
|
}
|
|
}
|
|
|
|
func TestGetSensitiveReadPatterns(t *testing.T) {
|
|
patterns := getSensitiveReadPatterns()
|
|
if len(patterns) == 0 {
|
|
t.Error("getSensitiveReadPatterns() returned empty list")
|
|
}
|
|
found := false
|
|
for _, p := range patterns {
|
|
if p == "~/.ssh/id_*" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("getSensitiveReadPatterns() missing ~/.ssh/id_*")
|
|
}
|
|
}
|
|
|
|
func TestToTildePath(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
home string
|
|
expected string
|
|
}{
|
|
{"/home/user/.cache/opencode", "/home/user", "~/.cache/opencode"},
|
|
{"/other/path", "/home/user", "/other/path"},
|
|
{"/home/user/.cache/opencode", "", "/home/user/.cache/opencode"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
got := toTildePath(tt.path, tt.home)
|
|
if got != tt.expected {
|
|
t.Errorf("toTildePath(%q, %q) = %q, want %q", tt.path, tt.home, got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListLearnedTemplates(t *testing.T) {
|
|
// Use a temp dir to isolate from real user config
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", tmpDir)
|
|
|
|
// Initially empty
|
|
templates, err := ListLearnedTemplates()
|
|
if err != nil {
|
|
t.Fatalf("ListLearnedTemplates() error: %v", err)
|
|
}
|
|
if len(templates) != 0 {
|
|
t.Errorf("expected empty list, got %v", templates)
|
|
}
|
|
|
|
// Create some templates
|
|
dir := LearnedTemplateDir()
|
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "opencode.json"), []byte("{}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "myapp.json"), []byte("{}"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "notjson.txt"), []byte(""), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
templates, err = ListLearnedTemplates()
|
|
if err != nil {
|
|
t.Fatalf("ListLearnedTemplates() error: %v", err)
|
|
}
|
|
if len(templates) != 2 {
|
|
t.Errorf("expected 2 templates, got %d: %v", len(templates), templates)
|
|
}
|
|
|
|
names := make(map[string]bool)
|
|
for _, tmpl := range templates {
|
|
names[tmpl.Name] = true
|
|
}
|
|
if !names["opencode"] {
|
|
t.Error("missing template 'opencode'")
|
|
}
|
|
if !names["myapp"] {
|
|
t.Error("missing template 'myapp'")
|
|
}
|
|
}
|
|
|
|
func TestBuildTemplate(t *testing.T) {
|
|
allowRead := []string{"~/external-data"}
|
|
allowWrite := []string{".", "~/.cache/opencode", "~/.config/opencode"}
|
|
result := buildTemplate("opencode", allowRead, allowWrite)
|
|
|
|
// Check header comments
|
|
if !strings.Contains(result, `Learned template for "opencode"`) {
|
|
t.Error("template missing header comment with command name")
|
|
}
|
|
if !strings.Contains(result, "greywall --learning -- opencode") {
|
|
t.Error("template missing generation command")
|
|
}
|
|
if !strings.Contains(result, "Review and adjust paths as needed") {
|
|
t.Error("template missing review comment")
|
|
}
|
|
|
|
// Check content
|
|
if !strings.Contains(result, `"allowRead"`) {
|
|
t.Error("template missing allowRead field")
|
|
}
|
|
if !strings.Contains(result, `"~/external-data"`) {
|
|
t.Error("template missing expected allowRead path")
|
|
}
|
|
if !strings.Contains(result, `"allowWrite"`) {
|
|
t.Error("template missing allowWrite field")
|
|
}
|
|
if !strings.Contains(result, `"~/.cache/opencode"`) {
|
|
t.Error("template missing expected allowWrite path")
|
|
}
|
|
if !strings.Contains(result, `"denyWrite"`) {
|
|
t.Error("template missing denyWrite field")
|
|
}
|
|
if !strings.Contains(result, `"denyRead"`) {
|
|
t.Error("template missing denyRead field")
|
|
}
|
|
// Check .env patterns are included in denyRead
|
|
if !strings.Contains(result, `".env"`) {
|
|
t.Error("template missing .env in denyRead")
|
|
}
|
|
if !strings.Contains(result, `".env.*"`) {
|
|
t.Error("template missing .env.* in denyRead")
|
|
}
|
|
}
|
|
|
|
func TestBuildTemplateNoAllowRead(t *testing.T) {
|
|
result := buildTemplate("simple-cmd", nil, []string{"."})
|
|
|
|
// When allowRead is nil, it should be omitted from JSON
|
|
if strings.Contains(result, `"allowRead"`) {
|
|
t.Error("template should omit allowRead when nil")
|
|
}
|
|
}
|
|
|
|
func TestGenerateLearnedTemplate(t *testing.T) {
|
|
// Create a temp dir for templates
|
|
tmpDir := t.TempDir()
|
|
t.Setenv("XDG_CONFIG_HOME", tmpDir)
|
|
|
|
// Create a fake strace log
|
|
home, _ := os.UserHomeDir()
|
|
logContent := strings.Join([]string{
|
|
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/db.sqlite") + `", O_WRONLY|O_CREAT, 0644) = 3`,
|
|
`12345 openat(AT_FDCWD, "` + filepath.Join(home, ".cache/testapp/version") + `", O_WRONLY|O_CREAT, 0644) = 3`,
|
|
`12345 mkdirat(AT_FDCWD, "` + filepath.Join(home, ".config/testapp") + `", 0755) = 0`,
|
|
`12345 openat(AT_FDCWD, "/tmp/somefile", O_WRONLY|O_CREAT, 0644) = 3`,
|
|
`12345 openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 3`,
|
|
}, "\n")
|
|
|
|
logFile := filepath.Join(tmpDir, "strace.log")
|
|
if err := os.WriteFile(logFile, []byte(logContent), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
templatePath, err := GenerateLearnedTemplate(logFile, "testapp", false)
|
|
if err != nil {
|
|
t.Fatalf("GenerateLearnedTemplate() error: %v", err)
|
|
}
|
|
|
|
if templatePath == "" {
|
|
t.Fatal("GenerateLearnedTemplate() returned empty path")
|
|
}
|
|
|
|
// Read and verify template
|
|
data, err := os.ReadFile(templatePath) //nolint:gosec // reading test-generated template file
|
|
if err != nil {
|
|
t.Fatalf("failed to read template: %v", err)
|
|
}
|
|
|
|
content := string(data)
|
|
if !strings.Contains(content, "testapp") {
|
|
t.Error("template doesn't contain command name")
|
|
}
|
|
if !strings.Contains(content, "allowWrite") {
|
|
t.Error("template doesn't contain allowWrite")
|
|
}
|
|
}
|