Skip to main content

xchecker_runner/
native.rs

1use crate::error::RunnerError;
2use std::process::Stdio;
3use std::time::Duration;
4
5use super::{CommandSpec, ProcessOutput, ProcessRunner};
6
7// ============================================================================
8// NativeRunner - Secure Native Process Execution
9// ============================================================================
10
11/// Native process runner using `std::process::Command`.
12///
13/// `NativeRunner` provides secure process execution using argv-style APIs only.
14/// It is the primary implementation of [`ProcessRunner`] for native execution
15/// without shell interpretation.
16///
17/// # Security
18///
19/// `NativeRunner` enforces the following security properties:
20/// - Uses `Command::new().args()` only - NO shell string evaluation
21/// - Arguments are passed as discrete `OsString` elements
22/// - No `sh -c` or `cmd /C` shell invocation
23/// - Shell metacharacters in arguments are NOT interpreted
24///
25/// # Threading
26///
27/// `NativeRunner` is a synchronous interface. It internally uses a thread-based
28/// approach for timeout handling to avoid exposing async in the public API.
29/// This aligns with NFR-ASYNC requirements.
30///
31/// # Example
32///
33/// ```rust,no_run
34/// use xchecker_utils::runner::{NativeRunner, ProcessRunner, CommandSpec};
35/// use std::time::Duration;
36///
37/// let runner = NativeRunner::new();
38/// let cmd = CommandSpec::new("echo")
39///     .arg("hello")
40///     .arg("world");
41///
42/// let output = runner.run(&cmd, Duration::from_secs(30)).unwrap();
43/// assert!(output.success());
44/// ```
45#[derive(Debug, Clone, Copy, Default)]
46pub struct NativeRunner;
47
48impl NativeRunner {
49    /// Create a new `NativeRunner`.
50    ///
51    /// # Example
52    ///
53    /// ```rust
54    /// use xchecker_utils::runner::NativeRunner;
55    ///
56    /// let runner = NativeRunner::new();
57    /// ```
58    #[must_use]
59    pub const fn new() -> Self {
60        Self
61    }
62}
63
64impl ProcessRunner for NativeRunner {
65    /// Execute a command natively using argv-style APIs.
66    ///
67    /// This implementation:
68    /// - Uses `Command::new().args()` only (no shell)
69    /// - Handles timeout via thread-based waiting
70    /// - Captures stdout and stderr
71    /// - Returns exit code or timeout error
72    ///
73    /// # Arguments
74    ///
75    /// * `cmd` - The command specification to execute
76    /// * `timeout` - Maximum duration to wait for the process to complete
77    ///
78    /// # Returns
79    ///
80    /// * `Ok(ProcessOutput)` - The process completed (possibly with non-zero exit code)
81    /// * `Err(RunnerError::Timeout)` - The process timed out
82    /// * `Err(RunnerError::NativeExecutionFailed)` - Failed to spawn or wait for process
83    ///
84    /// # Security
85    ///
86    /// This method uses `CommandSpec::to_command()` which builds a `Command`
87    /// using `Command::new().args()` only. No shell string evaluation occurs.
88    fn run(&self, cmd: &CommandSpec, timeout: Duration) -> Result<ProcessOutput, RunnerError> {
89        use std::sync::mpsc;
90        use std::thread;
91
92        // Build the command using argv-style APIs only
93        let mut command = cmd.to_command();
94        command
95            .stdin(Stdio::null())
96            .stdout(Stdio::piped())
97            .stderr(Stdio::piped());
98
99        // Spawn the process
100        let child = command
101            .spawn()
102            .map_err(|e| RunnerError::NativeExecutionFailed {
103                reason: format!(
104                    "Failed to spawn process '{}': {}",
105                    cmd.program.to_string_lossy(),
106                    e
107                ),
108            })?;
109
110        // Create a channel for the result
111        let (tx, rx) = mpsc::channel();
112
113        // Get the child's PID for potential termination
114        let child_id = child.id();
115
116        // Spawn a thread to wait for the process
117        let handle = thread::spawn(move || {
118            let output = child.wait_with_output();
119            let _ = tx.send(output);
120        });
121
122        // Wait for the result with timeout
123        match rx.recv_timeout(timeout) {
124            Ok(output_result) => {
125                // Process completed within timeout
126                let _ = handle.join();
127
128                let output = output_result.map_err(|e| RunnerError::NativeExecutionFailed {
129                    reason: format!("Failed to wait for process: {e}"),
130                })?;
131
132                Ok(ProcessOutput::new(
133                    output.stdout,
134                    output.stderr,
135                    output.status.code(),
136                    false,
137                ))
138            }
139            Err(mpsc::RecvTimeoutError::Timeout) => {
140                // Timeout occurred - attempt to terminate the process
141                Self::terminate_process(child_id);
142
143                // Wait for the thread to finish (it should complete after termination)
144                let _ = handle.join();
145
146                Err(RunnerError::Timeout {
147                    timeout_seconds: timeout.as_secs(),
148                })
149            }
150            Err(mpsc::RecvTimeoutError::Disconnected) => {
151                // Thread panicked or channel was closed unexpectedly
152                Err(RunnerError::NativeExecutionFailed {
153                    reason: "Process monitoring thread terminated unexpectedly".to_string(),
154                })
155            }
156        }
157    }
158}
159
160impl NativeRunner {
161    /// Terminate a process by its PID.
162    ///
163    /// On Unix, sends SIGKILL to the process.
164    /// On Windows, uses TerminateProcess.
165    fn terminate_process(pid: u32) {
166        #[cfg(unix)]
167        {
168            // Send SIGKILL to the process
169            unsafe {
170                libc::kill(pid as i32, libc::SIGKILL);
171            }
172        }
173
174        #[cfg(windows)]
175        {
176            use windows::Win32::Foundation::CloseHandle;
177            use windows::Win32::System::Threading::{
178                OpenProcess, PROCESS_TERMINATE, TerminateProcess,
179            };
180
181            unsafe {
182                if let Ok(handle) = OpenProcess(PROCESS_TERMINATE, false, pid) {
183                    let _ = TerminateProcess(handle, 1);
184                    let _ = CloseHandle(handle);
185                }
186            }
187        }
188
189        #[cfg(not(any(unix, windows)))]
190        {
191            // No-op on other platforms
192            let _ = pid;
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    // ============================================================================
202    // NativeRunner Tests (FR-SEC-15, FR-SEC-16)
203    // ============================================================================
204
205    #[test]
206    fn test_native_runner_new() {
207        let runner = NativeRunner::new();
208        // NativeRunner is a zero-sized type, just verify it can be created
209        assert!(std::mem::size_of_val(&runner) == 0);
210    }
211
212    #[test]
213    fn test_native_runner_default() {
214        let runner = NativeRunner;
215        assert!(std::mem::size_of_val(&runner) == 0);
216    }
217
218    #[test]
219    fn test_native_runner_clone() {
220        let runner = NativeRunner::new();
221        let cloned = runner;
222        // Both should be valid (Copy type)
223        assert!(std::mem::size_of_val(&runner) == 0);
224        assert!(std::mem::size_of_val(&cloned) == 0);
225    }
226
227    #[test]
228    fn test_native_runner_echo_command() {
229        // Test that NativeRunner can execute a simple echo command
230        // This verifies argv-style execution works correctly
231        let runner = NativeRunner::new();
232
233        #[cfg(windows)]
234        let cmd = CommandSpec::new("cmd")
235            .arg("/C")
236            .arg("echo")
237            .arg("hello world");
238
239        #[cfg(not(windows))]
240        let cmd = CommandSpec::new("echo").arg("hello world");
241
242        let result = runner.run(&cmd, Duration::from_secs(10));
243
244        assert!(result.is_ok(), "Echo command should succeed: {:?}", result);
245        let output = result.unwrap();
246        assert!(output.success(), "Echo should exit with code 0");
247        assert!(
248            output.stdout_string().contains("hello world"),
249            "Output should contain 'hello world', got: {}",
250            output.stdout_string()
251        );
252    }
253
254    #[test]
255    fn test_native_runner_shell_metacharacters_not_interpreted() {
256        // Test that shell metacharacters are NOT interpreted
257        // This is critical for security - verifies no shell injection
258        let runner = NativeRunner::new();
259
260        // Use echo with shell metacharacters that should be passed literally
261        #[cfg(windows)]
262        let cmd = CommandSpec::new("cmd").arg("/C").arg("echo").arg("$PATH");
263
264        #[cfg(not(windows))]
265        let cmd = CommandSpec::new("echo").arg("$PATH");
266
267        let result = runner.run(&cmd, Duration::from_secs(10));
268
269        assert!(result.is_ok(), "Command should succeed");
270        let output = result.unwrap();
271        // The literal string "$PATH" should appear in output, not the expanded PATH variable
272        // Note: On Windows cmd /C echo $PATH will output "$PATH" literally
273        // On Unix, echo "$PATH" will also output "$PATH" literally since we use argv
274        assert!(
275            output.stdout_string().contains("$PATH") || output.stdout_string().contains("PATH"),
276            "Shell metacharacter should be preserved or echoed, got: {}",
277            output.stdout_string()
278        );
279    }
280
281    #[test]
282    fn test_native_runner_nonexistent_command() {
283        // Test that running a nonexistent command returns an error
284        let runner = NativeRunner::new();
285        let cmd = CommandSpec::new("this_command_definitely_does_not_exist_12345");
286
287        let result = runner.run(&cmd, Duration::from_secs(10));
288
289        assert!(result.is_err(), "Nonexistent command should fail");
290        match result {
291            Err(RunnerError::NativeExecutionFailed { reason }) => {
292                assert!(
293                    reason.contains("this_command_definitely_does_not_exist_12345"),
294                    "Error should mention the command name: {}",
295                    reason
296                );
297            }
298            _ => panic!("Expected NativeExecutionFailed error"),
299        }
300    }
301
302    #[test]
303    fn test_native_runner_exit_code_propagation() {
304        // Test that non-zero exit codes are properly propagated
305        let runner = NativeRunner::new();
306
307        #[cfg(windows)]
308        let cmd = CommandSpec::new("cmd").arg("/C").arg("exit").arg("42");
309
310        #[cfg(not(windows))]
311        let cmd = CommandSpec::new("sh").arg("-c").arg("exit 42");
312
313        let result = runner.run(&cmd, Duration::from_secs(10));
314
315        assert!(
316            result.is_ok(),
317            "Command should complete (even with non-zero exit)"
318        );
319        let output = result.unwrap();
320        assert!(!output.success(), "Exit code 42 should not be success");
321        assert_eq!(output.exit_code, Some(42), "Exit code should be 42");
322    }
323
324    #[test]
325    fn test_native_runner_stderr_capture() {
326        // Test that stderr is properly captured
327        let runner = NativeRunner::new();
328
329        #[cfg(windows)]
330        let cmd = CommandSpec::new("cmd")
331            .arg("/C")
332            .arg("echo error message 1>&2");
333
334        #[cfg(not(windows))]
335        let cmd = CommandSpec::new("sh")
336            .arg("-c")
337            .arg("echo 'error message' >&2");
338
339        let result = runner.run(&cmd, Duration::from_secs(10));
340
341        assert!(result.is_ok(), "Command should succeed");
342        let output = result.unwrap();
343        assert!(
344            output.stderr_string().contains("error message"),
345            "Stderr should contain 'error message', got: {}",
346            output.stderr_string()
347        );
348    }
349
350    #[test]
351    fn test_native_runner_implements_process_runner() {
352        // Verify NativeRunner implements ProcessRunner trait
353        fn assert_process_runner<T: ProcessRunner>(_: &T) {}
354
355        let runner = NativeRunner::new();
356        assert_process_runner(&runner);
357    }
358}