Skip to main content

vanta_shim/
lib.rs

1//! `vanta-shim` — the per-tool dispatcher (see `docs/10-environments.md`).
2//!
3//! Installed under each tool's name in `~/.vanta/bin`. When invoked it finds the
4//! tool that its `argv[0]` names in the active generation, locates the real
5//! binary in the store, and `exec`s it. (Per-directory switching via a
6//! resolution cache is a later refinement; this cut dispatches from the active
7//! generation.)
8#![forbid(unsafe_code)]
9
10use std::path::{Path, PathBuf};
11use std::process::{Command, ExitCode};
12use vanta_core::{Platform, StoreKey};
13use vanta_lock::Lock;
14use vanta_state::State;
15
16/// Outcome of looking `name` up in the nearest project `vanta.lock`.
17enum LockResolution {
18    /// The tool is managed here and its binary is on disk.
19    Bin(PathBuf),
20    /// A `vanta.lock` pins this tool but the version isn't installed.
21    PinnedButMissing(String),
22    /// No `vanta.lock` up the tree manages this tool.
23    NotManaged,
24}
25
26fn basename(p: &str) -> &str {
27    p.rsplit(['/', '\\']).next().unwrap_or(p)
28}
29
30/// Walk up from the current directory; the first `vanta.lock` that manages
31/// `name` (by tool name or a linked bin) decides the version. This is the
32/// per-directory switching mechanism.
33fn resolve_from_lock(home: &Path, name: &str) -> LockResolution {
34    let plat = Platform::current().token();
35    let mut dir = std::env::current_dir().ok();
36    while let Some(d) = dir {
37        let lock_path = d.join("vanta.lock");
38        if lock_path.is_file() {
39            if let Ok(lock) = Lock::load_file(&lock_path) {
40                for tool in &lock.tools {
41                    let pin = tool.platform.get(&plat);
42                    let manages = tool.name == name
43                        || pin.is_some_and(|p| p.bin.iter().any(|b| basename(b) == name));
44                    if !manages {
45                        continue;
46                    }
47                    // This lock owns the tool. If it isn't built for us, say so
48                    // rather than silently falling back to a different version.
49                    let Some(pin) = pin.filter(|p| !p.store_key.is_empty()) else {
50                        return LockResolution::PinnedButMissing(tool.version.clone());
51                    };
52                    // Validate the key shape (L12/M7) before joining it onto the
53                    // store path, so a hand-edited lock cannot traverse out.
54                    let Ok(key) = StoreKey::new(pin.store_key.clone()) else {
55                        return LockResolution::PinnedButMissing(tool.version.clone());
56                    };
57                    let rel = pin
58                        .bin
59                        .iter()
60                        .find(|b| basename(b) == name)
61                        .cloned()
62                        .unwrap_or_else(|| name.to_string());
63                    // The bin path must stay inside the store entry.
64                    if Path::new(&rel).components().any(|c| {
65                        matches!(
66                            c,
67                            std::path::Component::ParentDir
68                                | std::path::Component::RootDir
69                                | std::path::Component::Prefix(_)
70                        )
71                    }) {
72                        return LockResolution::NotManaged;
73                    }
74                    let path = home.join("store").join(key.as_str()).join(&rel);
75                    if path.is_file() {
76                        return LockResolution::Bin(path);
77                    }
78                    return LockResolution::PinnedButMissing(tool.version.clone());
79                }
80            }
81        }
82        dir = d.parent().map(Path::to_path_buf);
83    }
84    LockResolution::NotManaged
85}
86
87/// Entry point: derive the tool name from `argv[0]`, dispatch, and map failures
88/// to an error exit code. The binary's `main` is a thin wrapper over this.
89#[must_use]
90pub fn run() -> ExitCode {
91    let invoked = std::env::args()
92        .next()
93        .and_then(|p| {
94            Path::new(&p)
95                .file_name()
96                .map(|s| s.to_string_lossy().into_owned())
97        })
98        .unwrap_or_default();
99    let name = invoked.strip_suffix(".exe").unwrap_or(&invoked).to_string();
100    let args: Vec<String> = std::env::args().skip(1).collect();
101
102    match dispatch(&name, &args) {
103        Ok(code) => code,
104        Err(msg) => {
105            eprintln!("vanta-shim: {msg}");
106            ExitCode::from(1)
107        }
108    }
109}
110
111/// Resolve `name` and `exec` its real binary with `args`. Resolution order:
112///  1. **Per-directory**: the nearest `vanta.lock` walking up from the current
113///     directory — this is what gives project-local tool versions (like mise).
114///  2. **Fallback**: the global active generation.
115///
116/// On unix `exec` replaces the process; it only returns on failure.
117pub fn dispatch(name: &str, args: &[String]) -> Result<ExitCode, String> {
118    let home = home().ok_or("cannot determine VANTA_HOME")?;
119
120    // (1) Project-local: the nearest vanta.lock that manages this tool wins.
121    match resolve_from_lock(&home, name) {
122        LockResolution::Bin(bin) => return exec(&bin, args),
123        LockResolution::PinnedButMissing(version) => {
124            return Err(format!(
125                "`{name}` is pinned to {version} here but not installed — run `vanta sync`"
126            ));
127        }
128        LockResolution::NotManaged => {} // fall through to the global generation
129    }
130
131    let state = State::open(&home.join("state.db")).map_err(|e| e.to_string())?;
132    let id = state
133        .current()
134        .map_err(|e| e.to_string())?
135        .ok_or("no active generation")?;
136    let generation = state
137        .get_generation(id)
138        .map_err(|e| e.to_string())?
139        .ok_or("active generation is missing")?;
140    let (_, key) = generation
141        .tools
142        .iter()
143        .find(|(tool, _)| tool == name)
144        .ok_or_else(|| format!("`{name}` is not managed by vanta"))?;
145    // L12/M7: validate the key shape before joining it onto the store path so a
146    // malformed generation record cannot traverse out of the store.
147    let key = StoreKey::new(key.clone()).map_err(|e| e.to_string())?;
148    let entry = home.join("store").join(key.as_str());
149    let bin = find_bin(&entry, name)
150        .ok_or_else(|| format!("executable for `{name}` not found in {}", entry.display()))?;
151    exec(&bin, args)
152}
153
154fn find_bin(entry: &Path, name: &str) -> Option<PathBuf> {
155    let candidates = [
156        entry.join("bin").join(name),
157        entry.join(name),
158        entry.join("bin").join(format!("{name}.exe")),
159        entry.join(format!("{name}.exe")),
160    ];
161    candidates.into_iter().find(|c| c.is_file())
162}
163
164#[cfg(unix)]
165fn exec(bin: &Path, args: &[String]) -> Result<ExitCode, String> {
166    use std::os::unix::process::CommandExt;
167    // `exec` replaces this process and only returns on failure.
168    let err = Command::new(bin).args(args).exec();
169    Err(format!("exec {}: {err}", bin.display()))
170}
171
172#[cfg(not(unix))]
173fn exec(bin: &Path, args: &[String]) -> Result<ExitCode, String> {
174    let status = Command::new(bin)
175        .args(args)
176        .status()
177        .map_err(|e| format!("running {}: {e}", bin.display()))?;
178    Ok(ExitCode::from(status.code().unwrap_or(1) as u8))
179}
180
181fn home() -> Option<PathBuf> {
182    if let Ok(h) = std::env::var("VANTA_HOME") {
183        return Some(PathBuf::from(h));
184    }
185    std::env::var("HOME")
186        .or_else(|_| std::env::var("USERPROFILE"))
187        .ok()
188        .map(|base| PathBuf::from(base).join(".vanta"))
189}