aft/windows_shell.rs
1//! Shared Windows shell selection for foreground and background bash commands.
2//!
3//! Mirrors OpenCode's resolver:
4//! 1. `$SHELL` env var (typically points at git-bash on Windows dev setups).
5//! 2. `pwsh.exe` (PowerShell 7+).
6//! 3. `powershell.exe` (Windows PowerShell 5.1).
7//! 4. Git-for-Windows `bash.exe` discovered next to `git` on PATH (catches
8//! users who installed Git for Windows but never set `$SHELL`).
9//! 5. `cmd.exe` (universal floor — always reachable on every Windows SKU).
10//!
11//! POSIX shells (bash, sh, zsh, ksh, dash) are invoked as `<shell> -c <cmd>`
12//! the same way Unix does. PowerShell variants take their `-Command` shape;
13//! cmd.exe takes `/D /C`.
14//!
15//! Compiled on all platforms so the cross-platform retry-decision unit
16//! tests in `commands::bash::try_spawn_with_fallback` (test-only — see the
17//! Windows foreground bash path in `crate::commands::bash`) can run on
18//! macOS/Linux dev machines. The production Windows background spawn path
19//! at `bash_background::registry::detached_shell_command_for` is the live
20//! caller.
21
22#![cfg_attr(not(windows), allow(dead_code))]
23
24use std::path::{Path, PathBuf};
25use std::process::Command;
26use std::sync::OnceLock;
27
28/// POSIX shells that can be invoked as `<shell> -c <command>`. Matches
29/// OpenCode's `POSIX` set in `packages/opencode/src/shell/shell.ts`.
30const POSIX_NAMES: &[&str] = &["bash", "sh", "zsh", "ksh", "dash"];
31
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub(crate) enum WindowsShell {
34 /// PowerShell 7+ (cross-platform). Supports `&&` pipeline operator.
35 Pwsh,
36 /// Windows PowerShell 5.1 (legacy, still default on most Windows desktops
37 /// but **absent on Windows 11 IoT Enterprise LTSC SKUs** — issue #27).
38 /// Does NOT support `&&` in pipelines (PS 7+ only feature).
39 Powershell,
40 /// `cmd.exe` — the universal fallback. Present on every Windows SKU.
41 /// Supports `&&` and `||` natively. Lacks PowerShell's piping/cmdlets but
42 /// handles bash-style chained shell invocations correctly.
43 Cmd,
44 /// User-supplied POSIX shell — typically Git for Windows' bash.exe,
45 /// resolved either from `$SHELL` or auto-detected next to `git` on PATH.
46 /// Invoked as `<binary> -c <command>` exactly like a Unix shell, so
47 /// agents that emit bash-syntax commands (`cmd /c "foo"`, `find . -name`,
48 /// quoting with backslash-escapes, etc.) work the same way they would
49 /// in a real bash session. The string is the absolute path to the binary.
50 Posix(PathBuf),
51}
52
53impl WindowsShell {
54 /// Binary path to spawn. PowerShell/cmd variants resolve via PATH lookup;
55 /// `Posix` carries an already-absolute path resolved at candidate-build
56 /// time so we don't accidentally pick a different bash.exe later.
57 pub(crate) fn binary(&self) -> std::borrow::Cow<'_, str> {
58 match self {
59 WindowsShell::Pwsh => std::borrow::Cow::Borrowed("pwsh.exe"),
60 WindowsShell::Powershell => std::borrow::Cow::Borrowed("powershell.exe"),
61 WindowsShell::Cmd => std::borrow::Cow::Borrowed("cmd.exe"),
62 WindowsShell::Posix(path) => std::borrow::Cow::Owned(path.display().to_string()),
63 }
64 }
65
66 /// Argument vector to pass alongside the user's command string.
67 /// PowerShell variants take `-Command <string>`; cmd takes `/D /C <string>`
68 /// (`/D` disables AutoRun macros that could otherwise inject env-trust
69 /// behavior into our isolated invocation); POSIX shells take `-c <string>`.
70 pub(crate) fn args<'a>(&'a self, command: &'a str) -> Vec<&'a str> {
71 match self {
72 WindowsShell::Pwsh | WindowsShell::Powershell => vec![
73 "-NoLogo",
74 "-NoProfile",
75 "-NonInteractive",
76 "-ExecutionPolicy",
77 "Bypass",
78 "-Command",
79 command,
80 ],
81 WindowsShell::Cmd => vec!["/D", "/C", command],
82 WindowsShell::Posix(_) => vec!["-c", command],
83 }
84 }
85
86 /// Args for invoking a wrapper file under a PTY-attached shell.
87 /// Returns owned strings so callers can pass temporary wrapper paths.
88 pub(crate) fn pty_wrapper_args(&self, wrapper_path: &Path) -> Vec<String> {
89 match self {
90 WindowsShell::Cmd => vec!["/c".into(), wrapper_path.display().to_string()],
91 WindowsShell::Pwsh | WindowsShell::Powershell => vec![
92 "-NoProfile".into(),
93 "-NonInteractive".into(),
94 "-File".into(),
95 wrapper_path.display().to_string(),
96 ],
97 WindowsShell::Posix(_) => vec![wrapper_path.display().to_string()],
98 }
99 }
100
101 /// Build a `Command` that runs an inline background wrapper script.
102 ///
103 /// Production background bash now writes wrappers to `.bat` / `.ps1` temp
104 /// files and invokes those files directly, so paths containing `!` remain
105 /// literal and cmd.exe does not have to parse a long inline wrapper. This
106 /// helper is retained for tests that exercise the legacy shell-arg shape.
107 #[allow(dead_code)]
108 pub(crate) fn bg_command(&self, wrapper: &str) -> Command {
109 let binary = self.binary();
110 let mut cmd = Command::new(binary.as_ref());
111 // PowerShell variants accept the wrapper string directly via
112 // `-Command`; the shell's `-Command` parser is generally happy
113 // with embedded quotes when the script doesn't contain literal
114 // `"` (we use only single quotes in the PS wrapper for that
115 // reason — see `wrapper_script` for `Pwsh|Powershell`).
116 //
117 // For cmd.exe the wrapper contains `cmd_quote`-quoted paths
118 // which CAN survive cmd's /C parser, but only if we add `/S`
119 // to enable simple-quote-stripping mode. Even with /S the
120 // interaction with Rust's std-lib argument quoting is fragile,
121 // so we rely on `args()` for cmd and live with the constraints.
122 //
123 // `/D` skips AutoRun macros; `/S` enables simple quote-stripping.
124 //
125 // POSIX shells (git-bash etc.) take `-c <wrapper>` and execute
126 // the wrapper as a normal shell script — the wrapper's `trap` and
127 // `printf "$?"` mechanics are POSIX-standard, so no special flags.
128 match self {
129 WindowsShell::Pwsh | WindowsShell::Powershell => {
130 cmd.args(self.args(wrapper));
131 }
132 WindowsShell::Cmd => {
133 cmd.args(["/D", "/S", "/C", wrapper]);
134 }
135 WindowsShell::Posix(_) => {
136 cmd.args(["-c", wrapper]);
137 }
138 }
139 cmd
140 }
141
142 /// Wrap a background command so the child writes its own exit marker.
143 ///
144 /// On Windows the sandbox is never active (`native_sandbox_enforced` is
145 /// `cfg!(unix)`-gated, so an enabled sandbox yields `SpawnPlan::Refused`),
146 /// meaning every Windows bash child runs unconfined with full user rights
147 /// and is authoritative for its own exit code. Windows also has no zombie
148 /// reaping, so once the spawning daemon's child handle closes across a
149 /// detach/rebind the exit code is unrecoverable daemon-side — only a
150 /// child-side write records it. The write goes into the child-writable
151 /// `io/` plane via temp-file + `Move-Item`/`move` rename: an in-place write
152 /// is refused because the daemon retains the pre-opened `io/exit` handle,
153 /// but the rename succeeds (the retained handle is opened with
154 /// `FILE_SHARE_DELETE`) and atomically swaps in a fully-written file, so the
155 /// daemon's fresh open in `read_exit_marker` never observes a partial value.
156 /// (Verified empirically on native Windows.)
157 ///
158 /// PowerShell and cmd do not expose a portable per-segment pipeline status
159 /// array, so Windows wrappers intentionally record only the overall exit
160 /// code. Unix bash/zsh pipeline diagnostics live in the Unix payload wrapper.
161 pub(crate) fn wrapper_script(&self, command: &str, exit_path: &Path) -> String {
162 match self {
163 WindowsShell::Pwsh | WindowsShell::Powershell => {
164 // CRITICAL: no literal double-quotes — inner `"` breaks the
165 // outer `-File` parse on some Windows console hosts. Use only
166 // single-quoted strings and `+` concat.
167 let exit_path = powershell_single_quote(&exit_path.display().to_string());
168 let command = powershell_single_quote(command);
169 format!(
170 concat!(
171 "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ",
172 "$OutputEncoding = [Console]::OutputEncoding; ",
173 "$exitPath = {exit_path}; ",
174 "$tmpPath = $exitPath + '.tmp.' + $PID; ",
175 "$global:LASTEXITCODE = $null; ",
176 "Invoke-Expression {command}; ",
177 "$success = $?; ",
178 "$nativeCode = $global:LASTEXITCODE; ",
179 "if ($null -ne $nativeCode) {{ $code = [int]$nativeCode }} ",
180 "elseif ($success) {{ $code = 0 }} ",
181 "else {{ $code = 1 }}; ",
182 "[System.IO.File]::WriteAllText($tmpPath, [string]$code); ",
183 "Move-Item -LiteralPath $tmpPath -Destination $exitPath -Force; ",
184 "exit $code"
185 ),
186 exit_path = exit_path,
187 command = command
188 )
189 }
190 WindowsShell::Cmd => {
191 // Written to a `.bat` invoked as `cmd /D /C <wrapper.bat>`.
192 // Batch expands `%ERRORLEVEL%` per line, so no `/V:ON` is
193 // needed; literal `!` in paths survives.
194 let tmp_path = format!("{}.tmp", exit_path.display());
195 format!(
196 concat!(
197 "@echo off\r\n",
198 "{command}\r\n",
199 "set CODE=%ERRORLEVEL%\r\n",
200 "echo %CODE% > {tmp}\r\n",
201 "move /Y {tmp} {exit} > nul\r\n",
202 "exit /B %CODE%\r\n"
203 ),
204 command = command,
205 tmp = cmd_quote(&tmp_path),
206 exit = cmd_quote(&exit_path.display().to_string())
207 )
208 }
209 WindowsShell::Posix(shell_path) => {
210 // git-bash speaks POSIX: same temp-file + `mv` rename. Single-
211 // quote the user command so embedded `;`/`&`/`$` stay literal.
212 let exit_str = exit_path.display().to_string();
213 let tmp_path = format!("{}.tmp", exit_str);
214 format!(
215 "{} -c {} ; printf '%s' \"$?\" > {} && mv {} {}",
216 posix_single_quote(&shell_path.display().to_string()),
217 posix_single_quote(command),
218 posix_single_quote(&tmp_path),
219 posix_single_quote(&tmp_path),
220 posix_single_quote(&exit_str),
221 )
222 }
223 }
224 }
225
226 pub(crate) fn wrapper_script_bytes(&self, command: &str, exit_path: &Path) -> Vec<u8> {
227 let script = self.wrapper_script(command, exit_path);
228 match self {
229 WindowsShell::Pwsh | WindowsShell::Powershell => {
230 let mut bytes = vec![0xEF, 0xBB, 0xBF];
231 bytes.extend_from_slice(script.as_bytes());
232 bytes
233 }
234 WindowsShell::Cmd | WindowsShell::Posix(_) => script.into_bytes(),
235 }
236 }
237}
238
239/// All Windows shells that the PATH probe believes are reachable, returned
240/// in priority order. Always non-empty on Windows because cmd.exe is the
241/// floor. Order:
242///
243/// 1. `$SHELL` env var (typically points at git-bash on Windows dev setups).
244/// 2. `pwsh.exe`.
245/// 3. `powershell.exe`.
246/// 4. Git-for-Windows `bash.exe` discovered next to `git` on PATH.
247/// 5. `cmd.exe`.
248///
249/// Used by the foreground bash spawn site to retry with the next candidate
250/// if the first one fails to spawn at runtime. Cached after the first
251/// resolve.
252pub(crate) fn shell_candidates() -> Vec<WindowsShell> {
253 static CACHED: OnceLock<Vec<WindowsShell>> = OnceLock::new();
254 CACHED
255 .get_or_init(|| {
256 shell_candidates_with(
257 |binary| which::which(binary).ok(),
258 || std::env::var_os("SHELL").map(PathBuf::from),
259 )
260 })
261 .clone()
262}
263
264/// Test seam for [`shell_candidates`]. The two closures let unit tests inject
265/// a fake `which`-like resolver and a fake `$SHELL` env value.
266///
267/// `which_for(binary)` should return `Some(absolute_path)` if the binary is
268/// reachable, `None` otherwise — matching the contract of `which::which`.
269pub(crate) fn shell_candidates_with<W, S>(which_for: W, shell_env: S) -> Vec<WindowsShell>
270where
271 W: Fn(&str) -> Option<PathBuf>,
272 S: FnOnce() -> Option<PathBuf>,
273{
274 let mut candidates: Vec<WindowsShell> = Vec::with_capacity(5);
275
276 // 1. $SHELL env var — typically points at git-bash on Windows dev
277 // setups (`/c/Program Files/Git/bin/bash.exe` style or a normal
278 // Windows path). Mirrors OpenCode's preferred() resolution.
279 // Only honored when the named binary is recognized as POSIX
280 // (bash/sh/zsh/ksh/dash) — we don't want SHELL=cmd.exe pinning us
281 // to cmd when the user already gets cmd as the floor candidate.
282 if let Some(shell_path) = shell_env() {
283 if let Some(resolved) = resolve_user_shell(&shell_path, &which_for) {
284 crate::slog_info!(
285 "bash candidate: $SHELL = {} (POSIX, invoked as -c)",
286 resolved.display()
287 );
288 candidates.push(WindowsShell::Posix(resolved));
289 }
290 }
291
292 // 2-3. PowerShell variants.
293 if which_for("pwsh.exe").is_some() {
294 crate::slog_info!(
295 "bash candidate: pwsh.exe (PowerShell 7+; supports && pipeline operator)"
296 );
297 candidates.push(WindowsShell::Pwsh);
298 }
299 if which_for("powershell.exe").is_some() {
300 crate::slog_info!("bash candidate: powershell.exe (Windows PowerShell 5.1; && in pipelines unsupported, will surface as parse error)");
301 candidates.push(WindowsShell::Powershell);
302 }
303
304 // 4. Git for Windows auto-detect — find bash.exe next to git on PATH.
305 // Catches the common case of "user installed Git for Windows but
306 // didn't set $SHELL". Skipped when $SHELL already produced a POSIX
307 // candidate (no point adding the same git-bash twice).
308 let already_posix = candidates
309 .iter()
310 .any(|c| matches!(c, WindowsShell::Posix(_)));
311 if !already_posix {
312 if let Some(git_bash) = locate_git_bash(&which_for) {
313 crate::slog_info!(
314 "bash candidate: git-bash auto-detected at {} (POSIX, invoked as -c)",
315 git_bash.display()
316 );
317 candidates.push(WindowsShell::Posix(git_bash));
318 }
319 }
320
321 // 5. cmd.exe is always added as the floor, regardless of PATH probe
322 // result. It lives in a Windows search-path location that PATH
323 // inheritance issues, ASR rules, and sandboxing generally cannot
324 // remove. Without this floor, foreground bash retry would have
325 // nowhere to fall back to when other shells fail to spawn at runtime.
326 candidates.push(WindowsShell::Cmd);
327
328 let only_cmd = candidates.len() == 1;
329 if only_cmd {
330 crate::slog_warn!(
331 "No bash, PowerShell, or git-bash is reachable from this \
332 aft process — using cmd.exe only. This can occur even when \
333 PowerShell is installed if PATH inheritance is restricted, \
334 antivirus / AppLocker / Defender ASR rules block PowerShell as a \
335 child process, or you're on a stripped Windows SKU. Bash-style \
336 commands using && and || still work; PowerShell-only cmdlets and \
337 POSIX-only commands will not. Details: \
338 https://github.com/cortexkit/aft/issues/27"
339 );
340 }
341 candidates
342}
343
344/// Resolve a `$SHELL` value into an absolute path to a POSIX shell binary,
345/// or `None` if the value is unusable on Windows. Handles three input
346/// shapes that show up in the wild:
347///
348/// - Full Windows path: `C:\Program Files\Git\bin\bash.exe`
349/// - MSYS/git-bash style: `/c/Program Files/Git/bin/bash.exe` or `/usr/bin/bash`
350/// - Bare name: `bash` or `bash.exe` (resolve via `which`)
351///
352/// Returns `None` if the resolved binary's filename isn't in `POSIX_NAMES`,
353/// so that someone with `SHELL=cmd.exe` doesn't accidentally pin us to a
354/// `Posix(cmd.exe)` invocation that breaks the `-c` contract.
355fn resolve_user_shell<W>(raw: &Path, which_for: &W) -> Option<PathBuf>
356where
357 W: Fn(&str) -> Option<PathBuf>,
358{
359 // Convert MSYS-style /c/foo/bar paths to C:\foo\bar so std::fs::metadata
360 // and Command::new can find them. Pure Windows paths and POSIX paths on
361 // a MSYS root pass through with /-to-\ normalization.
362 let resolved = normalize_shell_path(raw);
363
364 // If the (possibly-normalized) path is absolute and exists on disk,
365 // use it as-is. Otherwise treat it as a bare name and try PATH lookup.
366 let candidate = if resolved.is_absolute() && resolved.exists() {
367 resolved
368 } else {
369 let name = resolved.file_name()?.to_str()?.to_string();
370 which_for(&name)?
371 };
372
373 if !is_posix_shell_name(&candidate) {
374 crate::slog_info!(
375 "$SHELL points at {} which isn't a recognized POSIX shell; \
376 falling back to PowerShell/cmd resolution.",
377 candidate.display()
378 );
379 return None;
380 }
381 Some(candidate)
382}
383
384/// Look for git-bash next to `git` on PATH. Mirrors OpenCode's `gitbash()`:
385/// resolves `git`, then checks `<git_dir>/../../bin/bash.exe`. Returns
386/// `None` if git isn't on PATH, the expected bash.exe doesn't exist, or
387/// the file is empty.
388fn locate_git_bash<W>(which_for: &W) -> Option<PathBuf>
389where
390 W: Fn(&str) -> Option<PathBuf>,
391{
392 let git = which_for("git.exe").or_else(|| which_for("git"))?;
393 // git.exe typically lives at <install>/cmd/git.exe; bash.exe lives at
394 // <install>/bin/bash.exe. The two `parent()` calls walk up from
395 // `cmd/git.exe` to `<install>`, then we descend into `bin/bash.exe`.
396 let candidate = git.parent()?.parent()?.join("bin").join("bash.exe");
397 let metadata = std::fs::metadata(&candidate).ok()?;
398 if metadata.len() == 0 {
399 return None;
400 }
401 Some(candidate)
402}
403
404/// Normalize an MSYS / git-bash POSIX path to a Windows path, leaving
405/// already-Windows paths and bare names alone. This mirrors the relevant
406/// subset of OpenCode's `windowsPath()` for `$SHELL` values.
407fn normalize_shell_path(raw: &Path) -> PathBuf {
408 let s = raw.to_string_lossy();
409
410 // MSYS drive-letter form: /c/Foo/Bar -> C:\Foo\Bar
411 if let Some(rest) = s.strip_prefix('/') {
412 if let Some((drive, after)) = rest.split_once('/') {
413 if drive.len() == 1
414 && drive
415 .chars()
416 .next()
417 .is_some_and(|c| c.is_ascii_alphabetic())
418 {
419 let drive_upper = drive.to_uppercase();
420 let win = format!("{}:\\{}", drive_upper, after.replace('/', "\\"));
421 return PathBuf::from(win);
422 }
423 }
424 }
425
426 PathBuf::from(s.as_ref())
427}
428
429/// True when the file name (without extension) is in `POSIX_NAMES`.
430fn is_posix_shell_name(path: &Path) -> bool {
431 let stem = path
432 .file_stem()
433 .and_then(|s| s.to_str())
434 .unwrap_or("")
435 .to_lowercase();
436 POSIX_NAMES.iter().any(|name| *name == stem)
437}
438
439fn powershell_single_quote(value: &str) -> String {
440 format!("'{}'", value.replace('\'', "''"))
441}
442
443/// Single-quote a value for POSIX `sh -c`, escaping inner single quotes via
444/// the standard `'\''` close-and-reopen idiom. Used by the bg-bash wrapper
445/// for [`WindowsShell::Posix`] (git-bash) and matches the Unix wrapper's
446/// quoting contract.
447#[cfg_attr(not(windows), allow(dead_code))]
448fn posix_single_quote(value: &str) -> String {
449 format!("'{}'", value.replace('\'', "'\\''"))
450}
451
452// Used by `wrapper_script` for `WindowsShell::Cmd`; that wrapper is
453// only invoked from `bash_background::registry::detached_shell_command_for`
454// which is `#[cfg(windows)]`. The function compiles on all platforms so
455// `wrapper_script` stays cross-platform-testable.
456#[cfg_attr(not(windows), allow(dead_code))]
457fn cmd_quote(value: &str) -> String {
458 format!("\"{}\"", value.replace('"', "\"\""))
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 /// Helper: build a `which`-like closure that returns Some for the
466 /// listed binaries (mapping each to a synthetic absolute path) and
467 /// None for everything else. The synthetic path layout matches a
468 /// realistic Git for Windows install when `git.exe` is present,
469 /// so [`locate_git_bash`] can synthesize a sibling bash.exe path —
470 /// but the returned path won't exist on disk, so `locate_git_bash`
471 /// will bail at the metadata check, which is what the no-Posix-via-
472 /// auto-detect tests actually want.
473 fn fake_which(binaries: Vec<&'static str>) -> impl Fn(&str) -> Option<PathBuf> {
474 move |query| {
475 if binaries.contains(&query) {
476 match query {
477 "git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
478 _ => Some(PathBuf::from(format!(r"C:\fake\{}", query))),
479 }
480 } else {
481 None
482 }
483 }
484 }
485
486 #[test]
487 fn pty_wrapper_args_are_owned_and_shell_specific() {
488 let wrapper = PathBuf::from(r"C:\tmp\task.bat");
489 assert_eq!(
490 WindowsShell::Cmd.pty_wrapper_args(&wrapper),
491 vec!["/c".to_string(), wrapper.display().to_string()]
492 );
493 assert_eq!(
494 WindowsShell::Pwsh.pty_wrapper_args(&wrapper),
495 vec![
496 "-NoProfile".to_string(),
497 "-NonInteractive".to_string(),
498 "-File".to_string(),
499 wrapper.display().to_string(),
500 ]
501 );
502 assert_eq!(
503 WindowsShell::Posix(PathBuf::from(r"C:\Git\bin\bash.exe")).pty_wrapper_args(&wrapper),
504 vec![wrapper.display().to_string()]
505 );
506 }
507
508 // ---------------------------------------------------------------
509 // Fix for user report: $SHELL must be respected on Windows so
510 // git-bash (and other POSIX shells) can run agent-emitted bash
511 // syntax instead of getting routed to PowerShell where escaping
512 // breaks. Mirrors OpenCode's behavior.
513 // ---------------------------------------------------------------
514
515 #[test]
516 fn user_shell_pointing_at_bash_wins_over_powershell() {
517 // SHELL=C:\Program Files\Git\bin\bash.exe
518 // pwsh.exe also reachable.
519 // Expect: Posix(bash.exe) is the first candidate, pwsh second.
520 let tmp = tempfile::tempdir().expect("tempdir");
521 let bash = tmp.path().join("bash.exe");
522 std::fs::write(&bash, b"shebang").unwrap();
523
524 let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(bash.clone()));
525
526 assert!(matches!(candidates[0], WindowsShell::Posix(_)));
527 if let WindowsShell::Posix(p) = &candidates[0] {
528 assert_eq!(p, &bash);
529 }
530 assert_eq!(candidates[1], WindowsShell::Pwsh);
531 }
532
533 #[test]
534 fn user_shell_pointing_at_non_posix_binary_is_ignored() {
535 // SHELL=C:\Windows\System32\cmd.exe — not in POSIX_NAMES, so
536 // we should fall back to PowerShell/cmd resolution.
537 let tmp = tempfile::tempdir().expect("tempdir");
538 let cmd = tmp.path().join("cmd.exe");
539 std::fs::write(&cmd, b"").unwrap();
540
541 let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(cmd));
542
543 // No Posix candidate; pwsh wins.
544 assert!(!candidates
545 .iter()
546 .any(|c| matches!(c, WindowsShell::Posix(_))));
547 assert_eq!(candidates[0], WindowsShell::Pwsh);
548 }
549
550 #[test]
551 fn user_shell_msys_drive_letter_path_is_normalized() {
552 // SHELL=/c/Program Files/Git/bin/bash.exe — git-bash style.
553 // Without normalization this won't exist at all, so the
554 // resolver should at least *try* the normalized form before
555 // falling through.
556 //
557 // We can't easily fake an existing file at C:\... in a unit
558 // test, so we directly assert the normalization output here.
559 let raw = PathBuf::from("/c/Program Files/Git/bin/bash.exe");
560 let normalized = normalize_shell_path(&raw);
561 assert_eq!(
562 normalized,
563 PathBuf::from(r"C:\Program Files\Git\bin\bash.exe")
564 );
565 }
566
567 #[test]
568 fn user_shell_already_windows_path_passes_through() {
569 let raw = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
570 let normalized = normalize_shell_path(&raw);
571 assert_eq!(normalized, raw);
572 }
573
574 /// Note: this test runs on every platform but uses platform-native
575 /// path separators because `Path::file_stem()` only recognizes the
576 /// host OS's separator. On macOS/Linux that means a forward-slash
577 /// fake path (`/fake/bash`); on Windows the equivalent backslash
578 /// path. The production code only runs on Windows where backslash
579 /// works correctly, so the test's job is to verify the resolution
580 /// flow, not the path syntax.
581 #[test]
582 fn user_shell_bare_name_resolves_via_which() {
583 // SHELL=bash → not absolute → which("bash") returns the fake
584 // resolver's path → recognized as POSIX.
585 #[cfg(unix)]
586 let expected = PathBuf::from("/fake/bash");
587 #[cfg(windows)]
588 let expected = PathBuf::from(r"C:\fake\bash");
589
590 // Pre-translate the fake_which return so it uses the host's
591 // separator. We can't share fake_which here because that helper
592 // is hard-coded to Windows-style paths.
593 let expected_clone = expected.clone();
594 let which_for = move |query: &str| -> Option<PathBuf> {
595 if query == "bash" {
596 Some(expected_clone.clone())
597 } else {
598 None
599 }
600 };
601
602 let candidates = shell_candidates_with(which_for, || Some(PathBuf::from("bash")));
603 assert!(
604 matches!(&candidates[0], WindowsShell::Posix(p) if p == &expected),
605 "expected Posix({}) as first candidate, got {:?}",
606 expected.display(),
607 candidates
608 );
609 }
610
611 #[test]
612 fn no_user_shell_and_no_git_falls_back_to_pwsh_powershell_cmd() {
613 let candidates =
614 shell_candidates_with(fake_which(vec!["pwsh.exe", "powershell.exe"]), || None);
615 assert_eq!(candidates.len(), 3);
616 assert_eq!(candidates[0], WindowsShell::Pwsh);
617 assert_eq!(candidates[1], WindowsShell::Powershell);
618 assert_eq!(candidates[2], WindowsShell::Cmd);
619 }
620
621 #[test]
622 fn cmd_is_always_the_floor() {
623 // Nothing reachable, no $SHELL — only cmd.exe should be in the list.
624 let candidates = shell_candidates_with(|_| None, || None);
625 assert_eq!(candidates, vec![WindowsShell::Cmd]);
626 }
627
628 // ---------------------------------------------------------------
629 // git-bash auto-detect: when $SHELL is unset but the user installed
630 // Git for Windows, we should still pick up the bundled bash.exe.
631 // ---------------------------------------------------------------
632
633 #[test]
634 fn git_bash_auto_detect_when_shell_unset() {
635 let tmp = tempfile::tempdir().expect("tempdir");
636 // Mirror the Git for Windows layout: <root>/cmd/git.exe and
637 // <root>/bin/bash.exe.
638 std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
639 std::fs::create_dir_all(tmp.path().join("bin")).unwrap();
640 let git = tmp.path().join("cmd").join("git.exe");
641 std::fs::write(&git, b"git").unwrap();
642 let bash = tmp.path().join("bin").join("bash.exe");
643 std::fs::write(&bash, b"shebang").unwrap();
644
645 let which = |query: &str| -> Option<PathBuf> {
646 match query {
647 "git.exe" | "git" => Some(git.clone()),
648 _ => None,
649 }
650 };
651 let candidates = shell_candidates_with(which, || None);
652
653 // First candidate is the auto-detected git-bash.
654 assert!(matches!(&candidates[0], WindowsShell::Posix(p) if p == &bash));
655 // cmd.exe is still the floor.
656 assert_eq!(*candidates.last().unwrap(), WindowsShell::Cmd);
657 }
658
659 #[test]
660 fn git_bash_skipped_when_user_shell_already_posix() {
661 // $SHELL points at git-bash → no need to auto-detect a second
662 // POSIX candidate. The candidate list should not contain two
663 // Posix entries.
664 let tmp = tempfile::tempdir().expect("tempdir");
665 let bash = tmp.path().join("bash.exe");
666 std::fs::write(&bash, b"shebang").unwrap();
667
668 let candidates = shell_candidates_with(
669 // git is reachable, but git-bash should NOT be added because
670 // we already have a Posix from $SHELL.
671 |query: &str| match query {
672 "git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
673 _ => None,
674 },
675 || Some(bash.clone()),
676 );
677
678 let posix_count = candidates
679 .iter()
680 .filter(|c| matches!(c, WindowsShell::Posix(_)))
681 .count();
682 assert_eq!(
683 posix_count, 1,
684 "exactly one Posix candidate when $SHELL is already set: got {:?}",
685 candidates
686 );
687 }
688
689 // ---------------------------------------------------------------
690 // Spawn-shape tests: Posix(bash) must be invoked as `bash -c <cmd>`
691 // exactly the way Unix bash works.
692 // ---------------------------------------------------------------
693
694 #[test]
695 fn posix_shell_uses_dash_c_invocation() {
696 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
697 let shell = WindowsShell::Posix(bash);
698 let args = shell.args("ls -la /tmp");
699 assert_eq!(args, vec!["-c", "ls -la /tmp"]);
700 }
701
702 #[test]
703 fn posix_shell_binary_returns_full_path() {
704 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
705 let shell = WindowsShell::Posix(bash.clone());
706 assert_eq!(shell.binary().as_ref(), &bash.display().to_string());
707 }
708
709 #[test]
710 fn pwsh_args_unchanged() {
711 // Regression guard: refactor must not have altered PowerShell
712 // arg shape.
713 let shell = WindowsShell::Pwsh;
714 let args = shell.args("Get-ChildItem");
715 assert_eq!(
716 args,
717 vec![
718 "-NoLogo",
719 "-NoProfile",
720 "-NonInteractive",
721 "-ExecutionPolicy",
722 "Bypass",
723 "-Command",
724 "Get-ChildItem"
725 ]
726 );
727 }
728
729 #[test]
730 fn cmd_args_unchanged() {
731 let shell = WindowsShell::Cmd;
732 let args = shell.args("dir");
733 assert_eq!(args, vec!["/D", "/C", "dir"]);
734 }
735
736 // ---------------------------------------------------------------
737 // POSIX wrapper script: bg-bash exit-marker contract for git-bash.
738 // ---------------------------------------------------------------
739
740 #[test]
741 fn posix_wrapper_writes_exit_marker_atomically() {
742 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
743 let shell = WindowsShell::Posix(bash);
744 let script = shell.wrapper_script("echo hi", Path::new(r"C:\Temp\bash.exit"));
745 // The child runs the resolved shell directly (bash semantics) and then
746 // records its own exit code into the io/ marker via temp-file + rename,
747 // so detached tasks whose spawning daemon is gone still report exit.
748 assert!(
749 script.contains(r"'C:\Program Files\Git\bin\bash.exe' -c 'echo hi'"),
750 "wrapper must invoke the resolved shell directly: {script}",
751 );
752 assert!(
753 script.contains(r#"printf '%s' "$?" >"#),
754 "wrapper must capture $? for the exit marker: {script}"
755 );
756 assert!(
757 script.contains("bash.exit.tmp") && script.contains("mv "),
758 "wrapper must write the marker atomically via temp-file + rename: {script}"
759 );
760 }
761
762 #[test]
763 fn posix_wrapper_escapes_embedded_single_quotes() {
764 // User command contains a single quote — wrapper must use the
765 // standard `'\''` close-and-reopen idiom.
766 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
767 let shell = WindowsShell::Posix(bash);
768 let script = shell.wrapper_script("echo 'hi'", Path::new(r"C:\Temp\bash.exit"));
769 assert!(
770 script.contains(r"'echo '\''hi'\'''"),
771 "embedded single quote must be escaped: got {script}"
772 );
773 }
774
775 // ---------------------------------------------------------------
776 // is_posix_shell_name: case-insensitive, .exe-tolerant lookup.
777 // ---------------------------------------------------------------
778
779 #[test]
780 fn is_posix_shell_name_recognizes_known_shells() {
781 for name in ["bash", "BASH", "bash.exe", "Bash.Exe", "sh", "zsh.exe"] {
782 assert!(
783 is_posix_shell_name(Path::new(name)),
784 "{name} should be POSIX"
785 );
786 }
787 }
788
789 #[test]
790 fn is_posix_shell_name_rejects_non_posix() {
791 for name in ["cmd.exe", "powershell.exe", "pwsh.exe", "fish", "nu.exe"] {
792 assert!(
793 !is_posix_shell_name(Path::new(name)),
794 "{name} must NOT be POSIX"
795 );
796 }
797 }
798}