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}