返回 DeepSeek-Reasonix
shell_inventory_test.go
根目录 / internal / sandbox / shell_inventory_test.go
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 TestWindowsShellCapabilitiesReportGitBashAndPowerShellRuntimes(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) != 3 || caps[0].ID != ShellCapabilityGitBash || caps[1].ID != ShellCapabilityPwsh || caps[2].ID != ShellCapabilityPowerShell {
103 t.Fatalf("Windows shell capabilities = %+v, want Git Bash, pwsh and powershell", caps)
104 }
105 if caps[0].Available || caps[0].Reason != "not-installed" {
106 t.Fatalf("missing Git Bash must report not-installed: %+v", caps[0])
107 }
108 }
109
110 func TestConfiguredWindowsBashPathMatchesPreference(t *testing.T) {
111 exists := func(path string) bool {
112 return strings.EqualFold(path, `E:\Portable\Git\bin\bash.exe`)
113 }
114 tests := []struct {
115 name string
116 prefer string
117 path string
118 want string
119 }{
120 {"auto accepts bash", "auto", `E:\Portable\Git\bin\bash.exe`, `E:\Portable\Git\bin\bash.exe`},
121 {"auto rewrites git bash", "auto", `E:\Portable\Git\git-bash.exe`, `E:\Portable\Git\bin\bash.exe`},
122 {"auto rejects stale pwsh", "auto", `C:\Program Files\PowerShell\7\pwsh.exe`, ""},
123 {"powershell rejects stale bash", "powershell", `E:\Portable\Git\bin\bash.exe`, ""},
124 {"forced bash accepts custom wrapper", "bash", `E:\Custom\shell-wrapper.exe`, `E:\Custom\shell-wrapper.exe`},
125 }
126 for _, test := range tests {
127 t.Run(test.name, func(t *testing.T) {
128 if got := configuredWindowsBashPath(test.prefer, test.path, exists); got != test.want {
129 t.Fatalf("configuredWindowsBashPath(%q, %q) = %q, want %q", test.prefer, test.path, got, test.want)
130 }
131 })
132 }
133 }
134
135 func TestWindowsGitBashDetectionMatchesExplicitResolution(t *testing.T) {
136 const configured = `E:\Portable\Git\bin\bash.exe`
137 const onPath = `C:\Program Files\Git\bin\bash.exe`
138 const installed = `D:\Git\bin\bash.exe`
139 const wsl = `C:\Windows\System32\bash.exe`
140 for _, tc := range []struct {
141 name, config, path, failed, want, source string
142 }{
143 {"configured before PATH", configured, onPath, "", configured, ShellSourceConfig},
144 {"PATH before install locations", "", onPath, "", onPath, ShellSourcePath},
145 {"broken configured runtime", configured, onPath, configured, onPath, ShellSourcePath},
146 {"broken PATH runtime", "", onPath, onPath, installed, ShellSourceStandard},
147 {"WSL launcher excluded", "", wsl, "", installed, ShellSourceStandard},
148 } {
149 t.Run(tc.name, func(t *testing.T) {
150 snap := &shellSnapshot{
151 goos: "windows", lookPath: fakeLookPath(map[string]string{"bash": tc.path}),
152 exists: func(string) bool { return true },
153 isWSL: func(path string) bool { return path == wsl },
154 bashCands: []string{installed}, sources: map[string]string{strings.ToLower(installed): ShellSourceStandard},
155 probeFunc: func(path string) bool { return path != tc.failed }, probeCache: map[string]bool{},
156 }
157 if tc.config != "" {
158 snap.bashCands = append([]string{tc.config}, snap.bashCands...)
159 snap.sources[strings.ToLower(tc.config)] = ShellSourceConfig
160 }
161 cap := windowsShellCapabilities(snap)[0]
162 sh := resolveShell("bash", tc.config, nil, snap.goos, snap.lookPath, snap.exists, snap.bashCands, nil, snap.probe, snap.isWSL)
163 if !cap.Available || cap.Path != tc.want || cap.Source != tc.source || sh.Kind != ShellBash || sh.Path != cap.Path {
164 t.Fatalf("capability=%+v resolved=%+v, want %s (%s)", cap, sh, tc.want, tc.source)
165 }
166 })
167 }
168 }
169
170 func TestMissingWindowsGitBashFallsBackToPowerShellWithWarning(t *testing.T) {
171 var warn strings.Builder
172 got := resolveShell("bash", "", &warn, "windows", fakeLookPath(map[string]string{"pwsh": "pwsh.exe"}),
173 func(string) bool { return false }, nil, nil, func(string) bool { return false }, func(string) bool { return false })
174 if got.Kind != ShellPowerShell || got.Path != "pwsh.exe" || !strings.Contains(warn.String(), "not found") {
175 t.Fatalf("missing Bash resolution=%+v warning=%q", got, warn.String())
176 }
177 }
178
179 // TestWindowsBashCandidatesDedupeAndWSLExclusion exercises dedupe (the same
180 // path reachable through two buckets) and the %SystemRoot% WSL launcher
181 // exclusion, both of which need a Windows host to observe.
182 func TestWindowsBashCandidatesDedupeAndWSLExclusion(t *testing.T) {
183 if runtime.GOOS != "windows" {
184 t.Skip("WSL launcher detection reads %SystemRoot% and only fires on Windows")
185 }
186 t.Setenv("SystemRoot", `C:\Windows`)
187 lookPath := fakeLookPath(map[string]string{
188 "git.exe": `C:\Program Files\Git\cmd\git.exe`,
189 })
190 got, _ := windowsBashCandidateSources("auto", "", lookPath, fileExists)
191 seen := map[string]bool{}
192 for _, p := range got {
193 key := strings.ToLower(p)
194 if seen[key] {
195 t.Errorf("duplicate candidate %q", p)
196 }
197 seen[key] = true
198 if isWindowsWSLBash(p) {
199 t.Errorf("WSL launcher %q must be excluded", p)
200 }
201 }
202 }
203
204 // TestShellInventoryCache covers the singleflight cache contract: concurrent
205 // lookups share one build, the entry survives within the TTL, a different
206 // config path rebuilds, and InvalidateShellInventory forces a rebuild while a
207 // stale in-flight build cannot republish itself.
208 func TestShellInventoryCache(t *testing.T) {
209 inv := newShellInventory()
210 var builds sync.Mutex
211 buildCount := 0
212 release := make(chan struct{})
213 inv.build = func(goos, prefer, configPath string) *shellSnapshot {
214 builds.Lock()
215 buildCount++
216 builds.Unlock()
217 if configPath == "slow" {
218 <-release
219 }
220 return &shellSnapshot{key: shellInventoryKey(goos, prefer, configPath), builtAt: time.Now(), probeCache: map[string]bool{}}
221 }
222
223 // Singleflight: concurrent snapshot() calls for one key build once.
224 var wg sync.WaitGroup
225 for range 8 {
226 wg.Go(func() {
227 _ = inv.snapshot("windows", "auto", "")
228 })
229 }
230 // The first build ("") does not block, so wait for the burst to finish.
231 wg.Wait()
232 builds.Lock()
233 if buildCount != 1 {
234 builds.Unlock()
235 t.Fatalf("concurrent lookups built %d times, want 1", buildCount)
236 }
237 builds.Unlock()
238
239 // Within the TTL the cached entry is served without a rebuild.
240 _ = inv.snapshot("windows", "auto", "")
241 builds.Lock()
242 count := buildCount
243 builds.Unlock()
244 if count != 1 {
245 t.Fatalf("TTL hit rebuilt: %d", count)
246 }
247
248 // A different preference is a different cache key even when the configured
249 // path stays the same, because only Bash preferences may consume that path.
250 _ = inv.snapshot("windows", "bash", "")
251 builds.Lock()
252 count = buildCount
253 builds.Unlock()
254 if count != 2 {
255 t.Fatalf("different preference should rebuild: %d", count)
256 }
257
258 // A different config path is also a different cache key.
259 _ = inv.snapshot("windows", "auto", "other")
260 builds.Lock()
261 count = buildCount
262 builds.Unlock()
263 if count != 3 {
264 t.Fatalf("different path should rebuild: %d", count)
265 }
266
267 // Invalidation forces a rebuild and drops a stale in-flight result.
268 var inflight sync.WaitGroup
269 inflight.Go(func() {
270 _ = inv.snapshot("windows", "auto", "slow")
271 })
272 // Wait until the slow build has started, then invalidate under it.
273 deadline := time.Now().Add(2 * time.Second)
274 for time.Now().Before(deadline) {
275 inv.mu.Lock()
276 refreshing := inv.refreshing != nil
277 inv.mu.Unlock()
278 if refreshing {
279 break
280 }
281 time.Sleep(time.Millisecond)
282 }
283 inv.invalidate()
284 close(release)
285 inflight.Wait()
286
287 inv.mu.Lock()
288 current := inv.current
289 inv.mu.Unlock()
290 if current != nil {
291 t.Fatalf("stale in-flight build was cached under key %q after invalidation", current.key)
292 }
293
294 // And the next lookup builds fresh.
295 _ = inv.snapshot("windows", "auto", "")
296 builds.Lock()
297 count = buildCount
298 builds.Unlock()
299 if count != 5 {
300 t.Fatalf("post-invalidation lookup should rebuild: %d builds", count)
301 }
302 }
303
304 // TestSnapshotProbeCachesResults proves repeated resolutions inside one
305 // snapshot do not re-probe the same path.
306 func TestSnapshotProbeCachesResults(t *testing.T) {
307 snap := &shellSnapshot{probeFunc: func(string) bool { return true }, probeCache: map[string]bool{}}
308 if !snap.probe(`C:\Git\bin\bash.exe`) {
309 t.Fatal("probe on a non-Windows host always succeeds")
310 }
311 snap.probeMu.Lock()
312 cached, ok := snap.probeCache[`C:\Git\bin\bash.exe`]
313 snap.probeMu.Unlock()
314 if !ok || !cached {
315 t.Fatalf("probe result not cached: %v", snap.probeCache)
316 }
317 }
318
319 func TestUnixShellCapabilitiesReportsBashZshAndSh(t *testing.T) {
320 snap := &shellSnapshot{
321 lookPath: fakeLookPath(map[string]string{"zsh": "/opt/homebrew/bin/zsh"}),
322 exists: func(path string) bool {
323 return path == "/bin/sh"
324 },
325 }
326 caps := unixShellCapabilities(snap)
327 if len(caps) != 3 {
328 t.Fatalf("capabilities = %+v, want bash, zsh, and sh", caps)
329 }
330 byID := map[string]ShellCapability{}
331 for _, capability := range caps {
332 byID[capability.ID] = capability
333 }
334 if bash := byID[ShellCapabilityBash]; bash.Available || bash.Reason != "not-found" {
335 t.Fatalf("bash = %+v, want unavailable with a reason", bash)
336 }
337 if zsh := byID[ShellCapabilityZsh]; !zsh.Available || zsh.Path != "/opt/homebrew/bin/zsh" || zsh.Source != ShellSourcePath {
338 t.Fatalf("zsh = %+v, want PATH capability", zsh)
339 }
340 if sh := byID[ShellCapabilitySh]; !sh.Available || sh.Path != "/bin/sh" || sh.Source != ShellSourceStandard {
341 t.Fatalf("sh = %+v, want standard-path capability", sh)
342 }
343 }
344
345 func TestDiscoverGitCapabilityIsIndependentFromShells(t *testing.T) {
346 snap := &shellSnapshot{
347 goos: "darwin",
348 lookPath: fakeLookPath(map[string]string{}),
349 exists: func(path string) bool {
350 return path == "/opt/homebrew/bin/git"
351 },
352 }
353 got := discoverGitCapability(snap)
354 if !got.Available || got.ID != HostCapabilityGit || got.Path != "/opt/homebrew/bin/git" || got.Source != ShellSourceStandard {
355 t.Fatalf("Git capability = %+v, want independent Homebrew standard path", got)
356 }
357 }
358
359 func TestDiscoverGitCapabilityRejectsUnusableShim(t *testing.T) {
360 snap := &shellSnapshot{
361 goos: "darwin",
362 lookPath: fakeLookPath(map[string]string{"git": "/usr/bin/git"}),
363 exists: func(path string) bool { return path == "/usr/bin/git" },
364 gitProbe: func(string) bool { return false },
365 }
366 got := discoverGitCapability(snap)
367 if got.Available || got.Reason != "not-usable" {
368 t.Fatalf("Git capability = %+v, want unusable shim rejected", got)
369 }
370 }
371
372 func TestDiscoverGitCapabilityPreflightRejectsAppleShimWithoutRunningIt(t *testing.T) {
373 preflightCalls := 0
374 probeCalls := 0
375 snap := &shellSnapshot{
376 goos: "darwin",
377 lookPath: fakeLookPath(map[string]string{"git": "/usr/bin/git"}),
378 exists: func(path string) bool { return path == "/usr/bin/git" },
379 gitPreflight: func(path string) bool {
380 preflightCalls++
381 return false
382 },
383 gitProbe: func(string) bool {
384 probeCalls++
385 return true
386 },
387 }
388 got := discoverGitCapability(snap)
389 if got.Available || got.Reason != "not-usable" {
390 t.Fatalf("Git capability = %+v, want inactive Apple shim rejected", got)
391 }
392 if preflightCalls != 1 {
393 t.Fatalf("Apple shim preflight calls = %d, want one cached decision", preflightCalls)
394 }
395 if probeCalls != 0 {
396 t.Fatalf("git --version probe ran %d times after preflight rejection", probeCalls)
397 }
398 }
399
400 func TestGitCandidatesFromWindowsBashFindInstallRoot(t *testing.T) {
401 got := gitCandidatesFromWindowsBash(`C:\Program Files\Git\bin\bash.exe`)
402 norm := func(path string) string {
403 return strings.ToLower(strings.ReplaceAll(filepath.Clean(path), `\`, "/"))
404 }
405 want := norm(`C:\Program Files\Git\cmd\git.exe`)
406 found := false
407 for _, candidate := range got {
408 if norm(candidate) == want {
409 found = true
410 break
411 }
412 }
413 if !found {
414 t.Fatalf("candidates = %v, missing %q", got, want)
415 }
416 }
417
418 // TestShellCapabilitiesShape ensures the exported capability report matches
419 // the platform: Git Bash and both native PowerShells on Windows, and bash/zsh/sh on
420 // Unix — with unavailable entries carrying a reason, never an error.
421 func TestShellCapabilitiesShape(t *testing.T) {
422 caps := ShellCapabilities()
423 if len(caps) == 0 {
424 t.Fatal("ShellCapabilities returned no entries")
425 }
426 ids := map[string]bool{}
427 for _, cap := range caps {
428 ids[cap.ID] = true
429 if cap.Available && cap.Path == "" {
430 t.Errorf("capability %q is available without a path", cap.ID)
431 }
432 if !cap.Available && cap.Reason == "" {
433 t.Errorf("unavailable capability %q must carry a reason", cap.ID)
434 }
435 }
436 if runtime.GOOS == "windows" {
437 for _, id := range []string{ShellCapabilityGitBash, ShellCapabilityPowerShell, ShellCapabilityPwsh} {
438 if !ids[id] {
439 t.Errorf("Windows report missing %q: %v", id, caps)
440 }
441 }
442 for _, id := range []string{ShellCapabilityBash} {
443 if ids[id] {
444 t.Errorf("Windows report must not advertise %q: %v", id, caps)
445 }
446 }
447 for _, id := range []string{ShellCapabilityZsh, ShellCapabilitySh} {
448 if ids[id] {
449 t.Errorf("Windows report must not advertise %q: %v", id, caps)
450 }
451 }
452 } else {
453 for _, id := range []string{ShellCapabilityBash, ShellCapabilityZsh, ShellCapabilitySh} {
454 if !ids[id] {
455 t.Errorf("non-Windows report missing %q: %v", id, caps)
456 }
457 }
458 for _, id := range []string{ShellCapabilityGitBash, ShellCapabilityPowerShell, ShellCapabilityPwsh} {
459 if ids[id] {
460 t.Errorf("non-Windows report must not advertise %q: %v", id, caps)
461 }
462 }
463 }
464 }
465
465 lines GO