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 pub(crate) fn wrapper_script(&self, command: &str, exit_path: &Path) -> String {
158 match self {
159 WindowsShell::Pwsh | WindowsShell::Powershell => {
160 // CRITICAL: no literal double-quotes — inner `"` breaks the
161 // outer `-File` parse on some Windows console hosts. Use only
162 // single-quoted strings and `+` concat.
163 let exit_path = powershell_single_quote(&exit_path.display().to_string());
164 let command = powershell_single_quote(command);
165 format!(
166 concat!(
167 "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ",
168 "$OutputEncoding = [Console]::OutputEncoding; ",
169 "$exitPath = {exit_path}; ",
170 "$tmpPath = $exitPath + '.tmp.' + $PID; ",
171 "$global:LASTEXITCODE = $null; ",
172 "Invoke-Expression {command}; ",
173 "$success = $?; ",
174 "$nativeCode = $global:LASTEXITCODE; ",
175 "if ($null -ne $nativeCode) {{ $code = [int]$nativeCode }} ",
176 "elseif ($success) {{ $code = 0 }} ",
177 "else {{ $code = 1 }}; ",
178 "[System.IO.File]::WriteAllText($tmpPath, [string]$code); ",
179 "Move-Item -LiteralPath $tmpPath -Destination $exitPath -Force; ",
180 "exit $code"
181 ),
182 exit_path = exit_path,
183 command = command
184 )
185 }
186 WindowsShell::Cmd => {
187 // Written to a `.bat` invoked as `cmd /D /C <wrapper.bat>`.
188 // Batch expands `%ERRORLEVEL%` per line, so no `/V:ON` is
189 // needed; literal `!` in paths survives.
190 let tmp_path = format!("{}.tmp", exit_path.display());
191 format!(
192 concat!(
193 "@echo off\r\n",
194 "{command}\r\n",
195 "set CODE=%ERRORLEVEL%\r\n",
196 "echo %CODE% > {tmp}\r\n",
197 "move /Y {tmp} {exit} > nul\r\n",
198 "exit /B %CODE%\r\n"
199 ),
200 command = command,
201 tmp = cmd_quote(&tmp_path),
202 exit = cmd_quote(&exit_path.display().to_string())
203 )
204 }
205 WindowsShell::Posix(shell_path) => {
206 // git-bash speaks POSIX: same temp-file + `mv` rename. Single-
207 // quote the user command so embedded `;`/`&`/`$` stay literal.
208 let exit_str = exit_path.display().to_string();
209 let tmp_path = format!("{}.tmp", exit_str);
210 format!(
211 "{} -c {} ; printf '%s' \"$?\" > {} && mv {} {}",
212 posix_single_quote(&shell_path.display().to_string()),
213 posix_single_quote(command),
214 posix_single_quote(&tmp_path),
215 posix_single_quote(&tmp_path),
216 posix_single_quote(&exit_str),
217 )
218 }
219 }
220 }
221
222 pub(crate) fn wrapper_script_bytes(&self, command: &str, exit_path: &Path) -> Vec<u8> {
223 let script = self.wrapper_script(command, exit_path);
224 match self {
225 WindowsShell::Pwsh | WindowsShell::Powershell => {
226 let mut bytes = vec![0xEF, 0xBB, 0xBF];
227 bytes.extend_from_slice(script.as_bytes());
228 bytes
229 }
230 WindowsShell::Cmd | WindowsShell::Posix(_) => script.into_bytes(),
231 }
232 }
233}
234
235/// All Windows shells that the PATH probe believes are reachable, returned
236/// in priority order. Always non-empty on Windows because cmd.exe is the
237/// floor. Order:
238///
239/// 1. `$SHELL` env var (typically points at git-bash on Windows dev setups).
240/// 2. `pwsh.exe`.
241/// 3. `powershell.exe`.
242/// 4. Git-for-Windows `bash.exe` discovered next to `git` on PATH.
243/// 5. `cmd.exe`.
244///
245/// Used by the foreground bash spawn site to retry with the next candidate
246/// if the first one fails to spawn at runtime. Cached after the first
247/// resolve.
248pub(crate) fn shell_candidates() -> Vec<WindowsShell> {
249 static CACHED: OnceLock<Vec<WindowsShell>> = OnceLock::new();
250 CACHED
251 .get_or_init(|| {
252 shell_candidates_with(
253 |binary| which::which(binary).ok(),
254 || std::env::var_os("SHELL").map(PathBuf::from),
255 )
256 })
257 .clone()
258}
259
260/// Test seam for [`shell_candidates`]. The two closures let unit tests inject
261/// a fake `which`-like resolver and a fake `$SHELL` env value.
262///
263/// `which_for(binary)` should return `Some(absolute_path)` if the binary is
264/// reachable, `None` otherwise — matching the contract of `which::which`.
265pub(crate) fn shell_candidates_with<W, S>(which_for: W, shell_env: S) -> Vec<WindowsShell>
266where
267 W: Fn(&str) -> Option<PathBuf>,
268 S: FnOnce() -> Option<PathBuf>,
269{
270 let mut candidates: Vec<WindowsShell> = Vec::with_capacity(5);
271
272 // 1. $SHELL env var — typically points at git-bash on Windows dev
273 // setups (`/c/Program Files/Git/bin/bash.exe` style or a normal
274 // Windows path). Mirrors OpenCode's preferred() resolution.
275 // Only honored when the named binary is recognized as POSIX
276 // (bash/sh/zsh/ksh/dash) — we don't want SHELL=cmd.exe pinning us
277 // to cmd when the user already gets cmd as the floor candidate.
278 if let Some(shell_path) = shell_env() {
279 if let Some(resolved) = resolve_user_shell(&shell_path, &which_for) {
280 crate::slog_info!(
281 "bash candidate: $SHELL = {} (POSIX, invoked as -c)",
282 resolved.display()
283 );
284 candidates.push(WindowsShell::Posix(resolved));
285 }
286 }
287
288 // 2-3. PowerShell variants.
289 if which_for("pwsh.exe").is_some() {
290 crate::slog_info!(
291 "bash candidate: pwsh.exe (PowerShell 7+; supports && pipeline operator)"
292 );
293 candidates.push(WindowsShell::Pwsh);
294 }
295 if which_for("powershell.exe").is_some() {
296 crate::slog_info!("bash candidate: powershell.exe (Windows PowerShell 5.1; && in pipelines unsupported, will surface as parse error)");
297 candidates.push(WindowsShell::Powershell);
298 }
299
300 // 4. Git for Windows auto-detect — find bash.exe next to git on PATH.
301 // Catches the common case of "user installed Git for Windows but
302 // didn't set $SHELL". Skipped when $SHELL already produced a POSIX
303 // candidate (no point adding the same git-bash twice).
304 let already_posix = candidates
305 .iter()
306 .any(|c| matches!(c, WindowsShell::Posix(_)));
307 if !already_posix {
308 if let Some(git_bash) = locate_git_bash(&which_for) {
309 crate::slog_info!(
310 "bash candidate: git-bash auto-detected at {} (POSIX, invoked as -c)",
311 git_bash.display()
312 );
313 candidates.push(WindowsShell::Posix(git_bash));
314 }
315 }
316
317 // 5. cmd.exe is always added as the floor, regardless of PATH probe
318 // result. It lives in a Windows search-path location that PATH
319 // inheritance issues, ASR rules, and sandboxing generally cannot
320 // remove. Without this floor, foreground bash retry would have
321 // nowhere to fall back to when other shells fail to spawn at runtime.
322 candidates.push(WindowsShell::Cmd);
323
324 let only_cmd = candidates.len() == 1;
325 if only_cmd {
326 crate::slog_warn!(
327 "No bash, PowerShell, or git-bash is reachable from this \
328 aft process — using cmd.exe only. This can occur even when \
329 PowerShell is installed if PATH inheritance is restricted, \
330 antivirus / AppLocker / Defender ASR rules block PowerShell as a \
331 child process, or you're on a stripped Windows SKU. Bash-style \
332 commands using && and || still work; PowerShell-only cmdlets and \
333 POSIX-only commands will not. Details: \
334 https://github.com/cortexkit/aft/issues/27"
335 );
336 }
337 candidates
338}
339
340/// Resolve a `$SHELL` value into an absolute path to a POSIX shell binary,
341/// or `None` if the value is unusable on Windows. Handles three input
342/// shapes that show up in the wild:
343///
344/// - Full Windows path: `C:\Program Files\Git\bin\bash.exe`
345/// - MSYS/git-bash style: `/c/Program Files/Git/bin/bash.exe` or `/usr/bin/bash`
346/// - Bare name: `bash` or `bash.exe` (resolve via `which`)
347///
348/// Returns `None` if the resolved binary's filename isn't in `POSIX_NAMES`,
349/// so that someone with `SHELL=cmd.exe` doesn't accidentally pin us to a
350/// `Posix(cmd.exe)` invocation that breaks the `-c` contract.
351fn resolve_user_shell<W>(raw: &Path, which_for: &W) -> Option<PathBuf>
352where
353 W: Fn(&str) -> Option<PathBuf>,
354{
355 // Convert MSYS-style /c/foo/bar paths to C:\foo\bar so std::fs::metadata
356 // and Command::new can find them. Pure Windows paths and POSIX paths on
357 // a MSYS root pass through with /-to-\ normalization.
358 let resolved = normalize_shell_path(raw);
359
360 // If the (possibly-normalized) path is absolute and exists on disk,
361 // use it as-is. Otherwise treat it as a bare name and try PATH lookup.
362 let candidate = if resolved.is_absolute() && resolved.exists() {
363 resolved
364 } else {
365 let name = resolved.file_name()?.to_str()?.to_string();
366 which_for(&name)?
367 };
368
369 if !is_posix_shell_name(&candidate) {
370 crate::slog_info!(
371 "$SHELL points at {} which isn't a recognized POSIX shell; \
372 falling back to PowerShell/cmd resolution.",
373 candidate.display()
374 );
375 return None;
376 }
377 Some(candidate)
378}
379
380/// Look for git-bash next to `git` on PATH. Mirrors OpenCode's `gitbash()`:
381/// resolves `git`, then checks `<git_dir>/../../bin/bash.exe`. Returns
382/// `None` if git isn't on PATH, the expected bash.exe doesn't exist, or
383/// the file is empty.
384fn locate_git_bash<W>(which_for: &W) -> Option<PathBuf>
385where
386 W: Fn(&str) -> Option<PathBuf>,
387{
388 let git = which_for("git.exe").or_else(|| which_for("git"))?;
389 // git.exe typically lives at <install>/cmd/git.exe; bash.exe lives at
390 // <install>/bin/bash.exe. The two `parent()` calls walk up from
391 // `cmd/git.exe` to `<install>`, then we descend into `bin/bash.exe`.
392 let candidate = git.parent()?.parent()?.join("bin").join("bash.exe");
393 let metadata = std::fs::metadata(&candidate).ok()?;
394 if metadata.len() == 0 {
395 return None;
396 }
397 Some(candidate)
398}
399
400/// Normalize an MSYS / git-bash POSIX path to a Windows path, leaving
401/// already-Windows paths and bare names alone. This mirrors the relevant
402/// subset of OpenCode's `windowsPath()` for `$SHELL` values.
403fn normalize_shell_path(raw: &Path) -> PathBuf {
404 let s = raw.to_string_lossy();
405
406 // MSYS drive-letter form: /c/Foo/Bar -> C:\Foo\Bar
407 if let Some(rest) = s.strip_prefix('/') {
408 if let Some((drive, after)) = rest.split_once('/') {
409 if drive.len() == 1
410 && drive
411 .chars()
412 .next()
413 .is_some_and(|c| c.is_ascii_alphabetic())
414 {
415 let drive_upper = drive.to_uppercase();
416 let win = format!("{}:\\{}", drive_upper, after.replace('/', "\\"));
417 return PathBuf::from(win);
418 }
419 }
420 }
421
422 PathBuf::from(s.as_ref())
423}
424
425/// True when the file name (without extension) is in `POSIX_NAMES`.
426fn is_posix_shell_name(path: &Path) -> bool {
427 let stem = path
428 .file_stem()
429 .and_then(|s| s.to_str())
430 .unwrap_or("")
431 .to_lowercase();
432 POSIX_NAMES.iter().any(|name| *name == stem)
433}
434
435fn powershell_single_quote(value: &str) -> String {
436 format!("'{}'", value.replace('\'', "''"))
437}
438
439/// Single-quote a value for POSIX `sh -c`, escaping inner single quotes via
440/// the standard `'\''` close-and-reopen idiom. Used by the bg-bash wrapper
441/// for [`WindowsShell::Posix`] (git-bash) and matches the Unix wrapper's
442/// quoting contract.
443#[cfg_attr(not(windows), allow(dead_code))]
444fn posix_single_quote(value: &str) -> String {
445 format!("'{}'", value.replace('\'', "'\\''"))
446}
447
448// Used by `wrapper_script` for `WindowsShell::Cmd`; that wrapper is
449// only invoked from `bash_background::registry::detached_shell_command_for`
450// which is `#[cfg(windows)]`. The function compiles on all platforms so
451// `wrapper_script` stays cross-platform-testable.
452#[cfg_attr(not(windows), allow(dead_code))]
453fn cmd_quote(value: &str) -> String {
454 format!("\"{}\"", value.replace('"', "\"\""))
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 /// Helper: build a `which`-like closure that returns Some for the
462 /// listed binaries (mapping each to a synthetic absolute path) and
463 /// None for everything else. The synthetic path layout matches a
464 /// realistic Git for Windows install when `git.exe` is present,
465 /// so [`locate_git_bash`] can synthesize a sibling bash.exe path —
466 /// but the returned path won't exist on disk, so `locate_git_bash`
467 /// will bail at the metadata check, which is what the no-Posix-via-
468 /// auto-detect tests actually want.
469 fn fake_which(binaries: Vec<&'static str>) -> impl Fn(&str) -> Option<PathBuf> {
470 move |query| {
471 if binaries.contains(&query) {
472 match query {
473 "git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
474 _ => Some(PathBuf::from(format!(r"C:\fake\{}", query))),
475 }
476 } else {
477 None
478 }
479 }
480 }
481
482 #[test]
483 fn pty_wrapper_args_are_owned_and_shell_specific() {
484 let wrapper = PathBuf::from(r"C:\tmp\task.bat");
485 assert_eq!(
486 WindowsShell::Cmd.pty_wrapper_args(&wrapper),
487 vec!["/c".to_string(), wrapper.display().to_string()]
488 );
489 assert_eq!(
490 WindowsShell::Pwsh.pty_wrapper_args(&wrapper),
491 vec![
492 "-NoProfile".to_string(),
493 "-NonInteractive".to_string(),
494 "-File".to_string(),
495 wrapper.display().to_string(),
496 ]
497 );
498 assert_eq!(
499 WindowsShell::Posix(PathBuf::from(r"C:\Git\bin\bash.exe")).pty_wrapper_args(&wrapper),
500 vec![wrapper.display().to_string()]
501 );
502 }
503
504 // ---------------------------------------------------------------
505 // Fix for user report: $SHELL must be respected on Windows so
506 // git-bash (and other POSIX shells) can run agent-emitted bash
507 // syntax instead of getting routed to PowerShell where escaping
508 // breaks. Mirrors OpenCode's behavior.
509 // ---------------------------------------------------------------
510
511 #[test]
512 fn user_shell_pointing_at_bash_wins_over_powershell() {
513 // SHELL=C:\Program Files\Git\bin\bash.exe
514 // pwsh.exe also reachable.
515 // Expect: Posix(bash.exe) is the first candidate, pwsh second.
516 let tmp = tempfile::tempdir().expect("tempdir");
517 let bash = tmp.path().join("bash.exe");
518 std::fs::write(&bash, b"shebang").unwrap();
519
520 let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(bash.clone()));
521
522 assert!(matches!(candidates[0], WindowsShell::Posix(_)));
523 if let WindowsShell::Posix(p) = &candidates[0] {
524 assert_eq!(p, &bash);
525 }
526 assert_eq!(candidates[1], WindowsShell::Pwsh);
527 }
528
529 #[test]
530 fn user_shell_pointing_at_non_posix_binary_is_ignored() {
531 // SHELL=C:\Windows\System32\cmd.exe — not in POSIX_NAMES, so
532 // we should fall back to PowerShell/cmd resolution.
533 let tmp = tempfile::tempdir().expect("tempdir");
534 let cmd = tmp.path().join("cmd.exe");
535 std::fs::write(&cmd, b"").unwrap();
536
537 let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(cmd));
538
539 // No Posix candidate; pwsh wins.
540 assert!(!candidates
541 .iter()
542 .any(|c| matches!(c, WindowsShell::Posix(_))));
543 assert_eq!(candidates[0], WindowsShell::Pwsh);
544 }
545
546 #[test]
547 fn user_shell_msys_drive_letter_path_is_normalized() {
548 // SHELL=/c/Program Files/Git/bin/bash.exe — git-bash style.
549 // Without normalization this won't exist at all, so the
550 // resolver should at least *try* the normalized form before
551 // falling through.
552 //
553 // We can't easily fake an existing file at C:\... in a unit
554 // test, so we directly assert the normalization output here.
555 let raw = PathBuf::from("/c/Program Files/Git/bin/bash.exe");
556 let normalized = normalize_shell_path(&raw);
557 assert_eq!(
558 normalized,
559 PathBuf::from(r"C:\Program Files\Git\bin\bash.exe")
560 );
561 }
562
563 #[test]
564 fn user_shell_already_windows_path_passes_through() {
565 let raw = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
566 let normalized = normalize_shell_path(&raw);
567 assert_eq!(normalized, raw);
568 }
569
570 /// Note: this test runs on every platform but uses platform-native
571 /// path separators because `Path::file_stem()` only recognizes the
572 /// host OS's separator. On macOS/Linux that means a forward-slash
573 /// fake path (`/fake/bash`); on Windows the equivalent backslash
574 /// path. The production code only runs on Windows where backslash
575 /// works correctly, so the test's job is to verify the resolution
576 /// flow, not the path syntax.
577 #[test]
578 fn user_shell_bare_name_resolves_via_which() {
579 // SHELL=bash → not absolute → which("bash") returns the fake
580 // resolver's path → recognized as POSIX.
581 #[cfg(unix)]
582 let expected = PathBuf::from("/fake/bash");
583 #[cfg(windows)]
584 let expected = PathBuf::from(r"C:\fake\bash");
585
586 // Pre-translate the fake_which return so it uses the host's
587 // separator. We can't share fake_which here because that helper
588 // is hard-coded to Windows-style paths.
589 let expected_clone = expected.clone();
590 let which_for = move |query: &str| -> Option<PathBuf> {
591 if query == "bash" {
592 Some(expected_clone.clone())
593 } else {
594 None
595 }
596 };
597
598 let candidates = shell_candidates_with(which_for, || Some(PathBuf::from("bash")));
599 assert!(
600 matches!(&candidates[0], WindowsShell::Posix(p) if p == &expected),
601 "expected Posix({}) as first candidate, got {:?}",
602 expected.display(),
603 candidates
604 );
605 }
606
607 #[test]
608 fn no_user_shell_and_no_git_falls_back_to_pwsh_powershell_cmd() {
609 let candidates =
610 shell_candidates_with(fake_which(vec!["pwsh.exe", "powershell.exe"]), || None);
611 assert_eq!(candidates.len(), 3);
612 assert_eq!(candidates[0], WindowsShell::Pwsh);
613 assert_eq!(candidates[1], WindowsShell::Powershell);
614 assert_eq!(candidates[2], WindowsShell::Cmd);
615 }
616
617 #[test]
618 fn cmd_is_always_the_floor() {
619 // Nothing reachable, no $SHELL — only cmd.exe should be in the list.
620 let candidates = shell_candidates_with(|_| None, || None);
621 assert_eq!(candidates, vec![WindowsShell::Cmd]);
622 }
623
624 // ---------------------------------------------------------------
625 // git-bash auto-detect: when $SHELL is unset but the user installed
626 // Git for Windows, we should still pick up the bundled bash.exe.
627 // ---------------------------------------------------------------
628
629 #[test]
630 fn git_bash_auto_detect_when_shell_unset() {
631 let tmp = tempfile::tempdir().expect("tempdir");
632 // Mirror the Git for Windows layout: <root>/cmd/git.exe and
633 // <root>/bin/bash.exe.
634 std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
635 std::fs::create_dir_all(tmp.path().join("bin")).unwrap();
636 let git = tmp.path().join("cmd").join("git.exe");
637 std::fs::write(&git, b"git").unwrap();
638 let bash = tmp.path().join("bin").join("bash.exe");
639 std::fs::write(&bash, b"shebang").unwrap();
640
641 let which = |query: &str| -> Option<PathBuf> {
642 match query {
643 "git.exe" | "git" => Some(git.clone()),
644 _ => None,
645 }
646 };
647 let candidates = shell_candidates_with(which, || None);
648
649 // First candidate is the auto-detected git-bash.
650 assert!(matches!(&candidates[0], WindowsShell::Posix(p) if p == &bash));
651 // cmd.exe is still the floor.
652 assert_eq!(*candidates.last().unwrap(), WindowsShell::Cmd);
653 }
654
655 #[test]
656 fn git_bash_skipped_when_user_shell_already_posix() {
657 // $SHELL points at git-bash → no need to auto-detect a second
658 // POSIX candidate. The candidate list should not contain two
659 // Posix entries.
660 let tmp = tempfile::tempdir().expect("tempdir");
661 let bash = tmp.path().join("bash.exe");
662 std::fs::write(&bash, b"shebang").unwrap();
663
664 let candidates = shell_candidates_with(
665 // git is reachable, but git-bash should NOT be added because
666 // we already have a Posix from $SHELL.
667 |query: &str| match query {
668 "git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
669 _ => None,
670 },
671 || Some(bash.clone()),
672 );
673
674 let posix_count = candidates
675 .iter()
676 .filter(|c| matches!(c, WindowsShell::Posix(_)))
677 .count();
678 assert_eq!(
679 posix_count, 1,
680 "exactly one Posix candidate when $SHELL is already set: got {:?}",
681 candidates
682 );
683 }
684
685 // ---------------------------------------------------------------
686 // Spawn-shape tests: Posix(bash) must be invoked as `bash -c <cmd>`
687 // exactly the way Unix bash works.
688 // ---------------------------------------------------------------
689
690 #[test]
691 fn posix_shell_uses_dash_c_invocation() {
692 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
693 let shell = WindowsShell::Posix(bash);
694 let args = shell.args("ls -la /tmp");
695 assert_eq!(args, vec!["-c", "ls -la /tmp"]);
696 }
697
698 #[test]
699 fn posix_shell_binary_returns_full_path() {
700 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
701 let shell = WindowsShell::Posix(bash.clone());
702 assert_eq!(shell.binary().as_ref(), &bash.display().to_string());
703 }
704
705 #[test]
706 fn pwsh_args_unchanged() {
707 // Regression guard: refactor must not have altered PowerShell
708 // arg shape.
709 let shell = WindowsShell::Pwsh;
710 let args = shell.args("Get-ChildItem");
711 assert_eq!(
712 args,
713 vec![
714 "-NoLogo",
715 "-NoProfile",
716 "-NonInteractive",
717 "-ExecutionPolicy",
718 "Bypass",
719 "-Command",
720 "Get-ChildItem"
721 ]
722 );
723 }
724
725 #[test]
726 fn cmd_args_unchanged() {
727 let shell = WindowsShell::Cmd;
728 let args = shell.args("dir");
729 assert_eq!(args, vec!["/D", "/C", "dir"]);
730 }
731
732 // ---------------------------------------------------------------
733 // POSIX wrapper script: bg-bash exit-marker contract for git-bash.
734 // ---------------------------------------------------------------
735
736 #[test]
737 fn posix_wrapper_writes_exit_marker_atomically() {
738 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
739 let shell = WindowsShell::Posix(bash);
740 let script = shell.wrapper_script("echo hi", Path::new(r"C:\Temp\bash.exit"));
741 // The child runs the resolved shell directly (bash semantics) and then
742 // records its own exit code into the io/ marker via temp-file + rename,
743 // so detached tasks whose spawning daemon is gone still report exit.
744 assert!(
745 script.contains(r"'C:\Program Files\Git\bin\bash.exe' -c 'echo hi'"),
746 "wrapper must invoke the resolved shell directly: {script}",
747 );
748 assert!(
749 script.contains(r#"printf '%s' "$?" >"#),
750 "wrapper must capture $? for the exit marker: {script}"
751 );
752 assert!(
753 script.contains("bash.exit.tmp") && script.contains("mv "),
754 "wrapper must write the marker atomically via temp-file + rename: {script}"
755 );
756 }
757
758 #[test]
759 fn posix_wrapper_escapes_embedded_single_quotes() {
760 // User command contains a single quote — wrapper must use the
761 // standard `'\''` close-and-reopen idiom.
762 let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
763 let shell = WindowsShell::Posix(bash);
764 let script = shell.wrapper_script("echo 'hi'", Path::new(r"C:\Temp\bash.exit"));
765 assert!(
766 script.contains(r"'echo '\''hi'\'''"),
767 "embedded single quote must be escaped: got {script}"
768 );
769 }
770
771 // ---------------------------------------------------------------
772 // is_posix_shell_name: case-insensitive, .exe-tolerant lookup.
773 // ---------------------------------------------------------------
774
775 #[test]
776 fn is_posix_shell_name_recognizes_known_shells() {
777 for name in ["bash", "BASH", "bash.exe", "Bash.Exe", "sh", "zsh.exe"] {
778 assert!(
779 is_posix_shell_name(Path::new(name)),
780 "{name} should be POSIX"
781 );
782 }
783 }
784
785 #[test]
786 fn is_posix_shell_name_rejects_non_posix() {
787 for name in ["cmd.exe", "powershell.exe", "pwsh.exe", "fish", "nu.exe"] {
788 assert!(
789 !is_posix_shell_name(Path::new(name)),
790 "{name} must NOT be POSIX"
791 );
792 }
793 }
794}