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