use std::path::{Path, PathBuf};
use std::process::{Output, Stdio};
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tracing::debug;
pub(crate) const DEFAULT_PATH: &str =
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
pub(crate) const MAX_SCRIPT_OUTPUT_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub struct ScriptHook {
path: PathBuf,
args: Vec<String>,
timeout: Duration,
}
pub enum ScriptStdin<'a> {
Null,
Json(&'a serde_json::Value),
}
#[derive(Debug, thiserror::Error)]
pub enum ScriptError {
#[error("failed to spawn script {}: {detail}", path.display())]
Spawn { path: PathBuf, detail: String },
#[error("failed to serialize JSON stdin: {0}")]
Serialize(String),
#[error("script failed: {0}")]
Wait(String),
#[error("script timed out after {} ms", .0.as_millis())]
Timeout(Duration),
#[error("script wrote more than {limit} bytes to {stream}")]
OutputTooLarge { limit: usize, stream: &'static str },
}
#[derive(Debug)]
pub struct ScriptOutcome {
pub output: Output,
pub stdin_error: Option<String>,
}
impl ScriptHook {
pub fn new(script_path: &str, args: &[String], timeout_ms: u64) -> Option<Self> {
if script_path.trim().is_empty() {
return None;
}
Some(Self {
path: PathBuf::from(script_path),
args: args.to_vec(),
timeout: Duration::from_millis(timeout_ms),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub async fn run(
&self,
envs: &[(&str, &str)],
stdin: ScriptStdin<'_>,
) -> Result<ScriptOutcome, ScriptError> {
let mut cmd = Command::new(&self.path);
cmd.args(&self.args);
cmd.env_clear();
cmd.env("PATH", DEFAULT_PATH);
for (key, value) in envs {
cmd.env(key, value);
}
cmd.kill_on_drop(true);
let piped_stdin = matches!(stdin, ScriptStdin::Json(_));
cmd.stdin(if piped_stdin {
Stdio::piped()
} else {
Stdio::null()
});
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd.spawn().map_err(|error| ScriptError::Spawn {
path: self.path.clone(),
detail: error.to_string(),
})?;
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let payload = match stdin {
ScriptStdin::Json(payload) => Some(
serde_json::to_vec(payload).map_err(|e| ScriptError::Serialize(e.to_string()))?,
),
ScriptStdin::Null => None,
};
let stdin_pipe = child.stdin.take();
let feed = async move {
let (Some(bytes), Some(mut pipe)) = (payload, stdin_pipe) else {
return Ok::<_, ScriptError>(None);
};
let mut stdin_error = None;
if let Err(error) = pipe.write_all(&bytes).await {
stdin_error = Some(error.to_string());
} else if let Err(error) = pipe.flush().await {
stdin_error = Some(error.to_string());
}
if let Some(detail) = &stdin_error {
debug!(
event = "script_stdin_write_failed",
outcome = "failure",
script_path = %self.path.display(),
error = %detail,
);
}
Ok(stdin_error)
};
let collect = async {
let (status, stdout, stderr, stdin_error) = tokio::try_join!(
async {
child
.wait()
.await
.map_err(|e| ScriptError::Wait(e.to_string()))
},
read_capped(stdout_pipe, "stdout"),
read_capped(stderr_pipe, "stderr"),
feed,
)?;
Ok(ScriptOutcome {
output: Output {
status,
stdout,
stderr,
},
stdin_error,
})
};
match tokio::time::timeout(self.timeout, collect).await {
Ok(result) => result,
Err(_) => Err(ScriptError::Timeout(self.timeout)),
}
}
pub fn detail(outcome: &ScriptOutcome, noun: &str) -> String {
let stdout = String::from_utf8_lossy(&outcome.output.stdout);
let stderr = String::from_utf8_lossy(&outcome.output.stderr);
let first_line = stdout
.lines()
.find(|line| !line.trim().is_empty())
.or_else(|| stderr.lines().find(|line| !line.trim().is_empty()))
.unwrap_or("")
.trim();
let base = if first_line.is_empty() {
format!("{noun} exited with status {}", outcome.output.status)
} else {
first_line.to_string()
};
match &outcome.stdin_error {
Some(error) => format!("{base} (the script did not read its input: {error})"),
None => base,
}
}
}
async fn read_capped(
pipe: Option<impl tokio::io::AsyncRead + Unpin>,
stream: &'static str,
) -> Result<Vec<u8>, ScriptError> {
use tokio::io::AsyncReadExt;
let Some(pipe) = pipe else {
return Ok(Vec::new());
};
let limit = MAX_SCRIPT_OUTPUT_BYTES;
let mut buffer = Vec::new();
pipe.take(limit as u64 + 1)
.read_to_end(&mut buffer)
.await
.map_err(|error| ScriptError::Wait(error.to_string()))?;
if buffer.len() > limit {
return Err(ScriptError::OutputTooLarge { limit, stream });
}
Ok(buffer)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{TempDir, write_script};
fn hook(path: &Path, timeout_ms: u64) -> ScriptHook {
ScriptHook::new(&path.display().to_string(), &[], timeout_ms).unwrap()
}
#[test]
fn a_blank_script_path_builds_no_hook() {
assert!(ScriptHook::new("", &[], 1000).is_none());
assert!(ScriptHook::new(" ", &[], 1000).is_none());
assert!(ScriptHook::new("/bin/true", &[], 1000).is_some());
}
#[tokio::test]
async fn a_missing_script_is_a_spawn_error() {
let hook = ScriptHook::new("/nonexistent/script", &[], 1000).unwrap();
let error = hook.run(&[], ScriptStdin::Null).await.unwrap_err();
assert!(matches!(error, ScriptError::Spawn { .. }), "got {error:?}");
assert!(error.to_string().contains("/nonexistent/script"));
}
#[tokio::test]
async fn the_script_does_not_inherit_the_server_environment() {
let dir = TempDir::new("script-hook");
let script = write_script(
&dir,
"env.sh",
"#!/bin/sh\necho \"MANIFEST=${CARGO_MANIFEST_DIR:-unset}\"\necho \"GIVEN=${ACME_TEST_VAR:-unset}\"\nexit 0\n",
);
let outcome = hook(&script, 5_000)
.run(&[("ACME_TEST_VAR", "provided")], ScriptStdin::Null)
.await
.unwrap();
let stdout = String::from_utf8_lossy(&outcome.output.stdout);
assert!(
stdout.contains("MANIFEST=unset"),
"the server's own environment must not leak: {stdout}"
);
assert!(
stdout.contains("GIVEN=provided"),
"the documented variables must be passed: {stdout}"
);
}
#[tokio::test]
async fn the_script_receives_its_json_payload_on_stdin() {
let dir = TempDir::new("script-hook");
let script = write_script(&dir, "cat.sh", "#!/bin/sh\ncat\nexit 0\n");
let payload = serde_json::json!({ "hook": "issue", "order_id": "abc" });
let outcome = hook(&script, 5_000)
.run(&[], ScriptStdin::Json(&payload))
.await
.unwrap();
let stdout = String::from_utf8_lossy(&outcome.output.stdout);
assert!(stdout.contains("\"order_id\":\"abc\""), "{stdout}");
assert!(outcome.stdin_error.is_none());
}
#[tokio::test]
async fn a_script_that_ignores_its_stdin_still_succeeds() {
let dir = TempDir::new("script-hook");
let script = write_script(&dir, "ignore.sh", "#!/bin/sh\nexit 0\n");
let payload = serde_json::json!({ "blob": "x".repeat(256 * 1024) });
let outcome = hook(&script, 5_000)
.run(&[], ScriptStdin::Json(&payload))
.await
.unwrap();
assert!(outcome.output.status.success());
}
#[tokio::test]
async fn a_script_that_never_reads_a_large_payload_still_times_out() {
let dir = TempDir::new("script-hook");
let script = write_script(
&dir,
"deaf.sh",
"#!/bin/sh
exec sleep 5
",
);
let payload = serde_json::json!({ "blob": "x".repeat(256 * 1024) });
let started = std::time::Instant::now();
let error = hook(&script, 300)
.run(&[], ScriptStdin::Json(&payload))
.await
.unwrap_err();
assert!(matches!(error, ScriptError::Timeout(_)), "got {error:?}");
assert!(
started.elapsed() < Duration::from_secs(3),
"the deadline did not bound the write: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn a_timed_out_script_is_killed_rather_than_left_running() {
let dir = TempDir::new("script-hook");
let marker = dir.path().join("still-running");
let script = write_script(
&dir,
"slow.sh",
&format!("#!/bin/sh\nsleep 1\ntouch {}\nexit 0\n", marker.display()),
);
let error = hook(&script, 100)
.run(&[], ScriptStdin::Null)
.await
.unwrap_err();
assert!(matches!(error, ScriptError::Timeout(_)), "got {error:?}");
tokio::time::sleep(Duration::from_millis(1_500)).await;
assert!(
!marker.exists(),
"the script outlived its deadline and kept running"
);
}
#[tokio::test]
async fn detail_prefers_stdout_then_stderr_then_the_status() {
let dir = TempDir::new("script-hook");
let both = write_script(
&dir,
"both.sh",
"#!/bin/sh\necho 'from stdout'\necho 'from stderr' >&2\nexit 1\n",
);
let outcome = hook(&both, 5_000)
.run(&[], ScriptStdin::Null)
.await
.unwrap();
assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stdout");
let stderr_only = write_script(
&dir,
"stderr.sh",
"#!/bin/sh\necho 'from stderr' >&2\nexit 1\n",
);
let outcome = hook(&stderr_only, 5_000)
.run(&[], ScriptStdin::Null)
.await
.unwrap();
assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stderr");
let silent = write_script(&dir, "silent.sh", "#!/bin/sh\nexit 3\n");
let outcome = hook(&silent, 5_000)
.run(&[], ScriptStdin::Null)
.await
.unwrap();
let detail = ScriptHook::detail(&outcome, "test script");
assert!(
detail.starts_with("test script exited with status"),
"{detail}"
);
}
#[tokio::test]
async fn detail_says_when_the_script_never_read_its_input() {
let outcome = ScriptOutcome {
output: std::process::Output {
status: Default::default(),
stdout: b"bad CSR\n".to_vec(),
stderr: Vec::new(),
},
stdin_error: Some("Broken pipe (os error 32)".to_string()),
};
let detail = ScriptHook::detail(&outcome, "custom signer script");
assert!(detail.contains("bad CSR"), "{detail}");
assert!(
detail.contains("did not read its input"),
"a script that never saw the CSR must not read as one that rejected it: {detail}"
);
}
#[tokio::test]
async fn the_configured_arguments_are_passed() {
let dir = TempDir::new("script-hook");
let script = write_script(&dir, "args.sh", "#!/bin/sh\necho \"$1|$2\"\nexit 0\n");
let hook = ScriptHook::new(
&script.display().to_string(),
&["first".to_string(), "second".to_string()],
5_000,
)
.unwrap();
let outcome = hook.run(&[], ScriptStdin::Null).await.unwrap();
assert_eq!(
String::from_utf8_lossy(&outcome.output.stdout).trim(),
"first|second"
);
}
#[tokio::test]
async fn a_script_that_floods_a_stream_is_refused_rather_than_buffered() {
let dir = TempDir::new("script-hook");
let cases = [
("stdout", "#!/bin/sh\nyes 0123456789abcdef\n"),
("stderr", "#!/bin/sh\nyes 0123456789abcdef >&2\n"),
];
for (stream, body) in cases {
let script = write_script(&dir, &format!("flood-{stream}.sh"), body);
let error = hook(&script, 30_000)
.run(&[], ScriptStdin::Null)
.await
.unwrap_err();
match error {
ScriptError::OutputTooLarge { limit, stream: got } => {
assert_eq!(limit, MAX_SCRIPT_OUTPUT_BYTES);
assert_eq!(got, stream, "the wrong stream was named");
}
other => panic!("expected OutputTooLarge for {stream}, got {other:?}"),
}
}
}
#[tokio::test]
async fn output_under_the_cap_arrives_intact() {
let dir = TempDir::new("script-hook");
let count = MAX_SCRIPT_OUTPUT_BYTES / 4 / 16;
let script = write_script(
&dir,
"bulk.sh",
&format!("#!/bin/sh\nyes 0123456789abcde | head -n {count}\nexit 0\n"),
);
let outcome = hook(&script, 30_000).run(&[], ScriptStdin::Null).await;
let outcome = outcome.expect("output under the cap must not be refused");
assert!(outcome.output.status.success());
assert_eq!(outcome.output.stdout.len(), count * 16);
assert!(outcome.output.stderr.is_empty());
}
#[tokio::test]
async fn a_script_writing_more_than_one_pipe_buffer_does_not_deadlock() {
let dir = TempDir::new("script-hook");
let count = 32_768;
let script = write_script(
&dir,
"chatty.sh",
&format!("#!/bin/sh\nyes 0123456789abcde | head -n {count}\nexit 0\n"),
);
let outcome = hook(&script, 10_000)
.run(&[], ScriptStdin::Null)
.await
.expect("a script filling the pipe buffer must not time out");
assert_eq!(outcome.output.stdout.len(), count * 16);
}
}