Skip to main content

vanta_install/
lib.rs

1//! `vanta-install` — the install engine.
2//!
3//! Drives the lifecycle stages `[4 Fetch]`..`[8 Commit]` (`docs/08-installation.md`)
4//! for a resolved artifact: download (mirror-aware, resumable), verify the
5//! checksum (fail-closed), materialize (extract) into a staging tree, publish it
6//! atomically into the content-addressed store, and record a new generation.
7//!
8//! The entry point takes a resolved [`Artifact`] (produced by `vanta-resolve`).
9//! Supported archive formats: `tar.gz`/`tgz` and `raw`.
10#![forbid(unsafe_code)]
11
12use std::fs;
13use std::path::{Path, PathBuf};
14use vanta_core::{Area, Artifact, Platform, StoreKey, VtaError, VtaResult};
15use vanta_net::Downloader;
16use vanta_state::{GenerationRecord, State, StoreEntryMeta};
17use vanta_store::Store;
18
19/// The install engine, bound to a `$VANTA_HOME`.
20pub struct Engine {
21    store: Store,
22    state: State,
23    downloader: Downloader,
24    home: PathBuf,
25}
26
27impl Engine {
28    /// Open the engine over `home` (`$VANTA_HOME`).
29    pub fn open(home: impl AsRef<Path>) -> VtaResult<Engine> {
30        let home = home.as_ref().to_path_buf();
31        let store = Store::open(&home)?;
32        let state = State::open(&home.join("state.db"))?;
33        let downloader = Downloader::new()?;
34        Ok(Engine {
35            store,
36            state,
37            downloader,
38            home,
39        })
40    }
41
42    /// Borrow the underlying store / state (for `gc`, `which`, etc.).
43    pub fn store(&self) -> &Store {
44        &self.store
45    }
46    pub fn state(&self) -> &State {
47        &self.state
48    }
49
50    /// Install one resolved artifact for the current platform, returning its
51    /// store key. Fetch → verify → materialize → publish → commit a generation.
52    /// A store hit short-circuits fetch/verify/materialize.
53    pub fn install_artifact(
54        &self,
55        tool: &str,
56        version: &str,
57        artifact: &Artifact,
58    ) -> VtaResult<StoreKey> {
59        // [3 Plan] — if the lock already named a key and it is present, reuse it.
60        if let Some(key) = &artifact.store_key {
61            if self.store.has(key) {
62                self.link_bins(key, &artifact.bin)?;
63                self.record(tool, version, key, &artifact.checksum.value)?;
64                return Ok(key.clone());
65            }
66        }
67
68        // [4 Fetch]
69        let dl = self
70            .store
71            .downloads_dir()
72            .join(format!("incoming-{tool}-{}", std::process::id()));
73        let mut urls = vec![artifact.url.clone()];
74        urls.extend(artifact.mirrors.clone());
75        self.downloader.download_any(&urls, &dl)?;
76
77        // [5 Verify] — fail closed (centralized in vanta-security).
78        if let Err(e) =
79            vanta_security::verify_file(&dl, &artifact.checksum.algo, &artifact.checksum.value)
80        {
81            let _ = fs::remove_file(&dl);
82            return Err(e);
83        }
84        // Signature verification when the registry pinned a signature + trusted key.
85        if let (Some(sig), Some(key_text)) = (&artifact.signature, &artifact.signature_key) {
86            let key = vanta_security::parse_minisign_pubkey(key_text)?;
87            let bytes = fs::read(&dl).map_err(|e| io(&dl, e))?;
88            if let Err(e) = vanta_security::minisign_verify(&bytes, sig, &key) {
89                let _ = fs::remove_file(&dl);
90                return Err(e);
91            }
92        }
93
94        // [6 Materialize]
95        let staging = self.store.new_staging()?;
96        let name = artifact
97            .bin
98            .first()
99            .map(|b| basename(b))
100            .unwrap_or_else(|| tool.to_string());
101        extract(&artifact.archive, &dl, &staging, &name, artifact.strip)?;
102        let _ = fs::remove_file(&dl);
103
104        // [6 Materialize, cont.] atomic publish into the store.
105        let key = self.store.publish_tree(&staging)?;
106
107        // [7 Link] expose the tool's executables on PATH via ~/.vanta/bin.
108        self.link_bins(&key, &artifact.bin)?;
109
110        // [8 Commit]
111        self.record(tool, version, &key, &artifact.checksum.value)?;
112        Ok(key)
113    }
114
115    /// Link a store entry's declared executables into `~/.vanta/bin` (placed on
116    /// PATH by the shell hook). Per-directory environment views are composed by
117    /// `vanta-env` (`docs/10-environments.md`).
118    fn link_bins(&self, key: &StoreKey, bins: &[String]) -> VtaResult<()> {
119        let bin_dir = self.home.join("bin");
120        fs::create_dir_all(&bin_dir).map_err(|e| io(&bin_dir, e))?;
121        let entry = self.store.entry_path(key);
122        for bin in bins {
123            let src = entry.join(bin);
124            if src.exists() {
125                let dst = bin_dir.join(basename(bin));
126                vanta_store::link_best(&src, &dst)?;
127            }
128        }
129        Ok(())
130    }
131
132    fn record(&self, tool: &str, version: &str, key: &StoreKey, sha256: &str) -> VtaResult<()> {
133        let platform = Platform::current().token();
134        self.state.put_store_entry(
135            key.as_str(),
136            &StoreEntryMeta {
137                tool: tool.to_string(),
138                version: version.to_string(),
139                platform,
140                size: 0,
141                sha256: sha256.to_string(),
142            },
143        )?;
144        let parent = self.state.current()?;
145        let id = parent.map(|c| c + 1).unwrap_or(1);
146        self.state.append_generation(&GenerationRecord {
147            id,
148            parent,
149            command: format!("vanta add {tool}@{version}"),
150            reason: "add".to_string(),
151            tools: vec![(tool.to_string(), key.as_str().to_string())],
152        })?;
153        self.state.set_current(id)?;
154        Ok(())
155    }
156
157    /// Store keys referenced by the active generation.
158    fn active_store_keys(&self) -> VtaResult<Vec<StoreKey>> {
159        let mut keys = Vec::new();
160        if let Some(current) = self.state.current()? {
161            if let Some(gen) = self.state.get_generation(current)? {
162                for (_, k) in gen.tools {
163                    if let Ok(sk) = StoreKey::new(k) {
164                        keys.push(sk);
165                    }
166                }
167            }
168        }
169        Ok(keys)
170    }
171
172    /// Bundle the active generation's store entries into a portable archive
173    /// (`docs/13-offline.md`). Returns the number of entries written.
174    pub fn bundle_current(&self, out: &Path) -> VtaResult<usize> {
175        let keys = self.active_store_keys()?;
176        let file = fs::File::create(out).map_err(|e| io(out, e))?;
177        let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
178        let mut builder = tar::Builder::new(enc);
179        let list = keys
180            .iter()
181            .map(|k| k.as_str())
182            .collect::<Vec<_>>()
183            .join("\n");
184        let mut header = tar::Header::new_gnu();
185        header.set_size(list.len() as u64);
186        header.set_mode(0o644);
187        header.set_cksum();
188        builder
189            .append_data(&mut header, "KEYS", list.as_bytes())
190            .map_err(|e| inst(format!("bundle KEYS: {e}")))?;
191        for key in &keys {
192            let dir = self.store.entry_path(key);
193            if dir.is_dir() {
194                builder
195                    .append_dir_all(key.as_str(), &dir)
196                    .map_err(|e| inst(format!("bundle {key}: {e}")))?;
197            }
198        }
199        let enc = builder
200            .into_inner()
201            .map_err(|e| inst(format!("bundle finalize: {e}")))?;
202        enc.finish()
203            .map_err(|e| inst(format!("bundle gzip: {e}")))?;
204        Ok(keys.len())
205    }
206
207    /// Restore store entries from a bundle, verifying each entry's integrity
208    /// against its content-addressed key. Returns the number newly imported.
209    pub fn restore(&self, bundle: &Path) -> VtaResult<usize> {
210        let file = fs::File::open(bundle).map_err(|e| io(bundle, e))?;
211        let gz = flate2::read::GzDecoder::new(file);
212        let mut archive = tar::Archive::new(gz);
213        let staging = self.store.new_staging()?;
214        archive
215            .unpack(&staging)
216            .map_err(|e| inst(format!("restore unpack: {e}")))?;
217        let keys_txt =
218            fs::read_to_string(staging.join("KEYS")).map_err(|e| io(&staging.join("KEYS"), e))?;
219        let mut restored = 0;
220        for line in keys_txt.lines() {
221            let key = line.trim();
222            if key.is_empty() {
223                continue;
224            }
225            let sk = StoreKey::new(key)?;
226            let dst = self.store.entry_path(&sk);
227            let src = staging.join(key);
228            if !dst.exists() && src.is_dir() {
229                // Bundled entries are read-only; add write so the dir can be moved.
230                let _ = vanta_store::ensure_writable(&src);
231                fs::rename(&src, &dst).map_err(|e| io(&dst, e))?;
232                restored += 1;
233            }
234            if !self.store.verify_entry(&sk)? {
235                return Err(VtaError::new(
236                    Area::Vrf,
237                    1,
238                    format!("restored entry {key} failed integrity verification"),
239                ));
240            }
241        }
242        let _ = fs::remove_dir_all(&staging);
243        Ok(restored)
244    }
245
246    /// Remove a tool: record a new generation without it and unlink its primary
247    /// executable. Returns whether the tool was present.
248    pub fn remove(&self, tool: &str) -> VtaResult<bool> {
249        let current = match self.state.current()? {
250            Some(c) => c,
251            None => return Ok(false),
252        };
253        let gen = match self.state.get_generation(current)? {
254            Some(g) => g,
255            None => return Ok(false),
256        };
257        if !gen.tools.iter().any(|(t, _)| t == tool) {
258            return Ok(false);
259        }
260        let tools: Vec<(String, String)> = gen
261            .tools
262            .iter()
263            .filter(|(t, _)| t != tool)
264            .cloned()
265            .collect();
266        let id = current + 1;
267        self.state.append_generation(&GenerationRecord {
268            id,
269            parent: Some(current),
270            command: format!("vanta remove {tool}"),
271            reason: "remove".to_string(),
272            tools,
273        })?;
274        self.state.set_current(id)?;
275        let _ = fs::remove_file(self.home.join("bin").join(tool));
276        Ok(true)
277    }
278}
279
280fn inst(msg: String) -> VtaError {
281    VtaError::new(Area::Inst, 1, msg)
282}
283
284/// Materialize an artifact's bytes into `dest` according to its archive kind,
285/// stripping `strip` leading path components (the provider's layout).
286pub fn extract(
287    archive: &str,
288    src: &Path,
289    dest: &Path,
290    raw_name: &str,
291    strip: u32,
292) -> VtaResult<()> {
293    match archive {
294        "tar.gz" | "tgz" => extract_targz(src, dest, strip),
295        "raw" => {
296            fs::create_dir_all(dest).map_err(|e| io(dest, e))?;
297            let out = dest.join(raw_name);
298            fs::copy(src, &out).map_err(|e| io(&out, e))?;
299            set_executable(&out);
300            Ok(())
301        }
302        other => Err(VtaError::new(
303            Area::Inst,
304            3,
305            format!("unsupported archive kind `{other}` (supported: tar.gz, tgz, raw)"),
306        )),
307    }
308}
309
310fn extract_targz(src: &Path, dest: &Path, strip: u32) -> VtaResult<()> {
311    use std::path::{Component, PathBuf};
312    let file = fs::File::open(src).map_err(|e| io(src, e))?;
313    let gz = flate2::read::GzDecoder::new(file);
314    let mut archive = tar::Archive::new(gz);
315    archive.set_preserve_permissions(true);
316    let entries = archive
317        .entries()
318        .map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive: {e}")))?;
319    for entry in entries {
320        let mut entry = entry
321            .map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive entry: {e}")))?;
322        let path = entry
323            .path()
324            .map_err(|e| VtaError::new(Area::Inst, 1, format!("entry path: {e}")))?
325            .into_owned();
326        let stripped: PathBuf = path.components().skip(strip as usize).collect();
327        if stripped.as_os_str().is_empty() {
328            continue;
329        }
330        // Reject anything that could escape the destination (zip-slip / traversal).
331        if stripped.components().any(|c| {
332            matches!(
333                c,
334                Component::ParentDir | Component::RootDir | Component::Prefix(_)
335            )
336        }) {
337            return Err(VtaError::new(
338                Area::Inst,
339                1,
340                "archive entry escapes destination (path traversal rejected)".to_string(),
341            ));
342        }
343        let out = dest.join(&stripped);
344        if let Some(parent) = out.parent() {
345            fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
346        }
347        entry
348            .unpack(&out)
349            .map_err(|e| VtaError::new(Area::Inst, 1, format!("unpacking entry: {e}")))?;
350    }
351    Ok(())
352}
353
354fn basename(p: &str) -> String {
355    p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
356}
357
358#[cfg(unix)]
359fn set_executable(path: &Path) {
360    use std::os::unix::fs::PermissionsExt;
361    if let Ok(meta) = fs::metadata(path) {
362        let mut perms = meta.permissions();
363        perms.set_mode(perms.mode() | 0o755);
364        let _ = fs::set_permissions(path, perms);
365    }
366}
367
368#[cfg(not(unix))]
369fn set_executable(_path: &Path) {}
370
371fn io(path: &Path, e: std::io::Error) -> VtaError {
372    VtaError::new(Area::Inst, 2, format!("{}: {e}", path.display()))
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn home(tag: &str) -> PathBuf {
380        let p = std::env::temp_dir().join(format!("vanta-install-{}-{}", tag, std::process::id()));
381        let _ = fs::remove_dir_all(&p);
382        p
383    }
384
385    #[test]
386    fn engine_opens_and_creates_state() {
387        let h = home("open");
388        let e = Engine::open(&h).unwrap();
389        assert_eq!(
390            e.state().schema_version().unwrap(),
391            vanta_state::SCHEMA_VERSION
392        );
393        let _ = fs::remove_dir_all(&h);
394    }
395
396    #[test]
397    fn extracts_targz_then_publishes() {
398        use flate2::write::GzEncoder;
399        use flate2::Compression;
400
401        // Build a small .tar.gz in memory: one file `bin/tool`.
402        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
403        let mut header = tar::Header::new_gnu();
404        let payload = b"#!/bin/sh\necho hi\n";
405        header.set_size(payload.len() as u64);
406        header.set_mode(0o755);
407        header.set_cksum();
408        builder
409            .append_data(&mut header, "bin/tool", &payload[..])
410            .unwrap();
411        let gz = builder.into_inner().unwrap();
412        let bytes = gz.finish().unwrap();
413
414        let h = home("targz");
415        let store = Store::open(&h).unwrap();
416        let archive_path = store.downloads_dir().join("a.tar.gz");
417        fs::write(&archive_path, &bytes).unwrap();
418
419        let staging = store.new_staging().unwrap();
420        extract("tar.gz", &archive_path, &staging, "tool", 0).unwrap();
421        assert!(staging.join("bin/tool").exists());
422
423        let key = store.publish_tree(&staging).unwrap();
424        assert!(store.has(&key));
425        assert!(store.verify_entry(&key).unwrap());
426        let _ = fs::remove_dir_all(&h);
427    }
428
429    #[test]
430    fn rejects_unsupported_archive() {
431        let err = extract("tar.xz", Path::new("/x"), Path::new("/y"), "t", 0).unwrap_err();
432        assert_eq!(err.area, Area::Inst);
433    }
434}