返回 DeepSeek-Reasonix
project_programs_test.go
根目录 / internal / config / project_programs_test.go
1 package config
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "runtime"
8 "slices"
9 "strings"
10 "testing"
11 )
12
13 func reasonsFor(cfg *Config, key string) []IgnoredProjectReason {
14 var out []IgnoredProjectReason
15 for _, ig := range cfg.IgnoredProjectSettings() {
16 if ig.Key == key {
17 out = append(out, ig.Reason)
18 }
19 }
20 return out
21 }
22
23 // A shell or ripgrep elsewhere on the machine waits for approval; one inside the
24 // checkout is refused outright, since what runs there can rewrite it.
25 func TestProjectShellAndRipgrepWaitForApproval(t *testing.T) {
26 home := t.TempDir()
27 root := t.TempDir()
28 t.Setenv("REASONIX_HOME", home)
29 shell := installedPath("sh")
30 project := "[tools.shell]\npath = " + tomlQuote(shell) + "\n[tools.search]\nrg_path = \"tools/rg\"\n"
31 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
32 t.Fatal(err)
33 }
34
35 cfg, err := LoadForRootReadOnly(root)
36 if err != nil {
37 t.Fatal(err)
38 }
39 if cfg.Tools.Shell.Path != "" || cfg.Tools.Search.RgPath != "" {
40 t.Fatalf("tools = %+v, want the project's programs held back", cfg.Tools)
41 }
42 if !slices.Equal(reasonsFor(cfg, "tools.shell.path"), []IgnoredProjectReason{ProjectAwaitingApproval}) {
43 t.Fatalf("shell reasons = %v", reasonsFor(cfg, "tools.shell.path"))
44 }
45 if !slices.Equal(reasonsFor(cfg, "tools.search.rg_path"), []IgnoredProjectReason{ProjectProgramWritable}) {
46 t.Fatalf("rg reasons = %v", reasonsFor(cfg, "tools.search.rg_path"))
47 }
48 pending := cfg.PendingProjectPrograms()
49 if len(pending) != 1 {
50 t.Fatalf("pending = %+v, want only the shell", pending)
51 }
52 if err := NewProjectProgramStore(home).Approve(root, pending...); err != nil {
53 t.Fatal(err)
54 }
55 if cfg, err = LoadForRootReadOnly(root); err != nil {
56 t.Fatal(err)
57 }
58 if cfg.Tools.Shell.Path != shell || cfg.Tools.Search.RgPath != "" {
59 t.Fatalf("tools = %+v, want the approved shell and still no workspace ripgrep", cfg.Tools)
60 }
61 }
62
63 // Changing a server's declaration, or a workspace file it names, needs a new
64 // approval; a changed file after loading is caught before the server starts.
65 func TestProjectLanguageServerFilesAreCheckedAgainBeforeStart(t *testing.T) {
66 home := t.TempDir()
67 root := t.TempDir()
68 t.Setenv("REASONIX_HOME", home)
69 server := filepath.Join(root, "bin", "pyls")
70 if err := os.MkdirAll(filepath.Dir(server), 0o700); err != nil {
71 t.Fatal(err)
72 }
73 if err := os.WriteFile(server, []byte("one"), 0o700); err != nil {
74 t.Fatal(err)
75 }
76 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[lsp.servers.python]\ncommand = \"./bin/pyls\"\nenv = { PYTHONPATH = \"x\" }\n"), 0o600); err != nil {
77 t.Fatal(err)
78 }
79 held, err := LoadForRootReadOnly(root)
80 if err != nil {
81 t.Fatal(err)
82 }
83 if p := held.PendingProjectPrograms(); len(p) != 1 || !strings.Contains(p[0].Detail, "PYTHONPATH=x") || len(p[0].Files) != 1 {
84 t.Fatalf("pending = %+v, want the env shown and the named file covered", p)
85 }
86 cfg := approveWorkspacePrograms(t, root)
87 verify := cfg.ProjectProgramVerifier(ProjectProgramLSP, "python")
88 if verify == nil || verify() != nil {
89 t.Fatal("an approved, unchanged server does not verify")
90 }
91 if err := os.WriteFile(server, []byte("two"), 0o700); err != nil {
92 t.Fatal(err)
93 }
94 if err := verify(); !errors.Is(err, ErrProjectProgramChanged) {
95 t.Fatalf("verify after the change = %v, want ErrProjectProgramChanged", err)
96 }
97 }
98
99 func TestProjectLanguageServerWaitsForApproval(t *testing.T) {
100 home := t.TempDir()
101 root := t.TempDir()
102 t.Setenv("REASONIX_HOME", home)
103 user := "[lsp.servers.go]\ncommand = \"gopls\"\n"
104 if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(user), 0o600); err != nil {
105 t.Fatal(err)
106 }
107 write := func(args string) {
108 project := "[lsp.servers.python]\ncommand = \"./bin/pyls\"\nargs = [" + args + "]\n"
109 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
110 t.Fatal(err)
111 }
112 }
113 write(`"--stdio"`)
114 cfg, err := LoadForRootReadOnly(root)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if _, ok := cfg.LSP.Servers["python"]; ok {
119 t.Fatalf("servers = %+v, want the project's server held back", cfg.LSP.Servers)
120 }
121 if cfg.LSP.Servers["go"].Command != "gopls" {
122 t.Fatalf("servers = %+v, want the user's server kept", cfg.LSP.Servers)
123 }
124 if err := NewProjectProgramStore(home).Approve(root, cfg.PendingProjectPrograms()...); err != nil {
125 t.Fatal(err)
126 }
127 if cfg, _ = LoadForRootReadOnly(root); cfg.LSP.Servers["python"].Command != "./bin/pyls" {
128 t.Fatalf("servers = %+v, want the approved server", cfg.LSP.Servers)
129 }
130 write(`"--stdio", "--log=/tmp/x"`)
131 if cfg, _ = LoadForRootReadOnly(root); cfg.LSP.Servers["python"].Command != "" {
132 t.Fatalf("servers = %+v, want a changed declaration held back again", cfg.LSP.Servers)
133 }
134 }
135
136 func TestProjectProgramsFailClosedOnAnUnreadableRecord(t *testing.T) {
137 home := t.TempDir()
138 root := t.TempDir()
139 t.Setenv("REASONIX_HOME", home)
140 if err := os.WriteFile(filepath.Join(home, projectProgramsFilename), []byte("{"), 0o600); err != nil {
141 t.Fatal(err)
142 }
143 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[tools.shell]\npath = "+tomlQuote(installedPath("evil"))+"\n"), 0o600); err != nil {
144 t.Fatal(err)
145 }
146 cfg, err := LoadForRootReadOnly(root)
147 if err != nil {
148 t.Fatal(err)
149 }
150 if cfg.Tools.Shell.Path != "" {
151 t.Fatalf("shell = %q, want it held back", cfg.Tools.Shell.Path)
152 }
153 if !slices.Equal(reasonsFor(cfg, "tools.shell.path"), []IgnoredProjectReason{ProjectApprovalUnavailable}) {
154 t.Fatalf("reasons = %v, want the unreadable record named", reasonsFor(cfg, "tools.shell.path"))
155 }
156 if _, err := NewProjectProgramStore(home).Approved(root, ProjectProgram{}); !errors.Is(err, ErrProjectProgramsUnavailable) {
157 t.Fatalf("err = %v, want ErrProjectProgramsUnavailable", err)
158 }
159 }
160
161 func TestWorkspaceGrantsApplyToTheirWorkspaceOnly(t *testing.T) {
162 home := t.TempDir()
163 root := t.TempDir()
164 other := t.TempDir()
165 extra := t.TempDir()
166 t.Setenv("REASONIX_HOME", home)
167 store := NewProjectGrantStore(home)
168 if err := store.Update(root, func(g ProjectGrant) (ProjectGrant, error) {
169 g.Allow = append(g.Allow, "Bash(go test:*)")
170 g.AllowWrite = append(g.AllowWrite, extra)
171 return g, nil
172 }); err != nil {
173 t.Fatal(err)
174 }
175 cfg, err := LoadForRootReadOnly(root)
176 if err != nil {
177 t.Fatal(err)
178 }
179 if !slices.Contains(cfg.Permissions.Allow, "Bash(go test:*)") || !slices.Contains(cfg.Sandbox.AllowWrite, extra) {
180 t.Fatalf("permissions = %+v sandbox = %+v, want the workspace's grants", cfg.Permissions, cfg.Sandbox)
181 }
182 elsewhere, err := LoadForRootReadOnly(other)
183 if err != nil {
184 t.Fatal(err)
185 }
186 if slices.Contains(elsewhere.Permissions.Allow, "Bash(go test:*)") || slices.Contains(elsewhere.Sandbox.AllowWrite, extra) {
187 t.Fatalf("grants leaked to another workspace: %+v %+v", elsewhere.Permissions, elsewhere.Sandbox)
188 }
189 if err := store.Update(root, func(g ProjectGrant) (ProjectGrant, error) {
190 g.Allow = append(g.Allow, "(x)")
191 return g, nil
192 }); err == nil {
193 t.Fatal("an unparseable rule was stored")
194 }
195 }
196
197 func TestProjectBrowserLaunchWaitsForApproval(t *testing.T) {
198 home := t.TempDir()
199 root := t.TempDir()
200 t.Setenv("REASONIX_HOME", home)
201 chrome := installedPath("evil-chrome")
202 project := "[browser]\nchrome_path = " + tomlQuote(chrome) + "\nchrome_args = [\"--x\"]\nendpoint = \"http://203.0.113.1:9222\"\nallow_remote_endpoint = true\n"
203 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(project), 0o600); err != nil {
204 t.Fatal(err)
205 }
206 cfg, err := LoadForRootReadOnly(root)
207 if err != nil {
208 t.Fatal(err)
209 }
210 if cfg.Browser.ChromePath != "" || cfg.Browser.AllowRemoteEndpoint || len(cfg.PendingProjectPrograms()) != 2 {
211 t.Fatalf("browser = %+v pending = %+v, want it held back", cfg.Browser, cfg.PendingProjectPrograms())
212 }
213 if err := NewProjectProgramStore(home).Approve(root, cfg.PendingProjectPrograms()...); err != nil {
214 t.Fatal(err)
215 }
216 if cfg, _ = LoadForRootReadOnly(root); cfg.Browser.ChromePath != chrome {
217 t.Fatalf("approved browser not applied: %+v", cfg.Browser)
218 }
219 }
220
221 func approveWorkspacePrograms(t *testing.T, root string) *Config {
222 t.Helper()
223 cfg, err := LoadForRoot(root)
224 if err != nil {
225 t.Fatalf("LoadForRoot: %v", err)
226 }
227 if pending := cfg.PendingProjectPrograms(); len(pending) > 0 {
228 if err := NewProjectProgramStore(reasonixHomeDir()).Approve(root, pending...); err != nil {
229 t.Fatal(err)
230 }
231 if cfg, err = LoadForRoot(root); err != nil {
232 t.Fatalf("LoadForRoot: %v", err)
233 }
234 }
235 return cfg
236 }
237
238 // approveWorkspace approves what dir's configuration names, standing in for a
239 // person who ran `reasonix trust` there. Tests of the gate itself never call it.
240 func approveWorkspace(t *testing.T, dir string) {
241 t.Helper()
242 _, _ = ApproveWorkspacePrograms(dir)
243 }
244
245 // installedPath is where an installed program could live: outside the
246 // workspace and outside anywhere the bash jail lets commands write.
247 func installedPath(name string) string {
248 return filepath.Join(filepath.VolumeName(os.TempDir())+string(filepath.Separator), "opt", "reasonix-test", name)
249 }
250
251 func TestProjectProgramInATemporaryDirectoryIsRefused(t *testing.T) {
252 shell := filepath.Join(os.TempDir(), "evil-sh")
253 cfg, _ := loadScoped(t, "", "[tools.shell]\npath = "+tomlQuote(shell)+"\n")
254 if cfg.Tools.Shell.Path != "" || !slices.Equal(reasonsFor(cfg, "tools.shell.path"), []IgnoredProjectReason{ProjectProgramWritable}) {
255 t.Fatalf("shell = %q reasons = %v, want it refused", cfg.Tools.Shell.Path, reasonsFor(cfg, "tools.shell.path"))
256 }
257 }
258
259 // A server's bare file argument is covered, resolved where the server runs.
260 func TestProjectLanguageServerBareFileArgumentIsCovered(t *testing.T) {
261 home := t.TempDir()
262 root := t.TempDir()
263 t.Setenv("REASONIX_HOME", home)
264 if err := os.WriteFile(filepath.Join(root, "server.js"), []byte("one"), 0o600); err != nil {
265 t.Fatal(err)
266 }
267 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[lsp.servers.js]\ncommand = \"node\"\nargs = [\"server.js\", \"--require=./boot.js\"]\n"), 0o600); err != nil {
268 t.Fatal(err)
269 }
270 cfg := approveWorkspacePrograms(t, root)
271 verify := cfg.ProjectProgramVerifier(ProjectProgramLSP, "js")
272 if verify == nil || verify() != nil {
273 t.Fatal("an approved, unchanged server does not verify")
274 }
275 if err := os.WriteFile(filepath.Join(root, "boot.js"), []byte("planted"), 0o600); err != nil {
276 t.Fatal(err)
277 }
278 if err := verify(); !errors.Is(err, ErrProjectProgramChanged) {
279 t.Fatalf("verify after a flag's file appeared = %v", err)
280 }
281 }
282
283 func TestProjectProgramInAToolchainCacheIsRefused(t *testing.T) {
284 home, err := os.UserHomeDir()
285 if err != nil || runtime.GOOS == "windows" {
286 t.Skip("no jailed toolchain caches here")
287 }
288 rg := filepath.Join(home, ".cargo", "bin", "rg")
289 cfg, _ := loadScoped(t, "", "[tools.search]\nrg_path = "+tomlQuote(rg)+"\n")
290 if cfg.Tools.Search.RgPath != "" || !slices.Equal(reasonsFor(cfg, "tools.search.rg_path"), []IgnoredProjectReason{ProjectProgramWritable}) {
291 t.Fatalf("rg = %q reasons = %v, want the cache path refused", cfg.Tools.Search.RgPath, reasonsFor(cfg, "tools.search.rg_path"))
292 }
293 }
294
294 lines GO