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}