Skip to main content

vanta_cli/
lib.rs

1//! `vanta-cli` — the command surface behind the `vanta` binary.
2//!
3//! Implements the commands documented in `docs/04-cli.md`, wiring the CLI to the
4//! resolver, registry, install engine, environment, and diagnostics subsystems.
5#![forbid(unsafe_code)]
6
7use std::cell::RefCell;
8use std::collections::{BTreeMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11use vanta_core::{Area, ExitCode, Platform, Request, StoreKey, VersionReq, VtaError, VtaResult};
12use vanta_install::Engine;
13use vanta_registry::Registry;
14use vanta_resolve::{artifact_for, Resolver};
15use vanta_ui::Progress;
16
17/// The crate version, surfaced by `vanta --version`.
18pub const VERSION: &str = env!("CARGO_PKG_VERSION");
19
20/// Drives the branded install UI (download bar + phase spinner) for one tool.
21///
22/// Implements [`vanta_install::Reporter`]; the engine calls back as it fetches
23/// and materializes the artifact. The active indicator is swapped in place so a
24/// single line is shown and cleared, leaving only the final `✓` summary.
25struct InstallUi {
26    /// Human label for the tool being installed, e.g. `node 22.3.0`.
27    label: String,
28    bar: RefCell<Option<Progress>>,
29}
30
31impl InstallUi {
32    fn new(label: String) -> InstallUi {
33        InstallUi {
34            label,
35            bar: RefCell::new(None),
36        }
37    }
38
39    /// Emit the final success summary for this tool, clearing any live bar.
40    fn finish_ok(&self, msg: &str) {
41        match self.bar.borrow().as_ref() {
42            Some(bar) => bar.finish_ok(msg),
43            None => vanta_ui::step(msg),
44        }
45    }
46}
47
48impl vanta_install::Reporter for InstallUi {
49    fn fetch_start(&self, total: Option<u64>) {
50        let bar = Progress::new_bar(&format!("downloading {}", self.label), total);
51        *self.bar.borrow_mut() = Some(bar);
52    }
53
54    fn fetch_inc(&self, n: u64) {
55        if let Some(bar) = self.bar.borrow().as_ref() {
56            bar.inc(n);
57        }
58    }
59
60    fn phase(&self, name: &str) {
61        // Swap the download bar for an indeterminate spinner during the
62        // (typically brief) verify/extract phases.
63        let mut slot = self.bar.borrow_mut();
64        if let Some(old) = slot.as_ref() {
65            old.clear();
66        }
67        *slot = Some(Progress::new_spinner(&format!("{name} {}", self.label)));
68    }
69}
70
71/// Install one artifact with a branded progress indicator, printing a concise
72/// `✓ <tool> <version> → <key>` summary on success.
73fn install_with_ui(
74    engine: &Engine,
75    tool: &str,
76    version: &str,
77    artifact: &vanta_core::Artifact,
78) -> VtaResult<StoreKey> {
79    let ui = InstallUi::new(format!("{tool} {version}"));
80    let key = engine.install_artifact_reported(tool, version, artifact, &ui)?;
81    ui.finish_ok(&format!("{tool} {version} → {key}"));
82    Ok(key)
83}
84
85/// Dispatch a parsed argv (without the program name). Returns the process exit code.
86pub fn run(args: &[String]) -> VtaResult<ExitCode> {
87    let cmd = args.first().map(String::as_str).unwrap_or("help");
88    let rest: &[String] = args.get(1..).unwrap_or(&[]);
89    // Branded wordmark, once, for interactive top-level runs. `banner` itself
90    // is a no-op unless stdout is a color-capable TTY, so this never pollutes
91    // scriptable/piped output; we additionally skip commands whose output is
92    // consumed by machines or shells.
93    if wants_banner(cmd) {
94        vanta_ui::banner(VERSION);
95    }
96    match cmd {
97        "--version" | "-V" | "version" => {
98            println!("vanta {VERSION}");
99            Ok(ExitCode::Ok)
100        }
101        "--help" | "-h" | "help" => {
102            print_help();
103            Ok(ExitCode::Ok)
104        }
105        "add" | "install" => cmd_add(rest),
106        "search" => cmd_search(rest),
107        "info" => cmd_info(rest),
108        "activate" => cmd_activate(rest),
109        "list" | "ls" => cmd_list(),
110        "which" => cmd_which(rest),
111        "doctor" => cmd_doctor(),
112        "sync" => cmd_sync(),
113        "generations" | "gen" => cmd_generations(),
114        "rollback" => cmd_rollback(rest),
115        "gc" => cmd_gc(),
116        "init" | "migrate" => cmd_import(has_flag(rest, "--force") || has_flag(rest, "-f")),
117        "exec" => cmd_exec(rest),
118        "x" => cmd_x(rest),
119        "remove" | "rm" => cmd_remove(rest),
120        "run" => cmd_run(rest),
121        "bundle" => cmd_bundle(rest),
122        "restore" => cmd_restore(rest),
123        "use" => cmd_add(rest),
124        "update" | "up" => cmd_sync(),
125        "outdated" => cmd_outdated(),
126        "cache" => cmd_cache(rest),
127        "config" => cmd_config(),
128        "completions" => cmd_completions(rest),
129        "trust" => cmd_trust(rest),
130        "registry" => cmd_registry(rest),
131        "shell" => cmd_shell(rest),
132        "self" => cmd_self(rest),
133        other => {
134            eprintln!("vanta: unknown command `{other}` (try `vanta help`)");
135            Ok(ExitCode::Usage)
136        }
137    }
138}
139
140/// `vanta add <tool>[@version] ...` — resolve and install each tool.
141fn cmd_add(rest: &[String]) -> VtaResult<ExitCode> {
142    let tools: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
143    if tools.is_empty() {
144        eprintln!("usage: vanta add <tool>[@version] ...");
145        return Ok(ExitCode::Usage);
146    }
147
148    let registry = load_registry()?;
149    let resolver = Resolver::new(&registry);
150    let platform = Platform::current();
151
152    // Resolve everything first (fail fast, no side effects on disk).
153    let mut resolutions = Vec::new();
154    for tool in &tools {
155        let request = Request::parse(tool)?;
156        resolutions.push(resolver.resolve(&request, &[platform])?);
157    }
158
159    // Install.
160    let engine = open_engine()?;
161    for resolution in &resolutions {
162        let artifact = artifact_for(resolution, &platform).ok_or_else(|| {
163            VtaError::new(
164                Area::Res,
165                5,
166                format!(
167                    "no artifact for `{}` on {}",
168                    resolution.tool,
169                    platform.token()
170                ),
171            )
172        })?;
173        install_with_ui(&engine, &resolution.tool, &resolution.version, artifact)?;
174    }
175    Ok(ExitCode::Ok)
176}
177
178/// `vanta search <query>` — search the registry.
179fn cmd_search(rest: &[String]) -> VtaResult<ExitCode> {
180    let query = rest
181        .iter()
182        .find(|a| !a.starts_with('-'))
183        .cloned()
184        .unwrap_or_default();
185    let registry = load_registry()?;
186    for name in registry.search(&query) {
187        println!("{name}");
188    }
189    Ok(ExitCode::Ok)
190}
191
192/// `vanta info <tool>` — show a tool's provider and available versions.
193fn cmd_info(rest: &[String]) -> VtaResult<ExitCode> {
194    let name = match rest.iter().find(|a| !a.starts_with('-')) {
195        Some(n) => n,
196        None => {
197            eprintln!("usage: vanta info <tool>");
198            return Ok(ExitCode::Usage);
199        }
200    };
201    let registry = load_registry()?;
202    let entry = registry
203        .tool(name)
204        .ok_or_else(|| VtaError::new(Area::Res, 3, format!("unknown tool `{name}`")))?;
205    println!("{name}  (provider: {})", entry.provider.id);
206    if let Some(summary) = &entry.summary {
207        println!("  {summary}");
208    }
209    println!("  versions:");
210    for v in &entry.versions {
211        let chan = v.channel.as_deref().unwrap_or("");
212        println!("    {} {}", v.version, chan);
213    }
214    Ok(ExitCode::Ok)
215}
216
217/// `vanta activate <shell>` — print the shell hook for `eval`.
218fn cmd_activate(rest: &[String]) -> VtaResult<ExitCode> {
219    let shell = match rest.iter().find(|a| !a.starts_with('-')) {
220        Some(s) => s,
221        None => {
222            eprintln!("usage: vanta activate <bash|zsh|fish|pwsh>");
223            return Ok(ExitCode::Usage);
224        }
225    };
226    match vanta_env::activate_hook(shell) {
227        Some(hook) => {
228            print!("{hook}");
229            Ok(ExitCode::Ok)
230        }
231        None => {
232            eprintln!("vanta: unsupported shell `{shell}`");
233            Ok(ExitCode::Usage)
234        }
235    }
236}
237
238/// `vanta list` — show the tools in the active generation.
239fn cmd_list() -> VtaResult<ExitCode> {
240    let engine = Engine::open(home()?)?;
241    match engine.state().current()? {
242        Some(id) => match engine.state().get_generation(id)? {
243            Some(gen) if !gen.tools.is_empty() => {
244                for (tool, key) in &gen.tools {
245                    println!("{tool}  ({key})");
246                }
247            }
248            _ => println!("(no tools installed)"),
249        },
250        None => println!("(no tools installed)"),
251    }
252    Ok(ExitCode::Ok)
253}
254
255/// `vanta which <tool>` — print the store path of the active tool.
256/// Resolve a tool's binary from the nearest `vanta.lock` walking up from the
257/// current directory — the same per-directory logic `vanta-shim` uses, so
258/// `vanta which` reports the binary that would actually run here.
259fn which_from_lock(home: &Path, name: &str) -> Option<PathBuf> {
260    let plat = Platform::current().token();
261    let mut dir = std::env::current_dir().ok();
262    while let Some(d) = dir {
263        let lock_path = d.join("vanta.lock");
264        if lock_path.is_file() {
265            if let Ok(lock) = vanta_lock::Lock::load_file(&lock_path) {
266                for tool in &lock.tools {
267                    let pin = tool.platform.get(&plat);
268                    let manages = tool.name == name
269                        || pin.is_some_and(|p| {
270                            p.bin
271                                .iter()
272                                .any(|b| b.rsplit(['/', '\\']).next() == Some(name))
273                        });
274                    if !manages {
275                        continue;
276                    }
277                    let pin = pin.filter(|p| !p.store_key.is_empty())?;
278                    let key = StoreKey::new(pin.store_key.clone()).ok()?;
279                    let rel = pin
280                        .bin
281                        .iter()
282                        .find(|b| b.rsplit(['/', '\\']).next() == Some(name))
283                        .cloned()
284                        .unwrap_or_else(|| name.to_string());
285                    return Some(home.join("store").join(key.as_str()).join(rel));
286                }
287            }
288        }
289        dir = d.parent().map(Path::to_path_buf);
290    }
291    None
292}
293
294fn cmd_which(rest: &[String]) -> VtaResult<ExitCode> {
295    let name = match rest.iter().find(|a| !a.starts_with('-')) {
296        Some(n) => n,
297        None => {
298            eprintln!("usage: vanta which <tool>");
299            return Ok(ExitCode::Usage);
300        }
301    };
302    let home = home()?;
303    // Per-directory first (matches the shim), then the global active generation.
304    if let Some(path) = which_from_lock(&home, name) {
305        println!("{}", path.display());
306        return Ok(ExitCode::Ok);
307    }
308    let engine = Engine::open(&home)?;
309    let id = engine
310        .state()
311        .current()?
312        .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
313    let gen = engine
314        .state()
315        .get_generation(id)?
316        .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
317    let (_, key) = gen
318        .tools
319        .iter()
320        .find(|(t, _)| t == name)
321        .ok_or_else(|| VtaError::new(Area::Env, 2, format!("`{name}` is not active")))?;
322    let store_key = StoreKey::new(key.clone())?;
323    println!("{}", engine.store().entry_path(&store_key).display());
324    Ok(ExitCode::Ok)
325}
326
327/// `vanta generations` — list the generation history (`*` marks the active one).
328fn cmd_generations() -> VtaResult<ExitCode> {
329    let engine = Engine::open(home()?)?;
330    match engine.state().current()? {
331        None => println!("(no generations)"),
332        Some(current) => {
333            for id in 1..=current {
334                if let Some(gen) = engine.state().get_generation(id)? {
335                    let mark = if id == current { "*" } else { " " };
336                    println!("{mark} {id:04}  {}  [{}]", gen.command, gen.reason);
337                }
338            }
339        }
340    }
341    Ok(ExitCode::Ok)
342}
343
344/// `vanta rollback [gen]` — switch the active generation (defaults to the previous).
345fn cmd_rollback(rest: &[String]) -> VtaResult<ExitCode> {
346    let engine = Engine::open(home()?)?;
347    let current = engine
348        .state()
349        .current()?
350        .ok_or_else(|| VtaError::new(Area::Env, 2, "no generations to roll back".to_string()))?;
351    let target = match rest
352        .iter()
353        .find(|a| !a.starts_with('-'))
354        .and_then(|s| s.parse::<u64>().ok())
355    {
356        Some(n) => n,
357        None if current > 1 => current - 1,
358        None => {
359            return Err(VtaError::new(
360                Area::Env,
361                2,
362                "already at the earliest generation".to_string(),
363            ))
364        }
365    };
366    if engine.state().get_generation(target)?.is_none() {
367        return Err(VtaError::new(
368            Area::Env,
369            2,
370            format!("generation {target} not found"),
371        ));
372    }
373    engine.state().set_current(target)?;
374    println!("rolled back to generation {target:04}");
375    Ok(ExitCode::Ok)
376}
377
378/// `vanta gc` — remove store entries unreachable from the retained generations
379/// (the active one plus the previous few, per the retention policy).
380fn cmd_gc() -> VtaResult<ExitCode> {
381    const RETAIN: u64 = 5;
382    let engine = Engine::open(home()?)?;
383    let mut roots: HashSet<StoreKey> = HashSet::new();
384    if let Some(current) = engine.state().current()? {
385        let start = current.saturating_sub(RETAIN - 1).max(1);
386        for id in start..=current {
387            if let Some(gen) = engine.state().get_generation(id)? {
388                for (_, key) in &gen.tools {
389                    if let Ok(k) = StoreKey::new(key.clone()) {
390                        roots.insert(k);
391                    }
392                }
393            }
394        }
395    }
396    let removed = engine.store().gc(&roots)?;
397    println!(
398        "removed {removed} unreferenced store entr{}",
399        if removed == 1 { "y" } else { "ies" }
400    );
401    Ok(ExitCode::Ok)
402}
403
404/// `vanta doctor` — run health checks and print fixes.
405fn cmd_doctor() -> VtaResult<ExitCode> {
406    let home = home()?;
407    let checks = vanta_diag::run(&home);
408    for c in &checks {
409        let mark = if c.ok { "✓" } else { "✗" };
410        println!("{mark} {} — {}", c.name, c.detail);
411    }
412    Ok(if vanta_diag::all_ok(&checks) {
413        ExitCode::Ok
414    } else {
415        ExitCode::Failure
416    })
417}
418
419/// `vanta sync` — reconcile to the nearest `vanta.toml`: install each tool for the
420/// current platform and write a **cross-platform** `vanta.lock` pinning every
421/// declared target the registry can serve (`docs/11-reproducibility.md`).
422fn cmd_sync() -> VtaResult<ExitCode> {
423    let manifest_path = find_manifest()?;
424    // M9: syncing executes a manifest's declared tool set; gate it on the trust
425    // list (TOFU) so a freshly-cloned, untrusted manifest cannot silently drive
426    // installs.
427    if !ensure_manifest_trusted(&manifest_path)? {
428        return Ok(ExitCode::Usage);
429    }
430    let manifest = vanta_config::load_file(&manifest_path)?;
431    if manifest.tools.is_empty() {
432        println!(
433            "nothing to sync ({} has no [tools])",
434            manifest_path.display()
435        );
436        return Ok(ExitCode::Ok);
437    }
438
439    // Target platforms: the manifest's `[settings] targets`, else a default set;
440    // always include the current platform so this machine can install.
441    let current = Platform::current();
442    let mut platforms: Vec<Platform> = manifest
443        .settings
444        .targets
445        .clone()
446        .unwrap_or_else(default_targets)
447        .iter()
448        .filter_map(|t| Platform::parse(t).ok())
449        .collect();
450    if !platforms.contains(&current) {
451        platforms.push(current);
452    }
453
454    let registry = load_registry()?;
455    let resolver = Resolver::new(&registry);
456    let engine = open_engine()?;
457    let mut lock = vanta_lock::Lock::new(
458        format!("vanta {VERSION}"),
459        platforms.iter().map(|p| p.token()).collect(),
460    );
461
462    // A previous lock lets us reuse an already-installed store entry instead of
463    // re-fetching/re-extracting an unchanged tool@version on every sync.
464    let lock_path = manifest_path
465        .parent()
466        .unwrap_or(Path::new("."))
467        .join("vanta.lock");
468    let prev_lock = vanta_lock::Lock::load_file(&lock_path).ok();
469    let current_token = current.token();
470
471    for (tool, spec) in &manifest.tools {
472        let request_str = spec.version().to_string();
473        let request = Request {
474            tool: tool.clone(),
475            version: VersionReq::parse(&request_str),
476        };
477        let resolution = resolver.resolve(&request, &platforms)?;
478
479        // Install only the current platform; lock pins all resolved platforms.
480        let mut current_artifact = artifact_for(&resolution, &current)
481            .ok_or_else(|| {
482                VtaError::new(
483                    Area::Res,
484                    5,
485                    format!("no artifact for `{tool}` on {}", current.token()),
486                )
487            })?
488            .clone();
489        // Reuse an already-installed store entry: if the previous lock pinned
490        // this exact tool@version for this platform, hand its store key to the
491        // engine so it verifies + relinks instead of re-fetching/re-extracting.
492        if let Some(prev) = &prev_lock {
493            if let Some(pin) = prev
494                .tools
495                .iter()
496                .find(|t| t.name == *tool && t.version == resolution.version)
497                .and_then(|t| t.platform.get(&current_token))
498                .filter(|p| !p.store_key.is_empty())
499            {
500                current_artifact.store_key = StoreKey::new(pin.store_key.clone()).ok();
501            }
502        }
503        let key = install_with_ui(
504            &engine,
505            &resolution.tool,
506            &resolution.version,
507            &current_artifact,
508        )?;
509
510        let mut platform_map = BTreeMap::new();
511        for (plat, art) in &resolution.per_platform {
512            // Materialized only for the current platform; others pin url+hash and
513            // get a store key when that platform later syncs.
514            let store_key = if *plat == current {
515                key.as_str().to_string()
516            } else {
517                String::new()
518            };
519            platform_map.insert(
520                plat.token(),
521                vanta_lock::PlatformPin {
522                    store_key,
523                    url: art.url.clone(),
524                    size: art.size,
525                    sha256: art.checksum.value.clone(),
526                    blake3: None,
527                    signature: art.signature.clone(),
528                    bin: art.bin.clone(),
529                },
530            );
531        }
532        lock.tools.push(vanta_lock::LockedTool {
533            name: tool.clone(),
534            request: request_str,
535            version: resolution.version.clone(),
536            provider: resolution.provider.clone(),
537            platform: platform_map,
538        });
539    }
540
541    lock.write_file(&lock_path)?;
542    println!(
543        "✓ wrote {} ({} targets)",
544        lock_path.display(),
545        platforms.len()
546    );
547    Ok(ExitCode::Ok)
548}
549
550/// The default lock target set when a manifest declares none.
551fn default_targets() -> Vec<String> {
552    [
553        "macos/aarch64",
554        "macos/x86_64",
555        "linux/x86_64/gnu",
556        "linux/aarch64/gnu",
557        "windows/x86_64",
558    ]
559    .iter()
560    .map(|s| s.to_string())
561    .collect()
562}
563
564/// Find the nearest `vanta.toml`, walking up from the current directory.
565fn find_manifest() -> VtaResult<PathBuf> {
566    let mut dir = std::env::current_dir()
567        .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
568    loop {
569        let candidate = dir.join("vanta.toml");
570        if candidate.is_file() {
571            return Ok(candidate);
572        }
573        if !dir.pop() {
574            return Err(VtaError::new(
575                Area::Cfg,
576                1,
577                "no vanta.toml found in this directory or any parent".to_string(),
578            ));
579        }
580    }
581}
582
583/// `vanta exec -- <cmd>` — run a command with `~/.vanta/bin` on PATH.
584fn cmd_exec(rest: &[String]) -> VtaResult<ExitCode> {
585    let cmdv: &[String] = match rest.iter().position(|a| a == "--") {
586        Some(i) => &rest[i + 1..],
587        None => rest,
588    };
589    if cmdv.is_empty() {
590        eprintln!("usage: vanta exec -- <command> [args]");
591        return Ok(ExitCode::Usage);
592    }
593    run_header(&cmdv[0], &cmdv[1..]);
594    let status = Command::new(&cmdv[0])
595        .args(&cmdv[1..])
596        .env("PATH", env_path_with_bin()?)
597        .status()
598        .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", cmdv[0])))?;
599    Ok(status_exit(status))
600}
601
602/// `vanta x <tool>[@ver] [args]` — resolve+install if needed, then run it.
603fn cmd_x(rest: &[String]) -> VtaResult<ExitCode> {
604    let spec = match rest.iter().find(|a| !a.starts_with('-')) {
605        Some(s) => s.clone(),
606        None => {
607            eprintln!("usage: vanta x <tool>[@version] [args]");
608            return Ok(ExitCode::Usage);
609        }
610    };
611    let request = Request::parse(&spec)?;
612    let registry = load_registry()?;
613    let resolver = Resolver::new(&registry);
614    let platform = Platform::current();
615    let resolution = resolver.resolve(&request, &[platform])?;
616    let engine = open_engine()?;
617    let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
618        VtaError::new(
619            Area::Res,
620            5,
621            format!("no artifact for `{}`", resolution.tool),
622        )
623    })?;
624    install_with_ui(&engine, &resolution.tool, &resolution.version, artifact)?;
625
626    let idx = rest.iter().position(|a| a == &spec).unwrap_or(0);
627    let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
628    let tool_bin = home()?.join("bin").join(&resolution.tool);
629    run_header(&resolution.tool, args);
630    let status = Command::new(&tool_bin)
631        .args(args)
632        .env("PATH", env_path_with_bin()?)
633        .status()
634        .map_err(|e| VtaError::new(Area::Env, 1, format!("running {}: {e}", resolution.tool)))?;
635    Ok(status_exit(status))
636}
637
638/// `vanta remove <tool>` — drop a tool (new generation) and unlink it.
639fn cmd_remove(rest: &[String]) -> VtaResult<ExitCode> {
640    let tool = match rest.iter().find(|a| !a.starts_with('-')) {
641        Some(t) => t,
642        None => {
643            eprintln!("usage: vanta remove <tool>");
644            return Ok(ExitCode::Usage);
645        }
646    };
647    let engine = Engine::open(home()?)?;
648    if engine.remove(tool)? {
649        println!("removed {tool}");
650        Ok(ExitCode::Ok)
651    } else {
652        Err(VtaError::new(
653            Area::Env,
654            2,
655            format!("`{tool}` is not installed"),
656        ))
657    }
658}
659
660/// `vanta run <task|tool> [args]` — run a manifest task, else a tool binary.
661fn cmd_run(rest: &[String]) -> VtaResult<ExitCode> {
662    let name = match rest.iter().find(|a| !a.starts_with('-')) {
663        Some(n) => n.clone(),
664        None => {
665            eprintln!("usage: vanta run <task|tool> [args]");
666            return Ok(ExitCode::Usage);
667        }
668    };
669    // A defined task wins over a tool of the same name.
670    if let Ok(manifest_path) = find_manifest() {
671        if let Ok(manifest) = vanta_config::load_file(&manifest_path) {
672            if let Some(task) = manifest.tasks.get(&name) {
673                // M9: a manifest task runs an arbitrary shell command. Refuse to
674                // run it from an untrusted manifest (a hostile cloned repo) until
675                // the operator trusts it.
676                if !ensure_manifest_trusted(&manifest_path)? {
677                    return Ok(ExitCode::Usage);
678                }
679                let cmd = match task {
680                    vanta_config::model::Task::Command(s) => s.clone(),
681                    vanta_config::model::Task::Detailed(d) => d.run.clone(),
682                };
683                vanta_ui::running(&cmd);
684                let status = shell_command(&cmd)
685                    .env("PATH", env_path_with_bin()?)
686                    .status()
687                    .map_err(|e| {
688                        VtaError::new(Area::Env, 1, format!("running task `{name}`: {e}"))
689                    })?;
690                return Ok(status_exit(status));
691            }
692        }
693    }
694    let idx = rest.iter().position(|a| a == &name).unwrap_or(0);
695    let args: &[String] = rest.get(idx + 1..).unwrap_or(&[]);
696    let tool_bin = home()?.join("bin").join(&name);
697    run_header(&name, args);
698    let status = Command::new(&tool_bin)
699        .args(args)
700        .env("PATH", env_path_with_bin()?)
701        .status()
702        .map_err(|e| VtaError::new(Area::Env, 1, format!("running `{name}`: {e}")))?;
703    Ok(status_exit(status))
704}
705
706/// `vanta bundle [--out file]` — pack the active generation for offline transfer.
707fn cmd_bundle(rest: &[String]) -> VtaResult<ExitCode> {
708    let out = rest
709        .iter()
710        .position(|a| a == "--out")
711        .and_then(|i| rest.get(i + 1))
712        .cloned()
713        .unwrap_or_else(|| "vanta-bundle.vbundle".to_string());
714    let engine = Engine::open(home()?)?;
715    let progress = Progress::new_spinner(&format!("bundling active generation → {out}"));
716    let n = match engine.bundle_current(Path::new(&out)) {
717        Ok(n) => n,
718        Err(e) => {
719            progress.finish_err("bundle failed");
720            return Err(e);
721        }
722    };
723    progress.finish_ok(&format!("bundled {n} store entries → {out}"));
724    Ok(ExitCode::Ok)
725}
726
727/// `vanta restore <file>` — import a bundle (verifying integrity).
728fn cmd_restore(rest: &[String]) -> VtaResult<ExitCode> {
729    let file = match rest.iter().find(|a| !a.starts_with('-')) {
730        Some(f) => f,
731        None => {
732            eprintln!("usage: vanta restore <file>");
733            return Ok(ExitCode::Usage);
734        }
735    };
736    let engine = Engine::open(home()?)?;
737    let progress = Progress::new_spinner(&format!("restoring bundle {file}"));
738    let n = match engine.restore(Path::new(file)) {
739        Ok(n) => n,
740        Err(e) => {
741            progress.finish_err("restore failed");
742            return Err(e);
743        }
744    };
745    progress.finish_ok(&format!("restored {n} store entries"));
746    Ok(ExitCode::Ok)
747}
748
749/// `vanta outdated` — show current (locked) vs allowed vs latest per manifest tool.
750#[allow(clippy::print_literal)] // aligned header columns read clearer as args
751fn cmd_outdated() -> VtaResult<ExitCode> {
752    let manifest_path = find_manifest()?;
753    let manifest = vanta_config::load_file(&manifest_path)?;
754    let registry = load_registry()?;
755    let resolver = Resolver::new(&registry);
756    let platform = Platform::current();
757
758    let lock_path = manifest_path
759        .parent()
760        .unwrap_or(Path::new("."))
761        .join("vanta.lock");
762    let locked: BTreeMap<String, String> = if lock_path.exists() {
763        vanta_lock::Lock::load_file(&lock_path)
764            .map(|l| l.tools.into_iter().map(|t| (t.name, t.version)).collect())
765            .unwrap_or_default()
766    } else {
767        BTreeMap::new()
768    };
769
770    println!(
771        "{:<16} {:<12} {:<12} {}",
772        "tool", "current", "allowed", "latest"
773    );
774    for (tool, spec) in &manifest.tools {
775        let allowed = resolver
776            .resolve(
777                &Request {
778                    tool: tool.clone(),
779                    version: VersionReq::parse(spec.version()),
780                },
781                &[platform],
782            )
783            .map(|r| r.version)
784            .unwrap_or_else(|_| "-".to_string());
785        let latest = resolver
786            .resolve(
787                &Request {
788                    tool: tool.clone(),
789                    version: VersionReq::Latest,
790                },
791                &[platform],
792            )
793            .map(|r| r.version)
794            .unwrap_or_else(|_| "-".to_string());
795        let current = locked.get(tool).cloned().unwrap_or_else(|| "-".to_string());
796        println!("{tool:<16} {current:<12} {allowed:<12} {latest}");
797    }
798    Ok(ExitCode::Ok)
799}
800
801/// `vanta cache <stats|prune>` — inspect or clear the download cache.
802fn cmd_cache(rest: &[String]) -> VtaResult<ExitCode> {
803    let sub = rest
804        .iter()
805        .find(|a| !a.starts_with('-'))
806        .map(|s| s.as_str())
807        .unwrap_or("stats");
808    let downloads = home()?.join("cache").join("downloads");
809    match sub {
810        "prune" => {
811            let mut n = 0;
812            if let Ok(rd) = std::fs::read_dir(&downloads) {
813                for e in rd.flatten() {
814                    if std::fs::remove_file(e.path()).is_ok() {
815                        n += 1;
816                    }
817                }
818            }
819            println!("pruned {n} cached downloads");
820        }
821        _ => {
822            let (mut files, mut bytes) = (0u64, 0u64);
823            if let Ok(rd) = std::fs::read_dir(&downloads) {
824                for e in rd.flatten() {
825                    if let Ok(m) = e.metadata() {
826                        if m.is_file() {
827                            files += 1;
828                            bytes += m.len();
829                        }
830                    }
831                }
832            }
833            println!("download cache: {files} files, {} KB", bytes / 1024);
834        }
835    }
836    Ok(ExitCode::Ok)
837}
838
839/// `vanta config` — show the global config path and contents.
840fn cmd_config() -> VtaResult<ExitCode> {
841    let path = home()?.join("config.toml");
842    println!("config: {}", path.display());
843    match std::fs::read_to_string(&path) {
844        Ok(contents) => {
845            println!("---");
846            print!("{contents}");
847        }
848        Err(_) => println!("(no global config; create it to set [tools]/[settings])"),
849    }
850    Ok(ExitCode::Ok)
851}
852
853/// `vanta completions <shell>` — emit a basic completion script.
854fn cmd_completions(rest: &[String]) -> VtaResult<ExitCode> {
855    let shell = rest
856        .iter()
857        .find(|a| !a.starts_with('-'))
858        .map(|s| s.as_str())
859        .unwrap_or("bash");
860    let cmds = "add remove update sync list which search info outdated init migrate doctor activate gc rollback generations run exec x bundle restore cache config completions use";
861    match shell {
862        "bash" => println!("complete -W \"{cmds}\" vanta vt"),
863        "zsh" => println!("#compdef vanta vt\n_values 'vanta command' {cmds}"),
864        "fish" => {
865            for c in cmds.split(' ') {
866                println!("complete -c vanta -a {c}");
867            }
868        }
869        other => {
870            eprintln!("vanta: no completions for `{other}`");
871            return Ok(ExitCode::Usage);
872        }
873    }
874    Ok(ExitCode::Ok)
875}
876
877/// `vanta trust [path]` — record a manifest's content hash as trusted (TOFU).
878fn cmd_trust(rest: &[String]) -> VtaResult<ExitCode> {
879    let trust_dir = home()?.join("trust");
880    if has_flag(rest, "--list") {
881        match std::fs::read_dir(&trust_dir) {
882            Ok(rd) => {
883                for e in rd.flatten() {
884                    if let Ok(target) = std::fs::read_to_string(e.path()) {
885                        println!("{}  {}", e.file_name().to_string_lossy(), target);
886                    }
887                }
888            }
889            Err(_) => println!("(nothing trusted yet)"),
890        }
891        return Ok(ExitCode::Ok);
892    }
893    let path = match rest.iter().find(|a| !a.starts_with('-')) {
894        Some(p) => PathBuf::from(p),
895        None => find_manifest()?,
896    };
897    let hash = vanta_security::sha256_file(&path)?;
898    std::fs::create_dir_all(&trust_dir)
899        .map_err(|e| VtaError::new(Area::Vrf, 3, format!("trust dir: {e}")))?;
900    std::fs::write(trust_dir.join(&hash), path.display().to_string())
901        .map_err(|e| VtaError::new(Area::Vrf, 3, format!("recording trust: {e}")))?;
902    println!("trusted {} ({hash})", path.display());
903    Ok(ExitCode::Ok)
904}
905
906/// Whether a manifest's content hash has been recorded as trusted (TOFU).
907fn manifest_is_trusted(trust_dir: &Path, hash: &str) -> bool {
908    trust_dir.join(hash).is_file()
909}
910
911/// Gate execution on the trust list (audit M9). Returns `Ok(true)` if the
912/// manifest is trusted (or the operator approves), `Ok(false)` to refuse.
913///
914/// Policy: already-trusted manifests pass silently. An untrusted manifest is
915/// **refused** in a non-interactive context (fail-closed) and **prompted** when
916/// stdin is a terminal; on a "yes" reply it is recorded as trusted and allowed.
917/// `VANTA_ASSUME_TRUST=1` approves non-interactively (for CI that opts in).
918fn ensure_manifest_trusted(manifest_path: &Path) -> VtaResult<bool> {
919    use std::io::IsTerminal;
920    let trust_dir = home()?.join("trust");
921    let hash = vanta_security::sha256_file(manifest_path)?;
922    if manifest_is_trusted(&trust_dir, &hash) {
923        return Ok(true);
924    }
925    let assume = matches!(
926        std::env::var("VANTA_ASSUME_TRUST").ok().as_deref(),
927        Some("1") | Some("true") | Some("yes")
928    );
929    let approved = if assume {
930        true
931    } else if std::io::stdin().is_terminal() {
932        eprint!(
933            "vanta: {} is not trusted. Trust it and continue? [y/N] ",
934            manifest_path.display()
935        );
936        use std::io::Write;
937        let _ = std::io::stderr().flush();
938        let mut line = String::new();
939        std::io::stdin().read_line(&mut line).ok();
940        matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
941    } else {
942        false
943    };
944    if approved {
945        std::fs::create_dir_all(&trust_dir)
946            .map_err(|e| VtaError::new(Area::Vrf, 3, format!("trust dir: {e}")))?;
947        let _ = std::fs::write(trust_dir.join(&hash), manifest_path.display().to_string());
948        Ok(true)
949    } else {
950        eprintln!(
951            "vanta: refusing to use untrusted manifest {} \
952             (run `vanta trust` to approve it)",
953            manifest_path.display()
954        );
955        Ok(false)
956    }
957}
958
959/// `vanta registry <list|add <name> <url>>` — manage configured registries.
960fn cmd_registry(rest: &[String]) -> VtaResult<ExitCode> {
961    let nonflags: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
962    let cfg = home()?.join("config.toml");
963    match nonflags.first().map(|s| s.as_str()) {
964        Some("add") => {
965            if nonflags.len() < 3 {
966                eprintln!("usage: vanta registry add <name> <url>");
967                return Ok(ExitCode::Usage);
968            }
969            let (name, url) = (nonflags[1], nonflags[2]);
970            let block = format!("\n[registries.{name}]\nurl = \"{url}\"\n");
971            if let Some(parent) = cfg.parent() {
972                let _ = std::fs::create_dir_all(parent);
973            }
974            let mut existing = std::fs::read_to_string(&cfg).unwrap_or_default();
975            existing.push_str(&block);
976            std::fs::write(&cfg, existing)
977                .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing config: {e}")))?;
978            println!("added registry {name} → {url}");
979            Ok(ExitCode::Ok)
980        }
981        _ => {
982            if cfg.exists() {
983                let manifest = vanta_config::load_file(&cfg)?;
984                if manifest.registries.is_empty() {
985                    println!("(no registries configured; the official registry is used)");
986                } else {
987                    for (name, reg) in &manifest.registries {
988                        println!("{name}  {}", reg.url);
989                    }
990                }
991            } else {
992                println!("(no config; the official registry is used by default)");
993            }
994            Ok(ExitCode::Ok)
995        }
996    }
997}
998
999/// `vanta shell <tool>@<ver> ...` — install (if needed) and start a subshell with
1000/// the tools on PATH.
1001fn cmd_shell(rest: &[String]) -> VtaResult<ExitCode> {
1002    let specs: Vec<&String> = rest.iter().filter(|a| !a.starts_with('-')).collect();
1003    if specs.is_empty() {
1004        eprintln!("usage: vanta shell <tool>[@version] ...");
1005        return Ok(ExitCode::Usage);
1006    }
1007    let registry = load_registry()?;
1008    let resolver = Resolver::new(&registry);
1009    let platform = Platform::current();
1010    let engine = open_engine()?;
1011    for spec in &specs {
1012        let request = Request::parse(spec)?;
1013        let resolution = resolver.resolve(&request, &[platform])?;
1014        let artifact = artifact_for(&resolution, &platform).ok_or_else(|| {
1015            VtaError::new(
1016                Area::Res,
1017                5,
1018                format!("no artifact for `{}`", resolution.tool),
1019            )
1020        })?;
1021        install_with_ui(&engine, &resolution.tool, &resolution.version, artifact)?;
1022    }
1023    let shell = std::env::var("SHELL").unwrap_or_else(|_| {
1024        if cfg!(windows) {
1025            "cmd".to_string()
1026        } else {
1027            "/bin/sh".to_string()
1028        }
1029    });
1030    vanta_ui::running(&format!(
1031        "{shell} (vanta subshell with {} tool(s); type `exit` to leave)",
1032        specs.len()
1033    ));
1034    let status = Command::new(shell)
1035        .env("PATH", env_path_with_bin()?)
1036        .status()
1037        .map_err(|e| VtaError::new(Area::Env, 1, format!("starting subshell: {e}")))?;
1038    Ok(status_exit(status))
1039}
1040
1041/// `vanta self <uninstall|update>` — manage the Vanta installation itself.
1042fn cmd_self(rest: &[String]) -> VtaResult<ExitCode> {
1043    match rest
1044        .iter()
1045        .find(|a| !a.starts_with('-'))
1046        .map(|s| s.as_str())
1047    {
1048        Some("uninstall") => {
1049            let h = home()?;
1050            if !has_flag(rest, "--yes") {
1051                eprintln!(
1052                    "this will permanently remove {} — re-run with --yes",
1053                    h.display()
1054                );
1055                return Ok(ExitCode::Usage);
1056            }
1057            std::fs::remove_dir_all(&h).map_err(|e| {
1058                VtaError::new(Area::Sys, 2, format!("removing {}: {e}", h.display()))
1059            })?;
1060            println!("removed {}", h.display());
1061            Ok(ExitCode::Ok)
1062        }
1063        Some("update") => {
1064            println!(
1065                "self-update is handled by the channel you installed from; \
1066                 see docs/32-release-engineering.md"
1067            );
1068            Ok(ExitCode::Ok)
1069        }
1070        _ => {
1071            eprintln!("usage: vanta self <uninstall|update>");
1072            Ok(ExitCode::Usage)
1073        }
1074    }
1075}
1076
1077fn env_path_with_bin() -> VtaResult<String> {
1078    let bin = home()?.join("bin");
1079    let sep = if cfg!(windows) { ';' } else { ':' };
1080    Ok(format!(
1081        "{}{}{}",
1082        bin.display(),
1083        sep,
1084        std::env::var("PATH").unwrap_or_default()
1085    ))
1086}
1087
1088/// Print a single branded header line before a subprocess inherits stdio.
1089/// Written to stderr so the child's own stdout stays unpolluted.
1090fn run_header(program: &str, args: &[String]) {
1091    let mut line = program.to_string();
1092    if !args.is_empty() {
1093        line.push(' ');
1094        line.push_str(&args.join(" "));
1095    }
1096    vanta_ui::running(&line);
1097}
1098
1099fn shell_command(cmd: &str) -> Command {
1100    if cfg!(windows) {
1101        let mut c = Command::new("cmd");
1102        c.arg("/C").arg(cmd);
1103        c
1104    } else {
1105        let mut c = Command::new("sh");
1106        c.arg("-c").arg(cmd);
1107        c
1108    }
1109}
1110
1111fn status_exit(status: std::process::ExitStatus) -> ExitCode {
1112    if status.success() {
1113        ExitCode::Ok
1114    } else {
1115        ExitCode::Failure
1116    }
1117}
1118
1119/// `vanta init` / `vanta migrate` — detect foreign version files and write a
1120/// `vanta.toml` (`docs/30-migration.md`).
1121fn cmd_import(force: bool) -> VtaResult<ExitCode> {
1122    let cwd = std::env::current_dir()
1123        .map_err(|e| VtaError::new(Area::Cfg, 1, format!("cannot read current directory: {e}")))?;
1124    let imported = vanta_migrate::import_dir(&cwd);
1125    if imported.is_empty() {
1126        println!("no version files detected in {}", cwd.display());
1127        return Ok(ExitCode::Ok);
1128    }
1129    let target = cwd.join("vanta.toml");
1130    if target.exists() && !force {
1131        eprintln!("vanta.toml already exists (use --force to overwrite)");
1132        return Ok(ExitCode::Usage);
1133    }
1134    println!("detected:");
1135    for i in &imported {
1136        println!("  {} = \"{}\"  (from {})", i.tool, i.version, i.source);
1137    }
1138    let body = vanta_migrate::to_manifest_toml(&imported);
1139    std::fs::write(&target, body)
1140        .map_err(|e| VtaError::new(Area::Cfg, 1, format!("writing {}: {e}", target.display())))?;
1141    println!(
1142        "✓ wrote {} — run `vanta sync` to install + lock",
1143        target.display()
1144    );
1145    Ok(ExitCode::Ok)
1146}
1147
1148fn has_flag(rest: &[String], flag: &str) -> bool {
1149    rest.iter().any(|a| a == flag)
1150}
1151
1152/// Whether to show the wordmark banner for `cmd`. Suppressed for machine- or
1153/// shell-consumed commands whose output must stay clean (versions, help text,
1154/// completion scripts, the `activate` shell hook, `which` paths, and the
1155/// `exec` passthrough), even on a TTY. Other commands still only render the
1156/// banner when [`vanta_ui::banner`] decides the terminal is interactive.
1157fn wants_banner(cmd: &str) -> bool {
1158    !matches!(
1159        cmd,
1160        "--version"
1161            | "-V"
1162            | "version"
1163            | "--help"
1164            | "-h"
1165            | "help"
1166            | "completions"
1167            | "activate"
1168            | "which"
1169            | "exec"
1170    )
1171}
1172
1173/// Resolve `$VANTA_HOME` (or `~/.vanta`).
1174fn home() -> VtaResult<PathBuf> {
1175    if let Ok(h) = std::env::var("VANTA_HOME") {
1176        return Ok(PathBuf::from(h));
1177    }
1178    let base = std::env::var("HOME")
1179        .or_else(|_| std::env::var("USERPROFILE"))
1180        .map_err(|_| {
1181            VtaError::new(
1182                Area::Sys,
1183                2,
1184                "cannot determine home directory; set VANTA_HOME".to_string(),
1185            )
1186        })?;
1187    Ok(PathBuf::from(base).join(".vanta"))
1188}
1189
1190/// Hard ceiling on the registry index download (defense against an oversized
1191/// index served by a hostile endpoint).
1192const REGISTRY_MAX_BYTES: u64 = 64 * 1024 * 1024; // 64 MiB
1193
1194/// The official, root-signed registry served from the project repository. Used
1195/// when `$VANTA_REGISTRY` is unset; fetched and verified over the same
1196/// pinned-root path as any other network registry (audit C1). Override with
1197/// `$VANTA_REGISTRY` (an `https://` URL or a local file path).
1198const DEFAULT_REGISTRY_URL: &str =
1199    "https://raw.githubusercontent.com/squaretick/vanta/main/registry/registry.toml";
1200
1201/// Whether the operator has explicitly opted into insecure registry handling
1202/// (`VANTA_INSECURE_REGISTRY=1`). DANGEROUS: this disables both the HTTPS
1203/// requirement and the pinned-root signature requirement on a network registry,
1204/// reducing the index to its (attacker-influenceable) contents. Per-artifact
1205/// signing keys are still treated as unverified unless individually pinned.
1206fn registry_insecure_optin() -> bool {
1207    matches!(
1208        std::env::var("VANTA_INSECURE_REGISTRY").ok().as_deref(),
1209        Some("1") | Some("true") | Some("yes")
1210    )
1211}
1212
1213/// Load the registry (audit C1: pinned-root trust model).
1214///
1215/// * A network (`$VANTA_REGISTRY`) index must be served over `https` and must
1216///   carry a detached signature (`<url>.minisig`) that verifies against a pinned
1217///   root key (compiled-in or `~/.vanta/trust/roots.toml`). On success the index
1218///   is marked verified so the resolver may trust the per-tool signing keys it
1219///   carries (transitive trust). Both requirements can be waived only via the
1220///   documented, dangerous `VANTA_INSECURE_REGISTRY` opt-in.
1221/// * A local-file index (`$VANTA_REGISTRY=/path`) is user-owned and treated as
1222///   trusted.
1223/// * With no `$VANTA_REGISTRY`, the official [`DEFAULT_REGISTRY_URL`] is fetched
1224///   and verified over the same pinned-root path; only if it cannot be
1225///   reached/verified do we fall back to the empty built-in index.
1226fn load_registry() -> VtaResult<Registry> {
1227    let roots = vanta_security::trust::load_root_keys(&home()?.join("trust"));
1228    match std::env::var("VANTA_REGISTRY") {
1229        Ok(loc) if loc.starts_with("http://") || loc.starts_with("https://") => {
1230            fetch_signed_index(&loc, roots)
1231        }
1232        Ok(path) => {
1233            // A local, user-owned index is trusted (the operator chose it).
1234            let mut registry = Registry::load_file(Path::new(&path))?;
1235            registry.index_verified = true;
1236            registry.trusted_root_keys = roots;
1237            Ok(registry)
1238        }
1239        Err(_) => {
1240            // No override: use the official, root-signed registry over the same
1241            // verified-network path. If it cannot be reached/verified, fall back
1242            // to the (empty) built-in index with an actionable error so offline
1243            // use still works rather than hard-failing every command.
1244            match fetch_signed_index(DEFAULT_REGISTRY_URL, roots) {
1245                Ok(registry) => Ok(registry),
1246                Err(e) => {
1247                    eprintln!(
1248                        "vanta: WARNING — could not load the official registry \
1249                         ({DEFAULT_REGISTRY_URL}): {e}. Falling back to the empty \
1250                         built-in index. Set $VANTA_REGISTRY to a reachable signed \
1251                         https URL or a local file path to override."
1252                    );
1253                    Ok(Registry::builtin())
1254                }
1255            }
1256        }
1257    }
1258}
1259
1260/// Fetch a network registry index and authenticate it against the pinned trust
1261/// roots before returning it (audit C1). Shared by the explicit
1262/// `$VANTA_REGISTRY=https://…` path and the built-in [`DEFAULT_REGISTRY_URL`].
1263fn fetch_signed_index(loc: &str, roots: Vec<String>) -> VtaResult<Registry> {
1264    let insecure = registry_insecure_optin();
1265    if loc.starts_with("http://") && !insecure {
1266        return Err(VtaError::new(
1267            Area::Reg,
1268            5,
1269            format!(
1270                "refusing plaintext http registry {loc} (https required; \
1271                 set VANTA_INSECURE_REGISTRY=1 to override — DANGEROUS)"
1272            ),
1273        ));
1274    }
1275    let downloader = if insecure {
1276        vanta_net::Downloader::insecure()?
1277    } else {
1278        vanta_net::Downloader::new()?
1279    };
1280    let pid = std::process::id();
1281    let tmp = std::env::temp_dir().join(format!("vanta-registry-{pid}.toml"));
1282    // The index length is not declared up front, so this renders as a
1283    // byte-counting spinner rather than a determinate bar.
1284    let progress = Progress::new_bar("fetching registry index", None);
1285    let dl = downloader.download_capped_with_progress(
1286        loc,
1287        &tmp,
1288        Some(REGISTRY_MAX_BYTES),
1289        Some(&|n| progress.inc(n)),
1290    );
1291    if let Err(e) = dl {
1292        progress.finish_err("registry index download failed");
1293        return Err(e);
1294    }
1295    progress.finish_ok("fetched registry index");
1296    let index_bytes = std::fs::read(&tmp)
1297        .map_err(|e| VtaError::new(Area::Reg, 1, format!("reading index: {e}")))?;
1298
1299    // Authenticate the index against a pinned root before trusting it.
1300    let sig_url = format!("{loc}.minisig");
1301    let sig_tmp = std::env::temp_dir().join(format!("vanta-registry-{pid}.minisig"));
1302    let signature = downloader
1303        .download_capped(&sig_url, &sig_tmp, Some(1024 * 1024))
1304        .ok()
1305        .and_then(|_| std::fs::read_to_string(&sig_tmp).ok());
1306    let _ = std::fs::remove_file(&sig_tmp);
1307    let index_verified = signature
1308        .as_deref()
1309        .map(|s| vanta_security::trust::index_signed_by_root(&index_bytes, s, &roots))
1310        .unwrap_or(false);
1311
1312    if !index_verified && !insecure {
1313        let _ = std::fs::remove_file(&tmp);
1314        return Err(VtaError::new(
1315            Area::Reg,
1316            6,
1317            format!(
1318                "registry index {loc} is not signed by a pinned trust root \
1319                 (expected detached signature at {loc}.minisig). Refusing to trust it. \
1320                 Add a root to ~/.vanta/trust/roots.toml, or set \
1321                 VANTA_INSECURE_REGISTRY=1 to override — DANGEROUS."
1322            ),
1323        ));
1324    }
1325    if insecure && !index_verified {
1326        eprintln!(
1327            "vanta: WARNING — using unverified registry {loc} (VANTA_INSECURE_REGISTRY). \
1328             Per-artifact signing keys will be treated as untrusted."
1329        );
1330    }
1331
1332    let src = String::from_utf8(index_bytes)
1333        .map_err(|e| VtaError::new(Area::Reg, 2, format!("index is not UTF-8: {e}")))?;
1334    let _ = std::fs::remove_file(&tmp);
1335    let mut registry = Registry::from_toml(&src)?;
1336    registry.index_verified = index_verified;
1337    registry.trusted_root_keys = roots;
1338    Ok(registry)
1339}
1340
1341/// Build the install [`Policy`] from configuration (audit H2). Reads
1342/// `settings.verify` from the global `~/.vanta/config.toml` and, if present, the
1343/// nearest project manifest (project wins). `verify = "require"` (and synonyms)
1344/// makes a missing/untrusted signature a hard error. The default (no setting)
1345/// stays backward-compatible: checksum-gated, signatures verified when present.
1346fn install_policy() -> vanta_security::Policy {
1347    let mut policy = vanta_security::Policy::default();
1348    let mut verify: Option<String> = None;
1349    if let Ok(h) = home() {
1350        if let Ok(m) = vanta_config::load_file(&h.join("config.toml")) {
1351            verify = m.settings.verify;
1352        }
1353    }
1354    if let Ok(path) = find_manifest() {
1355        if let Ok(m) = vanta_config::load_file(&path) {
1356            if m.settings.verify.is_some() {
1357                verify = m.settings.verify;
1358            }
1359        }
1360    }
1361    if let Some(v) = verify {
1362        if matches!(
1363            v.to_ascii_lowercase().as_str(),
1364            "require" | "required" | "signature" | "strict"
1365        ) {
1366            policy.require_signature = true;
1367        }
1368    }
1369    policy
1370}
1371
1372/// Open the install engine wired with the configured verification policy.
1373fn open_engine() -> VtaResult<Engine> {
1374    Engine::open_with_policy(home()?, install_policy())
1375}
1376
1377fn print_help() {
1378    println!(
1379        "vanta — every developer tool, one command\n\
1380         \n\
1381         USAGE:\n    vanta <command> [args]\n\
1382         \n\
1383         COMMON COMMANDS:\n\
1384         \x20   add <tool>[@ver]    resolve and install a tool (alias: install)\n\
1385         \x20   search <query>      search the registry\n\
1386         \x20   info <tool>         show a tool's versions\n\
1387         \x20   remove <tool>       remove a tool\n\
1388         \x20   update [tool]       update within constraints\n\
1389         \x20   sync                reconcile to vanta.toml + vanta.lock\n\
1390         \x20   doctor              diagnose the installation\n\
1391         \n\
1392         REGISTRY:\n\
1393         \x20   By default vanta uses the official, minisign-signed registry\n\
1394         \x20   (verified against a pinned root key). Override the source with\n\
1395         \x20   $VANTA_REGISTRY — an https:// URL (must carry a <url>.minisig\n\
1396         \x20   signed by a pinned root) or a local file path (trusted as-is).\n\
1397         \n\
1398         See docs/04-cli.md for the full reference."
1399    );
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404    use super::*;
1405
1406    #[test]
1407    fn version_ok() {
1408        assert_eq!(run(&["--version".into()]).unwrap(), ExitCode::Ok);
1409    }
1410
1411    #[test]
1412    fn unknown_is_usage() {
1413        assert_eq!(run(&["frobnicate".into()]).unwrap(), ExitCode::Usage);
1414    }
1415
1416    #[test]
1417    fn add_no_args_is_usage() {
1418        assert_eq!(run(&["add".into()]).unwrap(), ExitCode::Usage);
1419    }
1420
1421    /// Point `$VANTA_REGISTRY` at a local empty index so `load_registry` stays
1422    /// hermetic (no network fetch of the official default) during unit tests.
1423    fn use_empty_registry() {
1424        let p = std::env::temp_dir().join(format!("vanta-empty-reg-{}.toml", std::process::id()));
1425        std::fs::write(&p, "").unwrap();
1426        std::env::set_var("VANTA_REGISTRY", &p);
1427    }
1428
1429    #[test]
1430    fn add_unknown_tool_resolves_to_error() {
1431        // Resolution fails for an unknown tool before any disk/network side effect.
1432        use_empty_registry();
1433        let err = run(&["add".into(), "totally-unknown-tool".into()]).unwrap_err();
1434        assert_eq!(err.area, Area::Res);
1435    }
1436
1437    #[test]
1438    fn search_succeeds() {
1439        use_empty_registry();
1440        assert_eq!(
1441            run(&["search".into(), "node".into()]).unwrap(),
1442            ExitCode::Ok
1443        );
1444    }
1445
1446    // M9: the trust gate recognizes a recorded manifest hash and refuses one
1447    // that was never trusted.
1448    #[test]
1449    fn trust_list_gates_untrusted_manifest() {
1450        let dir = std::env::temp_dir().join(format!("vanta-cli-trust-{}", std::process::id()));
1451        let _ = std::fs::remove_dir_all(&dir);
1452        std::fs::create_dir_all(&dir).unwrap();
1453        let hash = "a".repeat(64);
1454        // Nothing recorded yet → untrusted.
1455        assert!(!manifest_is_trusted(&dir, &hash));
1456        // Record the hash (as `vanta trust` would) → trusted.
1457        std::fs::write(dir.join(&hash), "manifest path").unwrap();
1458        assert!(manifest_is_trusted(&dir, &hash));
1459        // A different manifest's hash remains untrusted.
1460        assert!(!manifest_is_trusted(&dir, &"b".repeat(64)));
1461        let _ = std::fs::remove_dir_all(&dir);
1462    }
1463}