| 1 | package sandbox |
| 2 | |
| 3 | import ( |
| 4 | "os/exec" |
| 5 | "path/filepath" |
| 6 | "runtime" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | func fakeLookPath(found map[string]string) func(string) (string, error) { |
| 14 | return func(name string) (string, error) { |
| 15 | if p, ok := found[name]; ok { |
| 16 | return p, nil |
| 17 | } |
| 18 | return "", exec.ErrNotFound |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | // TestBashCandidatesFromGitBinary pins the git.exe → bash.exe derivation: the |
| 23 | // install root's bin\bash.exe and usr\bin\bash.exe, walking up from the binary |
| 24 | // directory so cmd\, mingw64\bin, and root-level layouts all resolve. |
| 25 | // filepath.Join uses the host separator, so compare with separators |
| 26 | // normalized — the derivation itself only runs on Windows in production. |
| 27 | func TestBashCandidatesFromGitBinary(t *testing.T) { |
| 28 | got := bashCandidatesFromGitBinary(`C:\Program Files\Git\cmd\git.exe`) |
| 29 | want := []string{ |
| 30 | `C:\Program Files\Git\cmd\bin\bash.exe`, |
| 31 | `C:\Program Files\Git\cmd\usr\bin\bash.exe`, |
| 32 | `C:\Program Files\Git\bin\bash.exe`, |
| 33 | `C:\Program Files\Git\usr\bin\bash.exe`, |
| 34 | `C:\Program Files\bin\bash.exe`, |
| 35 | `C:\Program Files\usr\bin\bash.exe`, |
| 36 | } |
| 37 | norm := func(p string) string { return strings.ReplaceAll(filepath.Clean(p), "/", `\`) } |
| 38 | if len(got) != len(want) { |
| 39 | t.Fatalf("derived %d candidates, want %d: %v", len(got), len(want), got) |
| 40 | } |
| 41 | for i := range want { |
| 42 | if norm(got[i]) != want[i] { |
| 43 | t.Errorf("candidate[%d] = %q, want %q", i, norm(got[i]), want[i]) |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | // TestWindowsBashCandidateOrder pins discovery priority: the configured path |
| 49 | // first (with git-bash.exe rewritten to bin\bash.exe), then candidates derived |
| 50 | // from a PATH-visible git.exe, deduplicated case-insensitively. PATH bash.exe |
| 51 | // itself is resolved by resolveShell ahead of every candidate, so it must not |
| 52 | // appear in the list. |
| 53 | func TestWindowsBashCandidateOrder(t *testing.T) { |
| 54 | lookPath := fakeLookPath(map[string]string{ |
| 55 | "bash": `C:\Windows\System32\bash.exe`, |
| 56 | "git.exe": `D:\Tools\Git\mingw64\bin\git.exe`, |
| 57 | }) |
| 58 | // The configured git-bash.exe rewrites to a sibling bin\bash.exe that |
| 59 | // "exists", so the config candidate survives sanitization. |
| 60 | exists := func(p string) bool { return strings.EqualFold(p, `E:\Portable\Git\bin\bash.exe`) } |
| 61 | |
| 62 | got, sources := windowsBashCandidateSources("auto", `E:\Portable\Git\git-bash.exe`, lookPath, exists) |
| 63 | if len(got) == 0 { |
| 64 | t.Fatal("expected candidates") |
| 65 | } |
| 66 | if got[0] != `E:\Portable\Git\bin\bash.exe` { |
| 67 | t.Errorf("first candidate = %q, want the rewritten config path", got[0]) |
| 68 | } |
| 69 | if sources[strings.ToLower(got[0])] != ShellSourceConfig { |
| 70 | t.Errorf("first candidate source = %q, want %q", sources[strings.ToLower(got[0])], ShellSourceConfig) |
| 71 | } |
| 72 | gitRoot := filepath.Clean(`D:\Tools\Git`) |
| 73 | wantGitDerived := filepath.Join(gitRoot, "bin", "bash.exe") |
| 74 | found := false |
| 75 | for _, p := range got { |
| 76 | if p == wantGitDerived && sources[strings.ToLower(p)] == ShellSourceGitDerived { |
| 77 | found = true |
| 78 | } |
| 79 | } |
| 80 | if !found { |
| 81 | t.Errorf("derived candidate %q with source %q missing from %v", wantGitDerived, ShellSourceGitDerived, got) |
| 82 | } |
| 83 | for _, p := range got { |
| 84 | if strings.EqualFold(p, `C:\Windows\System32\bash.exe`) { |
| 85 | t.Errorf("PATH bash must not be listed as a candidate: %v", got) |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestWindowsShellCapabilitiesOnlyReportPowerShellRuntimes(t *testing.T) { |
| 91 | const pwsh = `C:\Program Files\PowerShell\7\pwsh.exe` |
| 92 | snap := &shellSnapshot{ |
| 93 | lookPath: fakeLookPath(map[string]string{"pwsh": pwsh}), |
| 94 | exists: func(path string) bool { return path == pwsh }, |
| 95 | isWSL: func(string) bool { return false }, |
| 96 | psCands: []string{pwsh}, |
| 97 | sources: map[string]string{}, |
| 98 | probeFunc: func(string) bool { return true }, |
| 99 | probeCache: map[string]bool{}, |
| 100 | } |
| 101 | caps := windowsShellCapabilities(snap) |
| 102 | if len(caps) != 2 || caps[0].ID != ShellCapabilityPwsh || caps[1].ID != ShellCapabilityPowerShell { |
| 103 | t.Fatalf("Windows shell capabilities = %+v, want pwsh and powershell only", caps) |
| 104 | } |
| 105 | for _, cap := range caps { |
| 106 | if cap.ID == ShellCapabilityGitBash || cap.ID == ShellCapabilityBash { |
| 107 | t.Fatalf("Windows shell capabilities must not advertise Bash: %+v", caps) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestConfiguredWindowsBashPathMatchesPreference(t *testing.T) { |
| 113 | exists := func(path string) bool { |
| 114 | return strings.EqualFold(path, `E:\Portable\Git\bin\bash.exe`) |
| 115 | } |
| 116 | tests := []struct { |
| 117 | name string |
| 118 | prefer string |
| 119 | path string |
| 120 | want string |
| 121 | }{ |
| 122 | {"auto accepts bash", "auto", `E:\Portable\Git\bin\bash.exe`, `E:\Portable\Git\bin\bash.exe`}, |
| 123 | {"auto rewrites git bash", "auto", `E:\Portable\Git\git-bash.exe`, `E:\Portable\Git\bin\bash.exe`}, |
| 124 | {"auto rejects stale pwsh", "auto", `C:\Program Files\PowerShell\7\pwsh.exe`, ""}, |
| 125 | {"powershell rejects stale bash", "powershell", `E:\Portable\Git\bin\bash.exe`, ""}, |
| 126 | {"forced bash accepts custom wrapper", "bash", `E:\Custom\shell-wrapper.exe`, `E:\Custom\shell-wrapper.exe`}, |
| 127 | } |
| 128 | for _, test := range tests { |
| 129 | t.Run(test.name, func(t *testing.T) { |
| 130 | if got := configuredWindowsBashPath(test.prefer, test.path, exists); got != test.want { |
| 131 | t.Fatalf("configuredWindowsBashPath(%q, %q) = %q, want %q", test.prefer, test.path, got, test.want) |
| 132 | } |
| 133 | }) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | // TestWindowsBashCandidatesDedupeAndWSLExclusion exercises dedupe (the same |
| 138 | // path reachable through two buckets) and the %SystemRoot% WSL launcher |
| 139 | // exclusion, both of which need a Windows host to observe. |
| 140 | func TestWindowsBashCandidatesDedupeAndWSLExclusion(t *testing.T) { |
| 141 | if runtime.GOOS != "windows" { |
| 142 | t.Skip("WSL launcher detection reads %SystemRoot% and only fires on Windows") |
| 143 | } |
| 144 | t.Setenv("SystemRoot", `C:\Windows`) |
| 145 | lookPath := fakeLookPath(map[string]string{ |
| 146 | "git.exe": `C:\Program Files\Git\cmd\git.exe`, |
| 147 | }) |
| 148 | got, _ := windowsBashCandidateSources("auto", "", lookPath, fileExists) |
| 149 | seen := map[string]bool{} |
| 150 | for _, p := range got { |
| 151 | key := strings.ToLower(p) |
| 152 | if seen[key] { |
| 153 | t.Errorf("duplicate candidate %q", p) |
| 154 | } |
| 155 | seen[key] = true |
| 156 | if isWindowsWSLBash(p) { |
| 157 | t.Errorf("WSL launcher %q must be excluded", p) |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // TestShellInventoryCache covers the singleflight cache contract: concurrent |
| 163 | // lookups share one build, the entry survives within the TTL, a different |
| 164 | // config path rebuilds, and InvalidateShellInventory forces a rebuild while a |
| 165 | // stale in-flight build cannot republish itself. |
| 166 | func TestShellInventoryCache(t *testing.T) { |
| 167 | inv := newShellInventory() |
| 168 | var builds sync.Mutex |
| 169 | buildCount := 0 |
| 170 | release := make(chan struct{}) |
| 171 | inv.build = func(goos, prefer, configPath string) *shellSnapshot { |
| 172 | builds.Lock() |
| 173 | buildCount++ |
| 174 | builds.Unlock() |
| 175 | if configPath == "slow" { |
| 176 | <-release |
| 177 | } |
| 178 | return &shellSnapshot{key: shellInventoryKey(goos, prefer, configPath), builtAt: time.Now(), probeCache: map[string]bool{}} |
| 179 | } |
| 180 | |
| 181 | // Singleflight: concurrent snapshot() calls for one key build once. |
| 182 | var wg sync.WaitGroup |
| 183 | for range 8 { |
| 184 | wg.Go(func() { |
| 185 | _ = inv.snapshot("windows", "auto", "") |
| 186 | }) |
| 187 | } |
| 188 | // The first build ("") does not block, so wait for the burst to finish. |
| 189 | wg.Wait() |
| 190 | builds.Lock() |
| 191 | if buildCount != 1 { |
| 192 | builds.Unlock() |
| 193 | t.Fatalf("concurrent lookups built %d times, want 1", buildCount) |
| 194 | } |
| 195 | builds.Unlock() |
| 196 | |
| 197 | // Within the TTL the cached entry is served without a rebuild. |
| 198 | _ = inv.snapshot("windows", "auto", "") |
| 199 | builds.Lock() |
| 200 | count := buildCount |
| 201 | builds.Unlock() |
| 202 | if count != 1 { |
| 203 | t.Fatalf("TTL hit rebuilt: %d", count) |
| 204 | } |
| 205 | |
| 206 | // A different preference is a different cache key even when the configured |
| 207 | // path stays the same, because only Bash preferences may consume that path. |
| 208 | _ = inv.snapshot("windows", "bash", "") |
| 209 | builds.Lock() |
| 210 | count = buildCount |
| 211 | builds.Unlock() |
| 212 | if count != 2 { |
| 213 | t.Fatalf("different preference should rebuild: %d", count) |
| 214 | } |
| 215 | |
| 216 | // A different config path is also a different cache key. |
| 217 | _ = inv.snapshot("windows", "auto", "other") |
| 218 | builds.Lock() |
| 219 | count = buildCount |
| 220 | builds.Unlock() |
| 221 | if count != 3 { |
| 222 | t.Fatalf("different path should rebuild: %d", count) |
| 223 | } |
| 224 | |
| 225 | // Invalidation forces a rebuild and drops a stale in-flight result. |
| 226 | var inflight sync.WaitGroup |
| 227 | inflight.Go(func() { |
| 228 | _ = inv.snapshot("windows", "auto", "slow") |
| 229 | }) |
| 230 | // Wait until the slow build has started, then invalidate under it. |
| 231 | deadline := time.Now().Add(2 * time.Second) |
| 232 | for time.Now().Before(deadline) { |
| 233 | inv.mu.Lock() |
| 234 | refreshing := inv.refreshing != nil |
| 235 | inv.mu.Unlock() |
| 236 | if refreshing { |
| 237 | break |
| 238 | } |
| 239 | time.Sleep(time.Millisecond) |
| 240 | } |
| 241 | inv.invalidate() |
| 242 | close(release) |
| 243 | inflight.Wait() |
| 244 | |
| 245 | inv.mu.Lock() |
| 246 | current := inv.current |
| 247 | inv.mu.Unlock() |
| 248 | if current != nil { |
| 249 | t.Fatalf("stale in-flight build was cached under key %q after invalidation", current.key) |
| 250 | } |
| 251 | |
| 252 | // And the next lookup builds fresh. |
| 253 | _ = inv.snapshot("windows", "auto", "") |
| 254 | builds.Lock() |
| 255 | count = buildCount |
| 256 | builds.Unlock() |
| 257 | if count != 5 { |
| 258 | t.Fatalf("post-invalidation lookup should rebuild: %d builds", count) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | // TestSnapshotProbeCachesResults proves repeated resolutions inside one |
| 263 | // snapshot do not re-probe the same path. |
| 264 | func TestSnapshotProbeCachesResults(t *testing.T) { |
| 265 | snap := &shellSnapshot{probeFunc: func(string) bool { return true }, probeCache: map[string]bool{}} |
| 266 | if !snap.probe(`C:\Git\bin\bash.exe`) { |
| 267 | t.Fatal("probe on a non-Windows host always succeeds") |
| 268 | } |
| 269 | snap.probeMu.Lock() |
| 270 | cached, ok := snap.probeCache[`C:\Git\bin\bash.exe`] |
| 271 | snap.probeMu.Unlock() |
| 272 | if !ok || !cached { |
| 273 | t.Fatalf("probe result not cached: %v", snap.probeCache) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | func TestUnixShellCapabilitiesReportsBashZshAndSh(t *testing.T) { |
| 278 | snap := &shellSnapshot{ |
| 279 | lookPath: fakeLookPath(map[string]string{"zsh": "/opt/homebrew/bin/zsh"}), |
| 280 | exists: func(path string) bool { |
| 281 | return path == "/bin/sh" |
| 282 | }, |
| 283 | } |
| 284 | caps := unixShellCapabilities(snap) |
| 285 | if len(caps) != 3 { |
| 286 | t.Fatalf("capabilities = %+v, want bash, zsh, and sh", caps) |
| 287 | } |
| 288 | byID := map[string]ShellCapability{} |
| 289 | for _, capability := range caps { |
| 290 | byID[capability.ID] = capability |
| 291 | } |
| 292 | if bash := byID[ShellCapabilityBash]; bash.Available || bash.Reason != "not-found" { |
| 293 | t.Fatalf("bash = %+v, want unavailable with a reason", bash) |
| 294 | } |
| 295 | if zsh := byID[ShellCapabilityZsh]; !zsh.Available || zsh.Path != "/opt/homebrew/bin/zsh" || zsh.Source != ShellSourcePath { |
| 296 | t.Fatalf("zsh = %+v, want PATH capability", zsh) |
| 297 | } |
| 298 | if sh := byID[ShellCapabilitySh]; !sh.Available || sh.Path != "/bin/sh" || sh.Source != ShellSourceStandard { |
| 299 | t.Fatalf("sh = %+v, want standard-path capability", sh) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | func TestDiscoverGitCapabilityIsIndependentFromShells(t *testing.T) { |
| 304 | snap := &shellSnapshot{ |
| 305 | goos: "darwin", |
| 306 | lookPath: fakeLookPath(map[string]string{}), |
| 307 | exists: func(path string) bool { |
| 308 | return path == "/opt/homebrew/bin/git" |
| 309 | }, |
| 310 | } |
| 311 | got := discoverGitCapability(snap) |
| 312 | if !got.Available || got.ID != HostCapabilityGit || got.Path != "/opt/homebrew/bin/git" || got.Source != ShellSourceStandard { |
| 313 | t.Fatalf("Git capability = %+v, want independent Homebrew standard path", got) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | func TestDiscoverGitCapabilityRejectsUnusableShim(t *testing.T) { |
| 318 | snap := &shellSnapshot{ |
| 319 | goos: "darwin", |
| 320 | lookPath: fakeLookPath(map[string]string{"git": "/usr/bin/git"}), |
| 321 | exists: func(path string) bool { return path == "/usr/bin/git" }, |
| 322 | gitProbe: func(string) bool { return false }, |
| 323 | } |
| 324 | got := discoverGitCapability(snap) |
| 325 | if got.Available || got.Reason != "not-usable" { |
| 326 | t.Fatalf("Git capability = %+v, want unusable shim rejected", got) |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | func TestDiscoverGitCapabilityPreflightRejectsAppleShimWithoutRunningIt(t *testing.T) { |
| 331 | preflightCalls := 0 |
| 332 | probeCalls := 0 |
| 333 | snap := &shellSnapshot{ |
| 334 | goos: "darwin", |
| 335 | lookPath: fakeLookPath(map[string]string{"git": "/usr/bin/git"}), |
| 336 | exists: func(path string) bool { return path == "/usr/bin/git" }, |
| 337 | gitPreflight: func(path string) bool { |
| 338 | preflightCalls++ |
| 339 | return false |
| 340 | }, |
| 341 | gitProbe: func(string) bool { |
| 342 | probeCalls++ |
| 343 | return true |
| 344 | }, |
| 345 | } |
| 346 | got := discoverGitCapability(snap) |
| 347 | if got.Available || got.Reason != "not-usable" { |
| 348 | t.Fatalf("Git capability = %+v, want inactive Apple shim rejected", got) |
| 349 | } |
| 350 | if preflightCalls != 1 { |
| 351 | t.Fatalf("Apple shim preflight calls = %d, want one cached decision", preflightCalls) |
| 352 | } |
| 353 | if probeCalls != 0 { |
| 354 | t.Fatalf("git --version probe ran %d times after preflight rejection", probeCalls) |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | func TestGitCandidatesFromWindowsBashFindInstallRoot(t *testing.T) { |
| 359 | got := gitCandidatesFromWindowsBash(`C:\Program Files\Git\bin\bash.exe`) |
| 360 | norm := func(path string) string { |
| 361 | return strings.ToLower(strings.ReplaceAll(filepath.Clean(path), `\`, "/")) |
| 362 | } |
| 363 | want := norm(`C:\Program Files\Git\cmd\git.exe`) |
| 364 | found := false |
| 365 | for _, candidate := range got { |
| 366 | if norm(candidate) == want { |
| 367 | found = true |
| 368 | break |
| 369 | } |
| 370 | } |
| 371 | if !found { |
| 372 | t.Fatalf("candidates = %v, missing %q", got, want) |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | // TestShellCapabilitiesShape ensures the exported capability report matches |
| 377 | // the platform: both native PowerShells on Windows, and bash/zsh/sh on |
| 378 | // Unix — with unavailable entries carrying a reason, never an error. |
| 379 | func TestShellCapabilitiesShape(t *testing.T) { |
| 380 | caps := ShellCapabilities() |
| 381 | if len(caps) == 0 { |
| 382 | t.Fatal("ShellCapabilities returned no entries") |
| 383 | } |
| 384 | ids := map[string]bool{} |
| 385 | for _, cap := range caps { |
| 386 | ids[cap.ID] = true |
| 387 | if cap.Available && cap.Path == "" { |
| 388 | t.Errorf("capability %q is available without a path", cap.ID) |
| 389 | } |
| 390 | if !cap.Available && cap.Reason == "" { |
| 391 | t.Errorf("unavailable capability %q must carry a reason", cap.ID) |
| 392 | } |
| 393 | } |
| 394 | if runtime.GOOS == "windows" { |
| 395 | for _, id := range []string{ShellCapabilityPowerShell, ShellCapabilityPwsh} { |
| 396 | if !ids[id] { |
| 397 | t.Errorf("Windows report missing %q: %v", id, caps) |
| 398 | } |
| 399 | } |
| 400 | for _, id := range []string{ShellCapabilityBash, ShellCapabilityGitBash} { |
| 401 | if ids[id] { |
| 402 | t.Errorf("Windows report must not advertise %q: %v", id, caps) |
| 403 | } |
| 404 | } |
| 405 | for _, id := range []string{ShellCapabilityZsh, ShellCapabilitySh} { |
| 406 | if ids[id] { |
| 407 | t.Errorf("Windows report must not advertise %q: %v", id, caps) |
| 408 | } |
| 409 | } |
| 410 | } else { |
| 411 | for _, id := range []string{ShellCapabilityBash, ShellCapabilityZsh, ShellCapabilitySh} { |
| 412 | if !ids[id] { |
| 413 | t.Errorf("non-Windows report missing %q: %v", id, caps) |
| 414 | } |
| 415 | } |
| 416 | for _, id := range []string{ShellCapabilityGitBash, ShellCapabilityPowerShell, ShellCapabilityPwsh} { |
| 417 | if ids[id] { |
| 418 | t.Errorf("non-Windows report must not advertise %q: %v", id, caps) |
| 419 | } |
| 420 | } |
| 421 | } |
| 422 | } |
| 423 |