返回 DeepSeek-Reasonix
sandbox_test.go
根目录 / internal / sandbox / sandbox_test.go
1 package sandbox
2
3 import (
4 "os"
5 "os/exec"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "testing"
10 )
11
12 // --- Spec.Enforce ---
13
14 func TestEnforce(t *testing.T) {
15 cases := []struct {
16 mode string
17 want bool
18 }{
19 {"", false},
20 {"off", false},
21 {"enforce", true},
22 {"Enforce", false}, // case-sensitive
23 {"something", false},
24 }
25 for _, c := range cases {
26 s := Spec{Mode: c.mode}
27 if got := s.Enforce(); got != c.want {
28 t.Errorf("Spec{%q}.Enforce() = %v, want %v", c.mode, got, c.want)
29 }
30 }
31 }
32
33 // --- Spec zero value ---
34
35 func TestSpecZeroValue(t *testing.T) {
36 var s Spec
37 if s.Enforce() {
38 t.Error("zero-value Spec should not enforce")
39 }
40 if s.Network {
41 t.Error("zero-value Spec should not allow network")
42 }
43 if len(s.WriteRoots) != 0 {
44 t.Error("zero-value Spec should have no write roots")
45 }
46 }
47
48 func TestUnavailableMessageIsActionable(t *testing.T) {
49 msg := UnavailableMessage()
50 want := []string{
51 "refusing to run unconfined",
52 `[sandbox] bash = "off"`,
53 "Settings -> Sandbox",
54 }
55 if runtime.GOOS == "windows" {
56 // Windows ships no OS-level Bash backend and the effective mode is
57 // fixed to off, so the remediation states that fact instead of
58 // pointing at a config edit the platform would ignore.
59 want = []string{
60 "refusing to run unconfined",
61 "OS-level Bash sandbox",
62 `fixed to "off"`,
63 }
64 }
65 for _, w := range want {
66 if !strings.Contains(msg, w) {
67 t.Fatalf("UnavailableMessage() = %q, want %q", msg, w)
68 }
69 }
70 }
71
72 // --- Command ---
73
74 func TestCommandNonEnforce(t *testing.T) {
75 spec := Spec{Mode: "off"}
76 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "bash"}, "ls")
77 if wrapped {
78 t.Error("non-enforce should not wrap")
79 }
80 if cmd[0] != "bash" {
81 t.Errorf("cmd[0] = %q, want bash", cmd[0])
82 }
83 }
84
85 func TestCommandEmptyMode(t *testing.T) {
86 spec := Spec{}
87 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
88 if wrapped {
89 t.Error("empty mode should not wrap")
90 }
91 if len(cmd) != 3 {
92 t.Errorf("cmd length = %d, want 3", len(cmd))
93 }
94 }
95
96 func TestCommandPowerShell(t *testing.T) {
97 cmd, wrapped := Command(Spec{Mode: "off"}, Shell{Kind: ShellPowerShell, Path: "powershell"}, "Get-ChildItem")
98 if wrapped {
99 t.Error("non-enforce should not wrap")
100 }
101 want := []string{"powershell", "-NoProfile", "-NonInteractive", "-Command", psUTF8Prologue + "Get-ChildItem"}
102 if len(cmd) != len(want) {
103 t.Fatalf("argv = %v, want %v", cmd, want)
104 }
105 for i := range want {
106 if cmd[i] != want[i] {
107 t.Fatalf("argv[%d] = %q, want %q", i, cmd[i], want[i])
108 }
109 }
110 }
111
112 func TestResolveShellDecisionTable(t *testing.T) {
113 onPath := func(names ...string) func(string) (string, error) {
114 set := map[string]bool{}
115 for _, n := range names {
116 set[n] = true
117 }
118 return func(name string) (string, error) {
119 if set[name] {
120 return `C:\fake\` + name + ".exe", nil
121 }
122 return "", exec.ErrNotFound
123 }
124 }
125 gitBash := []string{`C:\fake\Git\bin\bash.exe`}
126 always := func(string) bool { return true }
127 never := func(string) bool { return false }
128 // onPath("bash") returns C:\fake\bash.exe; treat exactly that as the WSL
129 // launcher so the exclusion is exercised without matching the Git candidate.
130 wslIsPathBash := func(p string) bool { return p == `C:\fake\bash.exe` }
131 cases := []struct {
132 name string
133 goos string
134 lookPath func(string) (string, error)
135 candidates []string
136 exists func(string) bool
137 probe func(string) bool
138 isWSL func(string) bool
139 wantKind ShellKind
140 wantPath string
141 }{
142 {"bash on PATH wins", "windows", onPath("bash", "powershell"), gitBash, never, always, never, ShellBash, `C:\fake\bash.exe`},
143 {"bash on PATH but probe fails", "windows", onPath("bash", "powershell"), gitBash, never, never, never, ShellPowerShell, ""},
144 {"no bash, git-bash on disk", "windows", onPath("powershell"), gitBash, always, always, never, ShellBash, ""},
145 {"git-bash on disk but probe fails", "windows", onPath("powershell"), gitBash, always, never, never, ShellPowerShell, ""},
146 {"no bash anywhere, pwsh", "windows", onPath("pwsh", "powershell"), gitBash, never, never, never, ShellPowerShell, ""},
147 {"no bash, only powershell", "windows", onPath("powershell"), gitBash, never, never, never, ShellPowerShell, ""},
148 {"windows, nothing found", "windows", onPath(), nil, never, never, never, ShellBash, ""},
149 {"linux, no bash → no PS fallback", "linux", onPath("powershell"), gitBash, always, always, never, ShellBash, ""},
150 {"wsl bash on PATH skipped for git-bash", "windows", onPath("bash", "powershell"), gitBash, always, always, wslIsPathBash, ShellBash, `C:\fake\Git\bin\bash.exe`},
151 {"wsl bash on PATH, no git → powershell not wsl", "windows", onPath("bash", "powershell"), gitBash, never, always, wslIsPathBash, ShellPowerShell, ""},
152 }
153 for _, c := range cases {
154 got := resolveShell("", "", nil, c.goos, c.lookPath, c.exists, c.candidates, nil, c.probe, c.isWSL)
155 if got.Kind != c.wantKind {
156 t.Errorf("%s: kind = %s, want %s (path=%s)", c.name, got.Kind, c.wantKind, got.Path)
157 }
158 if c.wantPath != "" && got.Path != c.wantPath {
159 t.Errorf("%s: path = %q, want %q", c.name, got.Path, c.wantPath)
160 }
161 }
162 }
163
164 func TestResolveShellPrefer(t *testing.T) {
165 onPath := func(names ...string) func(string) (string, error) {
166 set := map[string]bool{}
167 for _, n := range names {
168 set[n] = true
169 }
170 return func(name string) (string, error) {
171 if set[name] {
172 return `C:\fake\` + name + ".exe", nil
173 }
174 return "", exec.ErrNotFound
175 }
176 }
177 gitBash := []string{`C:\fake\Git\bin\bash.exe`}
178 always := func(string) bool { return true }
179 never := func(string) bool { return false }
180 noWSL := func(string) bool { return false }
181
182 // prefer=powershell forces PowerShell even when bash is present and probes ok.
183 got := resolveShell("powershell", "", nil, "windows", onPath("bash", "powershell", "pwsh"), never, gitBash, nil, always, noWSL)
184 if got.Kind != ShellPowerShell {
185 t.Errorf(`prefer="powershell": kind = %s, want powershell`, got.Kind)
186 }
187
188 // prefer=bash forces bash even on a host where PowerShell exists.
189 got = resolveShell("bash", "", nil, "windows", onPath("bash", "powershell"), never, gitBash, nil, always, noWSL)
190 if got.Kind != ShellBash {
191 t.Errorf(`prefer="bash": kind = %s, want bash`, got.Kind)
192 }
193
194 // An explicit path is honoured for the forced kind.
195 got = resolveShell("pwsh", `C:\custom\pwsh.exe`, nil, "windows", onPath(), always, gitBash, nil, never, noWSL)
196 if got.Kind != ShellPowerShell || got.Path != `C:\custom\pwsh.exe` {
197 t.Errorf(`prefer="pwsh" path: got {%s %q}, want {powershell "C:\custom\pwsh.exe"}`, got.Kind, got.Path)
198 }
199
200 // prefer=pwsh finds PowerShell 7 in its standard install path even when that
201 // directory has not been added to PATH.
202 got = resolveShell("pwsh", "", nil, "windows", onPath("powershell"), func(p string) bool {
203 return p == `C:/Program Files/PowerShell/7/pwsh.exe`
204 }, gitBash, []string{`C:/Program Files/PowerShell/7/pwsh.exe`}, never, noWSL)
205 if got.Kind != ShellPowerShell || got.Path != `C:/Program Files/PowerShell/7/pwsh.exe` {
206 t.Errorf(`prefer="pwsh" standard path: got {%s %q}, want {powershell "C:/Program Files/PowerShell/7/pwsh.exe"}`, got.Kind, got.Path)
207 }
208
209 // A forced shell that isn't installed warns and falls back to auto-detection.
210 var warn strings.Builder
211 got = resolveShell("powershell", "", &warn, "linux", onPath("bash"), never, gitBash, nil, always, noWSL)
212 if got.Kind != ShellBash {
213 t.Errorf("missing forced powershell should fall back to bash, got %s", got.Kind)
214 }
215 if !strings.Contains(warn.String(), "powershell") {
216 t.Errorf("fallback should warn about the missing shell, got %q", warn.String())
217 }
218
219 // An unrecognised value is treated as auto, not an error.
220 got = resolveShell("fish", "", nil, "windows", onPath("bash"), never, gitBash, nil, always, noWSL)
221 if got.Kind != ShellBash {
222 t.Errorf("unknown prefer should auto-detect, got %s", got.Kind)
223 }
224 }
225
226 func TestIsWindowsWSLBash(t *testing.T) {
227 if runtime.GOOS != "windows" {
228 t.Skip("windows-only path detection")
229 }
230 t.Setenv("SystemRoot", `C:\Windows`)
231 if !isWindowsWSLBash(`C:\Windows\System32\bash.exe`) {
232 t.Error("System32 bash launcher should be detected as WSL")
233 }
234 if !isWindowsWSLBash(`c:\windows\system32\BASH.EXE`) {
235 t.Error("detection should be case-insensitive")
236 }
237 if isWindowsWSLBash(`C:\Program Files\Git\bin\bash.exe`) {
238 t.Error("Git-for-Windows bash must not be flagged as WSL")
239 }
240 if isWindowsWSLBash("") {
241 t.Error("empty path is not WSL")
242 }
243 }
244
245 func TestSupportsChaining(t *testing.T) {
246 cases := []struct {
247 sh Shell
248 want bool
249 }{
250 {Shell{Kind: ShellBash, Path: "bash"}, true},
251 {Shell{Kind: ShellPowerShell, Path: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`}, false},
252 {Shell{Kind: ShellPowerShell, Path: "powershell"}, false},
253 {Shell{Kind: ShellPowerShell, Path: `C:\Program Files\PowerShell\7\pwsh.exe`}, true},
254 {Shell{Kind: ShellPowerShell, Path: "pwsh"}, true},
255 }
256 for _, c := range cases {
257 if got := c.sh.SupportsChaining(); got != c.want {
258 t.Errorf("SupportsChaining(%+v) = %v, want %v", c.sh, got, c.want)
259 }
260 }
261 }
262
263 func TestShellArgvDefaultsPath(t *testing.T) {
264 if got := (Shell{Kind: ShellBash}).argv("ls"); got[0] != "bash" {
265 t.Errorf("empty bash path argv[0] = %q, want bash", got[0])
266 }
267 if got := (Shell{Kind: ShellPowerShell}).argv("ls"); got[0] != "powershell" {
268 t.Errorf("empty powershell path argv[0] = %q, want powershell", got[0])
269 }
270 }
271
272 // --- Command (platform-specific) ---
273
274 func TestCommandNonDarwin(t *testing.T) {
275 if runtime.GOOS == "darwin" {
276 t.Skip("testing non-darwin path")
277 }
278 spec := Spec{Mode: "enforce", WriteRoots: []string{"/tmp"}}
279 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
280 if Available() {
281 if !wrapped || cmd[0] == "sh" {
282 t.Fatalf("non-darwin enforce with available sandbox should wrap: %v wrapped=%v", cmd, wrapped)
283 }
284 return
285 }
286 if wrapped {
287 t.Error("non-darwin without sandbox should not wrap")
288 }
289 if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" || cmd[2] != "echo hi" {
290 t.Errorf("unexpected cmd: %v", cmd)
291 }
292 }
293
294 func TestCommandDarwinEnforce(t *testing.T) {
295 if runtime.GOOS != "darwin" {
296 t.Skip("darwin-only test")
297 }
298 if !Available() {
299 t.Skip("sandbox-exec not available")
300 }
301 spec := Spec{Mode: "enforce", WriteRoots: []string{"/workspace"}}
302 cmd, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
303 if !wrapped {
304 t.Error("darwin enforce with sandbox-exec should wrap")
305 }
306 if cmd[0] != "sandbox-exec" {
307 t.Errorf("cmd[0] = %q, want sandbox-exec", cmd[0])
308 }
309 if len(cmd) != 6 {
310 t.Errorf("cmd length = %d, want 6", len(cmd))
311 }
312 }
313
314 func TestCommandDarwinNonEnforce(t *testing.T) {
315 if runtime.GOOS != "darwin" {
316 t.Skip("darwin-only test")
317 }
318 spec := Spec{Mode: "off", WriteRoots: []string{"/workspace"}}
319 _, wrapped := Command(spec, Shell{Kind: ShellBash, Path: "sh"}, "echo hi")
320 if wrapped {
321 t.Error("non-enforce should not wrap even on darwin")
322 }
323 }
324
325 // --- Available ---
326
327 func TestAvailableNonDarwin(t *testing.T) {
328 if runtime.GOOS == "darwin" {
329 t.Skip("testing non-darwin path")
330 }
331 if runtime.GOOS == "windows" {
332 t.Skip("windows has its own helper-backed sandbox availability")
333 }
334 if Available() {
335 if _, err := exec.LookPath("bwrap"); err != nil {
336 t.Errorf("Available() = true, but bwrap lookup failed: %v", err)
337 }
338 }
339 }
340
341 func TestInstalledButUnusableBwrapIsUnavailable(t *testing.T) {
342 if runtime.GOOS == "darwin" || runtime.GOOS == "windows" {
343 t.Skip("bubblewrap-only test")
344 }
345 dir := t.TempDir()
346 bwrap := filepath.Join(dir, "bwrap")
347 if err := os.WriteFile(bwrap, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
348 t.Fatal(err)
349 }
350 t.Setenv("PATH", dir)
351 if Available() {
352 t.Fatal("non-functional bwrap binary was reported available")
353 }
354 argv, wrapped := Command(Spec{Mode: "enforce"}, Shell{Kind: ShellBash, Path: "sh"}, "true")
355 if wrapped || len(argv) == 0 || argv[0] != "sh" {
356 t.Fatalf("Command with unusable bwrap = %v, wrapped=%v; want unwrapped shell for caller fail-closed", argv, wrapped)
357 }
358 }
359
359 lines GO