use super::secrets::Secrets;
use super::signals;
use crate::error::{Result, ShlaneError};
use std::collections::BTreeMap;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const POSIX_SHELL: &str = "sh";
#[cfg(windows)]
const GIT_BASH: &[&str] = &[
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\sh.exe",
];
const POLL_INTERVAL: Duration = Duration::from_millis(25);
#[cfg(unix)]
const GRACE: Duration = Duration::from_millis(500);
pub struct Outcome {
pub code: Option<i32>,
pub success: bool,
pub timed_out: bool,
pub interrupted: bool,
pub stdout: String,
pub stderr: String,
}
pub struct Spawn<'a> {
pub command: &'a str,
pub env: &'a BTreeMap<String, String>,
pub workdir: &'a Path,
pub timeout: Option<Duration>,
pub quiet: bool,
pub secrets: &'a Secrets,
}
pub fn shell(env: &BTreeMap<String, String>) -> Result<String> {
if let Some(configured) = env
.get("SHLANE_SHELL")
.cloned()
.or_else(|| std::env::var("SHLANE_SHELL").ok())
.filter(|value| !value.is_empty())
{
return Ok(configured);
}
#[cfg(not(windows))]
{
Ok(POSIX_SHELL.to_string())
}
#[cfg(windows)]
{
if let Some(found) = which("bash.exe").or_else(|| which("sh.exe")) {
return Ok(found.display().to_string());
}
for candidate in GIT_BASH {
if Path::new(candidate).is_file() {
return Ok((*candidate).to_string());
}
}
Err(ShlaneError::ShellUnavailable {
shell: "bash".to_string(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
"shlane runs steps in a POSIX shell, and there is none on PATH. Install Git for Windows, which ships one, or point SHLANE_SHELL at the shell you want.",
),
})
}
}
#[cfg(windows)]
fn which(program: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH")?;
let system_root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from);
std::env::split_paths(&path)
.map(|dir| dir.join(program))
.find(|candidate| {
candidate.is_file() && !is_wsl_launcher(candidate, system_root.as_deref())
})
}
#[cfg(any(windows, test))]
fn is_wsl_launcher(candidate: &Path, system_root: Option<&Path>) -> bool {
let Some(system_root) = system_root else {
return false;
};
let normalise = |path: &Path| {
path.to_string_lossy()
.to_ascii_lowercase()
.replace('\\', "/")
};
let candidate = normalise(candidate);
let root = normalise(system_root).trim_end_matches('/').to_string();
["system32", "sysnative"].iter().any(|directory| {
candidate
.strip_prefix(&format!("{root}/{directory}/"))
.is_some_and(|rest| !rest.contains('/'))
})
}
pub fn run(spawn: Spawn<'_>) -> Result<Outcome> {
let shell = shell(spawn.env)?;
let mut builder = Command::new(&shell);
builder
.arg("-c")
.arg(spawn.command)
.current_dir(spawn.workdir)
.envs(spawn.env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
let own_group = true;
#[cfg(not(unix))]
let own_group = false;
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
builder.process_group(0);
}
let mut child = builder
.spawn()
.map_err(|source| ShlaneError::ShellUnavailable {
shell: shell.clone(),
source,
})?;
signals::register_child(child.id() as i32, own_group);
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let out_reader = reader(stdout, spawn.secrets.clone(), spawn.quiet, false);
let err_reader = reader(stderr, spawn.secrets.clone(), spawn.quiet, true);
let timed_out = match spawn.timeout {
None => {
child
.wait()
.map_err(|source| ShlaneError::ShellUnavailable {
shell: shell.clone(),
source,
})?;
false
}
Some(timeout) => wait_with_timeout(&mut child, timeout)?,
};
let status = child
.wait()
.map_err(|source| ShlaneError::ShellUnavailable {
shell: shell.clone(),
source,
})?;
signals::clear_child();
let stdout = out_reader.join().unwrap_or_default();
let stderr = err_reader.join().unwrap_or_default();
Ok(Outcome {
code: status.code(),
success: status.success() && !timed_out,
timed_out,
interrupted: signals::interrupted(),
stdout,
stderr,
})
}
fn reader(
stream: Option<impl Read + Send + 'static>,
secrets: Secrets,
quiet: bool,
is_stderr: bool,
) -> thread::JoinHandle<String> {
thread::spawn(move || {
let Some(stream) = stream else {
return String::new();
};
let mut collected = String::new();
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { break };
let line = secrets.mask(&line);
collected.push_str(&line);
collected.push('\n');
if quiet {
continue;
}
if is_stderr {
let mut handle = std::io::stderr().lock();
let _ = writeln!(handle, "{line}");
} else {
let mut handle = std::io::stdout().lock();
let _ = writeln!(handle, "{line}");
}
}
collected
})
}
fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Result<bool> {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(_)) => return Ok(false),
Ok(None) => {}
Err(source) => {
return Err(ShlaneError::ShellUnavailable {
shell: POSIX_SHELL.to_string(),
source,
})
}
}
if signals::interrupted() {
stop(child);
return Ok(false);
}
if Instant::now() >= deadline {
stop(child);
return Ok(true);
}
thread::sleep(POLL_INTERVAL);
}
}
fn stop(child: &mut Child) {
#[cfg(unix)]
{
let group = child.id() as i32;
unsafe {
libc::killpg(group, libc::SIGTERM);
}
let deadline = Instant::now() + GRACE;
while Instant::now() < deadline {
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
thread::sleep(POLL_INTERVAL);
}
unsafe {
libc::killpg(group, libc::SIGKILL);
}
}
let _ = child.kill();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_wsl_launcher_is_recognised_wherever_windows_keeps_it() {
let system_root = Path::new(r"C:\Windows");
for launcher in [
r"C:\Windows\System32\bash.exe",
r"C:\Windows\system32\bash.exe",
r"C:\WINDOWS\System32\bash.exe",
r"C:\Windows\Sysnative\bash.exe",
] {
assert!(
is_wsl_launcher(Path::new(launcher), Some(system_root)),
"{launcher} is the WSL launcher, not a POSIX shell"
);
}
}
#[test]
fn a_real_shell_is_not_mistaken_for_the_launcher() {
let system_root = Path::new(r"C:\Windows");
for shell in [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\sh.exe",
r"C:\tools\msys64\usr\bin\bash.exe",
r"C:\Windows\System32\config\bash.exe",
"/bin/sh",
] {
assert!(
!is_wsl_launcher(Path::new(shell), Some(system_root)),
"{shell} is a real shell"
);
}
}
#[test]
fn without_a_system_root_nothing_is_excluded() {
assert!(!is_wsl_launcher(
Path::new(r"C:\Windows\System32\bash.exe"),
None
));
}
}