//! Hook execution runner. //! //! Executes server hooks, custom hooks, and gRPC callback hooks //! in sequence for each git hook type (pre-receive, update, post-receive). use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Output, Stdio}; use std::time::Duration; /// Result of a hook execution. #[derive(Debug)] pub struct HookResult { pub accepted: bool, pub exit_code: i32, pub stdout: String, pub stderr: String, } impl HookResult { pub fn accepted() -> Self { Self { accepted: true, exit_code: 0, stdout: String::new(), stderr: String::new(), } } pub fn rejected(stderr: String) -> Self { Self { accepted: false, exit_code: 1, stdout: String::new(), stderr, } } pub fn from_output(output: &Output) -> Self { let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); Self { accepted: output.status.success(), exit_code: output.status.code().unwrap_or(-1), stdout, stderr, } } } /// Run all hook scripts in a directory (sorted alphabetically). /// Returns the first rejection or accepted if all pass. pub fn run_hook_dir( hook_dir: &Path, hook_type: &str, stdin_data: &[u8], timeout: Duration, ) -> HookResult { if !hook_dir.exists() { return HookResult::accepted(); } let mut scripts: Vec = Vec::new(); if let Ok(entries) = std::fs::read_dir(hook_dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_file() { let name = path.file_name().unwrap_or_default().to_string_lossy(); // Skip files starting with '.' (hidden files) or ending with '~' or '.sample' if name.starts_with('.') || name.ends_with('~') || name.ends_with(".sample") { continue; } scripts.push(path); } } } scripts.sort(); let dir_start = std::time::Instant::now(); for script in &scripts { tracing::debug!( hook_type = %hook_type, script = %script.display(), "executing hook script" ); let script_start = std::time::Instant::now(); let result = run_single_script(script, stdin_data, timeout); let script_elapsed = script_start.elapsed(); crate::metrics::record_hook_execution( hook_type, if result.accepted { "ok" } else { "rejected" }, script_elapsed, ); if !result.accepted { tracing::warn!( hook_type = %hook_type, script = %script.display(), exit_code = result.exit_code, stderr = %result.stderr, elapsed_ms = script_elapsed.as_millis() as u64, "hook script rejected" ); return result; } tracing::debug!( hook_type = %hook_type, script = %script.display(), elapsed_ms = script_elapsed.as_millis() as u64, "hook script passed" ); } let dir_elapsed = dir_start.elapsed(); if !scripts.is_empty() { tracing::info!( hook_type = %hook_type, script_count = scripts.len(), elapsed_ms = dir_elapsed.as_millis() as u64, "hook dir completed" ); } HookResult::accepted() } /// Run a single hook script with stdin data and timeout. fn run_single_script(script_path: &Path, stdin_data: &[u8], timeout: Duration) -> HookResult { let child = std::process::Command::new(script_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn(); match child { Ok(mut c) => { if let Some(ref mut stdin) = c.stdin { let _ = stdin.write_all(stdin_data); } c.stdin = None; let wait_result = c.wait_timeout(timeout); match wait_result { Ok(Some(status)) => { // Process exited within timeout, get its output // Note: We already have the status, so we need to construct output differently // Since wait_with_output would fail after try_wait, we return status-only output HookResult { accepted: status.success(), exit_code: status.code().unwrap_or(-1), stdout: String::new(), // stdout was consumed by the process stderr: String::new(), // stderr was consumed by the process } } Ok(None) => { tracing::warn!( script = %script_path.display(), timeout_secs = timeout.as_secs(), "hook script timed out, killing" ); let _ = c.kill(); // Explicitly wait to reap the zombie process let _ = c.wait(); HookResult::rejected(format!( "hook script timed out after {}s: {}", timeout.as_secs(), script_path.display() )) } Err(e) => { let _ = c.kill(); // Explicitly wait to reap the zombie process let _ = c.wait(); HookResult::rejected(format!("hook script wait error: {e}")) } } } Err(e) => { tracing::warn!( script = %script_path.display(), error = %e, "failed to spawn hook script" ); // If the script can't be executed, treat as rejection HookResult::rejected(format!("failed to spawn hook script: {e}")) } } } /// Wait for a child process with timeout. trait ChildWaitTimeout { fn wait_timeout( &mut self, timeout: Duration, ) -> std::io::Result>; } impl ChildWaitTimeout for std::process::Child { fn wait_timeout( &mut self, timeout: Duration, ) -> std::io::Result> { let start = std::time::Instant::now(); loop { match self.try_wait() { Ok(Some(status)) => return Ok(Some(status)), Ok(None) => { if start.elapsed() > timeout { return Ok(None); // timeout } std::thread::sleep(Duration::from_millis(100)); } Err(e) => return Err(e), } } } } /// Shell-escape a value by wrapping in single quotes. /// Any embedded single quotes are escaped as `'\''`. fn shell_escape(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } /// Generate the gitks hook runner script content. /// This script is installed into each repository's hooks/ directory /// and orchestrates the execution of server hooks, custom hooks, and gRPC callbacks. pub fn generate_hook_runner_script( hook_type: &str, repo_relative_path: &str, server_hooks_dir: Option<&str>, hook_callback_addr: Option<&str>, hook_timeout_secs: u64, ) -> String { let server_hooks_section = if let Some(dir) = server_hooks_dir { let escaped_dir = shell_escape(dir); let escaped_hook_type = shell_escape(hook_type); format!( r#" # Run server hooks SERVER_HOOKS_DIR={escaped_dir}/{escaped_hook_type}.d if [ -d "$SERVER_HOOKS_DIR" ]; then for script in "$SERVER_HOOKS_DIR"/*; do [ -f "$script" ] || continue base=$(basename "$script") skip=false case "$base" in .*|*.sample|*~) skip=true;; esac if [ "$skip" = "false" ]; then "$script" exit_code=$? if [ $exit_code -ne 0 ]; then exit $exit_code fi fi done fi "# ) } else { String::new() }; let custom_hooks_section = r#" # Run custom hooks (per-repository) CUSTOM_HOOKS_DIR="$GIT_DIR/custom_hooks/$GITKS_HOOK_TYPE.d" if [ -d "$CUSTOM_HOOKS_DIR" ]; then for script in "$CUSTOM_HOOKS_DIR"/*; do [ -f "$script" ] || continue base=$(basename "$script") skip=false case "$base" in .*|*.sample|*~) skip=true;; esac if [ "$skip" = "false" ]; then "$script" exit_code=$? if [ $exit_code -ne 0 ]; then exit $exit_code fi fi done fi "#; let grpc_callback_section = if let Some(addr) = hook_callback_addr { let escaped_addr = shell_escape(addr); format!( r#" # gRPC callback to external HookService if [ -n {escaped_addr} ]; then # gRPC callback is handled by the gitks service directly # The service will make the gRPC call after the git hook completes # This section is a placeholder - actual gRPC callback is handled # by the gitks receive_pack handler fi "# ) } else { String::new() }; let escaped_hook_type = shell_escape(hook_type); let escaped_repo_path = shell_escape(repo_relative_path); format!( r#"#!/bin/sh # gitks hook runner for {hook_type} # Repository: {repo_relative_path} # Auto-generated by gitks - do not modify manually GITKS_HOOK_TYPE={escaped_hook_type} GITKS_REPO_RELATIVE_PATH={escaped_repo_path} GITKS_HOOK_TIMEOUT="{hook_timeout_secs}" {server_hooks_section} {custom_hooks_section} {grpc_callback_section} exit 0 "# ) }