use std::{
io::{self, Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
};
const STDERR_CAPTURE_LIMIT: usize = 2048;
const OUTPUT_QUOTE_LIMIT: usize = 512;
pub(crate) fn truncated_output(stdout: &str) -> String {
truncate(stdout.trim(), OUTPUT_QUOTE_LIMIT)
}
const MAX_POLL_INTERVAL: Duration = Duration::from_millis(20);
const DRAIN_GRACE: Duration = Duration::from_millis(250);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Completion {
Exited {
code: Option<i32>,
stdout: String,
stderr: String,
},
TimedOut,
}
pub(crate) fn execute(
command: &[String],
working_dir: &Path,
env: &[(String, String)],
payload: &str,
timeout: Duration,
) -> io::Result<Completion> {
let (program, args) = command
.split_first()
.ok_or_else(|| io::Error::other("no command to run"))?;
let deadline = Instant::now() + timeout;
let mut child = Command::new(resolve_program(program, working_dir))
.args(args)
.envs(env.iter().map(|(key, value)| (key, value)))
.current_dir(working_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let mut stdin = child.stdin.take().ok_or_else(|| pipe_missing("stdin"))?;
let mut stdout = child.stdout.take().ok_or_else(|| pipe_missing("stdout"))?;
let mut stderr = child.stderr.take().ok_or_else(|| pipe_missing("stderr"))?;
let owned_payload = payload.to_string();
thread::spawn(move || {
let _ = stdin.write_all(owned_payload.as_bytes());
let _ = stdin.flush();
});
let (out_tx, out_rx) = mpsc::channel();
let (err_tx, err_rx) = mpsc::channel();
thread::spawn(move || out_tx.send(read_all(&mut stdout)));
thread::spawn(move || err_tx.send(read_all(&mut stderr)));
let code = match supervise(&mut child, deadline)? {
Supervised::Exited(code) => code,
Supervised::Killed => return Ok(Completion::TimedOut),
};
let drain = (Instant::now() + DRAIN_GRACE).max(deadline);
let (Some(stdout), Some(stderr)) = (collect(&out_rx, drain)?, collect(&err_rx, drain)?) else {
return Ok(Completion::TimedOut);
};
Ok(Completion::Exited {
code,
stdout,
stderr: truncate(&stderr, STDERR_CAPTURE_LIMIT),
})
}
enum Supervised {
Exited(Option<i32>),
Killed,
}
fn supervise(child: &mut std::process::Child, deadline: Instant) -> io::Result<Supervised> {
let mut interval = Duration::from_millis(1);
loop {
if let Some(status) = child.try_wait()? {
return Ok(Supervised::Exited(status.code()));
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
let _ = child.kill();
let _ = child.wait();
return Ok(Supervised::Killed);
}
thread::sleep(interval.min(remaining));
interval = (interval * 2).min(MAX_POLL_INTERVAL);
}
}
fn resolve_program(program: &str, working_dir: &Path) -> PathBuf {
let path = Path::new(program);
let has_directory = path
.parent()
.is_some_and(|parent| !parent.as_os_str().is_empty());
if path.is_absolute() || !has_directory {
path.to_path_buf()
} else {
working_dir.join(path)
}
}
fn read_all(source: &mut impl Read) -> io::Result<Vec<u8>> {
let mut buffer = Vec::new();
source.read_to_end(&mut buffer)?;
Ok(buffer)
}
fn collect(
stream: &mpsc::Receiver<io::Result<Vec<u8>>>,
deadline: Instant,
) -> io::Result<Option<String>> {
let remaining = deadline.saturating_duration_since(Instant::now());
match stream.recv_timeout(remaining) {
Ok(Ok(bytes)) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
Ok(Err(error)) => Err(error),
Err(_) => Ok(None),
}
}
fn pipe_missing(stream: &str) -> io::Error {
io::Error::other(format!("hook {stream} pipe was not created"))
}
fn truncate(text: &str, limit: usize) -> String {
if text.len() <= limit {
return text.to_string();
}
let cut = (0..=limit)
.rev()
.find(|index| text.is_char_boundary(*index))
.unwrap_or(0);
format!("{}… ({} bytes total)", &text[..cut], text.len())
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn sh(script: &str) -> Vec<String> {
vec!["/bin/sh".to_string(), "-c".to_string(), script.to_string()]
}
fn run(script: &str, payload: &str, timeout: Duration) -> Completion {
execute(&sh(script), Path::new("."), &[], payload, timeout)
.expect("the process is supervised")
}
#[test]
fn stdin_reaches_the_hook_and_stdout_comes_back() {
let completion = run("cat", "hello", Duration::from_secs(5));
assert_eq!(
completion,
Completion::Exited {
code: Some(0),
stdout: "hello".to_string(),
stderr: String::new(),
}
);
}
#[test]
fn a_hook_that_never_reads_stdin_still_answers() {
let payload = "x".repeat(256 * 1024);
let completion = run("echo done", &payload, Duration::from_secs(5));
assert_eq!(
completion,
Completion::Exited {
code: Some(0),
stdout: "done\n".to_string(),
stderr: String::new(),
}
);
}
#[test]
fn an_exit_code_and_stderr_survive() {
let completion = run("echo trouble >&2; exit 3", "", Duration::from_secs(5));
match completion {
Completion::Exited {
code,
stdout,
stderr,
} => {
assert_eq!(code, Some(3));
assert!(stdout.is_empty());
assert_eq!(stderr, "trouble\n");
}
other => panic!("expected an exit, got {other:?}"),
}
}
#[test]
fn a_hanging_hook_is_killed_at_the_deadline() {
let started = Instant::now();
let completion = run("sleep 30", "", Duration::from_millis(150));
assert_eq!(completion, Completion::TimedOut);
assert!(
started.elapsed() < Duration::from_secs(5),
"the deadline, not the hook, decides how long this takes"
);
}
#[test]
fn a_descendant_holding_the_pipe_cannot_outlast_the_deadline() {
let started = Instant::now();
let completion = run(
r#"sleep 30 & echo '{"decision":"allow"}'"#,
"",
Duration::from_millis(300),
);
assert_eq!(
completion,
Completion::TimedOut,
"an answer that will not arrive is not an answer"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"took {:?}",
started.elapsed()
);
}
#[test]
fn supplied_variables_reach_the_child() {
let completion = execute(
&sh("printf %s \"$BASIS_TEST_TOKEN\""),
Path::new("."),
&[(
"BASIS_TEST_TOKEN".to_string(),
"from-the-caller".to_string(),
)],
"",
Duration::from_secs(5),
)
.expect("the process is supervised");
assert_eq!(
completion,
Completion::Exited {
code: Some(0),
stdout: "from-the-caller".to_string(),
stderr: String::new(),
}
);
}
#[test]
fn a_program_that_does_not_exist_is_an_error_not_a_verdict() {
let error = execute(
&["/definitely/not/a/real/program".to_string()],
Path::new("."),
&[],
"",
Duration::from_secs(1),
)
.expect_err("cannot be started");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn a_relative_program_is_found_next_to_the_workspace() {
let workspace = Path::new("/repo");
assert_eq!(
resolve_program("./hooks/guard.sh", workspace),
PathBuf::from("/repo/./hooks/guard.sh")
);
assert_eq!(
resolve_program("/bin/sh", workspace),
PathBuf::from("/bin/sh")
);
assert_eq!(
resolve_program("python3", workspace),
PathBuf::from("python3"),
"a bare name belongs to PATH"
);
}
#[test]
fn long_stderr_is_cut_on_a_character_boundary() {
let text = "é".repeat(100);
let cut = truncate(&text, 15);
assert!(cut.starts_with("ééééééé"));
assert!(cut.contains("200 bytes total"));
}
}