use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::{Child, Command};
use tokio_util::sync::CancellationToken;
use crate::Host;
#[derive(Debug, Clone)]
pub struct ExecRequest {
pub command: String,
pub cwd: PathBuf,
pub timeout: Option<Duration>,
pub env: Vec<(String, String)>,
pub shell: Option<ShellSpec>,
pub front_back: Option<FrontBackSpec>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ShellSpec {
pub detect_program: bool,
pub login_arg: bool,
pub login_path_probe: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct FrontBackSpec {
pub char_budget: usize,
pub spill: bool,
}
pub const SPILL_RETAIN_MAX: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct FrontBackCapture {
pub front: String,
pub back: Option<String>,
pub total_bytes: u64,
pub spill_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct ExecOutput {
pub stdout: String,
pub stderr: String,
pub combined: String,
pub exit_code: Option<i32>,
pub timed_out: bool,
pub cancelled: bool,
pub truncated: bool,
pub duration: Duration,
pub front_back: Option<FrontBackCapture>,
}
#[derive(Debug, thiserror::Error)]
pub enum ExecError {
#[error("failed to spawn shell: {0}")]
Spawn(String),
}
impl Host {
pub async fn exec(
&self,
req: ExecRequest,
cancel: &CancellationToken,
) -> Result<ExecOutput, ExecError> {
let timeout = req
.timeout
.unwrap_or(self.limits.default_timeout)
.min(self.limits.max_timeout);
let program = self.shell_program_for(req.shell);
let path_env = match req.shell {
Some(spec) if spec.login_path_probe => self.probed_login_path(&program).await,
_ => None,
};
let cmd = self.build_shell_command(&req, &program, path_env.as_deref());
self.run_captured(cmd, timeout, cancel, req.front_back)
.await
}
fn shell_program_for(&self, spec: Option<ShellSpec>) -> String {
if spec.is_some_and(|s| s.detect_program)
&& let Ok(user_shell) = std::env::var("SHELL")
{
let base = std::path::Path::new(&user_shell)
.file_name()
.map(|n| n.to_string_lossy().into_owned());
if matches!(base.as_deref(), Some("bash" | "zsh")) {
return user_shell;
}
}
self.shell_program.clone()
}
async fn probed_login_path(&self, program: &str) -> Option<String> {
self.login_path
.get_or_init(|| async {
let probe = Command::new(program)
.arg("-lc")
.arg("printf %s \"$PATH\"")
.stdin(Stdio::null())
.output();
match tokio::time::timeout(Duration::from_secs(5), probe).await {
Ok(Ok(out)) if out.status.success() => {
let path = String::from_utf8_lossy(&out.stdout).into_owned();
(!path.trim().is_empty()).then_some(path)
}
_ => None,
}
})
.await
.clone()
}
pub async fn run_capture(
&self,
program: &Path,
args: &[String],
cwd: &Path,
timeout: Option<Duration>,
cancel: &CancellationToken,
) -> Result<ExecOutput, ExecError> {
let timeout = timeout
.unwrap_or(self.limits.default_timeout)
.min(self.limits.max_timeout);
let mut cmd = Command::new(program);
cmd.args(args);
cmd.current_dir(cwd);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
cmd.process_group(0);
self.run_captured(cmd, timeout, cancel, None).await
}
async fn run_captured(
&self,
mut cmd: Command,
timeout: Duration,
cancel: &CancellationToken,
front_back: Option<FrontBackSpec>,
) -> Result<ExecOutput, ExecError> {
let started = Instant::now();
let cap = self.limits.max_output_bytes;
let mut child = cmd.spawn().map_err(|e| ExecError::Spawn(e.to_string()))?;
let pid = child.id();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (out_task, err_task, combined_task) = if let Some(spec) = front_back {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
let tx_err = tx.clone();
let out = tokio::spawn(read_capped_forwarding(stdout, cap, tx));
let err = tokio::spawn(read_capped_forwarding(stderr, cap, tx_err));
let collector = tokio::spawn(collect_front_back(rx, spec));
(out, err, Some(collector))
} else {
(
tokio::spawn(read_capped(stdout, cap)),
tokio::spawn(read_capped(stderr, cap)),
None,
)
};
let mut exit_code = None;
let mut timed_out = false;
let mut cancelled = false;
tokio::select! {
status = child.wait() => {
exit_code = status.ok().and_then(|s| s.code());
}
() = tokio::time::sleep(timeout) => {
timed_out = true;
terminate(&mut child, pid, self.limits.kill_grace).await;
}
() = cancel.cancelled() => {
cancelled = true;
terminate(&mut child, pid, self.limits.kill_grace).await;
}
}
let (stdout, out_trunc) = out_task.await.unwrap_or_else(|_| (Vec::new(), false));
let (stderr, err_trunc) = err_task.await.unwrap_or_else(|_| (Vec::new(), false));
let front_back_capture = match combined_task {
Some(task) => task.await.ok(),
None => None,
};
let stdout = String::from_utf8_lossy(&stdout).into_owned();
let stderr = String::from_utf8_lossy(&stderr).into_owned();
let combined = combine(&stdout, &stderr);
Ok(ExecOutput {
stdout,
stderr,
combined,
exit_code,
timed_out,
cancelled,
truncated: out_trunc || err_trunc,
duration: started.elapsed(),
front_back: front_back_capture,
})
}
fn build_shell_command(
&self,
req: &ExecRequest,
program: &str,
path_env: Option<&str>,
) -> Command {
let mut cmd = Command::new(program);
let login = req.shell.map_or(self.login_shell, |s| s.login_arg);
cmd.arg(if login { "-lc" } else { "-c" });
if let Some(path) = path_env {
cmd.env("PATH", path);
}
cmd.arg(&req.command);
cmd.current_dir(&req.cwd);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (key, value) in &req.env {
cmd.env(key, value);
}
#[cfg(unix)]
cmd.process_group(0);
cmd
}
}
async fn read_capped<R: AsyncRead + Unpin>(reader: Option<R>, cap: usize) -> (Vec<u8>, bool) {
let Some(mut reader) = reader else {
return (Vec::new(), false);
};
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
let mut truncated = false;
loop {
match reader.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.len() > cap {
let excess = buf.len() - cap;
buf.drain(..excess);
truncated = true;
}
}
}
}
(buf, truncated)
}
async fn read_capped_forwarding<R: AsyncRead + Unpin>(
reader: Option<R>,
cap: usize,
tx: tokio::sync::mpsc::UnboundedSender<Vec<u8>>,
) -> (Vec<u8>, bool) {
let Some(mut reader) = reader else {
return (Vec::new(), false);
};
let mut buf = Vec::new();
let mut chunk = [0u8; 8192];
let mut truncated = false;
loop {
match reader.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let _ = tx.send(chunk[..n].to_vec());
buf.extend_from_slice(&chunk[..n]);
if buf.len() > cap {
let excess = buf.len() - cap;
buf.drain(..excess);
truncated = true;
}
}
}
}
(buf, truncated)
}
async fn collect_front_back(
mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
spec: FrontBackSpec,
) -> FrontBackCapture {
use std::collections::VecDeque;
use tokio::io::AsyncWriteExt;
let front_budget = spec.char_budget / 2;
let back_budget = spec.char_budget - front_budget;
let mut front: Vec<u8> = Vec::new();
let mut back: VecDeque<u8> = VecDeque::new();
let mut total: u64 = 0;
let mut spill = None;
let mut spill_path = None;
if spec.spill {
let dir = std::env::temp_dir().join("locode").join("exec");
if tokio::fs::create_dir_all(&dir).await.is_ok() {
static SPILL_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SPILL_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let path = dir.join(format!("cmd-{}-{}.log", std::process::id(), seq));
if let Ok(file) = tokio::fs::File::create(&path).await {
spill = Some(file);
spill_path = Some(path);
}
}
}
while let Some(chunk) = rx.recv().await {
if let Some(file) = spill.as_mut()
&& total < SPILL_RETAIN_MAX
{
let room = usize::try_from(SPILL_RETAIN_MAX - total).unwrap_or(usize::MAX);
let write = &chunk[..chunk.len().min(room)];
let _ = file.write_all(write).await;
}
total += chunk.len() as u64;
if front.len() < front_budget {
let room = front_budget - front.len();
front.extend_from_slice(&chunk[..chunk.len().min(room)]);
if chunk.len() > room {
back.extend(&chunk[room..]);
}
} else {
back.extend(&chunk);
}
while back.len() > back_budget {
back.pop_front();
}
}
if let Some(mut file) = spill {
let _ = file.flush().await;
}
let truncated = total > (front.len() + back.len()) as u64;
let front_str = String::from_utf8_lossy(&front).into_owned();
let back_bytes: Vec<u8> = back.into_iter().collect();
let back_str = String::from_utf8_lossy(&back_bytes).into_owned();
if truncated {
FrontBackCapture {
front: front_str,
back: Some(back_str),
total_bytes: total,
spill_path,
}
} else {
FrontBackCapture {
front: format!("{front_str}{back_str}"),
back: None,
total_bytes: total,
spill_path,
}
}
}
async fn terminate(child: &mut Child, pid: Option<u32>, grace: Duration) {
if group_kill(child, pid, grace).await {
return;
}
let _ = child.start_kill();
let _ = child.wait().await;
}
#[cfg(unix)]
async fn group_kill(child: &mut Child, pid: Option<u32>, grace: Duration) -> bool {
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
let Some(pid) = pid else { return false };
let Ok(raw) = i32::try_from(pid) else {
return false;
};
if raw <= 1 {
return false;
}
let leader = Pid::from_raw(raw);
let _ = killpg(leader, Signal::SIGTERM);
if tokio::time::timeout(grace, child.wait()).await.is_err() {
let _ = killpg(leader, Signal::SIGKILL);
let _ = child.wait().await;
}
true
}
#[cfg(not(unix))]
async fn group_kill(_child: &mut Child, _pid: Option<u32>, _grace: Duration) -> bool {
false
}
fn combine(stdout: &str, stderr: &str) -> String {
match (stdout.is_empty(), stderr.is_empty()) {
(false, false) => format!("{stdout}\n{stderr}"),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(true, true) => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ExecLimits, Host, HostConfig, PathPolicy, test_host};
use std::time::Duration;
use tempfile::tempdir;
fn req(command: &str, cwd: PathBuf) -> ExecRequest {
ExecRequest {
command: command.to_string(),
cwd,
timeout: None,
env: Vec::new(),
shell: None,
front_back: None,
}
}
fn shell_host(root: &std::path::Path) -> Host {
test_host(root, PathPolicy::Jailed, false)
}
#[cfg(unix)]
#[tokio::test]
async fn captures_stdout_and_exit() {
let dir = tempdir().unwrap();
let host = shell_host(dir.path());
let out = host
.exec(
req("echo hello", dir.path().into()),
&CancellationToken::new(),
)
.await
.unwrap();
assert!(out.stdout.contains("hello"));
assert_eq!(out.exit_code, Some(0));
assert!(!out.timed_out && !out.cancelled);
}
#[cfg(unix)]
#[tokio::test]
async fn captures_stderr_and_nonzero_exit() {
let dir = tempdir().unwrap();
let host = shell_host(dir.path());
let out = host
.exec(
req(">&2 echo boom; exit 3", dir.path().into()),
&CancellationToken::new(),
)
.await
.unwrap();
assert!(out.stderr.contains("boom"));
assert_eq!(out.exit_code, Some(3));
}
#[cfg(unix)]
#[tokio::test]
async fn timeout_kills_sleeper() {
let dir = tempdir().unwrap();
let mut config = HostConfig::new(dir.path());
config.login_shell = false;
config.exec = ExecLimits {
default_timeout: Duration::from_millis(200),
..ExecLimits::default()
};
let host = Host::new(config).unwrap();
let started = Instant::now();
let out = host
.exec(
req("sleep 30", dir.path().into()),
&CancellationToken::new(),
)
.await
.unwrap();
assert!(out.timed_out);
assert!(out.exit_code.is_none());
assert!(
started.elapsed() < Duration::from_secs(5),
"should not wait for the sleep"
);
}
#[cfg(unix)]
#[tokio::test]
async fn cancellation_kills_running_command() {
let dir = tempdir().unwrap();
let host = shell_host(dir.path());
let cancel = CancellationToken::new();
let child_cancel = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
child_cancel.cancel();
});
let started = Instant::now();
let out = host
.exec(req("sleep 30", dir.path().into()), &cancel)
.await
.unwrap();
assert!(out.cancelled);
assert!(started.elapsed() < Duration::from_secs(5));
}
#[cfg(unix)]
#[tokio::test]
async fn output_over_cap_is_truncated() {
let dir = tempdir().unwrap();
let mut config = HostConfig::new(dir.path());
config.login_shell = false;
config.exec = ExecLimits {
max_output_bytes: 1000,
..ExecLimits::default()
};
let host = Host::new(config).unwrap();
let out = host
.exec(
req(
"for i in $(seq 1 20000); do echo 0123456789; done",
dir.path().into(),
),
&CancellationToken::new(),
)
.await
.unwrap();
assert!(out.truncated);
assert!(
out.stdout.len() <= 1000 + 16,
"kept ~cap bytes, got {}",
out.stdout.len()
);
}
#[cfg(unix)]
#[tokio::test]
async fn front_back_retains_head_and_tail_and_spills() {
let dir = tempdir().unwrap();
let host = shell_host(dir.path());
let mut request = req(
"for i in $(seq 1 2000); do echo line-$i; done",
dir.path().into(),
);
request.front_back = Some(FrontBackSpec {
char_budget: 400,
spill: true,
});
let out = host.exec(request, &CancellationToken::new()).await.unwrap();
let capture = out.front_back.expect("capture requested");
assert!(
capture.front.starts_with("line-1\n"),
"front holds the head"
);
let back = capture.back.expect("truncated -> back present");
assert!(
back.ends_with("line-2000\n"),
"back holds the tail: {back:?}"
);
assert!(capture.front.len() <= 200 && back.len() <= 200);
assert!(capture.total_bytes > 400, "true total, not retained size");
let spill = capture.spill_path.expect("spill requested");
let spilled = std::fs::read_to_string(&spill).unwrap();
assert!(spilled.starts_with("line-1\n") && spilled.ends_with("line-2000\n"));
assert_eq!(spilled.len() as u64, capture.total_bytes);
let _ = std::fs::remove_file(spill);
}
#[cfg(unix)]
#[tokio::test]
async fn front_back_small_output_is_untruncated() {
let dir = tempdir().unwrap();
let host = shell_host(dir.path());
let mut request = req("echo tiny", dir.path().into());
request.front_back = Some(FrontBackSpec {
char_budget: 400,
spill: false,
});
let out = host.exec(request, &CancellationToken::new()).await.unwrap();
let capture = out.front_back.expect("capture requested");
assert_eq!(capture.front, "tiny\n");
assert!(capture.back.is_none(), "nothing cut");
assert_eq!(capture.total_bytes, 5);
assert!(capture.spill_path.is_none());
}
#[cfg(unix)]
#[tokio::test]
async fn shell_spec_c_arg_and_path_probe() {
let dir = tempdir().unwrap();
let host = shell_host(dir.path());
let mut request = req("echo $PATH", dir.path().into());
request.shell = Some(ShellSpec {
detect_program: false,
login_arg: false,
login_path_probe: true,
});
let out = host.exec(request, &CancellationToken::new()).await.unwrap();
assert_eq!(out.exit_code, Some(0));
assert!(!out.stdout.trim().is_empty());
let mut request2 = req("echo $PATH", dir.path().into());
request2.shell = Some(ShellSpec {
detect_program: false,
login_arg: false,
login_path_probe: true,
});
let out2 = host
.exec(request2, &CancellationToken::new())
.await
.unwrap();
assert_eq!(out.stdout, out2.stdout);
}
#[cfg(unix)]
#[tokio::test]
async fn null_stdin_does_not_hang() {
let dir = tempdir().unwrap();
let host = shell_host(dir.path());
let out = host
.exec(req("cat", dir.path().into()), &CancellationToken::new())
.await
.unwrap();
assert_eq!(out.exit_code, Some(0));
}
}