Skip to main content

origin_process_std/
lib.rs

1//! The process contract (`ProcessRunner`) over the local machine (B1).
2//!
3//! The allowlist is the security boundary, and it is enforced here in Rust *before*
4//! anything reaches the operating system — exactly what the shared contract test
5//! checks. There is no general shell: a program that is not configured is refused
6//! with a permission error, and the arguments are passed as a vector, never through a
7//! shell string, so there is nothing to quote-escape.
8
9use async_trait::async_trait;
10use origin_domain::{AppError, Result};
11use origin_platform::{ProcessAllowlist, ProcessOutput, ProcessRunner};
12use std::path::Path;
13use tokio::process::Command;
14
15/// Largest amount of `stdout`/`stderr` we keep. A runaway program must not be able to
16/// grow the application's memory without bound.
17const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
18
19/// Runs allowlisted programs with `tokio::process`.
20///
21/// The allowlist is a field, not a parameter: the runner is constructed with the set
22/// of programs a product permits, and that set is fixed for the runner's lifetime —
23/// the same "named options, not a free list" posture as the security profiles.
24#[derive(Debug, Clone)]
25pub struct StdProcessRunner {
26    allowlist: ProcessAllowlist,
27}
28
29impl StdProcessRunner {
30    pub fn new(allowlist: ProcessAllowlist) -> Self {
31        Self { allowlist }
32    }
33
34    pub fn allowlist(&self) -> &ProcessAllowlist {
35        &self.allowlist
36    }
37}
38
39#[async_trait]
40impl ProcessRunner for StdProcessRunner {
41    async fn run(&self, program: &str, args: &[String], cwd: &Path) -> Result<ProcessOutput> {
42        // The gate. Anything the product did not configure stops here.
43        self.allowlist.check(program)?;
44
45        let output = Command::new(program)
46            .args(args)
47            .current_dir(cwd)
48            .output()
49            .await
50            .map_err(|error| {
51                AppError::ExternalService(format!("cannot run `{program}`: {error}"))
52            })?;
53
54        let status = output.status.code().unwrap_or(-1);
55
56        // Truncate rather than fail: a program that printed too much still ran, and the
57        // caller usually only needs the exit code.
58        let stdout = truncate(output.stdout);
59        let stderr = truncate(output.stderr);
60
61        Ok(ProcessOutput {
62            status,
63            stdout,
64            stderr,
65        })
66    }
67}
68
69fn truncate(mut bytes: Vec<u8>) -> Vec<u8> {
70    if bytes.len() > MAX_OUTPUT_BYTES {
71        tracing::warn!(
72            captured = bytes.len(),
73            limit = MAX_OUTPUT_BYTES,
74            "process output truncated"
75        );
76        bytes.truncate(MAX_OUTPUT_BYTES);
77    }
78    bytes
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    /// A benign program present on the platform, with arguments that exit cleanly.
86    ///
87    /// `uname` takes no arguments and exits 0. On Windows `cmd` without arguments opens
88    /// an interactive prompt, so `/C echo` is used instead — passing the arguments the
89    /// way a product would.
90    fn probe() -> (&'static str, Vec<String>) {
91        #[cfg(unix)]
92        {
93            ("uname", Vec::new())
94        }
95        #[cfg(windows)]
96        {
97            (
98                "cmd",
99                vec!["/C".to_owned(), "echo".to_owned(), "ok".to_owned()],
100            )
101        }
102    }
103
104    #[tokio::test]
105    async fn an_unlisted_program_never_reaches_the_operating_system() {
106        let (program, _) = probe();
107        let runner = StdProcessRunner::new(ProcessAllowlist::new([program]));
108
109        let error = runner
110            .run("definitely-not-allowed", &[], Path::new("."))
111            .await
112            .unwrap_err();
113
114        assert_eq!(error.kind(), origin_domain::ErrorKind::Permission);
115    }
116
117    #[tokio::test]
118    async fn an_allowlisted_program_runs_and_reports_its_output() {
119        let (program, args) = probe();
120        let runner = StdProcessRunner::new(ProcessAllowlist::new([program]));
121
122        let output = runner
123            .run(program, &args, Path::new("."))
124            .await
125            .expect("an allowlisted program must run");
126
127        assert_eq!(
128            output.status,
129            0,
130            "stderr: {}",
131            String::from_utf8_lossy(&output.stderr)
132        );
133        assert!(!output.stdout.is_empty(), "the program printed its output");
134    }
135
136    #[tokio::test]
137    async fn a_nonzero_exit_is_reported_not_treated_as_an_error() {
138        // A program that fails is not a *runner* failure: the exit code is the answer.
139        let (program, _) = probe();
140        let runner = StdProcessRunner::new(ProcessAllowlist::new([program]));
141
142        #[cfg(unix)]
143        let args = vec!["--definitely-not-a-real-flag".to_owned()];
144        #[cfg(windows)]
145        let args = vec!["/C".to_owned(), "exit".to_owned(), "3".to_owned()];
146
147        let output = runner
148            .run(program, &args, Path::new("."))
149            .await
150            .expect("a non-zero exit still yields a ProcessOutput");
151
152        assert_ne!(output.status, 0);
153    }
154}