Skip to main content

cove_runtime/
process.rs

1//! `process`: the run's own arguments, its exit status, and filtered
2//! subprocesses.
3//!
4//! The Language Card lists the process among the operations that are typed
5//! Host APIs rather than ambient authority, and the reason is sharpest here:
6//! a program that can start any other program has every authority the machine
7//! has, whatever the rest of its grants say. So `run` is filtered rather than
8//! merely granted. [`Process::real`] takes the executables a run may start
9//! from the host and refuses everything else, including a bare name that
10//! would otherwise be looked up in `PATH` — searching `PATH` is exactly the
11//! ambient authority Cove does not have. A host that names no executables has
12//! a `process` that cannot start one, which is the default the CLI uses.
13//!
14//! [`Process::recorded`] is the fake. It answers `run` from a table of canned
15//! output rather than starting anything, and it writes the exit code into a
16//! [`ProcessLog`] instead of ending the process, so a test can observe what a
17//! program asked the host to do without the host doing it.
18
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex, MutexGuard};
22
23use crate::error::RuntimeError;
24use crate::host::HostApi;
25use crate::schema::ModuleSchema;
26use crate::value::{Repr, Value};
27
28/// What a program asked a fake process to do, shared between the host and
29/// whoever inspects it.
30///
31/// Cloning shares the same record, including the clone already given to a
32/// [`Process`]. The record is synchronized because a host is reachable from
33/// every task of a run.
34#[derive(Clone, Debug, Default)]
35pub struct ProcessLog(Arc<Mutex<Recorded>>);
36
37#[derive(Debug, Default)]
38struct Recorded {
39    exit: Option<i64>,
40    runs: Vec<(String, Vec<String>)>,
41}
42
43impl ProcessLog {
44    /// A log with nothing in it yet.
45    pub fn new() -> Self {
46        ProcessLog::default()
47    }
48
49    /// The code the program asked to exit with, if it asked at all.
50    ///
51    /// A real process would not have come back from `exit`, so only the first
52    /// request is recorded: a fake that kept the last one would report an
53    /// exit that a real host could never have reached.
54    pub fn exit_code(&self) -> Option<i64> {
55        self.recorded().exit
56    }
57
58    /// Every subprocess the program asked to start, in order, as the program
59    /// and its arguments.
60    pub fn runs(&self) -> Vec<(String, Vec<String>)> {
61        self.recorded().runs.clone()
62    }
63
64    /// The record, taken back from a lock a panicking run may have poisoned:
65    /// a broken invariant in one task must not turn every later `process`
66    /// call in another into a second, unrelated failure.
67    fn recorded(&self) -> MutexGuard<'_, Recorded> {
68        self.0
69            .lock()
70            .unwrap_or_else(|poisoned| poisoned.into_inner())
71    }
72}
73
74/// `process`: the arguments a run was given, its exit status, and the
75/// executables the host allows it to start.
76pub struct Process {
77    args: Vec<String>,
78    allowed: Vec<PathBuf>,
79    control: Control,
80}
81
82enum Control {
83    /// The real operating-system process: `exit` ends it, and `run` starts a
84    /// real subprocess.
85    Real,
86    /// A process that records what it was asked to do. The map answers `run`
87    /// with canned output, and is also the allow-list: a fake can only start
88    /// a program it has an answer for.
89    Recorded {
90        outputs: BTreeMap<String, String>,
91        log: ProcessLog,
92    },
93}
94
95/// What `process` declares about itself.
96///
97/// The table is [`cove_schema::hosts::PROCESS`], so the description the
98/// compiler checks a call against and the one the boundary dispatches through
99/// are the same bytes.
100const SCHEMA: ModuleSchema = cove_schema::hosts::PROCESS;
101
102impl Process {
103    /// The real process.
104    ///
105    /// `args` are the arguments the host chose to pass on, not the host's own
106    /// command line: a run sees what it was given and nothing else. `allowed`
107    /// is the list of executables `run` may start, each an absolute path.
108    /// An empty list is a `process` that can read its arguments and end
109    /// itself but cannot start anything, which is the only safe default a
110    /// host that knows nothing about the program can offer.
111    pub fn real(args: Vec<String>, allowed: Vec<PathBuf>) -> Self {
112        Process {
113            args,
114            allowed,
115            control: Control::Real,
116        }
117    }
118
119    /// A fake process that records what it was asked to do, for tests.
120    ///
121    /// `outputs` maps an executable path to the standard output `run` should
122    /// answer with, and is also the allow-list: a program the fake has no
123    /// answer for is refused exactly as the real host refuses one the host
124    /// did not name.
125    pub fn recorded(args: Vec<String>, outputs: BTreeMap<String, String>, log: ProcessLog) -> Self {
126        Process {
127            allowed: outputs.keys().map(PathBuf::from).collect(),
128            args,
129            control: Control::Recorded { outputs, log },
130        }
131    }
132
133    /// Ends the run, or records that it was asked to.
134    ///
135    /// A code the platform cannot express becomes `1`: an exit status is a
136    /// small integer everywhere Cove runs, and reporting failure is closer to
137    /// what a program asking for an impossible code meant than truncating the
138    /// number into an unrelated one.
139    fn exit(&self, code: i64) -> Value {
140        match &self.control {
141            Control::Real => std::process::exit(i32::try_from(code).unwrap_or(1)),
142            Control::Recorded { log, .. } => {
143                let mut recorded = log.recorded();
144                if recorded.exit.is_none() {
145                    recorded.exit = Some(code);
146                }
147                Value(Repr::Unit)
148            }
149        }
150    }
151
152    /// Starts `program` with `arguments` and waits for it, or reports why
153    /// this host will not.
154    ///
155    /// The allow-list is compared after resolving both sides, so a path that
156    /// reaches an allowed executable by another name — through `..`, a
157    /// symbolic link, or a directory that is one — is still allowed, and one
158    /// that reaches anything else is not.
159    fn run(&self, program: &str, arguments: Vec<String>) -> Result<String, String> {
160        if !self.is_allowed(program) {
161            return Err(format!(
162                "process: `{program}` is not an executable this host allows"
163            ));
164        }
165        match &self.control {
166            Control::Real => {
167                let output = std::process::Command::new(program)
168                    .args(&arguments)
169                    .output()
170                    .map_err(|e| format!("process: cannot start `{program}`: {e}"))?;
171                if !output.status.success() {
172                    return Err(match output.status.code() {
173                        Some(code) => format!("process: `{program}` exited with status {code}"),
174                        None => format!("process: `{program}` was ended by a signal"),
175                    });
176                }
177                Ok(String::from_utf8_lossy(&output.stdout).into_owned())
178            }
179            Control::Recorded { outputs, log } => {
180                log.recorded().runs.push((program.to_string(), arguments));
181                Ok(outputs.get(program).cloned().unwrap_or_default())
182            }
183        }
184    }
185
186    /// Whether `program` names one of the executables the host allowed.
187    ///
188    /// A relative name is refused outright. Resolving one would mean
189    /// searching `PATH`, and which program `PATH` finds is decided by the
190    /// environment rather than by the host — the ambient authority Cove code
191    /// does not have.
192    fn is_allowed(&self, program: &str) -> bool {
193        let requested = Path::new(program);
194        if !requested.is_absolute() {
195            return false;
196        }
197        let resolved = requested.canonicalize().ok();
198        self.allowed.iter().any(|allowed| {
199            allowed == requested
200                || match (&resolved, allowed.canonicalize().ok()) {
201                    (Some(a), Some(b)) => a == &b,
202                    _ => false,
203                }
204        })
205    }
206}
207
208impl HostApi for Process {
209    fn module_schema(&self) -> ModuleSchema {
210        SCHEMA
211    }
212
213    fn call(&self, op: &str, args: Vec<Value>) -> Result<Value, RuntimeError> {
214        match op {
215            "args" => Ok(Value(Repr::Array(
216                self.args
217                    .iter()
218                    .map(|a| Value(Repr::Str(a.as_str().into())))
219                    .collect(),
220            ))),
221            "exit" => {
222                let [Value(Repr::Int(code))] = args.as_slice() else {
223                    unreachable!("checked by HostRegistry::call")
224                };
225                Ok(self.exit(*code))
226            }
227            "run" => {
228                let [Value(Repr::Str(program)), Value(Repr::Array(arguments))] = args.as_slice()
229                else {
230                    unreachable!("checked by HostRegistry::call")
231                };
232                let mut collected = Vec::with_capacity(arguments.len());
233                for argument in arguments.iter() {
234                    // The boundary followed `Array<String>` all the way down,
235                    // so every element is one.
236                    let Value(Repr::Str(argument)) = argument else {
237                        unreachable!("checked by HostRegistry::call")
238                    };
239                    collected.push(argument.to_string());
240                }
241                let program = program.to_string();
242                Ok(match self.run(&program, collected) {
243                    Ok(output) => Value::ok(Value(Repr::Str(output.into()))),
244                    Err(message) => Value::err(Value::error(message)),
245                })
246            }
247            _ => unreachable!("checked by HostRegistry::call"),
248        }
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::host::{Grants, HostRegistry};
256
257    fn str_arg(text: &str) -> Value {
258        Value(Repr::Str(text.into()))
259    }
260
261    fn array_arg(items: &[&str]) -> Value {
262        Value(Repr::Array(
263            items
264                .iter()
265                .map(|s| Value(Repr::Str((*s).into())))
266                .collect(),
267        ))
268    }
269
270    fn strings(value: Value) -> Vec<String> {
271        match value {
272            Value(Repr::Array(items)) => items.iter().map(ToString::to_string).collect(),
273            other => panic!("expected an `Array`, found {other}"),
274        }
275    }
276
277    fn ok_value(value: Value) -> Value {
278        match value.ok_payload() {
279            Some(payload) => payload.first().cloned().unwrap_or(Value(Repr::Unit)),
280            None => panic!("expected `Ok(...)`, found {value}"),
281        }
282    }
283
284    fn err_message(value: Value) -> String {
285        match value.err_payload() {
286            Some(payload) => payload.first().map(ToString::to_string).unwrap_or_default(),
287            None => panic!("expected `Err(...)`, found {value}"),
288        }
289    }
290
291    fn fake(outputs: BTreeMap<String, String>) -> (Process, ProcessLog) {
292        let log = ProcessLog::new();
293        let process = Process::recorded(
294            vec!["--name".to_string(), "cove".to_string()],
295            outputs,
296            log.clone(),
297        );
298        (process, log)
299    }
300
301    #[test]
302    fn args_answers_what_the_host_passed_on() {
303        let (process, _) = fake(BTreeMap::new());
304
305        let args = process.call("args", Vec::new()).unwrap();
306        assert_eq!(strings(args), ["--name", "cove"]);
307    }
308
309    #[test]
310    fn args_of_a_run_given_nothing_is_empty() {
311        let process = Process::real(Vec::new(), Vec::new());
312
313        let args = process.call("args", Vec::new()).unwrap();
314        assert!(strings(args).is_empty());
315    }
316
317    #[test]
318    fn a_fake_records_the_exit_code_instead_of_ending_the_process() {
319        let (process, log) = fake(BTreeMap::new());
320        assert_eq!(log.exit_code(), None);
321
322        let exited = process.call("exit", vec![Value(Repr::Int(3))]).unwrap();
323        assert!(matches!(exited, Value(Repr::Unit)), "{exited}");
324        assert_eq!(log.exit_code(), Some(3));
325    }
326
327    /// A real process never returns from `exit`, so a fake that let a second
328    /// request overwrite the first would report an exit no real host could
329    /// have reached.
330    #[test]
331    fn only_the_first_exit_is_recorded() {
332        let (process, log) = fake(BTreeMap::new());
333
334        process.call("exit", vec![Value(Repr::Int(3))]).unwrap();
335        process.call("exit", vec![Value(Repr::Int(0))]).unwrap();
336        assert_eq!(log.exit_code(), Some(3));
337    }
338
339    #[test]
340    fn a_fake_answers_run_from_its_table_and_records_the_call() {
341        let (process, log) = fake(BTreeMap::from([(
342            "/bin/echo".to_string(),
343            "hello\n".to_string(),
344        )]));
345
346        let output = process
347            .call("run", vec![str_arg("/bin/echo"), array_arg(&["hello"])])
348            .unwrap();
349        assert_eq!(ok_value(output).to_string(), "hello\n");
350        assert_eq!(
351            log.runs(),
352            [("/bin/echo".to_string(), vec!["hello".to_string()])]
353        );
354    }
355
356    /// Every program the host did not name, refused by both implementations
357    /// before anything is started.
358    #[test]
359    fn every_program_the_host_did_not_name_is_refused() {
360        let allowed = "/bin/echo";
361        let refused = [
362            // Not on the list at all.
363            "/bin/sh",
364            // A bare name, which would mean searching `PATH`.
365            "echo",
366            // A relative path, which would mean the working directory
367            // decides what runs.
368            "./echo",
369            "../bin/echo",
370            // An absolute path that does not reach an allowed executable.
371            "/usr/bin/env",
372        ];
373
374        let (mut fake_process, log) = fake(BTreeMap::from([(
375            allowed.to_string(),
376            "hello\n".to_string(),
377        )]));
378        let mut real_process = Process::real(Vec::new(), vec![PathBuf::from(allowed)]);
379
380        for program in refused {
381            for process in [&mut fake_process, &mut real_process] {
382                let outcome = process
383                    .call("run", vec![str_arg(program), array_arg(&[])])
384                    .unwrap();
385                assert_eq!(
386                    err_message(outcome),
387                    format!("process: `{program}` is not an executable this host allows"),
388                    "`{program}`"
389                );
390            }
391        }
392        assert!(log.runs().is_empty());
393    }
394
395    /// A host that named no executables cannot start one, which is the
396    /// default the CLI installs.
397    #[test]
398    fn a_host_with_an_empty_allow_list_starts_nothing() {
399        let process = Process::real(Vec::new(), Vec::new());
400
401        let outcome = process
402            .call("run", vec![str_arg("/bin/echo"), array_arg(&[])])
403            .unwrap();
404        assert_eq!(
405            err_message(outcome),
406            "process: `/bin/echo` is not an executable this host allows"
407        );
408    }
409
410    /// The allow-list names executables, not spellings: a path that reaches
411    /// an allowed executable by another route is the same executable.
412    #[cfg(unix)]
413    #[test]
414    fn a_different_spelling_of_an_allowed_executable_is_still_allowed() {
415        if !Path::new("/bin/echo").exists() {
416            return;
417        }
418        let process = Process::real(Vec::new(), vec![PathBuf::from("/bin/echo")]);
419
420        let output = process
421            .call(
422                "run",
423                vec![str_arg("/bin/../bin/echo"), array_arg(&["hello"])],
424            )
425            .unwrap();
426        assert_eq!(ok_value(output).to_string(), "hello\n");
427    }
428
429    #[cfg(unix)]
430    #[test]
431    fn a_real_run_answers_with_what_the_subprocess_wrote() {
432        if !Path::new("/bin/echo").exists() {
433            return;
434        }
435        let process = Process::real(Vec::new(), vec![PathBuf::from("/bin/echo")]);
436
437        let output = process
438            .call(
439                "run",
440                vec![str_arg("/bin/echo"), array_arg(&["one", "two"])],
441            )
442            .unwrap();
443        assert_eq!(ok_value(output).to_string(), "one two\n");
444    }
445
446    #[cfg(unix)]
447    #[test]
448    fn a_real_run_that_fails_reports_the_status() {
449        if !Path::new("/bin/sh").exists() {
450            return;
451        }
452        let process = Process::real(Vec::new(), vec![PathBuf::from("/bin/sh")]);
453
454        let outcome = process
455            .call(
456                "run",
457                vec![str_arg("/bin/sh"), array_arg(&["-c", "exit 7"])],
458            )
459            .unwrap();
460        assert_eq!(
461            err_message(outcome),
462            "process: `/bin/sh` exited with status 7"
463        );
464    }
465
466    #[test]
467    fn a_run_without_the_process_grant_cannot_read_its_arguments() {
468        let mut hosts = HostRegistry::new(Grants::new(["console"]));
469        hosts.register(Box::new(Process::real(Vec::new(), Vec::new())));
470
471        let error = hosts
472            .call("process", "args", Vec::new())
473            .expect_err("the call should be rejected");
474        assert_eq!(
475            error.message,
476            "`process.args` requires the `process` capability, which this run was not granted"
477        );
478    }
479
480    #[test]
481    fn a_granted_process_is_reachable_through_the_registry() {
482        let log = ProcessLog::new();
483        let mut hosts = HostRegistry::new(Grants::new(["process"]));
484        hosts.register(Box::new(Process::recorded(
485            vec!["one".to_string()],
486            BTreeMap::new(),
487            log.clone(),
488        )));
489
490        let args = hosts
491            .call("process", "args", Vec::new())
492            .expect("the call should be allowed");
493        assert_eq!(strings(args), ["one"]);
494
495        hosts
496            .call("process", "exit", vec![Value(Repr::Int(2))])
497            .expect("the call should be allowed");
498        assert_eq!(log.exit_code(), Some(2));
499    }
500
501    #[test]
502    fn signatures_read_like_source() {
503        let process = Process::real(Vec::new(), Vec::new());
504        let rendered: Vec<String> = process
505            .module_schema()
506            .operations
507            .iter()
508            .map(|op| op.signature())
509            .collect();
510        assert_eq!(
511            rendered,
512            [
513                "args() -> Array<String>",
514                "exit(Int) -> Unit",
515                "run(String, Array<String>) -> Result<String, Error>",
516            ]
517        );
518    }
519
520    /// Ending a run cannot be replayed by handing back a recorded result, so
521    /// `exit` is the one shipped operation that is not recordable.
522    #[test]
523    fn ending_the_run_is_not_recordable() {
524        let process = Process::real(Vec::new(), Vec::new());
525        for op in process.module_schema().operations {
526            assert_eq!(op.recordable, op.name != "exit", "`process.{}`", op.name);
527        }
528    }
529}