1use std::collections::BTreeMap;
10use std::ffi::{OsStr, OsString};
11use std::io::{self, Read};
12use std::path::{Path, PathBuf};
13use std::process::{Command, ExitStatus, Stdio};
14use std::sync::mpsc::{self, Receiver, TryRecvError};
15use std::thread;
16use std::time::{Duration, Instant};
17
18use crate::error::{Error, Result};
19
20#[derive(Clone)]
26pub struct CommandSpec {
27 program: OsString,
28 args: Vec<OsString>,
29 env: BTreeMap<OsString, OsString>,
30 cwd: Option<PathBuf>,
31 clear_env: bool,
32}
33
34impl CommandSpec {
35 pub fn new(program: impl Into<OsString>) -> Self {
36 Self {
37 program: program.into(),
38 args: Vec::new(),
39 env: BTreeMap::new(),
40 cwd: None,
41 clear_env: false,
42 }
43 }
44
45 pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
46 self.args.push(arg.into());
47 self
48 }
49
50 pub fn args<I, S>(mut self, args: I) -> Self
51 where
52 I: IntoIterator<Item = S>,
53 S: Into<OsString>,
54 {
55 self.args.extend(args.into_iter().map(Into::into));
56 self
57 }
58
59 pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
61 self.env.insert(key.into(), value.into());
62 self
63 }
64
65 pub fn envs<I, K, V>(mut self, env: I) -> Self
67 where
68 I: IntoIterator<Item = (K, V)>,
69 K: Into<OsString>,
70 V: Into<OsString>,
71 {
72 self.env.extend(
73 env.into_iter()
74 .map(|(key, value)| (key.into(), value.into())),
75 );
76 self
77 }
78
79 pub fn current_dir(mut self, cwd: impl Into<PathBuf>) -> Self {
80 self.cwd = Some(cwd.into());
81 self
82 }
83
84 pub fn clear_env(mut self) -> Self {
88 self.clear_env = true;
89 self
90 }
91
92 pub fn program(&self) -> &OsStr {
93 &self.program
94 }
95
96 pub fn arguments(&self) -> &[OsString] {
97 &self.args
98 }
99
100 pub fn environment(&self) -> &BTreeMap<OsString, OsString> {
101 &self.env
102 }
103
104 pub fn working_directory(&self) -> Option<&Path> {
105 self.cwd.as_deref()
106 }
107
108 pub fn environment_is_cleared(&self) -> bool {
109 self.clear_env
110 }
111
112 fn command(&self) -> Command {
113 let mut command = Command::new(&self.program);
114 if self.clear_env {
115 command.env_clear();
116 }
117 command.args(&self.args).envs(&self.env);
118 if let Some(cwd) = &self.cwd {
119 command.current_dir(cwd);
120 }
121 command
122 }
123}
124
125impl std::fmt::Debug for CommandSpec {
126 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 formatter
128 .debug_struct("CommandSpec")
129 .field("argument_count", &self.args.len())
130 .field("environment_count", &self.env.len())
131 .field("has_working_directory", &self.cwd.is_some())
132 .field("clears_environment", &self.clear_env)
133 .finish()
134 }
135}
136
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct CaptureLimits {
140 pub timeout: Duration,
141 pub stdout_bytes: usize,
142 pub stderr_bytes: usize,
143}
144
145impl CaptureLimits {
146 pub const fn new(timeout: Duration, stdout_bytes: usize, stderr_bytes: usize) -> Self {
147 Self {
148 timeout,
149 stdout_bytes,
150 stderr_bytes,
151 }
152 }
153}
154
155impl Default for CaptureLimits {
156 fn default() -> Self {
157 Self::new(Duration::from_secs(10), 64 * 1024, 64 * 1024)
158 }
159}
160
161#[derive(Clone, Default, PartialEq, Eq)]
165pub struct CapturedOutput {
166 pub stdout: Vec<u8>,
167 pub stderr: Vec<u8>,
168 pub stdout_truncated: bool,
169 pub stderr_truncated: bool,
170 pub elapsed: Duration,
171}
172
173impl std::fmt::Debug for CapturedOutput {
174 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 formatter
176 .debug_struct("CapturedOutput")
177 .field("stdout_bytes", &self.stdout.len())
178 .field("stderr_bytes", &self.stderr.len())
179 .field("stdout_truncated", &self.stdout_truncated)
180 .field("stderr_truncated", &self.stderr_truncated)
181 .field("elapsed", &self.elapsed)
182 .finish()
183 }
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub enum TerminationStatus {
189 Requested,
190 AlreadyExited,
191 Failed(io::ErrorKind),
192}
193
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub enum SpawnContext {
197 WithWorkingDirectory,
201}
202
203#[derive(Clone, PartialEq, Eq)]
205pub enum CommandOutcome {
206 NotInstalled,
208 PermissionDenied,
210 SpawnFailed {
214 kind: io::ErrorKind,
215 context: SpawnContext,
216 },
217 TimedOut {
221 output: CapturedOutput,
222 termination: TerminationStatus,
223 },
224 Exited {
227 status: ExitStatus,
228 output: CapturedOutput,
229 },
230 ExecutionFailed {
232 kind: io::ErrorKind,
233 output: CapturedOutput,
234 },
235}
236
237impl std::fmt::Debug for CommandOutcome {
238 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 match self {
240 Self::NotInstalled => formatter.write_str("NotInstalled"),
241 Self::PermissionDenied => formatter.write_str("PermissionDenied"),
242 Self::SpawnFailed { kind, context } => formatter
243 .debug_struct("SpawnFailed")
244 .field("kind", kind)
245 .field("context", context)
246 .finish(),
247 Self::TimedOut {
248 output,
249 termination,
250 } => formatter
251 .debug_struct("TimedOut")
252 .field("output", output)
253 .field("termination", termination)
254 .finish(),
255 Self::Exited { status, output } => formatter
256 .debug_struct("Exited")
257 .field("status", status)
258 .field("output", output)
259 .finish(),
260 Self::ExecutionFailed { kind, output } => formatter
261 .debug_struct("ExecutionFailed")
262 .field("kind", kind)
263 .field("output", output)
264 .finish(),
265 }
266 }
267}
268
269impl CommandOutcome {
270 pub fn output(&self) -> Option<&CapturedOutput> {
271 match self {
272 Self::TimedOut { output, .. }
273 | Self::Exited { output, .. }
274 | Self::ExecutionFailed { output, .. } => Some(output),
275 Self::NotInstalled | Self::PermissionDenied | Self::SpawnFailed { .. } => None,
276 }
277 }
278
279 pub fn exit_status(&self) -> Option<ExitStatus> {
280 match self {
281 Self::Exited { status, .. } => Some(*status),
282 _ => None,
283 }
284 }
285}
286
287pub trait CommandRunner: Send + Sync {
289 fn run_captured(&self, command: &CommandSpec, limits: CaptureLimits) -> CommandOutcome;
292
293 fn run_foreground(&self, command: &CommandSpec) -> io::Result<ExitStatus>;
297}
298
299#[derive(Clone, Copy, Debug, Default)]
301pub struct SystemCommandRunner;
302
303impl CommandRunner for SystemCommandRunner {
304 fn run_captured(&self, command: &CommandSpec, limits: CaptureLimits) -> CommandOutcome {
305 run_captured(command, limits)
306 }
307
308 fn run_foreground(&self, command: &CommandSpec) -> io::Result<ExitStatus> {
309 let mut child = command.command();
310 child
311 .stdin(Stdio::inherit())
312 .stdout(Stdio::inherit())
313 .stderr(Stdio::inherit());
314 child.status()
315 }
316}
317
318#[derive(Clone, Copy)]
319enum Stream {
320 Stdout,
321 Stderr,
322}
323
324enum StreamEvent {
325 Data(Stream, Vec<u8>),
326 Truncated(Stream),
327 Done(Stream),
328}
329
330#[derive(Default)]
331struct CaptureState {
332 output: CapturedOutput,
333 stdout_done: bool,
334 stderr_done: bool,
335}
336
337impl CaptureState {
338 fn apply(&mut self, event: StreamEvent) {
339 match event {
340 StreamEvent::Data(Stream::Stdout, bytes) => self.output.stdout.extend(bytes),
341 StreamEvent::Data(Stream::Stderr, bytes) => self.output.stderr.extend(bytes),
342 StreamEvent::Truncated(Stream::Stdout) => self.output.stdout_truncated = true,
343 StreamEvent::Truncated(Stream::Stderr) => self.output.stderr_truncated = true,
344 StreamEvent::Done(Stream::Stdout) => self.stdout_done = true,
345 StreamEvent::Done(Stream::Stderr) => self.stderr_done = true,
346 }
347 }
348
349 fn complete(&self) -> bool {
350 self.stdout_done && self.stderr_done
351 }
352}
353
354fn run_captured(command: &CommandSpec, limits: CaptureLimits) -> CommandOutcome {
355 let started = Instant::now();
356 if let Some(cwd) = &command.cwd {
357 match std::fs::metadata(cwd) {
358 Ok(metadata) if metadata.is_dir() => {}
359 Ok(_) => {
360 return CommandOutcome::SpawnFailed {
361 kind: io::ErrorKind::NotADirectory,
362 context: SpawnContext::WithWorkingDirectory,
363 };
364 }
365 Err(error) => {
366 return CommandOutcome::SpawnFailed {
367 kind: error.kind(),
368 context: SpawnContext::WithWorkingDirectory,
369 };
370 }
371 }
372 }
373 let mut process = command.command();
374 process
375 .stdin(Stdio::null())
376 .stdout(Stdio::piped())
377 .stderr(Stdio::piped());
378
379 let mut child = match process.spawn() {
380 Ok(child) => child,
381 Err(error) => {
382 if command.cwd.is_some() {
383 return CommandOutcome::SpawnFailed {
384 kind: error.kind(),
385 context: SpawnContext::WithWorkingDirectory,
386 };
387 }
388 return match error.kind() {
389 io::ErrorKind::NotFound => CommandOutcome::NotInstalled,
390 io::ErrorKind::PermissionDenied => CommandOutcome::PermissionDenied,
391 kind => CommandOutcome::ExecutionFailed {
392 kind,
393 output: CapturedOutput {
394 elapsed: started.elapsed(),
395 ..CapturedOutput::default()
396 },
397 },
398 };
399 }
400 };
401
402 let Some(stdout) = child.stdout.take() else {
403 terminate_and_reap(child);
404 return capture_failure(started, io::ErrorKind::Other);
405 };
406 let Some(stderr) = child.stderr.take() else {
407 terminate_and_reap(child);
408 return capture_failure(started, io::ErrorKind::Other);
409 };
410
411 let (sender, receiver) = mpsc::channel();
412 let stdout_sender = sender.clone();
413 if thread::Builder::new()
414 .name("osdk-stdout-drain".into())
415 .spawn(move || drain_stream(stdout, Stream::Stdout, limits.stdout_bytes, stdout_sender))
416 .is_err()
417 {
418 terminate_and_reap(child);
419 return capture_failure(started, io::ErrorKind::Other);
420 }
421 if thread::Builder::new()
422 .name("osdk-stderr-drain".into())
423 .spawn(move || drain_stream(stderr, Stream::Stderr, limits.stderr_bytes, sender))
424 .is_err()
425 {
426 terminate_and_reap(child);
427 return capture_failure(started, io::ErrorKind::Other);
428 }
429
430 let mut capture = CaptureState::default();
431 loop {
432 receive_available(&receiver, &mut capture);
433 match child.try_wait() {
434 Ok(Some(status)) => {
435 finish_capture(
436 &receiver,
437 &mut capture,
438 limits.timeout.saturating_sub(started.elapsed()),
439 );
440 capture.output.elapsed = started.elapsed();
441 return CommandOutcome::Exited {
442 status,
443 output: capture.output,
444 };
445 }
446 Ok(None) if started.elapsed() >= limits.timeout => {
447 if let Ok(Some(status)) = child.try_wait() {
449 finish_capture(&receiver, &mut capture, Duration::ZERO);
450 capture.output.elapsed = started.elapsed();
451 return CommandOutcome::Exited {
452 status,
453 output: capture.output,
454 };
455 }
456 let termination = terminate_and_reap(child);
457 finish_capture(&receiver, &mut capture, Duration::ZERO);
458 capture.output.elapsed = started.elapsed();
459 return CommandOutcome::TimedOut {
460 output: capture.output,
461 termination,
462 };
463 }
464 Ok(None) => {
465 let remaining = limits.timeout.saturating_sub(started.elapsed());
466 thread::sleep(remaining.min(Duration::from_millis(2)));
467 }
468 Err(error) => {
469 let kind = error.kind();
470 terminate_and_reap(child);
471 finish_capture(&receiver, &mut capture, Duration::ZERO);
472 capture.output.elapsed = started.elapsed();
473 return CommandOutcome::ExecutionFailed {
474 kind,
475 output: capture.output,
476 };
477 }
478 }
479 }
480}
481
482fn capture_failure(started: Instant, kind: io::ErrorKind) -> CommandOutcome {
483 CommandOutcome::ExecutionFailed {
484 kind,
485 output: CapturedOutput {
486 elapsed: started.elapsed(),
487 ..CapturedOutput::default()
488 },
489 }
490}
491
492fn drain_stream(
493 mut reader: impl Read,
494 stream: Stream,
495 limit: usize,
496 sender: mpsc::Sender<StreamEvent>,
497) {
498 let mut retained = 0usize;
499 let mut reported_truncation = false;
500 let mut buffer = [0u8; 8 * 1024];
501
502 loop {
503 let count = match reader.read(&mut buffer) {
504 Ok(0) => break,
505 Ok(count) => count,
506 Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
507 Err(_) => break,
508 };
509
510 let keep = count.min(limit.saturating_sub(retained));
511 if keep > 0 {
512 if sender
513 .send(StreamEvent::Data(stream, buffer[..keep].to_vec()))
514 .is_err()
515 {
516 return;
517 }
518 retained += keep;
519 }
520 if keep < count && !reported_truncation {
521 if sender.send(StreamEvent::Truncated(stream)).is_err() {
522 return;
523 }
524 reported_truncation = true;
525 }
526 }
527
528 let _ = sender.send(StreamEvent::Done(stream));
529}
530
531fn receive_available(receiver: &Receiver<StreamEvent>, capture: &mut CaptureState) {
532 loop {
533 match receiver.try_recv() {
534 Ok(event) => capture.apply(event),
535 Err(TryRecvError::Empty | TryRecvError::Disconnected) => return,
536 }
537 }
538}
539
540fn finish_capture(
541 receiver: &Receiver<StreamEvent>,
542 capture: &mut CaptureState,
543 max_wait: Duration,
544) {
545 receive_available(receiver, capture);
546 let started = Instant::now();
547 while !capture.complete() {
548 let remaining = max_wait.saturating_sub(started.elapsed());
549 if remaining.is_zero() {
550 break;
551 }
552 match receiver.recv_timeout(remaining) {
553 Ok(event) => capture.apply(event),
554 Err(_) => break,
555 }
556 }
557 receive_available(receiver, capture);
558}
559
560fn terminate_and_reap(mut child: std::process::Child) -> TerminationStatus {
561 let kill_error = child.kill().err().map(|error| error.kind());
562 let (termination, reaped) = match child.try_wait() {
563 Ok(Some(_)) => (TerminationStatus::AlreadyExited, true),
564 Ok(None) => (
565 kill_error
566 .map(TerminationStatus::Failed)
567 .unwrap_or(TerminationStatus::Requested),
568 false,
569 ),
570 Err(error) => (
571 TerminationStatus::Failed(kill_error.unwrap_or_else(|| error.kind())),
572 false,
573 ),
574 };
575
576 if !reaped {
577 let _ = thread::Builder::new()
581 .name("osdk-child-reaper".into())
582 .spawn(move || {
583 let _ = child.wait();
584 });
585 }
586 termination
587}
588
589pub fn run(
592 program: &str,
593 args: &[&str],
594 env: &BTreeMap<String, String>,
595 cwd: Option<&Path>,
596) -> Result<()> {
597 let mut cmd = Command::new(program);
598 cmd.args(args);
599 for (k, v) in env {
600 cmd.env(k, v);
601 }
602 if let Some(dir) = cwd {
603 cmd.current_dir(dir);
604 }
605 let output = cmd.output().map_err(|e| Error::Command {
606 cmd: format!("{program} {}", args.join(" ")),
607 status: format!("failed to spawn: {e}"),
608 stderr: None,
609 })?;
610 if output.status.success() {
611 Ok(())
612 } else {
613 Err(Error::Command {
614 cmd: format!("{program} {}", args.join(" ")),
615 status: output.status.to_string(),
616 stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()),
617 })
618 }
619}
620
621pub fn output(
622 program: &str,
623 args: &[&str],
624 env: &BTreeMap<String, String>,
625 cwd: Option<&Path>,
626) -> Result<std::process::Output> {
627 let mut command = Command::new(program);
628 command.args(args);
629 command.envs(env);
630 if let Some(directory) = cwd {
631 command.current_dir(directory);
632 }
633 let output = command.output().map_err(|error| Error::Command {
634 cmd: format!("{program} {}", args.join(" ")),
635 status: format!("failed to spawn: {error}"),
636 stderr: None,
637 })?;
638 if output.status.success() {
639 Ok(output)
640 } else {
641 Err(Error::Command {
642 cmd: format!("{program} {}", args.join(" ")),
643 status: output.status.to_string(),
644 stderr: Some(String::from_utf8_lossy(&output.stderr).into_owned()),
645 })
646 }
647}
648
649pub fn exists(program: &str) -> bool {
651 which::which(program).is_ok()
652}
653
654#[cfg(test)]
655mod tests {
656 use std::io::Write;
657
658 use super::*;
659
660 const CHILD_MODE: &str = "OSDK_PROCESS_TEST_CHILD_MODE";
661
662 fn child_command(test_name: &str, mode: &str) -> CommandSpec {
663 CommandSpec::new(std::env::current_exe().unwrap())
664 .args(["--exact", test_name, "--nocapture"])
665 .env(CHILD_MODE, mode)
666 }
667
668 #[test]
669 fn missing_program_is_classified_as_not_installed() {
670 let command = CommandSpec::new(
671 std::env::temp_dir().join("osdk-command-that-does-not-exist-4d912789"),
672 );
673 assert!(matches!(
674 SystemCommandRunner.run_captured(&command, CaptureLimits::default()),
675 CommandOutcome::NotInstalled
676 ));
677 }
678
679 #[test]
680 fn missing_working_directory_is_a_spawn_failure_not_not_installed() {
681 let temporary = tempfile::tempdir().unwrap();
682 let missing = temporary.path().join("missing-working-directory");
683 let command = CommandSpec::new(std::env::current_exe().unwrap()).current_dir(missing);
684
685 assert!(matches!(
686 SystemCommandRunner.run_captured(&command, CaptureLimits::default()),
687 CommandOutcome::SpawnFailed {
688 kind: io::ErrorKind::NotFound,
689 context: SpawnContext::WithWorkingDirectory,
690 }
691 ));
692 }
693
694 #[cfg(unix)]
695 #[test]
696 fn non_executable_program_is_classified_as_permission_denied() {
697 use std::os::unix::fs::PermissionsExt;
698
699 let temporary = tempfile::tempdir().unwrap();
700 let program = temporary.path().join("not-executable");
701 std::fs::write(&program, b"not executable").unwrap();
702 std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o600)).unwrap();
703
704 assert!(matches!(
705 SystemCommandRunner.run_captured(&CommandSpec::new(program), CaptureLimits::default()),
706 CommandOutcome::PermissionDenied
707 ));
708 }
709
710 #[test]
711 fn captured_output_is_bounded_while_both_pipes_are_drained() {
712 let outcome = SystemCommandRunner.run_captured(
713 &child_command("process::tests::child_emits_large_output", "large-output"),
714 CaptureLimits::new(Duration::from_secs(10), 113, 97),
715 );
716 let CommandOutcome::Exited { status, output } = outcome else {
717 panic!("expected exited child, got {outcome:?}");
718 };
719
720 assert!(status.success());
721 assert_eq!(output.stdout.len(), 113);
722 assert_eq!(output.stderr.len(), 97);
723 assert!(output.stdout_truncated);
724 assert!(output.stderr_truncated);
725 }
726
727 #[test]
728 fn captured_process_has_a_wall_clock_timeout() {
729 let outcome = SystemCommandRunner.run_captured(
730 &child_command("process::tests::child_blocks", "block"),
731 CaptureLimits::new(Duration::from_millis(100), 1024, 1024),
732 );
733 let CommandOutcome::TimedOut { output, .. } = outcome else {
734 panic!("expected timed-out child, got {outcome:?}");
735 };
736
737 assert!(output.elapsed >= Duration::from_millis(50));
738 let upper_bound = if cfg!(windows) {
739 Duration::from_secs(3)
740 } else {
741 Duration::from_millis(750)
742 };
743 assert!(output.elapsed < upper_bound, "{output:?}");
744 }
745
746 #[test]
747 fn debug_output_does_not_expose_captured_bytes_or_command_details() {
748 let secret = "never-print-this-secret";
749 let command = CommandSpec::new(secret)
750 .arg(secret)
751 .env(secret, secret)
752 .current_dir(secret);
753 let output = CapturedOutput {
754 stdout: secret.as_bytes().to_vec(),
755 stderr: secret.as_bytes().to_vec(),
756 ..CapturedOutput::default()
757 };
758 let outcome = CommandOutcome::ExecutionFailed {
759 kind: io::ErrorKind::Other,
760 output,
761 };
762
763 assert!(!format!("{command:?}").contains(secret));
764 assert!(!format!("{outcome:?}").contains(secret));
765 }
766
767 #[test]
768 fn child_emits_large_output() {
769 if std::env::var_os(CHILD_MODE).as_deref() != Some(OsStr::new("large-output")) {
770 return;
771 }
772
773 std::io::stdout()
774 .write_all(&vec![b'o'; 128 * 1024])
775 .unwrap();
776 std::io::stderr()
777 .write_all(&vec![b'e'; 128 * 1024])
778 .unwrap();
779 std::io::stdout().flush().unwrap();
780 std::io::stderr().flush().unwrap();
781 }
782
783 #[test]
784 fn child_blocks() {
785 if std::env::var_os(CHILD_MODE).as_deref() != Some(OsStr::new("block")) {
786 return;
787 }
788
789 thread::sleep(Duration::from_secs(5));
790 }
791}