Skip to main content

ito_core/
process.rs

1//! Process execution boundary for core-side command invocation.
2
3use std::fs;
4use std::io;
5use std::path::{Component, Path, PathBuf};
6use std::process::{Command, Stdio};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::thread;
9use std::time::{Duration, Instant};
10
11#[cfg(unix)]
12use std::os::unix::ffi::OsStrExt;
13
14#[cfg(windows)]
15use std::os::windows::ffi::OsStrExt;
16
17const MAX_TOTAL_ARG_BYTES: usize = 256 * 1024;
18static OUTPUT_COUNTER: AtomicU64 = AtomicU64::new(0);
19
20/// Process invocation request.
21#[derive(Debug, Clone, Default)]
22pub struct ProcessRequest {
23    /// Executable name or absolute path.
24    pub program: String,
25    /// Positional arguments.
26    pub args: Vec<String>,
27    /// Optional working directory.
28    pub current_dir: Option<PathBuf>,
29}
30
31impl ProcessRequest {
32    /// Create a new request for the given program.
33    ///
34    /// The program can be an executable name (resolved via PATH) or an absolute path.
35    /// Use the builder methods to configure arguments and working directory.
36    pub fn new(program: impl Into<String>) -> Self {
37        Self {
38            program: program.into(),
39            args: Vec::new(),
40            current_dir: None,
41        }
42    }
43
44    /// Add a single argument to the process invocation.
45    ///
46    /// This is a builder method that returns `self` for chaining.
47    /// Arguments are passed to the process in the order they are added.
48    pub fn arg(mut self, arg: impl Into<String>) -> Self {
49        self.args.push(arg.into());
50        self
51    }
52
53    /// Add multiple arguments to the process invocation.
54    ///
55    /// This is a builder method that returns `self` for chaining.
56    /// Arguments are appended in iteration order after any previously added arguments.
57    pub fn args<I, S>(mut self, args: I) -> Self
58    where
59        I: IntoIterator<Item = S>,
60        S: Into<String>,
61    {
62        for arg in args {
63            self.args.push(arg.into());
64        }
65        self
66    }
67
68    /// Set the working directory for the process.
69    ///
70    /// If not set, the process inherits the current working directory.
71    /// This is a builder method that returns `self` for chaining.
72    pub fn current_dir(mut self, dir: impl Into<PathBuf>) -> Self {
73        self.current_dir = Some(dir.into());
74        self
75    }
76}
77
78/// Structured process execution output.
79#[derive(Debug, Clone)]
80pub struct ProcessOutput {
81    /// Exit status code, or -1 if unavailable.
82    pub exit_code: i32,
83    /// Whether the process exited successfully.
84    pub success: bool,
85    /// Captured stdout.
86    pub stdout: String,
87    /// Captured stderr.
88    pub stderr: String,
89    /// True if execution was forcibly terminated due to timeout.
90    pub timed_out: bool,
91}
92
93/// Process execution failure modes.
94#[derive(Debug, thiserror::Error)]
95pub enum ProcessExecutionError {
96    /// Spawn failed before a child process was created.
97    #[error("failed to spawn '{program}': {source}")]
98    Spawn {
99        /// Program being executed.
100        program: String,
101        /// Underlying I/O error.
102        source: io::Error,
103    },
104    /// Waiting for process completion failed.
105    #[error("failed waiting for '{program}': {source}")]
106    Wait {
107        /// Program being executed.
108        program: String,
109        /// Underlying I/O error.
110        source: io::Error,
111    },
112    /// Creating a temporary output file failed.
113    #[error("failed to create temp output file '{path}': {source}")]
114    CreateTemp {
115        /// Temp path used for output capture.
116        path: PathBuf,
117        /// Underlying I/O error.
118        source: io::Error,
119    },
120    /// Reading a temporary output file failed.
121    #[error("failed to read temp output file '{path}': {source}")]
122    ReadTemp {
123        /// Temp path used for output capture.
124        path: PathBuf,
125        /// Underlying I/O error.
126        source: io::Error,
127    },
128    /// Invalid process request contents.
129    #[error("invalid process request: {detail}")]
130    InvalidRequest {
131        /// Reason the request is invalid.
132        detail: String,
133    },
134}
135
136/// Abstraction for process execution.
137pub trait ProcessRunner {
138    /// Execute a process and wait for completion, capturing all output.
139    ///
140    /// This method blocks until the process exits or fails to start.
141    /// Both stdout and stderr are captured and returned in the result.
142    fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError>;
143
144    /// Execute a process with a timeout, capturing all output.
145    ///
146    /// If the process doesn't complete within the timeout, it will be killed
147    /// and the result will have `timed_out` set to true. Output captured
148    /// before the timeout is still returned.
149    fn run_with_timeout(
150        &self,
151        request: &ProcessRequest,
152        timeout: Duration,
153    ) -> Result<ProcessOutput, ProcessExecutionError>;
154}
155
156/// Default runner backed by `std::process::Command`.
157#[derive(Debug, Default)]
158pub struct SystemProcessRunner;
159
160impl ProcessRunner for SystemProcessRunner {
161    fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError> {
162        validate_request(request)?;
163        let mut command = build_command(request);
164        let output = command
165            .output()
166            .map_err(|source| ProcessExecutionError::Spawn {
167                program: request.program.clone(),
168                source,
169            })?;
170        Ok(ProcessOutput {
171            exit_code: output.status.code().unwrap_or(-1),
172            success: output.status.success(),
173            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
174            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
175            timed_out: false,
176        })
177    }
178
179    /// Executes the given process request and returns its captured output, enforcing the supplied timeout.
180    ///
181    /// On timeout the child process is killed; any output written before termination is returned and `timed_out` is set to `true`.
182    ///
183    /// # Returns
184    ///
185    /// A `ProcessOutput` containing the process exit code (or -1 if unavailable), a `success` flag (false if timed out or exit indicates failure), captured `stdout` and `stderr` as `String`s, and `timed_out` indicating whether the process was terminated due to the timeout.
186    ///
187    /// # Examples
188    ///
189    /// ```
190    /// use std::time::Duration;
191    /// use ito_core::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
192    ///
193    /// let runner = SystemProcessRunner::default();
194    /// let req = ProcessRequest::new("sh")
195    ///     .arg("-c")
196    ///     .arg("echo hello; sleep 0.01; echo world");
197    /// let out = runner.run_with_timeout(&req, Duration::from_secs(1)).unwrap();
198    /// assert!(out.stdout.contains("hello"));
199    /// assert!(out.stdout.contains("world"));
200    /// assert!(!out.timed_out);
201    /// ```
202    fn run_with_timeout(
203        &self,
204        request: &ProcessRequest,
205        timeout: Duration,
206    ) -> Result<ProcessOutput, ProcessExecutionError> {
207        validate_request(request)?;
208        let now_ms = chrono::Utc::now().timestamp_millis();
209        let pid = std::process::id();
210        let stdout_path = temp_output_path("stdout", pid, now_ms);
211        let stderr_path = temp_output_path("stderr", pid, now_ms);
212
213        let stdout_file = fs::OpenOptions::new()
214            .write(true)
215            .create_new(true)
216            .open(&stdout_path)
217            .map_err(|source| ProcessExecutionError::CreateTemp {
218                path: stdout_path.clone(),
219                source,
220            })?;
221        let stderr_file = fs::OpenOptions::new()
222            .write(true)
223            .create_new(true)
224            .open(&stderr_path)
225            .map_err(|source| ProcessExecutionError::CreateTemp {
226                path: stderr_path.clone(),
227                source,
228            })?;
229
230        let mut command = build_command(request);
231        let mut child = command
232            .stdin(Stdio::null())
233            .stdout(Stdio::from(stdout_file))
234            .stderr(Stdio::from(stderr_file))
235            .spawn()
236            .map_err(|source| ProcessExecutionError::Spawn {
237                program: request.program.clone(),
238                source,
239            })?;
240
241        let started = Instant::now();
242        let mut timed_out = false;
243        let mut exit_code = -1;
244        let mut success = false;
245
246        loop {
247            if let Some(status) =
248                child
249                    .try_wait()
250                    .map_err(|source| ProcessExecutionError::Wait {
251                        program: request.program.clone(),
252                        source,
253                    })?
254            {
255                exit_code = status.code().unwrap_or(-1);
256                success = status.success();
257                break;
258            }
259
260            if started.elapsed() >= timeout {
261                timed_out = true;
262                let _ = child.kill();
263                let _ = child.wait();
264                break;
265            }
266
267            thread::sleep(Duration::from_millis(10));
268        }
269
270        let stdout =
271            fs::read_to_string(&stdout_path).map_err(|source| ProcessExecutionError::ReadTemp {
272                path: stdout_path.clone(),
273                source,
274            })?;
275        let stderr =
276            fs::read_to_string(&stderr_path).map_err(|source| ProcessExecutionError::ReadTemp {
277                path: stderr_path.clone(),
278                source,
279            })?;
280        let _ = fs::remove_file(&stdout_path);
281        let _ = fs::remove_file(&stderr_path);
282
283        Ok(ProcessOutput {
284            exit_code,
285            success: !timed_out && success,
286            stdout,
287            stderr,
288            timed_out,
289        })
290    }
291}
292
293fn build_command(request: &ProcessRequest) -> Command {
294    let mut command = Command::new(&request.program);
295    command.args(&request.args);
296    if let Some(dir) = &request.current_dir {
297        command.current_dir(dir);
298    }
299    command
300}
301
302fn validate_request(request: &ProcessRequest) -> Result<(), ProcessExecutionError> {
303    validate_program(&request.program)?;
304    validate_args(&request.program, &request.args)?;
305    validate_current_dir(&request.current_dir)?;
306    Ok(())
307}
308
309fn validate_program(program: &str) -> Result<(), ProcessExecutionError> {
310    if program.is_empty() {
311        return Err(ProcessExecutionError::InvalidRequest {
312            detail: "program is empty".to_string(),
313        });
314    }
315
316    if program.contains('\0') {
317        return Err(ProcessExecutionError::InvalidRequest {
318            detail: "program contains NUL byte".to_string(),
319        });
320    }
321
322    let program_path = Path::new(program);
323    if program_path.is_absolute() {
324        if contains_dot_components(program_path) {
325            return Err(ProcessExecutionError::InvalidRequest {
326                detail: "program path contains '.' or '..'".to_string(),
327            });
328        }
329        return Ok(());
330    }
331
332    let mut components = program_path.components();
333    let Some(component) = components.next() else {
334        return Err(ProcessExecutionError::InvalidRequest {
335            detail: "program path is empty".to_string(),
336        });
337    };
338
339    match component {
340        Component::Normal(_) => {}
341        Component::CurDir => {
342            return Err(ProcessExecutionError::InvalidRequest {
343                detail: "program path must not be '.'".to_string(),
344            });
345        }
346        Component::ParentDir => {
347            return Err(ProcessExecutionError::InvalidRequest {
348                detail: "program path must not include '..'".to_string(),
349            });
350        }
351        Component::RootDir => {
352            return Err(ProcessExecutionError::InvalidRequest {
353                detail: "program path must be absolute when rooted".to_string(),
354            });
355        }
356        Component::Prefix(_) => {
357            return Err(ProcessExecutionError::InvalidRequest {
358                detail: "program path prefix is not an executable name".to_string(),
359            });
360        }
361    }
362
363    if components.next().is_some() {
364        return Err(ProcessExecutionError::InvalidRequest {
365            detail: "program must be an executable name or absolute path".to_string(),
366        });
367    }
368
369    Ok(())
370}
371
372fn validate_args(program: &str, args: &[String]) -> Result<(), ProcessExecutionError> {
373    let mut total_bytes = program.len();
374    if total_bytes > MAX_TOTAL_ARG_BYTES {
375        return Err(ProcessExecutionError::InvalidRequest {
376            detail: "program name exceeds maximum size".to_string(),
377        });
378    }
379
380    for arg in args {
381        if arg.contains('\0') {
382            return Err(ProcessExecutionError::InvalidRequest {
383                detail: "argument contains NUL byte".to_string(),
384            });
385        }
386
387        total_bytes = total_bytes.saturating_add(arg.len());
388        if total_bytes > MAX_TOTAL_ARG_BYTES {
389            return Err(ProcessExecutionError::InvalidRequest {
390                detail: "arguments exceed maximum total size".to_string(),
391            });
392        }
393    }
394
395    Ok(())
396}
397
398fn validate_current_dir(dir: &Option<PathBuf>) -> Result<(), ProcessExecutionError> {
399    let Some(dir) = dir else {
400        return Ok(());
401    };
402
403    if os_str_has_nul(dir.as_os_str()) {
404        return Err(ProcessExecutionError::InvalidRequest {
405            detail: "current_dir contains NUL byte".to_string(),
406        });
407    }
408
409    if contains_dot_components(dir) {
410        return Err(ProcessExecutionError::InvalidRequest {
411            detail: "current_dir must not include '.' or '..'".to_string(),
412        });
413    }
414
415    Ok(())
416}
417
418fn contains_dot_components(path: &Path) -> bool {
419    for component in path.components() {
420        match component {
421            Component::CurDir | Component::ParentDir => return true,
422            Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {}
423        }
424    }
425    false
426}
427
428#[cfg(unix)]
429fn os_str_has_nul(value: &std::ffi::OsStr) -> bool {
430    value.as_bytes().contains(&0)
431}
432
433#[cfg(windows)]
434fn os_str_has_nul(value: &std::ffi::OsStr) -> bool {
435    value.encode_wide().any(|unit| unit == 0)
436}
437
438fn temp_output_path(stream: &str, pid: u32, now_ms: i64) -> PathBuf {
439    let counter = OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
440    let mut path = std::env::temp_dir();
441    path.push(format!("ito-process-{stream}-{pid}-{now_ms}-{counter}.log"));
442    path
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    /// Verifies that SystemProcessRunner captures both standard output and standard error and reports the exit status and timeout flag correctly.
450    ///
451    /// # Examples
452    ///
453    /// ```
454    /// let runner = SystemProcessRunner;
455    /// let request = ProcessRequest::new("sh").args(["-c", "echo out; echo err >&2"]);
456    /// let output = runner.run(&request).unwrap();
457    /// assert!(output.success);
458    /// assert_eq!(output.exit_code, 0);
459    /// assert!(output.stdout.contains("out"));
460    /// assert!(output.stderr.contains("err"));
461    /// assert!(!output.timed_out);
462    /// ```
463    #[test]
464    fn captures_stdout_and_stderr() {
465        let runner = SystemProcessRunner;
466        let request = ProcessRequest::new("sh").args(["-c", "echo out; echo err >&2"]);
467        let output = runner.run(&request).unwrap();
468        assert!(output.success);
469        assert_eq!(output.exit_code, 0);
470        assert!(output.stdout.contains("out"));
471        assert!(output.stderr.contains("err"));
472        assert!(!output.timed_out);
473    }
474
475    #[test]
476    fn captures_non_zero_exit() {
477        let runner = SystemProcessRunner;
478        let request = ProcessRequest::new("sh").args(["-c", "echo boom >&2; exit 7"]);
479        let output = runner.run(&request).unwrap();
480        assert!(!output.success);
481        assert_eq!(output.exit_code, 7);
482        assert!(output.stderr.contains("boom"));
483    }
484
485    #[test]
486    fn missing_executable_is_spawn_failure() {
487        let runner = SystemProcessRunner;
488        let request = ProcessRequest::new("__ito_missing_executable__");
489        let result = runner.run(&request);
490        match result {
491            Err(ProcessExecutionError::Spawn { .. }) => {}
492            other => panic!("expected spawn error, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn rejects_empty_program() {
498        let request = ProcessRequest::new("");
499        let result = validate_request(&request);
500        match result {
501            Err(ProcessExecutionError::InvalidRequest { detail }) => {
502                assert!(detail.contains("program is empty"));
503            }
504            other => panic!("expected invalid request, got {other:?}"),
505        }
506    }
507
508    #[test]
509    fn rejects_nul_in_program() {
510        let request = ProcessRequest::new("sh\0bad");
511        let result = validate_request(&request);
512        match result {
513            Err(ProcessExecutionError::InvalidRequest { detail }) => {
514                assert!(detail.contains("program contains NUL byte"));
515            }
516            other => panic!("expected invalid request, got {other:?}"),
517        }
518    }
519
520    #[test]
521    fn rejects_relative_program_with_components() {
522        let request = ProcessRequest::new("bin/sh");
523        let result = validate_request(&request);
524        match result {
525            Err(ProcessExecutionError::InvalidRequest { detail }) => {
526                assert!(detail.contains("executable name or absolute path"));
527            }
528            other => panic!("expected invalid request, got {other:?}"),
529        }
530    }
531
532    #[test]
533    fn rejects_current_dir_with_parent_component() {
534        let request = ProcessRequest::new("sh").current_dir("../tmp");
535        let result = validate_request(&request);
536        match result {
537            Err(ProcessExecutionError::InvalidRequest { detail }) => {
538                assert!(detail.contains("current_dir must not include"));
539            }
540            other => panic!("expected invalid request, got {other:?}"),
541        }
542    }
543
544    #[test]
545    fn rejects_nul_in_argument() {
546        let request = ProcessRequest::new("sh").arg("a\0b");
547        let result = validate_request(&request);
548        match result {
549            Err(ProcessExecutionError::InvalidRequest { detail }) => {
550                assert!(detail.contains("argument contains NUL byte"));
551            }
552            other => panic!("expected invalid request, got {other:?}"),
553        }
554    }
555
556    #[test]
557    fn rejects_excessive_argument_bytes() {
558        let oversized = "a".repeat(MAX_TOTAL_ARG_BYTES);
559        let request = ProcessRequest::new("sh").arg(oversized);
560        let result = validate_request(&request);
561        match result {
562            Err(ProcessExecutionError::InvalidRequest { detail }) => {
563                assert!(detail.contains("arguments exceed maximum total size"));
564            }
565            other => panic!("expected invalid request, got {other:?}"),
566        }
567    }
568
569    #[test]
570    fn run_returns_invalid_request_before_spawn() {
571        let runner = SystemProcessRunner;
572        let request = ProcessRequest::new("bin/sh");
573        let result = runner.run(&request);
574        match result {
575            Err(ProcessExecutionError::InvalidRequest { detail }) => {
576                assert!(detail.contains("executable name or absolute path"));
577            }
578            other => panic!("expected invalid request, got {other:?}"),
579        }
580    }
581}