1use 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#[derive(Debug, Clone, Default)]
22pub struct ProcessRequest {
23 pub program: String,
25 pub args: Vec<String>,
27 pub current_dir: Option<PathBuf>,
29}
30
31impl ProcessRequest {
32 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 pub fn arg(mut self, arg: impl Into<String>) -> Self {
49 self.args.push(arg.into());
50 self
51 }
52
53 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 pub fn current_dir(mut self, dir: impl Into<PathBuf>) -> Self {
73 self.current_dir = Some(dir.into());
74 self
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct ProcessOutput {
81 pub exit_code: i32,
83 pub success: bool,
85 pub stdout: String,
87 pub stderr: String,
89 pub timed_out: bool,
91}
92
93#[derive(Debug, thiserror::Error)]
95pub enum ProcessExecutionError {
96 #[error("failed to spawn '{program}': {source}")]
98 Spawn {
99 program: String,
101 source: io::Error,
103 },
104 #[error("failed waiting for '{program}': {source}")]
106 Wait {
107 program: String,
109 source: io::Error,
111 },
112 #[error("failed to create temp output file '{path}': {source}")]
114 CreateTemp {
115 path: PathBuf,
117 source: io::Error,
119 },
120 #[error("failed to read temp output file '{path}': {source}")]
122 ReadTemp {
123 path: PathBuf,
125 source: io::Error,
127 },
128 #[error("invalid process request: {detail}")]
130 InvalidRequest {
131 detail: String,
133 },
134}
135
136pub trait ProcessRunner {
138 fn run(&self, request: &ProcessRequest) -> Result<ProcessOutput, ProcessExecutionError>;
143
144 fn run_with_timeout(
150 &self,
151 request: &ProcessRequest,
152 timeout: Duration,
153 ) -> Result<ProcessOutput, ProcessExecutionError>;
154}
155
156#[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 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 #[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}