Skip to main content

opendev_hooks/
executor.rs

1//! Subprocess runner for hook commands.
2//!
3//! Hooks are executed as shell subprocesses. The command receives JSON on stdin
4//! and communicates results via exit codes and optional JSON on stdout.
5//!
6//! Exit codes:
7//! - 0: Success (operation proceeds)
8//! - 2: Block (operation is denied)
9//! - Other: Error (logged, operation proceeds)
10
11use crate::models::HookCommand;
12use serde_json::Value;
13use std::collections::HashMap;
14use std::time::Duration;
15use tokio::io::AsyncWriteExt;
16use tokio::process::Command;
17use tracing::{error, warn};
18
19/// Result from executing a single hook command.
20#[derive(Debug, Clone, Default)]
21pub struct HookResult {
22    /// Process exit code (0 = success, 2 = block).
23    pub exit_code: i32,
24    /// Captured stdout.
25    pub stdout: String,
26    /// Captured stderr.
27    pub stderr: String,
28    /// Whether the command timed out.
29    pub timed_out: bool,
30    /// Error message if the command failed to execute.
31    pub error: Option<String>,
32}
33
34impl HookResult {
35    /// Hook succeeded (exit code 0, no timeout, no error).
36    pub fn success(&self) -> bool {
37        self.exit_code == 0 && !self.timed_out && self.error.is_none()
38    }
39
40    /// Hook requests blocking the operation (exit code 2).
41    pub fn should_block(&self) -> bool {
42        self.exit_code == 2
43    }
44
45    /// Parse stdout as a JSON object.
46    ///
47    /// Returns an empty map if stdout is empty or not valid JSON.
48    pub fn parse_json_output(&self) -> HashMap<String, Value> {
49        let trimmed = self.stdout.trim();
50        if trimmed.is_empty() {
51            return HashMap::new();
52        }
53        serde_json::from_str(trimmed).unwrap_or_default()
54    }
55}
56
57/// Executes hook commands as subprocesses.
58///
59/// This is an async executor that spawns shell processes, pipes JSON on stdin,
60/// and captures output with a timeout.
61#[derive(Debug, Clone)]
62pub struct HookExecutor;
63
64impl HookExecutor {
65    pub fn new() -> Self {
66        Self
67    }
68
69    /// Execute a hook command.
70    ///
71    /// The command receives `stdin_data` as JSON on stdin and communicates
72    /// results via exit code and optional JSON on stdout.
73    pub async fn execute(&self, command: &HookCommand, stdin_data: &Value) -> HookResult {
74        let stdin_json = match serde_json::to_string(stdin_data) {
75            Ok(s) => s,
76            Err(e) => {
77                return HookResult {
78                    exit_code: 1,
79                    error: Some(format!("Failed to serialize stdin data: {e}")),
80                    ..Default::default()
81                };
82            }
83        };
84
85        let timeout = Duration::from_secs(command.effective_timeout() as u64);
86
87        // Determine shell to use
88        let (shell, flag) = if cfg!(target_os = "windows") {
89            ("cmd", "/C")
90        } else {
91            ("sh", "-c")
92        };
93
94        let mut child = match Command::new(shell)
95            .arg(flag)
96            .arg(&command.command)
97            .stdin(std::process::Stdio::piped())
98            .stdout(std::process::Stdio::piped())
99            .stderr(std::process::Stdio::piped())
100            .spawn()
101        {
102            Ok(child) => child,
103            Err(e) => {
104                error!(
105                    command = %command.command,
106                    error = %e,
107                    "Hook command failed to execute"
108                );
109                return HookResult {
110                    exit_code: 1,
111                    error: Some(format!("Failed to execute hook: {e}")),
112                    ..Default::default()
113                };
114            }
115        };
116
117        // Write stdin
118        if let Some(mut stdin) = child.stdin.take() {
119            if let Err(e) = stdin.write_all(stdin_json.as_bytes()).await {
120                warn!(error = %e, "Failed to write stdin to hook command");
121            }
122            // Drop stdin to close the pipe so the child can read EOF
123            drop(stdin);
124        }
125
126        // Read stdout/stderr handles before waiting (wait_with_output takes
127        // ownership, so we use the lower-level approach to allow killing on timeout).
128        let stdout_handle = child.stdout.take();
129        let stderr_handle = child.stderr.take();
130
131        // Wait with timeout
132        match tokio::time::timeout(timeout, child.wait()).await {
133            Ok(Ok(status)) => {
134                let exit_code = status.code().unwrap_or(1);
135
136                // Read captured output
137                let stdout = if let Some(mut out) = stdout_handle {
138                    use tokio::io::AsyncReadExt;
139                    let mut buf = Vec::new();
140                    let _ = out.read_to_end(&mut buf).await;
141                    String::from_utf8_lossy(&buf).to_string()
142                } else {
143                    String::new()
144                };
145
146                let stderr = if let Some(mut err) = stderr_handle {
147                    use tokio::io::AsyncReadExt;
148                    let mut buf = Vec::new();
149                    let _ = err.read_to_end(&mut buf).await;
150                    String::from_utf8_lossy(&buf).to_string()
151                } else {
152                    String::new()
153                };
154
155                HookResult {
156                    exit_code,
157                    stdout,
158                    stderr,
159                    timed_out: false,
160                    error: None,
161                }
162            }
163            Ok(Err(e)) => {
164                error!(
165                    command = %command.command,
166                    error = %e,
167                    "Hook command I/O error"
168                );
169                HookResult {
170                    exit_code: 1,
171                    error: Some(format!("Hook I/O error: {e}")),
172                    ..Default::default()
173                }
174            }
175            Err(_elapsed) => {
176                warn!(
177                    command = %command.command,
178                    timeout_secs = command.effective_timeout(),
179                    "Hook command timed out"
180                );
181                // Kill the child process
182                let _ = child.kill().await;
183                HookResult {
184                    exit_code: 1,
185                    timed_out: true,
186                    error: Some(format!(
187                        "Hook timed out after {}s",
188                        command.effective_timeout()
189                    )),
190                    ..Default::default()
191                }
192            }
193        }
194    }
195}
196
197impl Default for HookExecutor {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203#[cfg(test)]
204#[path = "executor_tests.rs"]
205mod tests;