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}