#![allow(dead_code)]
use std::io::Read;
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use std::{io, thread};
#[derive(Debug, Clone)]
#[must_use]
pub(crate) struct CommandOutput {
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) exit_code: Option<i32>,
pub(crate) duration: Duration,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum RunnerError {
#[error("failed to execute '{program}': {source}")]
SpawnFailed {
program: String,
#[source]
source: io::Error,
},
#[error("command timed out after {timeout:?}")]
Timeout { timeout: Duration },
#[error(transparent)]
Io(#[from] io::Error),
#[error("failed to capture child {pipe}")]
PipeCaptureFailed { pipe: &'static str },
#[error("{pipe} reader thread panicked")]
ReaderPanicked { pipe: &'static str },
}
pub(crate) struct CommandRunner {
timeout: Option<Duration>,
}
impl CommandRunner {
pub(crate) fn new(timeout: Option<Duration>) -> Self {
Self { timeout }
}
pub(crate) fn run(&self, program: &str, args: &[&str]) -> anyhow::Result<CommandOutput> {
self.run_with_env(program, args, &[])
}
pub(crate) fn run_with_env(
&self,
program: &str,
args: &[&str],
env_vars: &[(&str, &str)],
) -> anyhow::Result<CommandOutput> {
let start = Instant::now();
let mut cmd = Command::new(program);
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
for (key, value) in env_vars {
cmd.env(key, value);
}
let mut child = cmd.spawn().map_err(|source| RunnerError::SpawnFailed {
program: program.to_string(),
source,
})?;
let child_stdout = child
.stdout
.take()
.ok_or(RunnerError::PipeCaptureFailed { pipe: "stdout" })?;
let child_stderr = child
.stderr
.take()
.ok_or(RunnerError::PipeCaptureFailed { pipe: "stderr" })?;
let stdout_handle = thread::spawn(move || read_pipe(child_stdout));
let stderr_handle = thread::spawn(move || read_pipe(child_stderr));
let status = self.wait_with_timeout(&mut child, start)?;
let stdout = stdout_handle
.join()
.map_err(|_| RunnerError::ReaderPanicked { pipe: "stdout" })??;
let stderr = stderr_handle
.join()
.map_err(|_| RunnerError::ReaderPanicked { pipe: "stderr" })??;
let duration = start.elapsed();
Ok(CommandOutput {
stdout,
stderr,
exit_code: status.code(),
duration,
})
}
fn wait_with_timeout(&self, child: &mut Child, start: Instant) -> anyhow::Result<ExitStatus> {
let Some(timeout) = self.timeout else {
return Ok(child.wait()?);
};
let mut sleep_ms: u64 = 1;
const MAX_SLEEP_MS: u64 = 50;
loop {
match child.try_wait()? {
Some(status) => return Ok(status),
None => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(RunnerError::Timeout { timeout }.into());
}
thread::sleep(Duration::from_millis(sleep_ms));
sleep_ms = (sleep_ms * 2).min(MAX_SLEEP_MS);
}
}
}
}
}
const MAX_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
fn read_pipe<R: Read>(mut reader: R) -> io::Result<String> {
let mut buf = Vec::new();
let mut chunk = [0u8; 8 * 1024];
loop {
let n = reader.read(&mut chunk)?;
if n == 0 {
break;
}
if buf.len() + n > MAX_OUTPUT_BYTES {
return Err(io::Error::other(format!(
"output exceeded {} byte limit",
MAX_OUTPUT_BYTES
)));
}
buf.extend_from_slice(&chunk[..n]);
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
struct FixedSizeReader {
remaining: usize,
}
impl Read for FixedSizeReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = buf.len().min(self.remaining);
for b in buf[..n].iter_mut() {
*b = b'A';
}
self.remaining -= n;
Ok(n)
}
}
#[cfg(unix)]
#[test]
fn run_echo_captures_stdout() {
let runner = CommandRunner::new(None);
let result = runner.run("echo", &["hello world"]).unwrap();
assert_eq!(result.stdout.trim(), "hello world");
assert!(result.stderr.is_empty());
assert_eq!(result.exit_code, Some(0));
}
#[cfg(unix)]
#[test]
fn run_captures_stderr() {
let runner = CommandRunner::new(None);
let result = runner
.run("cat", &["/nonexistent/path/SKIM_TEST_404"])
.unwrap();
assert!(!result.stderr.is_empty());
assert_ne!(result.exit_code, Some(0));
}
#[cfg(unix)]
#[test]
fn run_preserves_nonzero_exit_code() {
let runner = CommandRunner::new(None);
let result = runner.run("false", &[]).unwrap();
assert_eq!(result.exit_code, Some(1));
}
#[cfg(unix)]
#[test]
fn run_preserves_zero_exit_code() {
let runner = CommandRunner::new(None);
let result = runner.run("true", &[]).unwrap();
assert_eq!(result.exit_code, Some(0));
}
#[cfg(unix)]
#[test]
fn run_does_not_interpret_shell_metacharacters() {
let runner = CommandRunner::new(None);
let result = runner.run("echo", &["&& rm -rf /"]).unwrap();
assert!(
result.stdout.contains("&&"),
"Expected literal '&&' in stdout, got: {:?}",
result.stdout
);
assert_eq!(result.exit_code, Some(0));
}
#[test]
fn run_returns_error_for_nonexistent_program() {
let runner = CommandRunner::new(None);
let err = runner
.run("skim_test_nonexistent_program_42", &[])
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("skim_test_nonexistent_program_42"),
"Error should contain program name, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn run_kills_on_timeout() {
let runner = CommandRunner::new(Some(Duration::from_millis(100)));
let err = runner.run("sleep", &["10"]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("timed out"),
"Expected 'timed out' in error, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn run_completes_within_timeout() {
let runner = CommandRunner::new(Some(Duration::from_secs(5)));
let result = runner.run("echo", &["fast"]).unwrap();
assert_eq!(result.stdout.trim(), "fast");
assert_eq!(result.exit_code, Some(0));
}
#[cfg(unix)]
#[test]
fn run_tracks_duration() {
let runner = CommandRunner::new(None);
let result = runner.run("echo", &["timing"]).unwrap();
assert!(
result.duration > Duration::ZERO,
"Duration should be positive"
);
assert!(
result.duration < Duration::from_secs(5),
"Echo should complete in well under 5 seconds, took {:?}",
result.duration
);
}
#[cfg(unix)]
#[test]
fn run_handles_large_stdout_without_deadlock() {
let runner = CommandRunner::new(Some(Duration::from_secs(10)));
let result = runner.run("head", &["-c", "131072", "/dev/zero"]).unwrap();
assert!(
result.stdout.len() >= 131072,
"Expected >= 131072 bytes, got {}",
result.stdout.len()
);
}
#[test]
fn read_pipe_enforces_max_output_limit() {
struct InfiniteZeros;
impl Read for InfiniteZeros {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
for b in buf.iter_mut() {
*b = 0;
}
Ok(buf.len())
}
}
let err = read_pipe(InfiniteZeros).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Other);
assert!(
err.to_string().contains("byte limit"),
"Expected 'byte limit' in error, got: {}",
err
);
}
#[test]
fn read_pipe_accepts_output_under_limit() {
let data = vec![b'A'; 1024];
let result = read_pipe(std::io::Cursor::new(data)).unwrap();
assert_eq!(result.len(), 1024);
}
#[cfg(unix)]
#[test]
fn run_handles_concurrent_stdout_stderr() {
let runner = CommandRunner::new(Some(Duration::from_secs(10)));
let result = runner
.run(
"python3",
&[
"-c",
"import sys; sys.stdout.write('x'*100000); sys.stderr.write('y'*100000)",
],
)
.unwrap();
assert!(
result.stdout.len() >= 100000,
"Expected >= 100000 bytes on stdout, got {}",
result.stdout.len()
);
assert!(
result.stderr.len() >= 100000,
"Expected >= 100000 bytes on stderr, got {}",
result.stderr.len()
);
}
#[cfg(unix)]
#[test]
fn run_handles_empty_output() {
let runner = CommandRunner::new(None);
let result = runner.run("true", &[]).unwrap();
assert!(result.stdout.is_empty());
assert!(result.stderr.is_empty());
}
#[cfg(unix)]
#[test]
fn dispatch_overhead_under_15ms() {
let runner = CommandRunner::new(None);
let result = runner.run("true", &[]).unwrap();
assert!(
result.duration < Duration::from_millis(50),
"Expected dispatch overhead < 50ms, got {:?}",
result.duration
);
}
#[test]
fn run_empty_program_name_errors() {
let runner = CommandRunner::new(None);
let err = runner.run("", &[]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("failed to execute"),
"Expected 'failed to execute' in error, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn run_stderr_with_zero_exit() {
let runner = CommandRunner::new(None);
let result = runner
.run("bash", &["-c", "echo warn >&2; exit 0"])
.unwrap();
assert_eq!(result.exit_code, Some(0));
assert!(
!result.stderr.is_empty(),
"Expected non-empty stderr with exit code 0"
);
assert!(result.stderr.contains("warn"));
}
#[cfg(unix)]
#[test]
fn run_timeout_zero_kills_immediately() {
let runner = CommandRunner::new(Some(Duration::ZERO));
let err = runner.run("sleep", &["10"]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("timed out"),
"Expected 'timed out' in error, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn run_args_with_literal_backslash_n() {
let runner = CommandRunner::new(None);
let result = runner.run("echo", &["line1\\nline2"]).unwrap();
assert!(
result.stdout.contains("line1\\nline2"),
"Expected literal backslash-n in output, got: {:?}",
result.stdout
);
}
#[cfg(unix)]
#[test]
fn run_binary_output_is_lossy() {
let runner = CommandRunner::new(Some(Duration::from_secs(10)));
let result = runner.run("head", &["-c", "1024", "/dev/urandom"]).unwrap();
assert!(
!result.stdout.is_empty(),
"Expected non-empty stdout from /dev/urandom"
);
assert!(
result.stdout.contains('\u{FFFD}'),
"Expected replacement character in lossy output, got {} bytes",
result.stdout.len()
);
}
#[test]
fn read_pipe_at_exact_limit_succeeds() {
let reader = FixedSizeReader {
remaining: MAX_OUTPUT_BYTES,
};
let result = read_pipe(reader).unwrap();
assert_eq!(
result.len(),
MAX_OUTPUT_BYTES,
"Expected exactly MAX_OUTPUT_BYTES ({MAX_OUTPUT_BYTES}) chars"
);
}
#[test]
fn read_pipe_one_byte_over_limit_fails() {
let reader = FixedSizeReader {
remaining: MAX_OUTPUT_BYTES + 1,
};
let err = read_pipe(reader).unwrap_err();
assert!(
err.to_string().contains("byte limit"),
"Expected 'byte limit' in error, got: {}",
err
);
}
}