Skip to main content

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<'a>(self, command: &'a str) -> Vec<&'a 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}