aft/windows_shell.rs
1//! Shared Windows shell selection for foreground and background bash commands.
2//!
3//! Mirrors OpenCode's resolver: prefer modern PowerShell (`pwsh.exe`), fall
4//! back to Windows PowerShell (`powershell.exe`), then to `cmd.exe`.
5//!
6//! Compiled on all platforms so the cross-platform retry-decision unit
7//! tests in `commands::bash::try_spawn_with_fallback` can run on macOS/Linux
8//! dev machines. Production callers (`commands::bash::spawn_shell_command`
9//! and `bash_background::registry::detached_shell_command_for`) are
10//! `#[cfg(windows)]`.
11
12#![cfg_attr(not(windows), allow(dead_code))]
13
14use std::path::Path;
15use std::process::Command;
16use std::sync::OnceLock;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub(crate) enum WindowsShell {
20 /// PowerShell 7+ (cross-platform). Supports `&&` pipeline operator.
21 Pwsh,
22 /// Windows PowerShell 5.1 (legacy, still default on most Windows desktops
23 /// but **absent on Windows 11 IoT Enterprise LTSC SKUs** — issue #27).
24 /// Does NOT support `&&` in pipelines (PS 7+ only feature).
25 Powershell,
26 /// `cmd.exe` — the universal fallback. Present on every Windows SKU.
27 /// Supports `&&` and `||` natively. Lacks PowerShell's piping/cmdlets but
28 /// handles bash-style chained shell invocations correctly.
29 Cmd,
30}
31
32impl WindowsShell {
33 /// Binary name to spawn. Caller relies on PATH lookup.
34 pub(crate) fn binary(self) -> &'static str {
35 match self {
36 WindowsShell::Pwsh => "pwsh.exe",
37 WindowsShell::Powershell => "powershell.exe",
38 WindowsShell::Cmd => "cmd.exe",
39 }
40 }
41
42 /// Argument vector to pass alongside the user's command string.
43 /// PowerShell variants take `-Command <string>`; cmd takes `/D /C <string>`
44 /// (`/D` disables AutoRun macros that could otherwise inject env-trust
45 /// behavior into our isolated invocation).
46 pub(crate) fn args(self, command: &str) -> Vec<&str> {
47 match self {
48 WindowsShell::Pwsh | WindowsShell::Powershell => vec![
49 "-NoLogo",
50 "-NoProfile",
51 "-NonInteractive",
52 "-ExecutionPolicy",
53 "Bypass",
54 "-Command",
55 command,
56 ],
57 WindowsShell::Cmd => vec!["/D", "/C", command],
58 }
59 }
60
61 pub(crate) fn command(self, command: &str) -> Command {
62 let mut cmd = Command::new(self.binary());
63 cmd.args(self.args(command));
64 cmd
65 }
66
67 /// Build a `Command` that runs the background wrapper script.
68 ///
69 /// For `Cmd`, this enables delayed environment-variable expansion via
70 /// `/V:ON` so the wrapper's `!ERRORLEVEL!` captures the **real** exit
71 /// code of the user command at run-time. Without this, `cmd.exe` parses
72 /// the whole compound line at spawn time, expands `%ERRORLEVEL%` to its
73 /// pre-execution value (typically 0 from cmd's startup), and the exit
74 /// marker permanently records that stale value rather than the user
75 /// command's actual exit code. PowerShell variants don't need this —
76 /// PowerShell evaluates `$LASTEXITCODE` lazily at use-site by design.
77 ///
78 /// For foreground bash, callers should use [`Self::command`] instead;
79 /// `/V:ON` would change the semantics of user commands containing `!`
80 /// (which would otherwise be passed through literally to the user).
81 // No longer called by production bg-bash (which writes the wrapper
82 // to a temp file and invokes via `-File` / `cmd /C path`), but kept
83 // for tests that exercise the shell-arg shape directly.
84 #[allow(dead_code)]
85 pub(crate) fn bg_command(self, wrapper: &str) -> Command {
86 let mut cmd = Command::new(self.binary());
87 // PowerShell variants accept the wrapper string directly via
88 // `-Command`; the shell's `-Command` parser is generally happy
89 // with embedded quotes when the script doesn't contain literal
90 // `"` (we use only single quotes in the PS wrapper for that
91 // reason — see `wrapper_script` for `Pwsh|Powershell`).
92 //
93 // For cmd.exe the wrapper contains `cmd_quote`-quoted paths
94 // which CAN survive cmd's /C parser, but only if we add `/S`
95 // to enable simple-quote-stripping mode. Even with /S the
96 // interaction with Rust's std-lib argument quoting is fragile,
97 // so we rely on `args()` for cmd and live with the constraints.
98 //
99 // `/V:ON` enables `!ERRORLEVEL!` delayed expansion for cmd;
100 // without it, `%ERRORLEVEL%` would be parse-time-expanded to
101 // cmd's startup value, recording a stale exit code. `/D` skips
102 // AutoRun macros; `/S` enables simple quote-stripping.
103 match self {
104 WindowsShell::Pwsh | WindowsShell::Powershell => {
105 cmd.args(self.args(wrapper));
106 }
107 WindowsShell::Cmd => {
108 cmd.args(["/V:ON", "/D", "/S", "/C", wrapper]);
109 }
110 }
111 cmd
112 }
113
114 /// Wrap a background command so shell termination writes an exit marker.
115 /// The marker is written via temp-file + rename for PowerShell variants and
116 /// via `move /Y` for cmd.exe, matching the Unix background wrapper contract.
117 pub(crate) fn wrapper_script(self, command: &str, exit_path: &Path) -> String {
118 match self {
119 WindowsShell::Pwsh | WindowsShell::Powershell => {
120 let exit_path = powershell_single_quote(&exit_path.display().to_string());
121 let command = powershell_single_quote(command);
122 // The wrapper itself runs as a PowerShell script (invoked
123 // via `pwsh -File <path>` by `detached_shell_command_for`),
124 // so we execute the user command directly with `Invoke-Expression`
125 // instead of nesting another shell. Earlier versions wrapped
126 // the user command in an inner `& 'pwsh.exe' -Command ...`
127 // which caused PS-on-PS recursion that silently produced
128 // empty output on Windows 11 (likely a console-host issue
129 // with nested non-interactive pwsh sessions).
130 //
131 // CRITICAL: this script must contain NO literal double-quote
132 // characters. Inner `"` would break the outer-shell parse on
133 // some Windows configurations even with `-File`. We use only
134 // single-quoted strings and string concat (`+`) for any
135 // interpolation needs.
136 format!(
137 concat!(
138 "$exitPath = {exit_path}; ",
139 "$tmpPath = $exitPath + '.tmp.' + $PID; ",
140 "$global:LASTEXITCODE = $null; ",
141 "Invoke-Expression {command}; ",
142 "$success = $?; ",
143 "$nativeCode = $global:LASTEXITCODE; ",
144 "if ($null -ne $nativeCode) {{ $code = [int]$nativeCode }} ",
145 "elseif ($success) {{ $code = 0 }} ",
146 "else {{ $code = 1 }}; ",
147 "[System.IO.File]::WriteAllText($tmpPath, [string]$code); ",
148 "Move-Item -LiteralPath $tmpPath -Destination $exitPath -Force; ",
149 "exit $code"
150 ),
151 exit_path = exit_path,
152 command = command
153 )
154 }
155 WindowsShell::Cmd => {
156 // CRITICAL: This wrapper MUST be invoked via `bg_command()`,
157 // which prepends `/V:ON` to enable delayed expansion. Without
158 // /V:ON, `cmd.exe` would parse the entire compound line at
159 // spawn time and expand `%ERRORLEVEL%` to its pre-execution
160 // value (typically 0 from cmd's startup), permanently
161 // recording a stale exit code in the marker file regardless
162 // of what the user command actually returned. With /V:ON,
163 // `!ERRORLEVEL!` is evaluated each time it's referenced,
164 // capturing the real run-time exit code after `{command}`
165 // completes.
166 //
167 // `move /Y ... > nul` suppresses the "1 file(s) moved." line
168 // that cmd would otherwise emit to the user's stdout.
169 let tmp_path = format!("{}.tmp", exit_path.display());
170 format!(
171 "{command} & echo !ERRORLEVEL! > {tmp} & move /Y {tmp} {exit} > nul",
172 command = command,
173 tmp = cmd_quote(&tmp_path),
174 exit = cmd_quote(&exit_path.display().to_string())
175 )
176 }
177 }
178 }
179}
180
181/// Resolve which Windows shell to use for `bash` invocations.
182///
183/// Cached after the first resolve to avoid repeated PATH probes — the user's
184/// installed shells don't change mid-session, so a static cache is safe and
185/// keeps bash dispatch fast.
186///
187/// **Note:** PATH probe via `which::which` can disagree with what
188/// `Command::spawn` actually sees at runtime — antivirus / AppLocker rules,
189/// PATH inheritance gaps in the spawning host, or sandbox flags can make
190/// a binary "exist" to `which` but fail to spawn with NotFound. Foreground
191/// bash uses [`shell_candidates`] + runtime retry to defend against this;
192/// callers that take this single-result API are accepting the cached
193/// outcome at face value.
194// No longer called by production bg-bash (the new path uses
195// `shell_candidates()` with retry directly). Kept for potential future
196// use and for parity with the foreground spawn loop.
197#[allow(dead_code)]
198pub(crate) fn resolve_windows_shell() -> WindowsShell {
199 shell_candidates()
200 .first()
201 .copied()
202 .unwrap_or(WindowsShell::Cmd)
203}
204
205/// All Windows shells that the PATH probe believes are reachable, returned
206/// in priority order (pwsh > powershell > cmd). Always non-empty on Windows
207/// because cmd.exe is always added as the floor.
208///
209/// Used by the foreground bash spawn site to retry with the next candidate
210/// if the first one fails to spawn at runtime. Cached after the first
211/// resolve.
212pub(crate) fn shell_candidates() -> Vec<WindowsShell> {
213 static CACHED: OnceLock<Vec<WindowsShell>> = OnceLock::new();
214 CACHED
215 .get_or_init(|| shell_candidates_with(|binary| which::which(binary).is_ok()))
216 .clone()
217}
218
219pub(crate) fn shell_candidates_with<F>(exists: F) -> Vec<WindowsShell>
220where
221 F: Fn(&str) -> bool,
222{
223 let mut candidates = Vec::with_capacity(3);
224 if exists("pwsh.exe") {
225 log::info!("[aft] bash candidate: pwsh.exe (PowerShell 7+; supports && pipeline operator)");
226 candidates.push(WindowsShell::Pwsh);
227 }
228 if exists("powershell.exe") {
229 log::info!(
230 "[aft] bash candidate: powershell.exe (Windows PowerShell 5.1; && in pipelines unsupported, will surface as parse error)"
231 );
232 candidates.push(WindowsShell::Powershell);
233 }
234 // cmd.exe is always added as the floor, regardless of PATH probe result.
235 // It lives in a Windows search-path location that PATH inheritance issues,
236 // ASR rules, and sandboxing generally cannot remove. Without this floor,
237 // foreground bash retry would have nowhere to fall back to when both
238 // PowerShell variants fail to spawn at runtime.
239 candidates.push(WindowsShell::Cmd);
240 if candidates.len() == 1 {
241 log::warn!(
242 "[aft] PowerShell (pwsh.exe / powershell.exe) is not reachable from \
243 this aft process — using cmd.exe only. This can occur even \
244 when PowerShell is installed if PATH inheritance is restricted, \
245 antivirus / AppLocker / Defender ASR rules block PowerShell as a \
246 child process, or you're on a stripped Windows SKU. Bash-style \
247 commands using && and || still work; PowerShell-only cmdlets will \
248 not. Details: https://github.com/cortexkit/aft/issues/27"
249 );
250 }
251 candidates
252}
253
254/// Single-result variant of [`shell_candidates_with`] — kept for tests
255/// and as a future hook for the background bash path (which currently
256/// uses cached `resolve_windows_shell()` because the wrapper script
257/// embeds the shell name and a retry would require regenerating the
258/// script plus re-cloning stdout/stderr handles).
259///
260/// Returns the highest-priority reachable shell. cmd.exe is the floor.
261#[allow(dead_code)] // Used by `#[cfg(windows)] #[test]` in bash_background::registry.
262pub(crate) fn resolve_windows_shell_with<F>(exists: F) -> WindowsShell
263where
264 F: Fn(&str) -> bool,
265{
266 let mut candidates = shell_candidates_with(exists);
267 // shell_candidates_with always pushes cmd.exe at minimum, so this is
268 // guaranteed to be non-empty.
269 candidates.remove(0)
270}
271
272fn powershell_single_quote(value: &str) -> String {
273 format!("'{}'", value.replace('\'', "''"))
274}
275
276// Used by `wrapper_script` for `WindowsShell::Cmd`; that wrapper is
277// only invoked from `bash_background::registry::detached_shell_command_for`
278// which is `#[cfg(windows)]`. The function compiles on all platforms so
279// `wrapper_script` stays cross-platform-testable.
280#[cfg_attr(not(windows), allow(dead_code))]
281fn cmd_quote(value: &str) -> String {
282 format!("\"{}\"", value.replace('"', "\"\""))
283}