| 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 |