main
  1package main
  2
  3import (
  4	"reflect"
  5	"testing"
  6)
  7
  8var (
  9	testContext = Context{Cwd: "/project", ProjectDir: "/project", Home: "/home/user"}
 10	testPolicy  = Policy{
 11		ALLOW: []string{"/nix/store", "/tmp/crush"},
 12		ASK:   []string{"/**/*.env.example"},
 13		HALT:  []string{"/**/*.env"},
 14	}
 15)
 16
 17func viewInput(path string) map[string]any {
 18	return map[string]any{"file_path": path}
 19}
 20
 21func bashInput(command string) map[string]any {
 22	return map[string]any{"command": command}
 23}
 24
 25func assertLevel(t *testing.T, decision Decision, want Level) {
 26	t.Helper()
 27	if decision.Level != want {
 28		t.Fatalf("level = %q, want %q", decision.Level, want)
 29	}
 30}
 31
 32func assertRender(t *testing.T, decision Decision, want string) {
 33	t.Helper()
 34	got, ok := render(decision)
 35	if !ok {
 36		t.Fatalf("render returned no output, want %q", want)
 37	}
 38	if got != want {
 39		t.Fatalf("render = %q, want %q", got, want)
 40	}
 41}
 42
 43func assertNoRender(t *testing.T, decision Decision) {
 44	t.Helper()
 45	if got, ok := render(decision); ok {
 46		t.Fatalf("render = %q, want no output", got)
 47	}
 48}
 49
 50func TestHaltsForbiddenFilePath(t *testing.T) {
 51	decision := decide("view", viewInput("/home/user/.env"), testContext, testPolicy)
 52	assertLevel(t, decision, Halt)
 53	assertRender(t, decision, `{"halt":true,"reason":"halt: /home/user/.env matched HALT pattern /**/*.env"}`)
 54}
 55
 56func TestAsksForTemplateSecretPath(t *testing.T) {
 57	decision := decide("view", viewInput("/home/user/.env.example"), testContext, testPolicy)
 58	assertLevel(t, decision, Ask)
 59	assertRender(t, decision, `{"reason":"ask: /home/user/.env.example matched ASK pattern /**/*.env.example"}`)
 60}
 61
 62func TestAllowsNixStorePathsOnComponentBoundaries(t *testing.T) {
 63	assertLevel(t, decide("view", viewInput("/nix/store/abc"), testContext, testPolicy), Allow)
 64	assertRender(t, decide("view", viewInput("/nix/store"), testContext, testPolicy), `{"decision":"allow"}`)
 65	assertLevel(t, decide("view", viewInput("/nix/store-other/abc"), testContext, testPolicy), Ask)
 66	assertNoRender(t, decide("view", viewInput("/nix/store-other/abc"), testContext, testPolicy))
 67}
 68
 69func TestPrefersAskOverHaltForSinglePath(t *testing.T) {
 70	policy := Policy{ALLOW: []string{}, ASK: []string{"/secret/**"}, HALT: []string{"/secret/**"}}
 71	decision := decide("view", viewInput("/secret/x"), testContext, policy)
 72	assertLevel(t, decision, Ask)
 73	want := []Match{{Level: Ask, Path: "/secret/x", Pattern: "/secret/**"}}
 74	if !reflect.DeepEqual(decision.Matches, want) {
 75		t.Fatalf("matches = %#v, want %#v", decision.Matches, want)
 76	}
 77}
 78
 79func TestPrefersHaltOverAskAcrossPaths(t *testing.T) {
 80	decision := decide("bash", bashInput("cat /home/user/.env /home/user/other/.env.example"), testContext, testPolicy)
 81	assertLevel(t, decision, Halt)
 82	assertRender(t, decision, `{"halt":true,"reason":"halt: /home/user/.env matched HALT pattern /**/*.env"}`)
 83}
 84
 85func TestAnchorsRelativePatternsToProjectDirectory(t *testing.T) {
 86	nested := Policy{ALLOW: []string{}, ASK: []string{}, HALT: []string{"**/*.env"}}
 87	assertLevel(t, decide("view", viewInput("sub/.env"), testContext, nested), Halt)
 88	assertLevel(t, decide("view", viewInput("/elsewhere/sub/.env"), testContext, nested), Ask)
 89
 90	prefix := Policy{ALLOW: []string{"src"}, ASK: []string{}, HALT: []string{}}
 91	assertLevel(t, decide("view", viewInput("src/main.ts"), testContext, prefix), Allow)
 92	assertLevel(t, decide("view", viewInput("/other/src/main.ts"), testContext, prefix), Ask)
 93}
 94
 95func TestResolvesRelativeToolPathsAgainstCwd(t *testing.T) {
 96	context := Context{Cwd: "/work", ProjectDir: "/project", Home: "/home/user"}
 97	policy := Policy{ALLOW: []string{"/work"}, ASK: []string{}, HALT: []string{}}
 98	assertLevel(t, decide("view", viewInput("a.txt"), context, policy), Allow)
 99}
