#![cfg_attr(not(windows), allow(dead_code))]
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
const POSIX_NAMES: &[&str] = &["bash", "sh", "zsh", "ksh", "dash"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum WindowsShell {
Pwsh,
Powershell,
Cmd,
Posix(PathBuf),
}
impl WindowsShell {
pub(crate) fn binary(&self) -> std::borrow::Cow<'_, str> {
match self {
WindowsShell::Pwsh => std::borrow::Cow::Borrowed("pwsh.exe"),
WindowsShell::Powershell => std::borrow::Cow::Borrowed("powershell.exe"),
WindowsShell::Cmd => std::borrow::Cow::Borrowed("cmd.exe"),
WindowsShell::Posix(path) => std::borrow::Cow::Owned(path.display().to_string()),
}
}
pub(crate) fn args<'a>(&'a self, command: &'a str) -> Vec<&'a str> {
match self {
WindowsShell::Pwsh | WindowsShell::Powershell => vec![
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
command,
],
WindowsShell::Cmd => vec!["/D", "/C", command],
WindowsShell::Posix(_) => vec!["-c", command],
}
}
pub(crate) fn command(&self, command: &str) -> Command {
let mut cmd = Command::new(self.binary().as_ref());
cmd.args(self.args(command));
cmd
}
#[allow(dead_code)]
pub(crate) fn bg_command(&self, wrapper: &str) -> Command {
let binary = self.binary();
let mut cmd = Command::new(binary.as_ref());
match self {
WindowsShell::Pwsh | WindowsShell::Powershell => {
cmd.args(self.args(wrapper));
}
WindowsShell::Cmd => {
cmd.args(["/V:ON", "/D", "/S", "/C", wrapper]);
}
WindowsShell::Posix(_) => {
cmd.args(["-c", wrapper]);
}
}
cmd
}
pub(crate) fn wrapper_script(&self, command: &str, exit_path: &Path) -> String {
match self {
WindowsShell::Pwsh | WindowsShell::Powershell => {
let exit_path = powershell_single_quote(&exit_path.display().to_string());
let command = powershell_single_quote(command);
format!(
concat!(
"$exitPath = {exit_path}; ",
"$tmpPath = $exitPath + '.tmp.' + $PID; ",
"$global:LASTEXITCODE = $null; ",
"Invoke-Expression {command}; ",
"$success = $?; ",
"$nativeCode = $global:LASTEXITCODE; ",
"if ($null -ne $nativeCode) {{ $code = [int]$nativeCode }} ",
"elseif ($success) {{ $code = 0 }} ",
"else {{ $code = 1 }}; ",
"[System.IO.File]::WriteAllText($tmpPath, [string]$code); ",
"Move-Item -LiteralPath $tmpPath -Destination $exitPath -Force; ",
"exit $code"
),
exit_path = exit_path,
command = command
)
}
WindowsShell::Cmd => {
let tmp_path = format!("{}.tmp", exit_path.display());
format!(
"{command} & echo !ERRORLEVEL! > {tmp} & move /Y {tmp} {exit} > nul",
command = command,
tmp = cmd_quote(&tmp_path),
exit = cmd_quote(&exit_path.display().to_string())
)
}
WindowsShell::Posix(_) => {
let exit_str = exit_path.display().to_string();
let tmp_path = format!("{}.tmp", exit_str);
format!(
"sh -c {} ; printf '%s' \"$?\" > {} && mv {} {}",
posix_single_quote(command),
posix_single_quote(&tmp_path),
posix_single_quote(&tmp_path),
posix_single_quote(&exit_str),
)
}
}
}
}
#[allow(dead_code)]
pub(crate) fn resolve_windows_shell() -> WindowsShell {
shell_candidates()
.first()
.cloned()
.unwrap_or(WindowsShell::Cmd)
}
pub(crate) fn shell_candidates() -> Vec<WindowsShell> {
static CACHED: OnceLock<Vec<WindowsShell>> = OnceLock::new();
CACHED
.get_or_init(|| {
shell_candidates_with(
|binary| which::which(binary).ok(),
|| std::env::var_os("SHELL").map(PathBuf::from),
)
})
.clone()
}
pub(crate) fn shell_candidates_with<W, S>(which_for: W, shell_env: S) -> Vec<WindowsShell>
where
W: Fn(&str) -> Option<PathBuf>,
S: FnOnce() -> Option<PathBuf>,
{
let mut candidates: Vec<WindowsShell> = Vec::with_capacity(5);
if let Some(shell_path) = shell_env() {
if let Some(resolved) = resolve_user_shell(&shell_path, &which_for) {
log::info!(
"[aft] bash candidate: $SHELL = {} (POSIX, invoked as -c)",
resolved.display()
);
candidates.push(WindowsShell::Posix(resolved));
}
}
if which_for("pwsh.exe").is_some() {
log::info!("[aft] bash candidate: pwsh.exe (PowerShell 7+; supports && pipeline operator)");
candidates.push(WindowsShell::Pwsh);
}
if which_for("powershell.exe").is_some() {
log::info!(
"[aft] bash candidate: powershell.exe (Windows PowerShell 5.1; && in pipelines unsupported, will surface as parse error)"
);
candidates.push(WindowsShell::Powershell);
}
let already_posix = candidates
.iter()
.any(|c| matches!(c, WindowsShell::Posix(_)));
if !already_posix {
if let Some(git_bash) = locate_git_bash(&which_for) {
log::info!(
"[aft] bash candidate: git-bash auto-detected at {} (POSIX, invoked as -c)",
git_bash.display()
);
candidates.push(WindowsShell::Posix(git_bash));
}
}
candidates.push(WindowsShell::Cmd);
let only_cmd = candidates.len() == 1;
if only_cmd {
log::warn!(
"[aft] No bash, PowerShell, or git-bash is reachable from this \
aft process — using cmd.exe only. This can occur even when \
PowerShell is installed if PATH inheritance is restricted, \
antivirus / AppLocker / Defender ASR rules block PowerShell as a \
child process, or you're on a stripped Windows SKU. Bash-style \
commands using && and || still work; PowerShell-only cmdlets and \
POSIX-only commands will not. Details: \
https://github.com/cortexkit/aft/issues/27"
);
}
candidates
}
fn resolve_user_shell<W>(raw: &Path, which_for: &W) -> Option<PathBuf>
where
W: Fn(&str) -> Option<PathBuf>,
{
let resolved = normalize_shell_path(raw);
let candidate = if resolved.is_absolute() && resolved.exists() {
resolved
} else {
let name = resolved.file_name()?.to_str()?.to_string();
which_for(&name)?
};
if !is_posix_shell_name(&candidate) {
log::info!(
"[aft] $SHELL points at {} which isn't a recognized POSIX shell; \
falling back to PowerShell/cmd resolution.",
candidate.display()
);
return None;
}
Some(candidate)
}
fn locate_git_bash<W>(which_for: &W) -> Option<PathBuf>
where
W: Fn(&str) -> Option<PathBuf>,
{
let git = which_for("git.exe").or_else(|| which_for("git"))?;
let candidate = git.parent()?.parent()?.join("bin").join("bash.exe");
let metadata = std::fs::metadata(&candidate).ok()?;
if metadata.len() == 0 {
return None;
}
Some(candidate)
}
fn normalize_shell_path(raw: &Path) -> PathBuf {
let s = raw.to_string_lossy();
if let Some(rest) = s.strip_prefix('/') {
if let Some((drive, after)) = rest.split_once('/') {
if drive.len() == 1
&& drive
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
{
let drive_upper = drive.to_uppercase();
let win = format!("{}:\\{}", drive_upper, after.replace('/', "\\"));
return PathBuf::from(win);
}
}
}
PathBuf::from(s.as_ref())
}
fn is_posix_shell_name(path: &Path) -> bool {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
POSIX_NAMES.iter().any(|name| *name == stem)
}
fn powershell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg_attr(not(windows), allow(dead_code))]
fn posix_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
#[cfg_attr(not(windows), allow(dead_code))]
fn cmd_quote(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_which(binaries: Vec<&'static str>) -> impl Fn(&str) -> Option<PathBuf> {
move |query| {
if binaries.contains(&query) {
match query {
"git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
_ => Some(PathBuf::from(format!(r"C:\fake\{}", query))),
}
} else {
None
}
}
}
#[test]
fn user_shell_pointing_at_bash_wins_over_powershell() {
let tmp = tempfile::tempdir().expect("tempdir");
let bash = tmp.path().join("bash.exe");
std::fs::write(&bash, b"shebang").unwrap();
let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(bash.clone()));
assert!(matches!(candidates[0], WindowsShell::Posix(_)));
if let WindowsShell::Posix(p) = &candidates[0] {
assert_eq!(p, &bash);
}
assert_eq!(candidates[1], WindowsShell::Pwsh);
}
#[test]
fn user_shell_pointing_at_non_posix_binary_is_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
let cmd = tmp.path().join("cmd.exe");
std::fs::write(&cmd, b"").unwrap();
let candidates = shell_candidates_with(fake_which(vec!["pwsh.exe"]), || Some(cmd));
assert!(!candidates
.iter()
.any(|c| matches!(c, WindowsShell::Posix(_))));
assert_eq!(candidates[0], WindowsShell::Pwsh);
}
#[test]
fn user_shell_msys_drive_letter_path_is_normalized() {
let raw = PathBuf::from("/c/Program Files/Git/bin/bash.exe");
let normalized = normalize_shell_path(&raw);
assert_eq!(
normalized,
PathBuf::from(r"C:\Program Files\Git\bin\bash.exe")
);
}
#[test]
fn user_shell_already_windows_path_passes_through() {
let raw = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
let normalized = normalize_shell_path(&raw);
assert_eq!(normalized, raw);
}
#[test]
fn user_shell_bare_name_resolves_via_which() {
#[cfg(unix)]
let expected = PathBuf::from("/fake/bash");
#[cfg(windows)]
let expected = PathBuf::from(r"C:\fake\bash");
let expected_clone = expected.clone();
let which_for = move |query: &str| -> Option<PathBuf> {
if query == "bash" {
Some(expected_clone.clone())
} else {
None
}
};
let candidates = shell_candidates_with(which_for, || Some(PathBuf::from("bash")));
assert!(
matches!(&candidates[0], WindowsShell::Posix(p) if p == &expected),
"expected Posix({}) as first candidate, got {:?}",
expected.display(),
candidates
);
}
#[test]
fn no_user_shell_and_no_git_falls_back_to_pwsh_powershell_cmd() {
let candidates =
shell_candidates_with(fake_which(vec!["pwsh.exe", "powershell.exe"]), || None);
assert_eq!(candidates.len(), 3);
assert_eq!(candidates[0], WindowsShell::Pwsh);
assert_eq!(candidates[1], WindowsShell::Powershell);
assert_eq!(candidates[2], WindowsShell::Cmd);
}
#[test]
fn cmd_is_always_the_floor() {
let candidates = shell_candidates_with(|_| None, || None);
assert_eq!(candidates, vec![WindowsShell::Cmd]);
}
#[test]
fn git_bash_auto_detect_when_shell_unset() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("cmd")).unwrap();
std::fs::create_dir_all(tmp.path().join("bin")).unwrap();
let git = tmp.path().join("cmd").join("git.exe");
std::fs::write(&git, b"git").unwrap();
let bash = tmp.path().join("bin").join("bash.exe");
std::fs::write(&bash, b"shebang").unwrap();
let which = |query: &str| -> Option<PathBuf> {
match query {
"git.exe" | "git" => Some(git.clone()),
_ => None,
}
};
let candidates = shell_candidates_with(which, || None);
assert!(matches!(&candidates[0], WindowsShell::Posix(p) if p == &bash));
assert_eq!(*candidates.last().unwrap(), WindowsShell::Cmd);
}
#[test]
fn git_bash_skipped_when_user_shell_already_posix() {
let tmp = tempfile::tempdir().expect("tempdir");
let bash = tmp.path().join("bash.exe");
std::fs::write(&bash, b"shebang").unwrap();
let candidates = shell_candidates_with(
|query: &str| match query {
"git.exe" | "git" => Some(PathBuf::from(r"C:\Program Files\Git\cmd\git.exe")),
_ => None,
},
|| Some(bash.clone()),
);
let posix_count = candidates
.iter()
.filter(|c| matches!(c, WindowsShell::Posix(_)))
.count();
assert_eq!(
posix_count, 1,
"exactly one Posix candidate when $SHELL is already set: got {:?}",
candidates
);
}
#[test]
fn posix_shell_uses_dash_c_invocation() {
let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
let shell = WindowsShell::Posix(bash);
let args = shell.args("ls -la /tmp");
assert_eq!(args, vec!["-c", "ls -la /tmp"]);
}
#[test]
fn posix_shell_binary_returns_full_path() {
let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
let shell = WindowsShell::Posix(bash.clone());
assert_eq!(shell.binary().as_ref(), &bash.display().to_string());
}
#[test]
fn pwsh_args_unchanged() {
let shell = WindowsShell::Pwsh;
let args = shell.args("Get-ChildItem");
assert_eq!(
args,
vec![
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
"Get-ChildItem"
]
);
}
#[test]
fn cmd_args_unchanged() {
let shell = WindowsShell::Cmd;
let args = shell.args("dir");
assert_eq!(args, vec!["/D", "/C", "dir"]);
}
#[test]
fn posix_wrapper_writes_exit_marker_atomically() {
let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
let shell = WindowsShell::Posix(bash);
let script = shell.wrapper_script("echo hi", Path::new(r"C:\Temp\bgb.exit"));
assert!(script.contains("sh -c 'echo hi'"), "{script}");
assert!(script.contains("printf '%s' \"$?\""), "{script}");
assert!(script.contains("mv "), "{script}");
assert!(script.contains(r"C:\Temp\bgb.exit"), "{script}");
assert!(script.contains(r"C:\Temp\bgb.exit.tmp"), "{script}");
}
#[test]
fn posix_wrapper_escapes_embedded_single_quotes() {
let bash = PathBuf::from(r"C:\Program Files\Git\bin\bash.exe");
let shell = WindowsShell::Posix(bash);
let script = shell.wrapper_script("echo 'hi'", Path::new(r"C:\Temp\bgb.exit"));
assert!(
script.contains(r"'echo '\''hi'\'''"),
"embedded single quote must be escaped: got {script}"
);
}
#[test]
fn is_posix_shell_name_recognizes_known_shells() {
for name in ["bash", "BASH", "bash.exe", "Bash.Exe", "sh", "zsh.exe"] {
assert!(
is_posix_shell_name(Path::new(name)),
"{name} should be POSIX"
);
}
}
#[test]
fn is_posix_shell_name_rejects_non_posix() {
for name in ["cmd.exe", "powershell.exe", "pwsh.exe", "fish", "nu.exe"] {
assert!(
!is_posix_shell_name(Path::new(name)),
"{name} must NOT be POSIX"
);
}
}
}