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::io::Read;
14use std::path::{Path, PathBuf};
15use vanta_core::{Area, Artifact, Platform, StoreKey, VtaError, VtaResult};
16use vanta_net::Downloader;
17use vanta_security::Policy;
18use vanta_state::{GenerationRecord, State, StoreEntryMeta};
19use vanta_store::Store;
20
21/// Observes the progress of an [`Engine::install_artifact_reported`] run so a
22/// caller (the CLI) can render download bars and phase spinners without this
23/// crate depending on a UI crate. All methods have no-op defaults; the unit
24/// type `()` implements it as a fully silent reporter.
25pub trait Reporter {
26    /// The fetch stage is about to begin; `total` is the artifact's declared
27    /// size in bytes when known (used as the download bar's length).
28    fn fetch_start(&self, total: Option<u64>) {
29        let _ = total;
30    }
31    /// `n` more bytes have been downloaded.
32    fn fetch_inc(&self, n: u64) {
33        let _ = n;
34    }
35    /// A new post-fetch phase has begun (e.g. `"verifying"`, `"extracting"`).
36    fn phase(&self, name: &str) {
37        let _ = name;
38    }
39}
40
41/// Silent reporter: the default when no progress UI is wired in.
42impl Reporter for () {}
43
44/// Default ceiling on the total decompressed size of an archive (audit M8). A
45/// gzip bomb that would expand past this aborts extraction rather than filling
46/// the disk. Overridable via [`Engine::with_max_decompressed`].
47pub const DEFAULT_MAX_DECOMPRESSED: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB
48
49/// The install engine, bound to a `$VANTA_HOME`.
50pub struct Engine {
51    store: Store,
52    state: State,
53    downloader: Downloader,
54    home: PathBuf,
55    /// Verification policy (audit H2). When `require_signature` is set, a missing
56    /// or untrusted signature is a hard error (fail-closed).
57    policy: Policy,
58    /// Hard ceiling on decompressed archive bytes (audit M8).
59    max_decompressed: u64,
60}
61
62impl Engine {
63    /// Open the engine over `home` (`$VANTA_HOME`) with the default (permissive)
64    /// policy — checksum-gated, signatures verified when present. Use
65    /// [`Engine::open_with_policy`] to require signatures.
66    pub fn open(home: impl AsRef<Path>) -> VtaResult<Engine> {
67        Self::open_with_policy(home, Policy::default())
68    }
69
70    /// Open the engine with an explicit verification [`Policy`] (audit H2).
71    pub fn open_with_policy(home: impl AsRef<Path>, policy: Policy) -> VtaResult<Engine> {
72        let home = home.as_ref().to_path_buf();
73        let store = Store::open(&home)?;
74        let state = State::open(&home.join("state.db"))?;
75        let downloader = Downloader::new()?;
76        Ok(Engine {
77            store,
78            state,
79            downloader,
80            home,
81            policy,
82            max_decompressed: DEFAULT_MAX_DECOMPRESSED,
83        })
84    }
85
86    /// Override the decompressed-size ceiling (audit M8).
87    pub fn with_max_decompressed(mut self, max: u64) -> Self {
88        self.max_decompressed = max;
89        self
90    }
91
92    /// Borrow the underlying store / state (for `gc`, `which`, etc.).
93    pub fn store(&self) -> &Store {
94        &self.store
95    }
96    pub fn state(&self) -> &State {
97        &self.state
98    }
99
100    /// Install one resolved artifact for the current platform, returning its
101    /// store key. Fetch → verify → materialize → publish → commit a generation.
102    /// A store hit short-circuits fetch/verify/materialize.
103    pub fn install_artifact(
104        &self,
105        tool: &str,
106        version: &str,
107        artifact: &Artifact,
108    ) -> VtaResult<StoreKey> {
109        self.install_artifact_reported(tool, version, artifact, &())
110    }
111
112    /// Like [`Engine::install_artifact`], but drives `reporter` with download
113    /// byte counts and phase transitions so a caller can render progress.
114    pub fn install_artifact_reported(
115        &self,
116        tool: &str,
117        version: &str,
118        artifact: &Artifact,
119        reporter: &dyn Reporter,
120    ) -> VtaResult<StoreKey> {
121        // Policy precheck (audit H2): when a signature is required, an artifact
122        // lacking a signature OR a *trusted* signing key (the resolver drops
123        // untrusted keys, audit C1) is refused — fail-closed, before any I/O.
124        let has_trusted_sig = artifact.signature.is_some() && artifact.signature_key.is_some();
125        if self.policy.require_signature && !has_trusted_sig {
126            return Err(VtaError::new(
127                Area::Vrf,
128                3,
129                format!(
130                    "signature required by policy but `{tool} {version}` is unsigned \
131                     or its signing key is not trusted"
132                ),
133            ));
134        }
135
136        // [3 Plan] — if the lock already named a key and it is present, reuse it
137        // ONLY if it still verifies (audit H4): a store hit must not be trusted
138        // blindly, since the entry could have been poisoned (audit H3) or the
139        // lockfile's `store_key` is attacker-influenceable. On mismatch, drop the
140        // bad entry and fall through to a fresh fetch + verify.
141        if let Some(key) = &artifact.store_key {
142            if self.store.has(key) {
143                if self.store.verify_entry(key)? {
144                    self.link_bins(key, &artifact.bin)?;
145                    self.record(tool, version, key, &artifact.checksum.value)?;
146                    return Ok(key.clone());
147                }
148                self.store.remove_entry(key)?;
149            }
150        }
151
152        // [4 Fetch] — cap downloaded bytes at the declared size when known (M8).
153        let dl = self
154            .store
155            .downloads_dir()
156            .join(format!("incoming-{tool}-{}", std::process::id()));
157        let mut urls = vec![artifact.url.clone()];
158        urls.extend(artifact.mirrors.clone());
159        reporter.fetch_start(artifact.size);
160        self.downloader.download_any_with_progress(
161            &urls,
162            &dl,
163            artifact.size,
164            Some(&|n| reporter.fetch_inc(n)),
165        )?;
166
167        // [5 Verify] — fail closed (centralized in vanta-security).
168        reporter.phase("verifying");
169        if let Err(e) =
170            vanta_security::verify_file(&dl, &artifact.checksum.algo, &artifact.checksum.value)
171        {
172            let _ = fs::remove_file(&dl);
173            return Err(e);
174        }
175        // Signature verification when the registry pinned a signature + trusted
176        // key. The key's trust is established upstream (audit C1, in the resolver);
177        // by this point a present `signature_key` is one we trust.
178        if let (Some(sig), Some(key_text)) = (&artifact.signature, &artifact.signature_key) {
179            let key = vanta_security::parse_minisign_pubkey(key_text)?;
180            let bytes = fs::read(&dl).map_err(|e| io(&dl, e))?;
181            if let Err(e) = vanta_security::minisign_verify(&bytes, sig, &key) {
182                let _ = fs::remove_file(&dl);
183                return Err(e);
184            }
185        }
186
187        // [6 Materialize]
188        reporter.phase("extracting");
189        let staging = self.store.new_staging()?;
190        let name = artifact
191            .bin
192            .first()
193            .map(|b| basename(b))
194            .unwrap_or_else(|| tool.to_string());
195        extract(
196            &artifact.archive,
197            &dl,
198            &staging,
199            &name,
200            artifact.strip,
201            self.max_decompressed,
202        )?;
203        let _ = fs::remove_file(&dl);
204
205        // [6 Materialize, cont.] atomic publish into the store.
206        let key = self.store.publish_tree(&staging)?;
207
208        // [7 Link] expose the tool's executables on PATH via ~/.vanta/bin.
209        self.link_bins(&key, &artifact.bin)?;
210
211        // [8 Commit]
212        self.record(tool, version, &key, &artifact.checksum.value)?;
213        Ok(key)
214    }
215
216    /// Link a store entry's declared executables into `~/.vanta/bin` (placed on
217    /// PATH by the shell hook). Per-directory environment views are composed by
218    /// `vanta-env` (`docs/10-environments.md`).
219    fn link_bins(&self, key: &StoreKey, bins: &[String]) -> VtaResult<()> {
220        let bin_dir = self.home.join("bin");
221        fs::create_dir_all(&bin_dir).map_err(|e| io(&bin_dir, e))?;
222        let entry = self.store.entry_path(key);
223        for bin in bins {
224            let src = entry.join(bin);
225            if src.exists() {
226                let dst = bin_dir.join(basename(bin));
227                vanta_store::link_best(&src, &dst)?;
228            }
229        }
230        Ok(())
231    }
232
233    fn record(&self, tool: &str, version: &str, key: &StoreKey, sha256: &str) -> VtaResult<()> {
234        let platform = Platform::current().token();
235        self.state.put_store_entry(
236            key.as_str(),
237            &StoreEntryMeta {
238                tool: tool.to_string(),
239                version: version.to_string(),
240                platform,
241                size: 0,
242                sha256: sha256.to_string(),
243            },
244        )?;
245        let parent = self.state.current()?;
246        let id = parent.map(|c| c + 1).unwrap_or(1);
247        self.state.append_generation(&GenerationRecord {
248            id,
249            parent,
250            command: format!("vanta add {tool}@{version}"),
251            reason: "add".to_string(),
252            tools: vec![(tool.to_string(), key.as_str().to_string())],
253        })?;
254        self.state.set_current(id)?;
255        Ok(())
256    }
257
258    /// Store keys referenced by the active generation.
259    fn active_store_keys(&self) -> VtaResult<Vec<StoreKey>> {
260        let mut keys = Vec::new();
261        if let Some(current) = self.state.current()? {
262            if let Some(gen) = self.state.get_generation(current)? {
263                for (_, k) in gen.tools {
264                    if let Ok(sk) = StoreKey::new(k) {
265                        keys.push(sk);
266                    }
267                }
268            }
269        }
270        Ok(keys)
271    }
272
273    /// Bundle the active generation's store entries into a portable archive
274    /// (`docs/13-offline.md`). Returns the number of entries written.
275    pub fn bundle_current(&self, out: &Path) -> VtaResult<usize> {
276        let keys = self.active_store_keys()?;
277        let file = fs::File::create(out).map_err(|e| io(out, e))?;
278        let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
279        let mut builder = tar::Builder::new(enc);
280        let list = keys
281            .iter()
282            .map(|k| k.as_str())
283            .collect::<Vec<_>>()
284            .join("\n");
285        let mut header = tar::Header::new_gnu();
286        header.set_size(list.len() as u64);
287        header.set_mode(0o644);
288        header.set_cksum();
289        builder
290            .append_data(&mut header, "KEYS", list.as_bytes())
291            .map_err(|e| inst(format!("bundle KEYS: {e}")))?;
292        for key in &keys {
293            let dir = self.store.entry_path(key);
294            if dir.is_dir() {
295                builder
296                    .append_dir_all(key.as_str(), &dir)
297                    .map_err(|e| inst(format!("bundle {key}: {e}")))?;
298            }
299        }
300        let enc = builder
301            .into_inner()
302            .map_err(|e| inst(format!("bundle finalize: {e}")))?;
303        enc.finish()
304            .map_err(|e| inst(format!("bundle gzip: {e}")))?;
305        Ok(keys.len())
306    }
307
308    /// Restore store entries from a bundle, verifying each entry's integrity
309    /// against its content-addressed key. Returns the number newly imported.
310    pub fn restore(&self, bundle: &Path) -> VtaResult<usize> {
311        let file = fs::File::open(bundle).map_err(|e| io(bundle, e))?;
312        let gz = flate2::read::GzDecoder::new(file);
313        let mut archive = tar::Archive::new(gz);
314        let staging = self.store.new_staging()?;
315        archive
316            .unpack(&staging)
317            .map_err(|e| inst(format!("restore unpack: {e}")))?;
318        let keys_txt =
319            fs::read_to_string(staging.join("KEYS")).map_err(|e| io(&staging.join("KEYS"), e))?;
320        let mut restored = 0;
321        for line in keys_txt.lines() {
322            let key = line.trim();
323            if key.is_empty() {
324                continue;
325            }
326            // `StoreKey::new` enforces the fixed-width lowercase-hex shape (M7),
327            // so `staging.join(key)` below cannot traverse out of staging.
328            let sk = StoreKey::new(key)?;
329            let dst = self.store.entry_path(&sk);
330            if dst.exists() {
331                // Already present (and immutable + verified at insert); nothing
332                // to import for this key.
333                continue;
334            }
335            let src = staging.join(key);
336            if !src.is_dir() {
337                continue;
338            }
339            // Audit H3: verify the staged subtree hashes to its claimed key
340            // BEFORE publishing it into the canonical store. A bundle whose
341            // contents do not match the `blake3-<hash>` dir name is rejected and
342            // the store is left unchanged (the staging dir is removed below).
343            let actual = vanta_store::hash_tree(&src)?;
344            if actual != sk.as_str() {
345                let _ = fs::remove_dir_all(&staging);
346                return Err(VtaError::new(
347                    Area::Vrf,
348                    1,
349                    format!("bundled entry {key} failed integrity verification (content mismatch)"),
350                ));
351            }
352            // Bundled entries are read-only; add write so the dir can be moved.
353            let _ = vanta_store::ensure_writable(&src);
354            fs::rename(&src, &dst).map_err(|e| io(&dst, e))?;
355            restored += 1;
356        }
357        let _ = fs::remove_dir_all(&staging);
358        Ok(restored)
359    }
360
361    /// Remove a tool: record a new generation without it and unlink its primary
362    /// executable. Returns whether the tool was present.
363    pub fn remove(&self, tool: &str) -> VtaResult<bool> {
364        let current = match self.state.current()? {
365            Some(c) => c,
366            None => return Ok(false),
367        };
368        let gen = match self.state.get_generation(current)? {
369            Some(g) => g,
370            None => return Ok(false),
371        };
372        if !gen.tools.iter().any(|(t, _)| t == tool) {
373            return Ok(false);
374        }
375        let tools: Vec<(String, String)> = gen
376            .tools
377            .iter()
378            .filter(|(t, _)| t != tool)
379            .cloned()
380            .collect();
381        let id = current + 1;
382        self.state.append_generation(&GenerationRecord {
383            id,
384            parent: Some(current),
385            command: format!("vanta remove {tool}"),
386            reason: "remove".to_string(),
387            tools,
388        })?;
389        self.state.set_current(id)?;
390        let _ = fs::remove_file(self.home.join("bin").join(tool));
391        Ok(true)
392    }
393}
394
395fn inst(msg: String) -> VtaError {
396    VtaError::new(Area::Inst, 1, msg)
397}
398
399/// Materialize an artifact's bytes into `dest` according to its archive kind,
400/// stripping `strip` leading path components (the provider's layout).
401/// `max_decompressed` caps the total decompressed bytes (audit M8).
402pub fn extract(
403    archive: &str,
404    src: &Path,
405    dest: &Path,
406    raw_name: &str,
407    strip: u32,
408    max_decompressed: u64,
409) -> VtaResult<()> {
410    match archive {
411        "tar.gz" | "tgz" => extract_targz(src, dest, strip, max_decompressed),
412        "raw" => {
413            fs::create_dir_all(dest).map_err(|e| io(dest, e))?;
414            let out = dest.join(raw_name);
415            fs::copy(src, &out).map_err(|e| io(&out, e))?;
416            set_executable(&out);
417            Ok(())
418        }
419        other => Err(VtaError::new(
420            Area::Inst,
421            3,
422            format!("unsupported archive kind `{other}` (supported: tar.gz, tgz, raw)"),
423        )),
424    }
425}
426
427fn extract_targz(src: &Path, dest: &Path, strip: u32, max_decompressed: u64) -> VtaResult<()> {
428    use std::path::PathBuf;
429    let file = fs::File::open(src).map_err(|e| io(src, e))?;
430    // M8: bound total decompressed bytes so a gzip bomb aborts rather than
431    // filling the disk.
432    let gz = LimitReader::new(flate2::read::GzDecoder::new(file), max_decompressed);
433    let mut archive = tar::Archive::new(gz);
434    // We re-apply a sanitized mode after unpack (M5: strip setuid/setgid), so we
435    // do not need tar to preserve raw permission bits.
436    archive.set_preserve_permissions(true);
437    let dest_canon = dest.canonicalize().map_err(|e| io(dest, e))?;
438    let entries = archive
439        .entries()
440        .map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive: {e}")))?;
441    for entry in entries {
442        let mut entry = entry
443            .map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive entry: {e}")))?;
444        let entry_type = entry.header().entry_type();
445        let path = entry
446            .path()
447            .map_err(|e| VtaError::new(Area::Inst, 1, format!("entry path: {e}")))?
448            .into_owned();
449        let stripped: PathBuf = path.components().skip(strip as usize).collect();
450        if stripped.as_os_str().is_empty() {
451            continue;
452        }
453        // Reject anything that could escape the destination (zip-slip / traversal).
454        if escapes(&stripped) {
455            return Err(traversal());
456        }
457        // M5: link entries (symlink/hardlink) get an extra check — their *target*
458        // must not be absolute or contain `..`. `entry.unpack` would otherwise
459        // create a link pointing outside the staging tree, which a later entry
460        // could write through.
461        if matches!(entry_type, tar::EntryType::Symlink | tar::EntryType::Link) {
462            let target = entry
463                .link_name()
464                .map_err(|e| VtaError::new(Area::Inst, 1, format!("link target: {e}")))?
465                .map(|c| c.into_owned())
466                .unwrap_or_default();
467            if target.is_absolute() || escapes(&target) {
468                return Err(VtaError::new(
469                    Area::Inst,
470                    1,
471                    format!(
472                        "archive link entry `{}` has an unsafe target `{}` (rejected)",
473                        stripped.display(),
474                        target.display()
475                    ),
476                ));
477            }
478        }
479        let out = dest.join(&stripped);
480        if let Some(parent) = out.parent() {
481            fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
482            // M5: after the parent exists, canonicalize it and confirm the
483            // realpath still lies under the staging root (defeats symlinked
484            // ancestors pointing elsewhere).
485            let parent_canon = parent.canonicalize().map_err(|e| io(parent, e))?;
486            if !parent_canon.starts_with(&dest_canon) {
487                return Err(traversal());
488            }
489        }
490        entry
491            .unpack(&out)
492            .map_err(|e| VtaError::new(Area::Inst, 1, format!("unpacking entry: {e}")))?;
493        // M5: strip setuid/setgid/sticky bits from materialized files.
494        strip_special_bits(&out);
495    }
496    Ok(())
497}
498
499/// Whether a relative path contains a component that would escape its base.
500fn escapes(p: &Path) -> bool {
501    use std::path::Component;
502    p.components().any(|c| {
503        matches!(
504            c,
505            Component::ParentDir | Component::RootDir | Component::Prefix(_)
506        )
507    })
508}
509
510fn traversal() -> VtaError {
511    VtaError::new(
512        Area::Inst,
513        1,
514        "archive entry escapes destination (path traversal rejected)".to_string(),
515    )
516}
517
518/// A reader that errors once more than `limit` bytes have been read (audit M8).
519struct LimitReader<R> {
520    inner: R,
521    remaining: u64,
522}
523
524impl<R> LimitReader<R> {
525    fn new(inner: R, limit: u64) -> Self {
526        LimitReader {
527            inner,
528            remaining: limit,
529        }
530    }
531}
532
533impl<R: Read> Read for LimitReader<R> {
534    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
535        let n = self.inner.read(buf)?;
536        let n64 = n as u64;
537        if n64 > self.remaining {
538            return Err(std::io::Error::new(
539                std::io::ErrorKind::InvalidData,
540                "decompressed size exceeds configured maximum (possible decompression bomb)",
541            ));
542        }
543        self.remaining -= n64;
544        Ok(n)
545    }
546}
547
548/// Strip setuid/setgid/sticky bits from a materialized path (audit M5).
549#[cfg(unix)]
550fn strip_special_bits(path: &Path) {
551    use std::os::unix::fs::PermissionsExt;
552    // Symlinks carry no meaningful permission bits; skip (and avoid following).
553    if let Ok(meta) = fs::symlink_metadata(path) {
554        if meta.file_type().is_symlink() {
555            return;
556        }
557        let mode = meta.permissions().mode();
558        let safe = mode & 0o777; // drop 0o7000 (setuid/setgid/sticky)
559        if safe != mode {
560            let mut perms = meta.permissions();
561            perms.set_mode(safe);
562            let _ = fs::set_permissions(path, perms);
563        }
564    }
565}
566
567#[cfg(not(unix))]
568fn strip_special_bits(_path: &Path) {}
569
570fn basename(p: &str) -> String {
571    p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
572}
573
574#[cfg(unix)]
575fn set_executable(path: &Path) {
576    use std::os::unix::fs::PermissionsExt;
577    if let Ok(meta) = fs::metadata(path) {
578        let mut perms = meta.permissions();
579        perms.set_mode(perms.mode() | 0o755);
580        let _ = fs::set_permissions(path, perms);
581    }
582}
583
584#[cfg(not(unix))]
585fn set_executable(_path: &Path) {}
586
587fn io(path: &Path, e: std::io::Error) -> VtaError {
588    VtaError::new(Area::Inst, 2, format!("{}: {e}", path.display()))
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn home(tag: &str) -> PathBuf {
596        let p = std::env::temp_dir().join(format!("vanta-install-{}-{}", tag, std::process::id()));
597        let _ = fs::remove_dir_all(&p);
598        p
599    }
600
601    #[test]
602    fn engine_opens_and_creates_state() {
603        let h = home("open");
604        let e = Engine::open(&h).unwrap();
605        assert_eq!(
606            e.state().schema_version().unwrap(),
607            vanta_state::SCHEMA_VERSION
608        );
609        let _ = fs::remove_dir_all(&h);
610    }
611
612    #[test]
613    fn extracts_targz_then_publishes() {
614        use flate2::write::GzEncoder;
615        use flate2::Compression;
616
617        // Build a small .tar.gz in memory: one file `bin/tool`.
618        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
619        let mut header = tar::Header::new_gnu();
620        let payload = b"#!/bin/sh\necho hi\n";
621        header.set_size(payload.len() as u64);
622        header.set_mode(0o755);
623        header.set_cksum();
624        builder
625            .append_data(&mut header, "bin/tool", &payload[..])
626            .unwrap();
627        let gz = builder.into_inner().unwrap();
628        let bytes = gz.finish().unwrap();
629
630        let h = home("targz");
631        let store = Store::open(&h).unwrap();
632        let archive_path = store.downloads_dir().join("a.tar.gz");
633        fs::write(&archive_path, &bytes).unwrap();
634
635        let staging = store.new_staging().unwrap();
636        extract(
637            "tar.gz",
638            &archive_path,
639            &staging,
640            "tool",
641            0,
642            DEFAULT_MAX_DECOMPRESSED,
643        )
644        .unwrap();
645        assert!(staging.join("bin/tool").exists());
646
647        let key = store.publish_tree(&staging).unwrap();
648        assert!(store.has(&key));
649        assert!(store.verify_entry(&key).unwrap());
650        let _ = fs::remove_dir_all(&h);
651    }
652
653    #[test]
654    fn rejects_unsupported_archive() {
655        let err = extract(
656            "tar.xz",
657            Path::new("/x"),
658            Path::new("/y"),
659            "t",
660            0,
661            DEFAULT_MAX_DECOMPRESSED,
662        )
663        .unwrap_err();
664        assert_eq!(err.area, Area::Inst);
665    }
666
667    // M5: an archive containing a symlink whose target escapes the tree (here an
668    // absolute path), followed by a write through that link, must be rejected.
669    #[test]
670    fn rejects_symlink_escape_archive() {
671        use flate2::write::GzEncoder;
672        use flate2::Compression;
673
674        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
675        // A symlink `evil` -> `/tmp/escape-target` (absolute).
676        let mut link = tar::Header::new_gnu();
677        link.set_entry_type(tar::EntryType::Symlink);
678        link.set_size(0);
679        link.set_mode(0o777);
680        builder
681            .append_link(&mut link, "evil", "/tmp/vanta-escape-target")
682            .unwrap();
683        // A regular write through the link path.
684        let payload = b"pwned";
685        let mut f = tar::Header::new_gnu();
686        f.set_size(payload.len() as u64);
687        f.set_mode(0o644);
688        f.set_cksum();
689        builder.append_data(&mut f, "evil", &payload[..]).unwrap();
690        let bytes = builder.into_inner().unwrap().finish().unwrap();
691
692        let h = home("symlink");
693        let store = Store::open(&h).unwrap();
694        let archive_path = store.downloads_dir().join("evil.tar.gz");
695        fs::write(&archive_path, &bytes).unwrap();
696        let staging = store.new_staging().unwrap();
697        let err = extract(
698            "tar.gz",
699            &archive_path,
700            &staging,
701            "tool",
702            0,
703            DEFAULT_MAX_DECOMPRESSED,
704        )
705        .unwrap_err();
706        assert_eq!(err.area, Area::Inst);
707        assert!(!Path::new("/tmp/vanta-escape-target").exists());
708        let _ = fs::remove_dir_all(&h);
709    }
710
711    // M8: a highly compressible archive that decompresses past the cap aborts.
712    #[test]
713    fn rejects_decompression_bomb() {
714        use flate2::write::GzEncoder;
715        use flate2::Compression;
716
717        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
718        let big = vec![0u8; 1_000_000]; // 1 MB of zeros, compresses tiny
719        let mut header = tar::Header::new_gnu();
720        header.set_size(big.len() as u64);
721        header.set_mode(0o644);
722        header.set_cksum();
723        builder.append_data(&mut header, "big", &big[..]).unwrap();
724        let bytes = builder.into_inner().unwrap().finish().unwrap();
725
726        let h = home("bomb");
727        let store = Store::open(&h).unwrap();
728        let archive_path = store.downloads_dir().join("bomb.tar.gz");
729        fs::write(&archive_path, &bytes).unwrap();
730        let staging = store.new_staging().unwrap();
731        // Cap well below the 1 MB payload → extraction must fail.
732        let err = extract("tar.gz", &archive_path, &staging, "tool", 0, 4096).unwrap_err();
733        assert_eq!(err.area, Area::Inst);
734        let _ = fs::remove_dir_all(&h);
735    }
736}