use crate::build::copy_with_tail; use log::debug; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; use tokio::io::AsyncWriteExt as _; use tokio::io::BufWriter; use tokio::process::Command; #[cfg(not(test))] const HOOK_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(test)] const HOOK_TIMEOUT: Duration = Duration::from_secs(2); /// Size of the in-memory tail buffer for stderr (last 256 bytes). /// Used for error context in `HookResult` without reading the full file. const STDERR_TAIL_SIZE: usize = 256; /// Result of a post-deploy hook execution. /// /// Stdout and stderr are streamed to temporary files on disk during execution. /// Callers should pass these paths to `logs::save_hook_log()` for composition. pub struct HookResult { pub command: Vec, pub stdout_file: PathBuf, pub stderr_file: PathBuf, pub last_stderr: String, pub exit_code: Option, pub duration: Duration, pub success: bool, } /// Execute a post-deploy hook command. /// /// Runs the command directly (no shell), with a minimal environment and a timeout. /// Stdout and stderr are streamed to the provided temporary files. /// Always returns a `HookResult` — never an `Err` — so callers can always log the outcome. #[allow( clippy::implicit_hasher, clippy::large_futures, clippy::too_many_arguments )] pub async fn run_post_deploy_hook( command: &[String], site_name: &str, build_dir: &Path, public_dir: &Path, timestamp: &str, build_status: &str, env: &HashMap, stdout_file: &Path, stderr_file: &Path, ) -> HookResult { let start = Instant::now(); let Some(executable) = command.first() else { let _ = tokio::fs::File::create(stdout_file).await; let _ = tokio::fs::File::create(stderr_file).await; return HookResult { command: command.to_vec(), stdout_file: stdout_file.to_path_buf(), stderr_file: stderr_file.to_path_buf(), last_stderr: "empty command".to_owned(), exit_code: None, duration: start.elapsed(), success: false, }; }; let args = command.get(1..).unwrap_or_default(); let path_env = std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_owned()); let home_env = std::env::var("HOME").unwrap_or_else(|_| "/nonexistent".to_owned()); let child = Command::new(executable) .args(args) .current_dir(build_dir) .kill_on_drop(true) .env_clear() .envs(env) .env("PATH", &path_env) .env("HOME", &home_env) .env("LANG", "C.UTF-8") .env("WITRYNA_SITE", site_name) .env("WITRYNA_BUILD_DIR", build_dir.as_os_str()) .env("WITRYNA_PUBLIC_DIR", public_dir.as_os_str()) .env("WITRYNA_BUILD_TIMESTAMP", timestamp) .env("WITRYNA_BUILD_STATUS", build_status) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn(); let mut child = match child { Ok(c) => c, Err(e) => { // Spawn failure — create empty temp files so log composition works let _ = tokio::fs::File::create(stdout_file).await; let _ = tokio::fs::File::create(stderr_file).await; return HookResult { command: command.to_vec(), stdout_file: stdout_file.to_path_buf(), stderr_file: stderr_file.to_path_buf(), last_stderr: format!("failed to spawn hook: {e}"), exit_code: None, duration: start.elapsed(), success: false, }; } }; debug!("hook process spawned: {command:?}"); let (last_stderr, exit_code, success) = stream_hook_output(&mut child, stdout_file, stderr_file).await; HookResult { command: command.to_vec(), stdout_file: stdout_file.to_path_buf(), stderr_file: stderr_file.to_path_buf(), last_stderr, exit_code, duration: start.elapsed(), success, } } /// Stream hook stdout/stderr to disk and wait for completion with timeout. /// /// Returns `(last_stderr, exit_code, success)`. On any setup or I/O failure, /// returns an error description in `last_stderr` with `success = false`. #[allow(clippy::large_futures)] async fn stream_hook_output( child: &mut tokio::process::Child, stdout_file: &Path, stderr_file: &Path, ) -> (String, Option, bool) { let Some(stdout_pipe) = child.stdout.take() else { let _ = tokio::fs::File::create(stdout_file).await; let _ = tokio::fs::File::create(stderr_file).await; return ("missing stdout pipe".to_owned(), None, false); }; let Some(stderr_pipe) = child.stderr.take() else { let _ = tokio::fs::File::create(stdout_file).await; let _ = tokio::fs::File::create(stderr_file).await; return ("missing stderr pipe".to_owned(), None, false); }; let stdout_writer = match tokio::fs::File::create(stdout_file).await { Ok(f) => BufWriter::new(f), Err(e) => { let _ = tokio::fs::File::create(stderr_file).await; return ( format!("failed to create stdout temp file: {e}"), None, false, ); } }; let stderr_writer = match tokio::fs::File::create(stderr_file).await { Ok(f) => BufWriter::new(f), Err(e) => { return ( format!("failed to create stderr temp file: {e}"), None, false, ); } }; let mut stdout_writer = stdout_writer; let mut stderr_writer = stderr_writer; #[allow(clippy::large_futures)] match tokio::time::timeout(HOOK_TIMEOUT, async { let (stdout_res, stderr_res, wait_result) = tokio::join!( copy_with_tail(stdout_pipe, &mut stdout_writer, 0), copy_with_tail(stderr_pipe, &mut stderr_writer, STDERR_TAIL_SIZE), child.wait(), ); (stdout_res, stderr_res, wait_result) }) .await { Ok((stdout_res, stderr_res, Ok(status))) => { let _ = stdout_writer.flush().await; let _ = stderr_writer.flush().await; let last_stderr = match stderr_res { Ok((_, tail)) => String::from_utf8_lossy(&tail).into_owned(), Err(_) => String::new(), }; // stdout_res error is non-fatal for hook result let _ = stdout_res; (last_stderr, status.code(), status.success()) } Ok((_, _, Err(e))) => { let _ = stdout_writer.flush().await; let _ = stderr_writer.flush().await; (format!("hook I/O error: {e}"), None, false) } Err(_) => { // Timeout — kill the child let _ = child.kill().await; let _ = stdout_writer.flush().await; let _ = stderr_writer.flush().await; (String::new(), None, false) } } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::large_futures)] mod tests { use super::*; use std::collections::HashMap; use tempfile::TempDir; use tokio::fs; fn cmd(args: &[&str]) -> Vec { args.iter().map(std::string::ToString::to_string).collect() } #[tokio::test] async fn hook_success() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&["echo", "hello"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); assert_eq!(result.exit_code, Some(0)); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); assert!(stdout.contains("hello")); } #[tokio::test] async fn hook_failure_exit_code() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&["false"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(!result.success); assert_eq!(result.exit_code, Some(1)); } #[tokio::test] async fn hook_timeout() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&["sleep", "10"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(!result.success); // Timeout path sets last_stderr to empty string — error context is in the log assert!(result.last_stderr.is_empty()); } #[tokio::test] async fn hook_env_vars() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let env = HashMap::from([ ("MY_VAR".to_owned(), "my_value".to_owned()), ("DEPLOY_TARGET".to_owned(), "staging".to_owned()), ]); let public_dir = tmp.path().join("current"); let result = run_post_deploy_hook( &cmd(&["env"]), "my-site", tmp.path(), &public_dir, "20260202-120000-000000", "success", &env, &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); assert!(stdout.contains("WITRYNA_SITE=my-site")); assert!(stdout.contains("WITRYNA_BUILD_TIMESTAMP=20260202-120000-000000")); assert!(stdout.contains("WITRYNA_BUILD_STATUS=success")); assert!(stdout.contains("WITRYNA_BUILD_DIR=")); assert!(stdout.contains("WITRYNA_PUBLIC_DIR=")); assert!(stdout.contains("PATH=")); assert!(stdout.contains("HOME=")); assert!(stdout.contains("LANG=C.UTF-8")); assert!(stdout.contains("MY_VAR=my_value")); assert!(stdout.contains("DEPLOY_TARGET=staging")); // Verify no unexpected env vars leak through let lines: Vec<&str> = stdout.lines().collect(); for line in &lines { let key = line.split('=').next().unwrap_or(""); assert!( [ "PATH", "HOME", "LANG", "WITRYNA_SITE", "WITRYNA_BUILD_DIR", "WITRYNA_PUBLIC_DIR", "WITRYNA_BUILD_TIMESTAMP", "WITRYNA_BUILD_STATUS", "MY_VAR", "DEPLOY_TARGET", ] .contains(&key), "unexpected env var: {line}" ); } } #[tokio::test] async fn hook_nonexistent_command() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&["/nonexistent-binary-xyz"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(!result.success); assert!(result.last_stderr.contains("failed to spawn hook")); } #[tokio::test] async fn hook_large_output_streams_to_disk() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); // Generate output larger than old MAX_OUTPUT_BYTES (256 KB) — now unbounded to disk let result = run_post_deploy_hook( &cmd(&["sh", "-c", "yes | head -c 300000"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); // All 300000 bytes should be on disk (no truncation) let stdout_len = fs::metadata(&stdout_tmp).await.unwrap().len(); assert_eq!(stdout_len, 300_000); } #[tokio::test] async fn hook_current_dir() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&["pwd"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); // Canonicalize to handle /tmp -> /private/tmp on macOS let expected = std::fs::canonicalize(tmp.path()).unwrap(); let actual = stdout.trim(); let actual_canonical = std::fs::canonicalize(actual).unwrap_or_default(); assert_eq!(actual_canonical, expected); } #[tokio::test] async fn hook_large_stdout_no_deadlock() { // Writes 128 KB to stdout, exceeding the ~64 KB OS pipe buffer. let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&["sh", "-c", "dd if=/dev/zero bs=1024 count=128 2>/dev/null"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); let stdout_len = fs::metadata(&stdout_tmp).await.unwrap().len(); assert_eq!(stdout_len, 128 * 1024); } #[tokio::test] async fn hook_large_stderr_no_deadlock() { // Writes 128 KB to stderr, covering the other pipe. let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&[ "sh", "-c", "dd if=/dev/zero bs=1024 count=128 >&2 2>/dev/null", ]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); let stderr_len = fs::metadata(&stderr_tmp).await.unwrap().len(); assert_eq!(stderr_len, 128 * 1024); } #[tokio::test] async fn hook_env_build_status_failed() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let result = run_post_deploy_hook( &cmd(&["env"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "failed", &HashMap::new(), &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); assert!( stdout.contains("WITRYNA_BUILD_STATUS=failed"), "WITRYNA_BUILD_STATUS should be 'failed'" ); } #[tokio::test] async fn hook_user_env_does_not_override_reserved() { let tmp = TempDir::new().unwrap(); let stdout_tmp = tmp.path().join("stdout.tmp"); let stderr_tmp = tmp.path().join("stderr.tmp"); let env = HashMap::from([("PATH".to_owned(), "/should-not-appear".to_owned())]); let result = run_post_deploy_hook( &cmd(&["env"]), "test-site", tmp.path(), &tmp.path().join("current"), "ts", "success", &env, &stdout_tmp, &stderr_tmp, ) .await; assert!(result.success); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); // PATH should be the system value, not the user override assert!(!stdout.contains("PATH=/should-not-appear")); assert!(stdout.contains("PATH=")); } }