Skip to main content

vantage_cmd/
exec.rs

1//! Subprocess execution with a locked-down environment.
2//!
3//! Synchronous on purpose: callers run this inside
4//! `tokio::task::spawn_blocking`, so the std blocking call never stalls
5//! the async runtime. The child gets a *cleared* environment plus only
6//! the declared vars (and optionally `PATH`/`HOME`).
7
8use std::path::{Path, PathBuf};
9
10use indexmap::IndexMap;
11use vantage_core::{Result, error};
12
13/// Captured result of a single command invocation.
14#[derive(Clone, Debug)]
15pub struct CmdOutput {
16    pub stdout: String,
17    pub stderr: String,
18    pub exit_code: i32,
19}
20
21/// Resolve the program to execute. A `command` that contains a path
22/// separator but isn't absolute (e.g. `./scripts/gh-stats.py`) is resolved
23/// against `base_dir`; bare names (e.g. `gh`) are left untouched so the OS
24/// can find them on `PATH`, and absolute paths pass through.
25fn resolve_program(command: &str, base_dir: Option<&Path>) -> PathBuf {
26    match base_dir {
27        Some(dir) if command.contains('/') && !Path::new(command).is_absolute() => {
28            dir.join(command)
29        }
30        _ => PathBuf::from(command),
31    }
32}
33
34/// Run `command` with `args`, passing only `env` (plus `PATH`/`HOME` when
35/// `pass_path`). When `base_dir` is set it resolves a relative `command`
36/// path against it and runs the child with `base_dir` as its working
37/// directory. Returns the captured output; a non-zero exit is *not* an
38/// error here — the Rhai script decides what to do with `exit_code`.
39pub fn run_command(
40    command: &str,
41    args: &[String],
42    env: &IndexMap<String, String>,
43    pass_path: bool,
44    base_dir: Option<&Path>,
45) -> Result<CmdOutput> {
46    let mut cmd = std::process::Command::new(resolve_program(command, base_dir));
47    cmd.args(args);
48    cmd.env_clear();
49
50    if let Some(dir) = base_dir {
51        cmd.current_dir(dir);
52    }
53
54    if pass_path {
55        if let Ok(path) = std::env::var("PATH") {
56            cmd.env("PATH", path);
57        }
58        if let Ok(home) = std::env::var("HOME") {
59            cmd.env("HOME", home);
60        }
61    }
62    for (k, v) in env {
63        cmd.env(k, v);
64    }
65
66    let output = cmd.output().map_err(|e| {
67        error!(
68            "failed to execute command",
69            command = command.to_string(),
70            detail = e.to_string()
71        )
72    })?;
73
74    Ok(CmdOutput {
75        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
76        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
77        exit_code: output.status.code().unwrap_or(-1),
78    })
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn bare_name_is_left_for_path_lookup() {
87        // No base dir, and with one: a bare command name is never rewritten.
88        assert_eq!(resolve_program("gh", None), PathBuf::from("gh"));
89        assert_eq!(
90            resolve_program("gh", Some(Path::new("/inv"))),
91            PathBuf::from("gh")
92        );
93    }
94
95    #[test]
96    fn relative_path_resolves_against_base_dir() {
97        assert_eq!(
98            resolve_program("./scripts/gh-stats.py", Some(Path::new("/inv"))),
99            PathBuf::from("/inv/./scripts/gh-stats.py")
100        );
101        assert_eq!(
102            resolve_program("scripts/gh-stats.py", Some(Path::new("/inv"))),
103            PathBuf::from("/inv/scripts/gh-stats.py")
104        );
105    }
106
107    #[test]
108    fn relative_path_without_base_dir_is_unchanged() {
109        assert_eq!(
110            resolve_program("./scripts/x.py", None),
111            PathBuf::from("./scripts/x.py")
112        );
113    }
114
115    #[test]
116    fn absolute_path_passes_through() {
117        assert_eq!(
118            resolve_program("/usr/bin/gh", Some(Path::new("/inv"))),
119            PathBuf::from("/usr/bin/gh")
120        );
121    }
122}