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 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 #[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}