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