Skip to main content

greentic_dev/util/
process.rs

1use std::ffi::OsString;
2use std::path::PathBuf;
3use std::process::{Command, ExitStatus, Stdio};
4
5use anyhow::{Context, Result};
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum StreamMode {
9    Inherit,
10    Capture,
11}
12
13pub struct CommandSpec {
14    pub program: OsString,
15    pub args: Vec<OsString>,
16    pub env: Vec<(OsString, OsString)>,
17    pub current_dir: Option<PathBuf>,
18    pub stdout: StreamMode,
19    pub stderr: StreamMode,
20}
21
22impl CommandSpec {
23    pub fn new(program: impl Into<OsString>) -> Self {
24        Self {
25            program: program.into(),
26            args: Vec::new(),
27            env: Vec::new(),
28            current_dir: None,
29            stdout: StreamMode::Inherit,
30            stderr: StreamMode::Inherit,
31        }
32    }
33}
34
35pub struct CommandOutput {
36    pub status: ExitStatus,
37    #[allow(dead_code)]
38    pub stdout: Option<Vec<u8>>,
39    pub stderr: Option<Vec<u8>>,
40}
41
42pub fn run(spec: CommandSpec) -> Result<CommandOutput> {
43    // Accepted risk: this shared runner receives program paths from Greentic delegation/config code and passes argv directly without a shell.
44    // foxguard: ignore[rs/no-command-injection]
45    let mut command = Command::new(&spec.program);
46    command.args(&spec.args);
47    if let Some(dir) = &spec.current_dir {
48        command.current_dir(dir);
49    }
50    for (key, value) in &spec.env {
51        command.env(key, value);
52    }
53
54    match (spec.stdout, spec.stderr) {
55        (StreamMode::Inherit, StreamMode::Inherit) => {
56            command.stdout(Stdio::inherit());
57            command.stderr(Stdio::inherit());
58            let status = command
59                .status()
60                .with_context(|| format!("failed to spawn `{}`", spec.program.to_string_lossy()))?;
61            Ok(CommandOutput {
62                status,
63                stdout: None,
64                stderr: None,
65            })
66        }
67        (StreamMode::Capture, StreamMode::Capture) => {
68            command.stdout(Stdio::piped());
69            command.stderr(Stdio::piped());
70            let output = command
71                .output()
72                .with_context(|| format!("failed to spawn `{}`", spec.program.to_string_lossy()))?;
73            Ok(CommandOutput {
74                status: output.status,
75                stdout: Some(output.stdout),
76                stderr: Some(output.stderr),
77            })
78        }
79        _ => anyhow::bail!("mixed capture/inherit mode is not supported yet"),
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::{CommandSpec, StreamMode, run};
86    use std::ffi::OsString;
87
88    #[test]
89    fn capture_mode_collects_stdout_and_stderr() {
90        let mut spec = CommandSpec::new("sh");
91        spec.args = vec![
92            OsString::from("-c"),
93            OsString::from("printf hello; printf world >&2"),
94        ];
95        spec.stdout = StreamMode::Capture;
96        spec.stderr = StreamMode::Capture;
97
98        let output = run(spec).unwrap();
99        assert!(output.status.success());
100        assert_eq!(output.stdout.unwrap(), b"hello");
101        assert_eq!(output.stderr.unwrap(), b"world");
102    }
103
104    #[test]
105    fn inherit_mode_returns_status_without_buffers() {
106        let mut spec = CommandSpec::new("sh");
107        spec.args = vec![OsString::from("-c"), OsString::from("exit 0")];
108
109        let output = run(spec).unwrap();
110        assert!(output.status.success());
111        assert!(output.stdout.is_none());
112        assert!(output.stderr.is_none());
113    }
114
115    #[test]
116    fn mixed_modes_are_rejected() {
117        let mut spec = CommandSpec::new("sh");
118        spec.args = vec![OsString::from("-c"), OsString::from("exit 0")];
119        spec.stdout = StreamMode::Capture;
120        spec.stderr = StreamMode::Inherit;
121
122        let err = run(spec).err().expect("expected mixed-mode failure");
123        assert!(err.to_string().contains("mixed capture/inherit mode"));
124    }
125}