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    if is_git_program(&request.program) {
296        command.env_remove("GIT_DIR");
297        command.env_remove("GIT_WORK_TREE");
298    }
299    command.args(&request.args);
300    if let Some(dir) = &request.current_dir {
301        command.current_dir(dir);
302    }
303    command
304}
305
306fn is_git_program(program: &str) -> bool {
307    if program == "git" {
308        return true;
309    }
310    matches!(
311        Path::new(program)
312            .file_name()
313            .and_then(|name| name.to_str()),
314        Some("git") | Some("git.exe")
315    )
316}
317
318fn validate_request(request: &ProcessRequest) -> Result<(), ProcessExecutionError> {
319    validate_program(&request.program)?;
320    validate_args(&request.program, &request.args)?;
321    validate_current_dir(&request.current_dir)?;
322    Ok(())
323}
324
325fn validate_program(program: &str) -> Result<(), ProcessExecutionError> {
326    if program.is_empty() {
327        return Err(ProcessExecutionError::InvalidRequest {
328            detail: "program is empty".to_string(),
329        });
330    }
331
332    if program.contains('\0') {
333        return Err(ProcessExecutionError::InvalidRequest {
334            detail: "program contains NUL byte".to_string(),
335        });
336    }
337
338    let program_path = Path::new(program);
339    if program_path.is_absolute() {
340        if contains_dot_components(program_path) {
341            return Err(ProcessExecutionError::InvalidRequest {
342                detail: "program path contains '.' or '..'".to_string(),
343            });
344        }
345        return Ok(());
346    }
347
348    let mut components = program_path.components();
349    let Some(component) = components.next() else {
350        return Err(ProcessExecutionError::InvalidRequest {
351            detail: "program path is empty".to_string(),
352        });
353    };
354
355    match component {
356        Component::Normal(_) => {}
357        Component::CurDir => {
358            return Err(ProcessExecutionError::InvalidRequest {
359                detail: "program path must not be '.'".to_string(),
360            });
361        }
362        Component::ParentDir => {
363            return Err(ProcessExecutionError::InvalidRequest {
364                detail: "program path must not include '..'".to_string(),
365            });
366        }
367        Component::RootDir => {
368            return Err(ProcessExecutionError::InvalidRequest {
369                detail: "program path must be absolute when rooted".to_string(),
370            });
371        }
372        Component::Prefix(_) => {
373            return Err(ProcessExecutionError::InvalidRequest {
374                detail: "program path prefix is not an executable name".to_string(),
375            });
376        }
377    }
378
379    if components.next().is_some() {
380        return Err(ProcessExecutionError::InvalidRequest {
381            detail: "program must be an executable name or absolute path".to_string(),
382        });
383    }
384
385    Ok(())
386}
387
388fn validate_args(program: &str, args: &[String]) -> Result<(), ProcessExecutionError> {
389    let mut total_bytes = program.len();
390    if total_bytes > MAX_TOTAL_ARG_BYTES {
391        return Err(ProcessExecutionError::InvalidRequest {
392            detail: "program name exceeds maximum size".to_string(),
393        });
394    }
395
396    for arg in args {
397        if arg.contains('\0') {
398            return Err(ProcessExecutionError::InvalidRequest {
399                detail: "argument contains NUL byte".to_string(),
400            });
401        }
402
403        total_bytes = total_bytes.saturating_add(arg.len());
404        if total_bytes > MAX_TOTAL_ARG_BYTES {
405            return Err(ProcessExecutionError::InvalidRequest {
406                detail: "arguments exceed maximum total size".to_string(),
407            });
408        }
409    }
410
411    Ok(())
412}
413
414fn validate_current_dir(dir: &Option<PathBuf>) -> Result<(), ProcessExecutionError> {
415    let Some(dir) = dir else {
416        return Ok(());
417    };
418
419    if os_str_has_nul(dir.as_os_str()) {
420        return Err(ProcessExecutionError::InvalidRequest {
421            detail: "current_dir contains NUL byte".to_string(),
422        });
423    }
424
425    if contains_dot_components(dir) {
426        return Err(ProcessExecutionError::InvalidRequest {
427            detail: "current_dir must not include '.' or '..'".to_string(),
428        });
429    }
430
431    Ok(())
432}
433
434fn contains_dot_components(path: &Path) -> bool {
435    for component in path.components() {
436        match component {
437            Component::CurDir | Component::ParentDir => return true,
438            Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {}
439        }
440    }
441    false
442}
443
444#[cfg(unix)]
445fn os_str_has_nul(value: &std::ffi::OsStr) -> bool {
446    value.as_bytes().contains(&0)
447}
448
449#[cfg(windows)]
450fn os_str_has_nul(value: &std::ffi::OsStr) -> bool {
451    value.encode_wide().any(|unit| unit == 0)
452}
453
454fn temp_output_path(stream: &str, pid: u32, now_ms: i64) -> PathBuf {
455    let counter = OUTPUT_COUNTER.fetch_add(1, Ordering::Relaxed);
456    let mut path = std::env::temp_dir();
457    path.push(format!("ito-process-{stream}-{pid}-{now_ms}-{counter}.log"));
458    path
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    /// Verifies that SystemProcessRunner captures both standard output and standard error and reports the exit status and timeout flag correctly.
466    ///
467    /// # Examples
468    ///
469    /// ```
470    /// let runner = SystemProcessRunner;
471    /// let request = ProcessRequest::new("sh").args(["-c", "echo out; echo err >&2"]);
472    /// let output = runner.run(&request).unwrap();
473    /// assert!(output.success);
474    /// assert_eq!(output.exit_code, 0);
475    /// assert!(output.stdout.contains("out"));
476    /// assert!(output.stderr.contains("err"));
477    /// assert!(!output.timed_out);
478    /// ```
479    #[test]
480    fn captures_stdout_and_stderr() {
481        let runner = SystemProcessRunner;
482        let request = ProcessRequest::new("sh").args(["-c", "echo out; echo err >&2"]);
483        let output = runner.run(&request).unwrap();
484        assert!(output.success);
485        assert_eq!(output.exit_code, 0);
486        assert!(output.stdout.contains("out"));
487        assert!(output.stderr.contains("err"));
488        assert!(!output.timed_out);
489    }
490
491    #[test]
492    fn captures_non_zero_exit() {
493        let runner = SystemProcessRunner;
494        let request = ProcessRequest::new("sh").args(["-c", "echo boom >&2; exit 7"]);
495        let output = runner.run(&request).unwrap();
496        assert!(!output.success);
497        assert_eq!(output.exit_code, 7);
498        assert!(output.stderr.contains("boom"));
499    }
500
501    #[test]
502    fn missing_executable_is_spawn_failure() {
503        let runner = SystemProcessRunner;
504        let request = ProcessRequest::new("__ito_missing_executable__");
505        let result = runner.run(&request);
506        match result {
507            Err(ProcessExecutionError::Spawn { .. }) => {}
508            other => panic!("expected spawn error, got {other:?}"),
509        }
510    }
511
512    #[test]
513    fn rejects_empty_program() {
514        let request = ProcessRequest::new("");
515        let result = validate_request(&request);
516        match result {
517            Err(ProcessExecutionError::InvalidRequest { detail }) => {
518                assert!(detail.contains("program is empty"));
519            }
520            other => panic!("expected invalid request, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn rejects_nul_in_program() {
526        let request = ProcessRequest::new("sh\0bad");
527        let result = validate_request(&request);
528        match result {
529            Err(ProcessExecutionError::InvalidRequest { detail }) => {
530                assert!(detail.contains("program contains NUL byte"));
531            }
532            other => panic!("expected invalid request, got {other:?}"),
533        }
534    }
535
536    #[test]
537    fn rejects_relative_program_with_components() {
538        let request = ProcessRequest::new("bin/sh");
539        let result = validate_request(&request);
540        match result {
541            Err(ProcessExecutionError::InvalidRequest { detail }) => {
542                assert!(detail.contains("executable name or absolute path"));
543            }
544            other => panic!("expected invalid request, got {other:?}"),
545        }
546    }
547
548    #[test]
549    fn rejects_current_dir_with_parent_component() {
550        let request = ProcessRequest::new("sh").current_dir("../tmp");
551        let result = validate_request(&request);
552        match result {
553            Err(ProcessExecutionError::InvalidRequest { detail }) => {
554                assert!(detail.contains("current_dir must not include"));
555            }
556            other => panic!("expected invalid request, got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn rejects_nul_in_argument() {
562        let request = ProcessRequest::new("sh").arg("a\0b");
563        let result = validate_request(&request);
564        match result {
565            Err(ProcessExecutionError::InvalidRequest { detail }) => {
566                assert!(detail.contains("argument contains NUL byte"));
567            }
568            other => panic!("expected invalid request, got {other:?}"),
569        }
570    }
571
572    #[test]
573    fn rejects_excessive_argument_bytes() {
574        let oversized = "a".repeat(MAX_TOTAL_ARG_BYTES);
575        let request = ProcessRequest::new("sh").arg(oversized);
576        let result = validate_request(&request);
577        match result {
578            Err(ProcessExecutionError::InvalidRequest { detail }) => {
579                assert!(detail.contains("arguments exceed maximum total size"));
580            }
581            other => panic!("expected invalid request, got {other:?}"),
582        }
583    }
584
585    #[test]
586    fn run_returns_invalid_request_before_spawn() {
587        let runner = SystemProcessRunner;
588        let request = ProcessRequest::new("bin/sh");
589        let result = runner.run(&request);
590        match result {
591            Err(ProcessExecutionError::InvalidRequest { detail }) => {
592                assert!(detail.contains("executable name or absolute path"));
593            }
594            other => panic!("expected invalid request, got {other:?}"),
595        }
596    }
597}