Skip to main content

origin_platform/
process.rs

1//! Process execution contract (B1 of the Gitbit platform requirements).
2//!
3//! Anything that runs local programs needs its own contract and its own capability
4//! (ADR-0007). This is deliberately not a general shell escape: the allowlist is
5//! configuration — a product lists its git, its editors, its terminals.
6
7use async_trait::async_trait;
8use origin_domain::{AppError, Result};
9use std::fmt::Debug;
10use std::path::Path;
11
12/// The result of running a process.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ProcessOutput {
15    pub status: i32,
16    pub stdout: Vec<u8>,
17    pub stderr: Vec<u8>,
18}
19
20impl ProcessOutput {
21    pub fn success(&self) -> bool {
22        self.status == 0
23    }
24}
25
26/// The programs a [`ProcessRunner`] may start.
27///
28/// Configuration, never code: a product declares its allowed programs (git,
29/// its configured editors, its terminal launchers). Every implementation must
30/// call [`ProcessAllowlist::check`] before handing a program to the operating
31/// system — the contract test enforces this.
32#[derive(Debug, Clone, Default)]
33pub struct ProcessAllowlist {
34    programs: Vec<String>,
35}
36
37impl ProcessAllowlist {
38    pub fn new(programs: impl IntoIterator<Item = impl Into<String>>) -> Self {
39        Self {
40            programs: programs.into_iter().map(Into::into).collect(),
41        }
42    }
43
44    pub fn allows(&self, program: &str) -> bool {
45        self.programs.iter().any(|p| p == program)
46    }
47
48    /// Reject a program not in the allowlist.
49    ///
50    /// The shared gate every implementation must call, so an allowlist violation
51    /// can never reach the operating system. The contract test verifies that
52    /// implementations actually delegate to this check.
53    pub fn check(&self, program: &str) -> Result<()> {
54        if self.allows(program) {
55            Ok(())
56        } else {
57            Err(AppError::Permission(format!(
58                "program `{program}` is not in the process allowlist"
59            )))
60        }
61    }
62
63    /// The programs currently allowed.
64    pub fn entries(&self) -> &[String] {
65        &self.programs
66    }
67}
68
69/// Runs a local program under a strict allowlist.
70///
71/// A product that does not need to start external programs never instantiates
72/// this dependency — the contract is optional by construction.
73#[async_trait]
74pub trait ProcessRunner: Debug + Send + Sync + 'static {
75    /// `program` must be in the configured allowlist.
76    /// `cwd` is the working directory for the launched process.
77    async fn run(&self, program: &str, args: &[String], cwd: &Path) -> Result<ProcessOutput>;
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn allowlist_rejects_an_unlisted_program_with_permission_error() {
86        let allowlist = ProcessAllowlist::new(["git"]);
87        let result = allowlist.check("rm");
88
89        match result {
90            Err(AppError::Permission(_)) => {} // expected
91            other => panic!("expected Permission error, got {other:?}"),
92        }
93    }
94
95    #[test]
96    fn allowlist_permits_a_listed_program() {
97        let allowlist = ProcessAllowlist::new(["git", "code"]);
98        assert!(allowlist.allows("git"));
99        assert!(allowlist.allows("code"));
100        allowlist
101            .check("git")
102            .expect("listed program must be allowed");
103    }
104}