100
101func TestDefaultsSearchPathToCwd(t *testing.T) {
102	context := Context{Cwd: "/nix/store/pkg", ProjectDir: "/project", Home: "/home/user"}
103	assertLevel(t, decide("grep", map[string]any{}, context, testPolicy), Allow)
104	assertLevel(t, decide("lsp_rename", map[string]any{"symbol": "a", "new_name": "b"}, context, testPolicy), Allow)
105}
106
107func TestParsesQuotedAndChainedBashArguments(t *testing.T) {
108	assertLevel(t, decide("bash", bashInput(`cat "/home/user/.env"`), testContext, testPolicy), Halt)
109	assertLevel(t, decide("bash", bashInput("true && cat /home/user/.env | wc -l"), testContext, testPolicy), Halt)
110	assertLevel(t, decide("bash", bashInput("cat '/home/user/.env.example'"), testContext, testPolicy), Ask)
111}
112
113func TestTracksCdWhenResolvingRelativeBashPaths(t *testing.T) {
114	assertLevel(t, decide("bash", bashInput("cat .env"), testContext, testPolicy), Halt)
115	assertLevel(t, decide("bash", bashInput("cd /home/user && cat .env"), testContext, testPolicy), Halt)
116	assertLevel(t, decide("bash", bashInput("cd sub && cat .env"), testContext, testPolicy), Halt)
117	assertLevel(t, decide("bash", bashInput("cd && cat .env"), testContext, testPolicy), Halt)
118}
119
120func TestUsesBashWorkingDirectoryAsRelativeBase(t *testing.T) {
121	input := map[string]any{"command": "cat .env", "working_dir": "sub"}
122	assertLevel(t, decide("bash", input, testContext, testPolicy), Halt)
123}
124
125func TestExpandsShellVariableDefinitions(t *testing.T) {
126	assertLevel(t, decide("bash", bashInput("FOO=/home/user; cat $FOO/.env"), testContext, testPolicy), Halt)
127	assertLevel(t, decide("bash", bashInput("FOO=/home/user cat $FOO/.env"), testContext, testPolicy), Halt)
128	assertLevel(t, decide("bash", bashInput("export FOO=/home/user && cat ${FOO}/.env"), testContext, testPolicy), Halt)
129	assertLevel(t, decide("bash", bashInput("A=/home/user; B=$A/dir; cat $B/.env"), testContext, testPolicy), Halt)
130}
131
132func TestMarksUnresolvableShellPathsAndBlocksAllow(t *testing.T) {
133	decision := decide("bash", bashInput("cat $UNKNOWN /nix/store/x"), testContext, testPolicy)
134	assertLevel(t, decision, Ask)
135	assertRender(t, decision, `{"reason":"ask: $UNKNOWN could not be resolved"}`)
136
137	assertLevel(t, decide("bash", bashInput("cd - && cat .env"), testContext, testPolicy), Ask)
138	assertLevel(t, decide("bash", bashInput("cd $DIR && cat /nix/store/x"), testContext, testPolicy), Ask)
139}
140
141func TestSwitchesToGlobMatchingOnGlobMetacharacters(t *testing.T) {
142	policy := Policy{ALLOW: []string{"/data/*.log"}, ASK: []string{}, HALT: []string{}}
143	assertLevel(t, decide("view", viewInput("/data/a.log"), testContext, policy), Allow)
144	assertLevel(t, decide("view", viewInput("/data/sub/a.log"), testContext, policy), Ask)
145}
146
147func TestAsksSilentlyOnMalformedOrEmptyInput(t *testing.T) {
148	assertNoRender(t, decide("view", map[string]any{}, testContext, testPolicy))
149	assertNoRender(t, decide("view", nil, testContext, testPolicy))
150}
151
152func TestAutoAllowsProjectPathsForEveryTool(t *testing.T) {
153	empty := Policy{ALLOW: []string{}, ASK: []string{}, HALT: []string{}}
154	decision := decide("view", viewInput("src/main.ts"), testContext, empty)
155	assertLevel(t, decision, Allow)
156	want := []Match{{Level: Allow, Path: "src/main.ts", Pattern: "/project"}}
157	if !reflect.DeepEqual(decision.Matches, want) {
158		t.Fatalf("matches = %#v, want %#v", decision.Matches, want)
159	}
160	assertLevel(t, decide("bash", bashInput("rm -rf build"), testContext, empty), Allow)
161	assertLevel(t, decide("view", viewInput(".env"), testContext, testPolicy), Halt)
162}
163
164func TestResolvesSymlinksBeforeMatching(t *testing.T) {
165	context := testContext
166	context.RealPath = func(path string) (string, bool) {
167		if path == "/tmp/crush/link" {
168			return "/home/user/.env", true
169		}
170		return "", false
171	}
172	assertLevel(t, decide("view", viewInput("/tmp/crush/link"), context, testPolicy), Halt)
173
174	outside := testContext
175	outside.RealPath = func(path string) (string, bool) {
176		if path == "/project/link" {
177			return "/nix/store/abc", true
178		}
179		return "", false
180	}
181	decision := decide("view", viewInput("link"), outside, testPolicy)
182	assertLevel(t, decision, Allow)
183	if decision.Matches[0].Pattern != "/nix/store" {
184		t.Fatalf("pattern = %q, want %q", decision.Matches[0].Pattern, "/nix/store")
185	}
186}