Skip to main content

kovra_wrapper/
runner.rs

1//! The process runner — the seam that actually launches the child (or mocks it).
2//!
3//! The runner is a trait so the Wrapper's orchestration (resolve → policy →
4//! confirm → allowlist → inject) is tested deterministically with [`MockRunner`],
5//! while production uses [`SystemRunner`]. The injected values are
6//! [`SecretValue`]s exposed **only** at the moment of spawning and placed into
7//! the child's environment — never written to disk (I7).
8
9use std::path::PathBuf;
10use std::sync::Mutex;
11
12use kovra_core::SecretValue;
13
14use crate::error::WrapperError;
15
16/// A fully-resolved command ready to launch: the program, its arguments, and the
17/// environment to inject into the child.
18pub struct Command {
19    /// The program to execute (the resolved `argv[0]`).
20    pub program: PathBuf,
21    /// The arguments after the program.
22    pub args: Vec<String>,
23    /// Variables to inject into the child's environment. Values stay protected
24    /// until the runner exposes them at spawn time (I7 — never to disk).
25    pub env: Vec<(String, SecretValue)>,
26}
27
28/// The captured result of a finished child process.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Output {
31    /// The exit status code, or `None` if the process was terminated by a signal.
32    pub status: Option<i32>,
33    /// Captured standard output (possibly sanitized by the Wrapper, §5.1).
34    pub stdout: Vec<u8>,
35    /// Captured standard error (possibly sanitized by the Wrapper, §5.1).
36    pub stderr: Vec<u8>,
37}
38
39/// Launches a [`Command`]. The Wrapper depends on this trait, not on
40/// `std::process`, so its logic is testable with [`MockRunner`].
41pub trait ProcessRunner {
42    /// Run the command to completion and capture its output.
43    fn run(&self, command: &Command) -> Result<Output, WrapperError>;
44}
45
46/// The real runner: launches via `std::process::Command`, injecting the resolved
47/// environment into the child. Inherits the parent environment and overrides it
48/// with the injected variables. Nothing is written to disk (I7).
49pub struct SystemRunner;
50
51impl ProcessRunner for SystemRunner {
52    fn run(&self, command: &Command) -> Result<Output, WrapperError> {
53        let mut cmd = std::process::Command::new(&command.program);
54        cmd.args(&command.args);
55        for (name, value) in &command.env {
56            // Expose the value only here, straight into the child's env. A value
57            // that is not valid UTF-8 cannot be placed in the process
58            // environment portably; reject it without echoing the value (I12).
59            let s = std::str::from_utf8(value.expose()).map_err(|_| {
60                WrapperError::Spawn(format!(
61                    "value for `{name}` is not valid UTF-8 and cannot be injected"
62                ))
63            })?;
64            cmd.env(name, s);
65        }
66        let out = cmd.output().map_err(|e| {
67            WrapperError::Spawn(format!("launch {}: {e}", command.program.display()))
68        })?;
69        Ok(Output {
70            status: out.status.code(),
71            stdout: out.stdout,
72            stderr: out.stderr,
73        })
74    }
75}
76
77/// A single recorded invocation, for test assertions. The exposed env values are
78/// captured **only** because this is a test double; production never copies a
79/// value out like this.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct RecordedRun {
82    /// The program that would have launched.
83    pub program: PathBuf,
84    /// Its arguments.
85    pub args: Vec<String>,
86    /// The injected environment, exposed for assertions (name → value).
87    pub env: Vec<(String, String)>,
88}
89
90impl RecordedRun {
91    /// The injected value for `name`, if present.
92    pub fn env_value(&self, name: &str) -> Option<&str> {
93        self.env
94            .iter()
95            .find(|(k, _)| k == name)
96            .map(|(_, v)| v.as_str())
97    }
98}
99
100/// A test runner that records each invocation and returns a configured
101/// [`Output`] without launching anything.
102pub struct MockRunner {
103    output: Output,
104    invocations: Mutex<Vec<RecordedRun>>,
105}
106
107impl MockRunner {
108    /// A runner returning `output` for every call.
109    pub fn new(output: Output) -> Self {
110        Self {
111            output,
112            invocations: Mutex::new(Vec::new()),
113        }
114    }
115
116    /// A runner returning a successful empty output (exit code 0).
117    pub fn ok() -> Self {
118        Self::new(Output {
119            status: Some(0),
120            stdout: Vec::new(),
121            stderr: Vec::new(),
122        })
123    }
124
125    /// A snapshot of the recorded invocations.
126    pub fn invocations(&self) -> Vec<RecordedRun> {
127        self.invocations
128            .lock()
129            .expect("runner mutex poisoned")
130            .clone()
131    }
132
133    /// Whether the runner was ever invoked (i.e. a child would have launched).
134    pub fn was_invoked(&self) -> bool {
135        !self
136            .invocations
137            .lock()
138            .expect("runner mutex poisoned")
139            .is_empty()
140    }
141}
142
143impl ProcessRunner for MockRunner {
144    fn run(&self, command: &Command) -> Result<Output, WrapperError> {
145        let env = command
146            .env
147            .iter()
148            .map(|(k, v)| (k.clone(), String::from_utf8_lossy(v.expose()).into_owned()))
149            .collect();
150        self.invocations
151            .lock()
152            .expect("runner mutex poisoned")
153            .push(RecordedRun {
154                program: command.program.clone(),
155                args: command.args.clone(),
156                env,
157            });
158        Ok(self.output.clone())
159    }
160}