origin_process_std/
lib.rs1use async_trait::async_trait;
10use origin_domain::{AppError, Result};
11use origin_platform::{ProcessAllowlist, ProcessOutput, ProcessRunner};
12use std::path::Path;
13use tokio::process::Command;
14
15const MAX_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
18
19#[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 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 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 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 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}