use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[cfg(feature = "local-shell")]
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use async_trait::async_trait;
#[cfg(feature = "local-shell")]
use tokio::io::{AsyncBufReadExt, BufReader};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Executed {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub code: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ShellError {
#[error("command timed out after {0}s and was killed")]
Timeout(u64),
#[error("cancelled")]
Cancelled,
#[error("failed to run bash: {0}")]
Failed(String),
}
pub fn bash_executable() -> PathBuf {
if let Ok(shell) = std::env::var("RHEMA_SHELL")
&& !shell.trim().is_empty()
{
return shell.into();
}
#[cfg(windows)]
for base in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Ok(dir) = std::env::var(base) {
let candidate = PathBuf::from(dir).join("Git").join("bin").join("bash.exe");
if candidate.is_file() {
return candidate;
}
}
}
PathBuf::from("bash")
}
pub fn bash_missing_message() -> String {
if cfg!(windows) {
"bash was not found on this windows host — install Git for \
Windows (https://git-scm.com/download/win), or point \
RHEMA_SHELL at a bash.exe"
} else {
"bash was not found on this host — install bash, or point \
RHEMA_SHELL at the bash executable"
}
.to_owned()
}
fn spawn_error(err: std::io::Error) -> ShellError {
match err.kind() {
std::io::ErrorKind::NotFound => ShellError::Failed(bash_missing_message()),
_ => ShellError::Failed(format!("{err}")),
}
}
#[async_trait]
pub trait ShellExecutor: Send + Sync {
async fn run(
&self,
command: &str,
cwd: &Path,
timeout: Duration,
cancelled: &AtomicBool,
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> Result<Executed, ShellError>;
}
#[cfg(feature = "local-shell")]
const CANCEL_TICK: Duration = Duration::from_millis(100);
#[cfg(feature = "local-shell")]
const CAPTURE_WINDOW: usize = 50 * 1024 + 1024 * 1024;
#[cfg(feature = "local-shell")]
const CAPTURE_EVICT: usize = 256 * 1024;
#[cfg(feature = "local-shell")]
#[derive(Debug, Clone)]
pub struct LocalShell {
env: Arc<[(String, String)]>,
}
#[cfg(feature = "local-shell")]
impl Default for LocalShell {
fn default() -> Self {
Self { env: Arc::from([]) }
}
}
#[cfg(feature = "local-shell")]
#[async_trait]
impl ShellExecutor for LocalShell {
async fn run(
&self,
command: &str,
cwd: &Path,
timeout: Duration,
cancelled: &AtomicBool,
progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> Result<Executed, ShellError> {
use std::process::Stdio;
use std::sync::atomic::Ordering;
let mut child = tokio::process::Command::new(bash_executable())
.arg("-c")
.arg(command)
.current_dir(cwd)
.envs(
self.env
.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(spawn_error)?;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
if let Some(stdout) = child.stdout.take() {
pump(stdout, false, tx.clone());
}
if let Some(stderr) = child.stderr.take() {
pump(stderr, true, tx.clone());
}
drop(tx);
let deadline = tokio::time::Instant::now() + timeout;
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
loop {
tokio::select! {
_ = tokio::time::sleep_until(deadline) => {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(ShellError::Timeout(timeout.as_secs()));
}
line = rx.recv() => match line {
Some((is_stderr, line)) => {
let sink = if is_stderr { &mut stderr } else { &mut stdout };
sink.extend_from_slice(line.as_bytes());
sink.push(b'\n');
if sink.len() >= CAPTURE_WINDOW + CAPTURE_EVICT {
let keep_from = sink.len() - CAPTURE_WINDOW;
let boundary = sink[keep_from..]
.iter()
.position(|&byte| byte == b'\n')
.map_or(keep_from, |position| keep_from + position + 1);
sink.drain(..boundary);
}
progress(&format!("{line}\n"));
}
None => break,
},
_ = tokio::time::sleep(CANCEL_TICK) => {
if cancelled.load(Ordering::Relaxed) {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(ShellError::Cancelled);
}
}
}
if cancelled.load(Ordering::Relaxed) {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(ShellError::Cancelled);
}
}
let status = match tokio::time::timeout(timeout, child.wait()).await {
Ok(Ok(status)) => status,
_ => return Err(ShellError::Timeout(timeout.as_secs())),
};
Ok(Executed {
stdout,
stderr,
code: status.code(),
})
}
}
#[cfg(feature = "local-shell")]
fn pump<R>(
stream: R,
is_stderr: bool,
tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
) -> tokio::task::JoinHandle<()>
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx.send((is_stderr, line)).is_err() {
break; }
}
})
}
#[cfg(not(feature = "local-shell"))]
#[derive(Debug, Clone, Copy, Default)]
pub struct NoShell;
#[cfg(not(feature = "local-shell"))]
#[async_trait]
impl ShellExecutor for NoShell {
async fn run(
&self,
_command: &str,
_cwd: &Path,
_timeout: Duration,
_cancelled: &AtomicBool,
_progress: &mut (dyn for<'a> FnMut(&'a str) + Send),
) -> Result<Executed, ShellError> {
Err(ShellError::Failed(
"this build has no local shell; install a shell executor \
(ShellTools::with_executor)"
.into(),
))
}
}
#[cfg(feature = "local-shell")]
pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(LocalShell::default())
}
#[cfg(feature = "local-shell")]
pub fn default_executor_with(env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(LocalShell {
env: Arc::from(
env.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Vec<_>>(),
),
})
}
#[cfg(not(feature = "local-shell"))]
pub fn default_executor_with(_env: &BTreeMap<String, String>) -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(NoShell)
}
#[cfg(not(feature = "local-shell"))]
pub fn default_executor() -> std::sync::Arc<dyn ShellExecutor> {
std::sync::Arc::new(NoShell)
}
#[cfg(all(test, feature = "local-shell"))]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
fn silent(_line: &str) {}
#[tokio::test]
async fn a_chatty_command_cannot_grow_the_capture_without_bound() {
let shell = LocalShell::default();
let cancelled = Arc::new(AtomicBool::new(false));
let executed = shell
.run(
"seq 1 300000",
Path::new("/tmp"),
Duration::from_secs(60),
&cancelled,
&mut silent,
)
.await
.unwrap();
assert_eq!(executed.code, Some(0));
assert!(
executed.stdout.len() < CAPTURE_WINDOW + CAPTURE_EVICT,
"capture must stay bounded, got {} bytes",
executed.stdout.len()
);
let tail = String::from_utf8_lossy(&executed.stdout);
assert!(
tail.ends_with("300000\n"),
"tail lost: …{}",
&tail[tail.len().saturating_sub(40)..]
);
}
}
#[cfg(test)]
mod bash_resolution_tests {
use super::*;
use std::io::ErrorKind;
#[cfg(unix)]
#[test]
fn bash_executable_defaults_to_the_path_name() {
assert_eq!(bash_executable(), PathBuf::from("bash"));
}
#[test]
fn a_missing_bash_names_the_gap_not_the_io_error() {
let ShellError::Failed(message) = spawn_error(ErrorKind::NotFound.into()) else {
panic!("a not-found spawn is a Failed error");
};
assert!(message.contains("bash was not found"), "{message}");
#[cfg(windows)]
assert!(message.contains("Git for Windows"), "{message}");
}
}