1use 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#[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 pub fn new() -> Self {
46 ProcessLog::default()
47 }
48
49 pub fn exit_code(&self) -> Option<i64> {
55 self.recorded().exit
56 }
57
58 pub fn runs(&self) -> Vec<(String, Vec<String>)> {
61 self.recorded().runs.clone()
62 }
63
64 fn recorded(&self) -> MutexGuard<'_, Recorded> {
68 self.0
69 .lock()
70 .unwrap_or_else(|poisoned| poisoned.into_inner())
71 }
72}
73
74pub struct Process {
77 args: Vec<String>,
78 allowed: Vec<PathBuf>,
79 control: Control,
80}
81
82enum Control {
83 Real,
86 Recorded {
90 outputs: BTreeMap<String, String>,
91 log: ProcessLog,
92 },
93}
94
95const SCHEMA: ModuleSchema = cove_schema::hosts::PROCESS;
101
102impl Process {
103 pub fn real(args: Vec<String>, allowed: Vec<PathBuf>) -> Self {
112 Process {
113 args,
114 allowed,
115 control: Control::Real,
116 }
117 }
118
119 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 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 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 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 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 #[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 #[test]
359 fn every_program_the_host_did_not_name_is_refused() {
360 let allowed = "/bin/echo";
361 let refused = [
362 "/bin/sh",
364 "echo",
366 "./echo",
369 "../bin/echo",
370 "/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 #[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 #[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 #[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}