Skip to main content

xchecker_runner/
process.rs

1use crate::error::RunnerError;
2use std::time::Duration;
3
4use super::CommandSpec;
5
6// ============================================================================
7// ProcessRunner Trait - Secure Process Execution Interface
8// ============================================================================
9
10/// Output from a process execution.
11///
12/// This is a simplified output type for the `ProcessRunner` trait,
13/// containing the essential information from process execution.
14#[derive(Debug, Clone)]
15pub struct ProcessOutput {
16    /// Standard output from the process
17    pub stdout: Vec<u8>,
18    /// Standard error from the process
19    pub stderr: Vec<u8>,
20    /// Exit code from the process (None if terminated by signal)
21    pub exit_code: Option<i32>,
22    /// Whether the execution timed out
23    pub timed_out: bool,
24}
25
26impl ProcessOutput {
27    /// Create a new `ProcessOutput` with the given values.
28    #[must_use]
29    pub fn new(stdout: Vec<u8>, stderr: Vec<u8>, exit_code: Option<i32>, timed_out: bool) -> Self {
30        Self {
31            stdout,
32            stderr,
33            exit_code,
34            timed_out,
35        }
36    }
37
38    /// Get stdout as a UTF-8 string, lossy conversion.
39    #[must_use]
40    pub fn stdout_string(&self) -> String {
41        String::from_utf8_lossy(&self.stdout).to_string()
42    }
43
44    /// Get stderr as a UTF-8 string, lossy conversion.
45    #[must_use]
46    pub fn stderr_string(&self) -> String {
47        String::from_utf8_lossy(&self.stderr).to_string()
48    }
49
50    /// Check if the process exited successfully (exit code 0).
51    #[must_use]
52    pub fn success(&self) -> bool {
53        self.exit_code == Some(0) && !self.timed_out
54    }
55}
56
57/// Trait for process execution.
58///
59/// Implementations MUST use argv-style APIs only (no shell string evaluation).
60/// This trait provides a synchronous interface for process execution.
61///
62/// # Security
63///
64/// All implementations MUST:
65/// - Use `Command::new().args()` style APIs only
66/// - NOT use shell string evaluation (`sh -c`, `cmd /C`)
67/// - Pass arguments as discrete elements, not concatenated strings
68///
69/// # Threading
70///
71/// `ProcessRunner` is a synchronous interface. Implementations MAY internally
72/// drive an async runtime (e.g., Tokio for timeouts) but MUST NOT expose async
73/// in the public API. This aligns with NFR-ASYNC.
74///
75/// # Example
76///
77/// ```rust
78/// use xchecker_utils::runner::{ProcessRunner, CommandSpec, ProcessOutput};
79/// use xchecker_utils::error::RunnerError;
80/// use std::time::Duration;
81///
82/// struct SimpleRunner;
83///
84/// impl ProcessRunner for SimpleRunner {
85///     fn run(&self, cmd: &CommandSpec, _timeout: Duration) -> Result<ProcessOutput, RunnerError> {
86///         // Use argv-style execution via CommandSpec::to_command()
87///         let output = cmd.to_command()
88///             .output()
89///             .map_err(|e| RunnerError::NativeExecutionFailed {
90///                 reason: e.to_string(),
91///             })?;
92///
93///         Ok(ProcessOutput::new(
94///             output.stdout,
95///             output.stderr,
96///             output.status.code(),
97///             false,
98///         ))
99///     }
100/// }
101///
102/// // Usage example
103/// let runner = SimpleRunner;
104/// let cmd = CommandSpec::new("echo").arg("hello");
105/// let result = runner.run(&cmd, Duration::from_secs(30));
106/// assert!(result.is_ok());
107/// ```
108pub trait ProcessRunner {
109    /// Execute a command with the given timeout.
110    ///
111    /// # Arguments
112    ///
113    /// * `cmd` - The command specification to execute
114    /// * `timeout` - Maximum duration to wait for the process to complete
115    ///
116    /// # Returns
117    ///
118    /// * `Ok(ProcessOutput)` - The process completed (possibly with non-zero exit code)
119    /// * `Err(RunnerError::Timeout)` - The process timed out
120    /// * `Err(RunnerError::*)` - Other execution errors
121    ///
122    /// # Security
123    ///
124    /// Implementations MUST use argv-style APIs only. The `CommandSpec` ensures
125    /// arguments are passed as discrete elements, preventing shell injection.
126    fn run(&self, cmd: &CommandSpec, timeout: Duration) -> Result<ProcessOutput, RunnerError>;
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    // ============================================================================
134    // ProcessOutput Tests
135    // ============================================================================
136
137    #[test]
138    fn test_process_output_new() {
139        let output = ProcessOutput::new(
140            b"stdout content".to_vec(),
141            b"stderr content".to_vec(),
142            Some(0),
143            false,
144        );
145        assert_eq!(output.stdout, b"stdout content");
146        assert_eq!(output.stderr, b"stderr content");
147        assert_eq!(output.exit_code, Some(0));
148        assert!(!output.timed_out);
149    }
150
151    #[test]
152    fn test_process_output_stdout_string() {
153        let output = ProcessOutput::new(b"hello world".to_vec(), Vec::new(), Some(0), false);
154        assert_eq!(output.stdout_string(), "hello world");
155    }
156
157    #[test]
158    fn test_process_output_stderr_string() {
159        let output = ProcessOutput::new(Vec::new(), b"error message".to_vec(), Some(1), false);
160        assert_eq!(output.stderr_string(), "error message");
161    }
162
163    #[test]
164    fn test_process_output_success() {
165        // Success case: exit code 0, not timed out
166        let success = ProcessOutput::new(Vec::new(), Vec::new(), Some(0), false);
167        assert!(success.success());
168
169        // Failure case: non-zero exit code
170        let failure = ProcessOutput::new(Vec::new(), Vec::new(), Some(1), false);
171        assert!(!failure.success());
172
173        // Failure case: timed out
174        let timeout = ProcessOutput::new(Vec::new(), Vec::new(), Some(0), true);
175        assert!(!timeout.success());
176
177        // Failure case: no exit code (killed by signal)
178        let killed = ProcessOutput::new(Vec::new(), Vec::new(), None, false);
179        assert!(!killed.success());
180    }
181
182    #[test]
183    fn test_process_output_clone() {
184        let output = ProcessOutput::new(b"stdout".to_vec(), b"stderr".to_vec(), Some(42), true);
185        let cloned = output.clone();
186        assert_eq!(cloned.stdout, output.stdout);
187        assert_eq!(cloned.stderr, output.stderr);
188        assert_eq!(cloned.exit_code, output.exit_code);
189        assert_eq!(cloned.timed_out, output.timed_out);
190    }
191
192    #[test]
193    fn test_process_output_lossy_utf8() {
194        // Test that invalid UTF-8 is handled gracefully
195        let invalid_utf8 = vec![0xff, 0xfe, 0x00, 0x01];
196        let output = ProcessOutput::new(invalid_utf8.clone(), invalid_utf8, Some(0), false);
197
198        // Should not panic, should produce replacement characters
199        let stdout = output.stdout_string();
200        let stderr = output.stderr_string();
201        assert!(!stdout.is_empty());
202        assert!(!stderr.is_empty());
203    }
204
205    // ============================================================================
206    // ProcessRunner Trait Tests
207    // ============================================================================
208
209    /// A mock implementation of ProcessRunner for testing
210    struct MockRunner {
211        expected_output: ProcessOutput,
212    }
213
214    impl ProcessRunner for MockRunner {
215        fn run(
216            &self,
217            _cmd: &CommandSpec,
218            _timeout: Duration,
219        ) -> Result<ProcessOutput, RunnerError> {
220            Ok(self.expected_output.clone())
221        }
222    }
223
224    #[test]
225    fn test_process_runner_trait_implementation() {
226        // Verify that we can implement the ProcessRunner trait
227        let mock = MockRunner {
228            expected_output: ProcessOutput::new(
229                b"mock stdout".to_vec(),
230                b"mock stderr".to_vec(),
231                Some(0),
232                false,
233            ),
234        };
235
236        let cmd = CommandSpec::new("test").arg("--flag");
237        let result = mock.run(&cmd, Duration::from_secs(30));
238
239        assert!(result.is_ok());
240        let output = result.unwrap();
241        assert_eq!(output.stdout_string(), "mock stdout");
242        assert_eq!(output.stderr_string(), "mock stderr");
243        assert!(output.success());
244    }
245
246    #[test]
247    fn test_process_runner_with_error() {
248        /// A mock runner that always returns an error
249        struct ErrorRunner;
250
251        impl ProcessRunner for ErrorRunner {
252            fn run(
253                &self,
254                _cmd: &CommandSpec,
255                _timeout: Duration,
256            ) -> Result<ProcessOutput, RunnerError> {
257                Err(RunnerError::NativeExecutionFailed {
258                    reason: "mock error".to_string(),
259                })
260            }
261        }
262
263        let runner = ErrorRunner;
264        let cmd = CommandSpec::new("test");
265        let result = runner.run(&cmd, Duration::from_secs(30));
266
267        assert!(result.is_err());
268        match result {
269            Err(RunnerError::NativeExecutionFailed { reason }) => {
270                assert_eq!(reason, "mock error");
271            }
272            _ => panic!("Expected NativeExecutionFailed error"),
273        }
274    }
275
276    #[test]
277    fn test_process_runner_with_timeout_error() {
278        /// A mock runner that simulates a timeout
279        struct TimeoutRunner;
280
281        impl ProcessRunner for TimeoutRunner {
282            fn run(
283                &self,
284                _cmd: &CommandSpec,
285                timeout: Duration,
286            ) -> Result<ProcessOutput, RunnerError> {
287                Err(RunnerError::Timeout {
288                    timeout_seconds: timeout.as_secs(),
289                })
290            }
291        }
292
293        let runner = TimeoutRunner;
294        let cmd = CommandSpec::new("test");
295        let result = runner.run(&cmd, Duration::from_secs(60));
296
297        assert!(result.is_err());
298        match result {
299            Err(RunnerError::Timeout { timeout_seconds }) => {
300                assert_eq!(timeout_seconds, 60);
301            }
302            _ => panic!("Expected Timeout error"),
303        }
304    }
305}