use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
const MAX_TOTAL_ARG_BYTES: usize = 256 * 1024;
static OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Default)]
pub struct ProcessRequest {
pub program: String,
pub args: Vec<String>,
pub current_dir: Option<PathBuf>,
}
impl ProcessRequest {
pub fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
current_dir: None,
}
}
pub fn arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
for arg in args {
self.args.push(arg.into());
}
self
}
pub fn current_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.current_dir = Some(dir.into());
self
}
}
#[derive(Debug, Clone)]
pub struct ProcessOutput {
pub exit_code: i32,
pub success: bool,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ProcessExecutionError {
#[error("failed to spawn '{program}': {source}")]
Spawn {
program: String,
source: io::Error,
},
#[error("failed waiting for '{program}': {source}")]
Wait {
program: String,
source: io::Error,
},
#[error("failed to create temp output file '{path}': {source}")]
CreateTemp {
path: PathBuf,
source: io::Error,
},
#[error("failed to read temp output file '{path}': {source}")]
ReadTemp {
path: PathBuf,
source: io::Error,
},
#[error("invalid process request: {detail}")]
InvalidRequest {
detail: String,
},
}
pub trait ProcessRunner {
fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError>;
fn run_with_timeout(
&self,
request: &ProcessRequest,
timeout: Duration,
) -> Result<ProcessOutput, ProcessExecutionError>;
}
#[derive(Debug, Default)]
pub struct SystemProcessRunner;
impl ProcessRunner for SystemProcessRunner {
fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError> {
validate_request(request)?;
let mut command = build_command(request);
let output = command
.output()
.map_err(|source| ProcessExecutionError::Spawn {
program: request.program.clone(),
source,
})?;
Ok(ProcessOutput {
exit_code: output.status.code().unwrap_or(-1),
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
timed_out: false,
})
}
fn run_with_timeout(
&self,
request: &ProcessRequest,
timeout: Duration,
) -> Result<ProcessOutput, ProcessExecutionError> {
validate_request(request)?;
let now_ms = chrono::Utc::now().timestamp_millis();
let pid = std::process::id();
let stdout_path = temp_output_path("stdout", pid, now_ms);
let stderr_path = temp_output_path("stderr", pid, now_ms);
let stdout_file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&stdout_path)
.map_err(|source| ProcessExecutionError::CreateTemp {
path: stdout_path.clone(),
source,
})?;
let stderr_file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&stderr_path)
.map_err(|source| ProcessExecutionError::CreateTemp {
path: stderr_path.clone(),
source,
})?;
let mut command = build_command(request);
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
.spawn()
.map_err(|source| ProcessExecutionError::Spawn {
program: request.program.clone(),
source,
})?;
let started = Instant::now();
let mut timed_out = false;
let mut exit_code = -1;
let mut success = false;
loop {
if let Some(status) =
child
.try_wait()
.map_err(|source| ProcessExecutionError::Wait {
program: request.program.clone(),
source,
})?
{
exit_code = status.code().unwrap_or(-1);
success = status.success();
break;
}
if started.elapsed() >= timeout {
timed_out = true;
let _ = child.kill();
let _ = child.wait();
break;
}
thread::sleep(Duration::from_millis(10));
}
let stdout =
fs::read_to_string(&stdout_path).map_err(|source| ProcessExecutionError::ReadTemp {
path: stdout_path.clone(),
source,
})?;
let stderr =
fs::read_to_string(&stderr_path).map_err(|source| ProcessExecutionError::ReadTemp {
path: stderr_path.clone(),
source,
})?;
let _ = fs::remove_file(&stdout_path);
let _ = fs::remove_file(&stderr_path);
Ok(ProcessOutput {
exit_code,
success: !timed_out && success,
stdout,
stderr,
timed_out,
})
}
}
fn build_command(request: &ProcessRequest) -> Command {
let mut command = Command::new(&request.program);
if is_git_program(&request.program) {
command.env_remove("GIT_DIR");
command.env_remove("GIT_WORK_TREE");
}
command.args(&request.args);
if let Some(dir) = &request.current_dir {
command.current_dir(dir);
}
command
}
fn is_git_program(program: &str) -> bool {
if program == "git" {
return true;
}
matches!(
Path::new(program)
.file_name()
.and_then(|name| name.to_str()),
Some("git") | Some("git.exe")
)
}
fn validate_request(request: &ProcessRequest) -> Result<(), ProcessExecutionError> {
validate_program(&request.program)?;
validate_args(&request.program, &request.args)?;
validate_current_dir(&request.current_dir)?;
Ok(())
}
fn validate_program(program: &str) -> Result<(), ProcessExecutionError> {
if program.is_empty() {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program is empty".to_string(),
});
}
if program.contains('\0') {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program contains NUL byte".to_string(),
});
}
let program_path = Path::new(program);
if program_path.is_absolute() {
if contains_dot_components(program_path) {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program path contains '.' or '..'".to_string(),
});
}
return Ok(());
}
let mut components = program_path.components();
let Some(component) = components.next() else {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program path is empty".to_string(),
});
};
match component {
Component::Normal(_) => {}
Component::CurDir => {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program path must not be '.'".to_string(),
});
}
Component::ParentDir => {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program path must not include '..'".to_string(),
});
}
Component::RootDir => {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program path must be absolute when rooted".to_string(),
});
}
Component::Prefix(_) => {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program path prefix is not an executable name".to_string(),
});
}
}
if components.next().is_some() {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program must be an executable name or absolute path".to_string(),
});
}
Ok(())
}
fn validate_args(program: &str, args: &[String]) -> Result<(), ProcessExecutionError> {
let mut total_bytes = program.len();
if total_bytes > MAX_TOTAL_ARG_BYTES {
return Err(ProcessExecutionError::InvalidRequest {
detail: "program name exceeds maximum size".to_string(),
});
}
for arg in args {
if arg.contains('\0') {
return Err(ProcessExecutionError::InvalidRequest {
detail: "argument contains NUL byte".to_string(),
});
}
total_bytes = total_bytes.saturating_add(arg.len());
if total_bytes > MAX_TOTAL_ARG_BYTES {
return Err(ProcessExecutionError::InvalidRequest {
detail: "arguments exceed maximum total size".to_string(),
});
}
}
Ok(())
}
fn validate_current_dir(dir: &Option<PathBuf>) -> Result<(), ProcessExecutionError> {
let Some(dir) = dir else {
return Ok(());
};
if os_str_has_nul(dir.as_os_str()) {
return Err(ProcessExecutionError::InvalidRequest {
detail: "current_dir contains NUL byte".to_string(),
});
}
if contains_dot_components(dir) {
return Err(ProcessExecutionError::InvalidRequest {
detail: "current_dir must not include '.' or '..'".to_string(),
});
}
Ok(())
}
fn contains_dot_components(path: &Path) -> bool {
for component in path.components() {
match component {
Component::CurDir | Component::ParentDir => return true,
Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {}
}
}
false
}
#[cfg(unix)]
fn os_str_has_nul(value: &std::ffi::OsStr) -> bool {
value.as_bytes().contains(&0)
}
#[cfg(windows)]
fn os_str_has_nul(value: &std::ffi::OsStr) -> bool {
value.encode_wide().any(|unit| unit == 0)
}
fn temp_output_path(stream: &str, pid: u32, now_ms: i64) -> PathBuf {
let counter = OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!("ito-process-{stream}-{pid}-{now_ms}-{counter}.log"));
path
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn captures_stdout_and_stderr() {
let runner = SystemProcessRunner;
let request = ProcessRequest::new("sh").args(["-c", "echo out; echo err >&2"]);
let output = runner.run(&request).unwrap();
assert!(output.success);
assert_eq!(output.exit_code, 0);
assert!(output.stdout.contains("out"));
assert!(output.stderr.contains("err"));
assert!(!output.timed_out);
}
#[test]
fn captures_non_zero_exit() {
let runner = SystemProcessRunner;
let request = ProcessRequest::new("sh").args(["-c", "echo boom >&2; exit 7"]);
let output = runner.run(&request).unwrap();
assert!(!output.success);
assert_eq!(output.exit_code, 7);
assert!(output.stderr.contains("boom"));
}
#[test]
fn missing_executable_is_spawn_failure() {
let runner = SystemProcessRunner;
let request = ProcessRequest::new("__ito_missing_executable__");
let result = runner.run(&request);
match result {
Err(ProcessExecutionError::Spawn { .. }) => {}
other => panic!("expected spawn error, got {other:?}"),
}
}
#[test]
fn rejects_empty_program() {
let request = ProcessRequest::new("");
let result = validate_request(&request);
match result {
Err(ProcessExecutionError::InvalidRequest { detail }) => {
assert!(detail.contains("program is empty"));
}
other => panic!("expected invalid request, got {other:?}"),
}
}
#[test]
fn rejects_nul_in_program() {
let request = ProcessRequest::new("sh\0bad");
let result = validate_request(&request);
match result {
Err(ProcessExecutionError::InvalidRequest { detail }) => {
assert!(detail.contains("program contains NUL byte"));
}
other => panic!("expected invalid request, got {other:?}"),
}
}
#[test]
fn rejects_relative_program_with_components() {
let request = ProcessRequest::new("bin/sh");
let result = validate_request(&request);
match result {
Err(ProcessExecutionError::InvalidRequest { detail }) => {
assert!(detail.contains("executable name or absolute path"));
}
other => panic!("expected invalid request, got {other:?}"),
}
}
#[test]
fn rejects_current_dir_with_parent_component() {
let request = ProcessRequest::new("sh").current_dir("../tmp");
let result = validate_request(&request);
match result {
Err(ProcessExecutionError::InvalidRequest { detail }) => {
assert!(detail.contains("current_dir must not include"));
}
other => panic!("expected invalid request, got {other:?}"),
}
}
#[test]
fn rejects_nul_in_argument() {
let request = ProcessRequest::new("sh").arg("a\0b");
let result = validate_request(&request);
match result {
Err(ProcessExecutionError::InvalidRequest { detail }) => {
assert!(detail.contains("argument contains NUL byte"));
}
other => panic!("expected invalid request, got {other:?}"),
}
}
#[test]
fn rejects_excessive_argument_bytes() {
let oversized = "a".repeat(MAX_TOTAL_ARG_BYTES);
let request = ProcessRequest::new("sh").arg(oversized);
let result = validate_request(&request);
match result {
Err(ProcessExecutionError::InvalidRequest { detail }) => {
assert!(detail.contains("arguments exceed maximum total size"));
}
other => panic!("expected invalid request, got {other:?}"),
}
}
#[test]
fn run_returns_invalid_request_before_spawn() {
let runner = SystemProcessRunner;
let request = ProcessRequest::new("bin/sh");
let result = runner.run(&request);
match result {
Err(ProcessExecutionError::InvalidRequest { detail }) => {
assert!(detail.contains("executable name or absolute path"));
}
other => panic!("expected invalid request, got {other:?}"),
}
}
}