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)]
462#[path = "process_tests.rs"]
463mod process_tests;