Skip to main content

origin_platform/
memory_process.rs

1//! Memory double for [`ProcessRunner`] — records calls and returns configured outputs.
2//!
3//! Never use this in a shipped application. It exists so tests and contract suites
4//! can validate behaviour without starting real processes.
5
6use crate::process::{ProcessAllowlist, ProcessOutput, ProcessRunner};
7use async_trait::async_trait;
8use origin_domain::Result;
9use std::path::Path;
10use std::sync::Mutex;
11
12/// Records process invocations and returns pre-configured outputs.
13///
14/// Programs not in the allowlist are rejected with `AppError::Permission`
15/// before the `run` method returns — exactly as the contract requires.
16#[derive(Debug)]
17pub struct MemoryProcessRunner {
18    allowlist: ProcessAllowlist,
19    output: ProcessOutput,
20    /// (program, args, cwd) for every run that passed the allowlist gate.
21    calls: Mutex<Vec<(String, Vec<String>, String)>>,
22}
23
24impl MemoryProcessRunner {
25    pub fn new(allowlist: ProcessAllowlist, output: ProcessOutput) -> Self {
26        Self {
27            allowlist,
28            output,
29            calls: Mutex::new(Vec::new()),
30        }
31    }
32
33    /// Construct a runner that returns a successful exit code (0) and empty output.
34    pub fn success(allowlist: ProcessAllowlist) -> Self {
35        Self::new(
36            allowlist,
37            ProcessOutput {
38                status: 0,
39                stdout: Vec::new(),
40                stderr: Vec::new(),
41            },
42        )
43    }
44
45    /// Every call that passed the allowlist gate.
46    pub fn calls(&self) -> Vec<(String, Vec<String>, String)> {
47        self.calls.lock().expect("recorder poisoned").clone()
48    }
49}
50
51#[async_trait]
52impl ProcessRunner for MemoryProcessRunner {
53    async fn run(&self, program: &str, args: &[String], cwd: &Path) -> Result<ProcessOutput> {
54        self.allowlist.check(program)?;
55
56        self.calls.lock().expect("recorder poisoned").push((
57            program.to_owned(),
58            args.to_vec(),
59            cwd.display().to_string(),
60        ));
61
62        Ok(self.output.clone())
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[tokio::test]
71    async fn rejects_unlisted_programs() {
72        let allowlist = ProcessAllowlist::new(["git"]);
73        let runner = MemoryProcessRunner::new(
74            allowlist,
75            ProcessOutput {
76                status: 0,
77                stdout: vec![],
78                stderr: vec![],
79            },
80        );
81
82        let result = runner.run("rm", &[], Path::new("/")).await;
83
84        assert!(result.is_err());
85        assert_eq!(
86            result.unwrap_err().kind(),
87            origin_domain::ErrorKind::Permission
88        );
89    }
90
91    #[tokio::test]
92    async fn allows_listed_programs_and_records_calls() {
93        let allowlist = ProcessAllowlist::new(["git", "npm"]);
94        let output = ProcessOutput {
95            status: 0,
96            stdout: b"ok".to_vec(),
97            stderr: vec![],
98        };
99        let runner = MemoryProcessRunner::new(allowlist, output.clone());
100
101        let result = runner
102            .run("git", &["status".to_owned()], Path::new("/repo"))
103            .await;
104
105        assert!(result.is_ok());
106        assert_eq!(result.unwrap(), output);
107
108        let calls = runner.calls();
109        assert_eq!(calls.len(), 1);
110        assert_eq!(calls[0].0, "git");
111        assert_eq!(calls[0].1, vec!["status"]);
112        assert!(calls[0].2.contains("repo"));
113    }
114}