1use std::path::{Path, PathBuf};
19use std::process::{Output, Stdio};
20use std::time::Duration;
21
22use tokio::io::AsyncWriteExt;
23use tokio::process::Command;
24use tracing::debug;
25
26pub(crate) const DEFAULT_PATH: &str =
30 "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
31
32#[derive(Debug, Clone)]
34pub(crate) struct ScriptHook {
35 path: PathBuf,
36 args: Vec<String>,
37 timeout: Duration,
38}
39
40pub(crate) enum ScriptStdin<'a> {
42 Null,
44 Json(&'a serde_json::Value),
46}
47
48#[derive(Debug, thiserror::Error)]
51pub(crate) enum ScriptError {
52 #[error("failed to spawn script {}: {detail}", path.display())]
53 Spawn { path: PathBuf, detail: String },
54 #[error("failed to serialize JSON stdin: {0}")]
55 Serialize(String),
56 #[error("script failed: {0}")]
57 Wait(String),
58 #[error("script timed out after {} ms", .0.as_millis())]
59 Timeout(Duration),
60}
61
62#[derive(Debug)]
64pub(crate) struct ScriptOutcome {
65 pub output: Output,
66 pub stdin_error: Option<String>,
76}
77
78impl ScriptHook {
79 pub(crate) fn new(script_path: &str, args: &[String], timeout_ms: u64) -> Option<Self> {
85 if script_path.trim().is_empty() {
86 return None;
87 }
88 Some(Self {
89 path: PathBuf::from(script_path),
90 args: args.to_vec(),
91 timeout: Duration::from_millis(timeout_ms),
92 })
93 }
94
95 pub(crate) fn path(&self) -> &Path {
96 &self.path
97 }
98
99 pub(crate) async fn run(
101 &self,
102 envs: &[(&str, &str)],
103 stdin: ScriptStdin<'_>,
104 ) -> Result<ScriptOutcome, ScriptError> {
105 let mut cmd = Command::new(&self.path);
106 cmd.args(&self.args);
107
108 cmd.env_clear();
117 cmd.env("PATH", DEFAULT_PATH);
118 for (key, value) in envs {
119 cmd.env(key, value);
120 }
121
122 cmd.kill_on_drop(true);
127
128 let piped_stdin = matches!(stdin, ScriptStdin::Json(_));
129 cmd.stdin(if piped_stdin {
130 Stdio::piped()
131 } else {
132 Stdio::null()
133 });
134 cmd.stdout(Stdio::piped());
135 cmd.stderr(Stdio::piped());
136
137 let mut child = cmd.spawn().map_err(|error| ScriptError::Spawn {
138 path: self.path.clone(),
139 detail: error.to_string(),
140 })?;
141
142 let mut stdin_error = None;
143 if let ScriptStdin::Json(payload) = stdin {
144 let bytes =
145 serde_json::to_vec(payload).map_err(|e| ScriptError::Serialize(e.to_string()))?;
146 if let Some(mut pipe) = child.stdin.take() {
147 if let Err(error) = pipe.write_all(&bytes).await {
149 stdin_error = Some(error.to_string());
150 } else if let Err(error) = pipe.flush().await {
151 stdin_error = Some(error.to_string());
152 }
153 if let Some(detail) = &stdin_error {
154 debug!(
155 event = "script_stdin_write_failed",
156 outcome = "failure",
157 script_path = %self.path.display(),
158 error = %detail,
159 );
160 }
161 }
163 }
164
165 match tokio::time::timeout(self.timeout, child.wait_with_output()).await {
166 Ok(Ok(output)) => Ok(ScriptOutcome {
167 output,
168 stdin_error,
169 }),
170 Ok(Err(error)) => Err(ScriptError::Wait(error.to_string())),
171 Err(_) => Err(ScriptError::Timeout(self.timeout)),
172 }
173 }
174
175 pub(crate) fn detail(outcome: &ScriptOutcome, noun: &str) -> String {
182 let stdout = String::from_utf8_lossy(&outcome.output.stdout);
183 let stderr = String::from_utf8_lossy(&outcome.output.stderr);
184 let first_line = stdout
185 .lines()
186 .find(|line| !line.trim().is_empty())
187 .or_else(|| stderr.lines().find(|line| !line.trim().is_empty()))
188 .unwrap_or("")
189 .trim();
190
191 let base = if first_line.is_empty() {
192 format!("{noun} exited with status {}", outcome.output.status)
193 } else {
194 first_line.to_string()
195 };
196
197 match &outcome.stdin_error {
198 Some(error) => format!("{base} (the script did not read its input: {error})"),
199 None => base,
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::testutil::{TempDir, write_script};
208
209 fn hook(path: &Path, timeout_ms: u64) -> ScriptHook {
210 ScriptHook::new(&path.display().to_string(), &[], timeout_ms).unwrap()
211 }
212
213 #[test]
214 fn a_blank_script_path_builds_no_hook() {
215 assert!(ScriptHook::new("", &[], 1000).is_none());
216 assert!(ScriptHook::new(" ", &[], 1000).is_none());
217 assert!(ScriptHook::new("/bin/true", &[], 1000).is_some());
218 }
219
220 #[tokio::test]
221 async fn a_missing_script_is_a_spawn_error() {
222 let hook = ScriptHook::new("/nonexistent/script", &[], 1000).unwrap();
223 let error = hook.run(&[], ScriptStdin::Null).await.unwrap_err();
224 assert!(matches!(error, ScriptError::Spawn { .. }), "got {error:?}");
225 assert!(error.to_string().contains("/nonexistent/script"));
226 }
227
228 #[tokio::test]
232 async fn the_script_does_not_inherit_the_server_environment() {
233 let dir = TempDir::new("script-hook");
234 let script = write_script(
235 &dir,
236 "env.sh",
237 "#!/bin/sh\necho \"MANIFEST=${CARGO_MANIFEST_DIR:-unset}\"\necho \"GIVEN=${ACME_TEST_VAR:-unset}\"\nexit 0\n",
238 );
239
240 let outcome = hook(&script, 5_000)
241 .run(&[("ACME_TEST_VAR", "provided")], ScriptStdin::Null)
242 .await
243 .unwrap();
244 let stdout = String::from_utf8_lossy(&outcome.output.stdout);
245
246 assert!(
247 stdout.contains("MANIFEST=unset"),
248 "the server's own environment must not leak: {stdout}"
249 );
250 assert!(
251 stdout.contains("GIVEN=provided"),
252 "the documented variables must be passed: {stdout}"
253 );
254 }
255
256 #[tokio::test]
257 async fn the_script_receives_its_json_payload_on_stdin() {
258 let dir = TempDir::new("script-hook");
259 let script = write_script(&dir, "cat.sh", "#!/bin/sh\ncat\nexit 0\n");
260
261 let payload = serde_json::json!({ "hook": "issue", "order_id": "abc" });
262 let outcome = hook(&script, 5_000)
263 .run(&[], ScriptStdin::Json(&payload))
264 .await
265 .unwrap();
266
267 let stdout = String::from_utf8_lossy(&outcome.output.stdout);
268 assert!(stdout.contains("\"order_id\":\"abc\""), "{stdout}");
269 assert!(outcome.stdin_error.is_none());
270 }
271
272 #[tokio::test]
275 async fn a_script_that_ignores_its_stdin_still_succeeds() {
276 let dir = TempDir::new("script-hook");
277 let script = write_script(&dir, "ignore.sh", "#!/bin/sh\nexit 0\n");
280
281 let payload = serde_json::json!({ "blob": "x".repeat(256 * 1024) });
282 let outcome = hook(&script, 5_000)
283 .run(&[], ScriptStdin::Json(&payload))
284 .await
285 .unwrap();
286
287 assert!(outcome.output.status.success());
288 }
289
290 #[tokio::test]
291 async fn a_timed_out_script_is_killed_rather_than_left_running() {
292 let dir = TempDir::new("script-hook");
293 let marker = dir.path().join("still-running");
294 let script = write_script(
295 &dir,
296 "slow.sh",
297 &format!("#!/bin/sh\nsleep 1\ntouch {}\nexit 0\n", marker.display()),
298 );
299
300 let error = hook(&script, 100)
301 .run(&[], ScriptStdin::Null)
302 .await
303 .unwrap_err();
304 assert!(matches!(error, ScriptError::Timeout(_)), "got {error:?}");
305
306 tokio::time::sleep(Duration::from_millis(1_500)).await;
309 assert!(
310 !marker.exists(),
311 "the script outlived its deadline and kept running"
312 );
313 }
314
315 #[tokio::test]
316 async fn detail_prefers_stdout_then_stderr_then_the_status() {
317 let dir = TempDir::new("script-hook");
318
319 let both = write_script(
320 &dir,
321 "both.sh",
322 "#!/bin/sh\necho 'from stdout'\necho 'from stderr' >&2\nexit 1\n",
323 );
324 let outcome = hook(&both, 5_000)
325 .run(&[], ScriptStdin::Null)
326 .await
327 .unwrap();
328 assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stdout");
329
330 let stderr_only = write_script(
331 &dir,
332 "stderr.sh",
333 "#!/bin/sh\necho 'from stderr' >&2\nexit 1\n",
334 );
335 let outcome = hook(&stderr_only, 5_000)
336 .run(&[], ScriptStdin::Null)
337 .await
338 .unwrap();
339 assert_eq!(ScriptHook::detail(&outcome, "test script"), "from stderr");
340
341 let silent = write_script(&dir, "silent.sh", "#!/bin/sh\nexit 3\n");
342 let outcome = hook(&silent, 5_000)
343 .run(&[], ScriptStdin::Null)
344 .await
345 .unwrap();
346 let detail = ScriptHook::detail(&outcome, "test script");
347 assert!(
348 detail.starts_with("test script exited with status"),
349 "{detail}"
350 );
351 }
352
353 #[tokio::test]
354 async fn detail_says_when_the_script_never_read_its_input() {
355 let outcome = ScriptOutcome {
356 output: std::process::Output {
357 status: Default::default(),
358 stdout: b"bad CSR\n".to_vec(),
359 stderr: Vec::new(),
360 },
361 stdin_error: Some("Broken pipe (os error 32)".to_string()),
362 };
363 let detail = ScriptHook::detail(&outcome, "custom signer script");
364 assert!(detail.contains("bad CSR"), "{detail}");
365 assert!(
366 detail.contains("did not read its input"),
367 "a script that never saw the CSR must not read as one that rejected it: {detail}"
368 );
369 }
370
371 #[tokio::test]
372 async fn the_configured_arguments_are_passed() {
373 let dir = TempDir::new("script-hook");
374 let script = write_script(&dir, "args.sh", "#!/bin/sh\necho \"$1|$2\"\nexit 0\n");
375
376 let hook = ScriptHook::new(
377 &script.display().to_string(),
378 &["first".to_string(), "second".to_string()],
379 5_000,
380 )
381 .unwrap();
382 let outcome = hook.run(&[], ScriptStdin::Null).await.unwrap();
383 assert_eq!(
384 String::from_utf8_lossy(&outcome.output.stdout).trim(),
385 "first|second"
386 );
387 }
388}