返回 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 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
423 lines GO