#![forbid(unsafe_code)]
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use vanta_core::{Area, Artifact, Platform, StoreKey, VtaError, VtaResult};
use vanta_net::Downloader;
use vanta_security::Policy;
use vanta_state::{GenerationRecord, State, StoreEntryMeta};
use vanta_store::Store;
pub trait Reporter {
fn fetch_start(&self, total: Option<u64>) {
let _ = total;
}
fn fetch_inc(&self, n: u64) {
let _ = n;
}
fn phase(&self, name: &str) {
let _ = name;
}
}
impl Reporter for () {}
pub const DEFAULT_MAX_DECOMPRESSED: u64 = 2 * 1024 * 1024 * 1024;
pub struct Engine {
store: Store,
state: State,
downloader: Downloader,
home: PathBuf,
policy: Policy,
max_decompressed: u64,
}
impl Engine {
pub fn open(home: impl AsRef<Path>) -> VtaResult<Engine> {
Self::open_with_policy(home, Policy::default())
}
pub fn open_with_policy(home: impl AsRef<Path>, policy: Policy) -> VtaResult<Engine> {
let home = home.as_ref().to_path_buf();
let store = Store::open(&home)?;
let state = State::open(&home.join("state.db"))?;
let downloader = Downloader::new()?;
Ok(Engine {
store,
state,
downloader,
home,
policy,
max_decompressed: DEFAULT_MAX_DECOMPRESSED,
})
}
pub fn with_max_decompressed(mut self, max: u64) -> Self {
self.max_decompressed = max;
self
}
pub fn store(&self) -> &Store {
&self.store
}
pub fn state(&self) -> &State {
&self.state
}
pub fn install_artifact(
&self,
tool: &str,
version: &str,
artifact: &Artifact,
) -> VtaResult<StoreKey> {
self.install_artifact_reported(tool, version, artifact, &())
}
pub fn install_artifact_reported(
&self,
tool: &str,
version: &str,
artifact: &Artifact,
reporter: &dyn Reporter,
) -> VtaResult<StoreKey> {
let has_trusted_sig = artifact.signature.is_some() && artifact.signature_key.is_some();
if self.policy.require_signature && !has_trusted_sig {
return Err(VtaError::new(
Area::Vrf,
3,
format!(
"signature required by policy but `{tool} {version}` is unsigned \
or its signing key is not trusted"
),
));
}
if let Some(key) = &artifact.store_key {
if self.store.has(key) {
if self.store.verify_entry(key)? {
self.link_bins(key, &artifact.bin)?;
self.record(tool, version, key, &artifact.checksum.value)?;
return Ok(key.clone());
}
self.store.remove_entry(key)?;
}
}
let dl = self
.store
.downloads_dir()
.join(format!("incoming-{tool}-{}", std::process::id()));
let mut urls = vec![artifact.url.clone()];
urls.extend(artifact.mirrors.clone());
reporter.fetch_start(artifact.size);
self.downloader.download_any_with_progress(
&urls,
&dl,
artifact.size,
Some(&|n| reporter.fetch_inc(n)),
)?;
reporter.phase("verifying");
if let Err(e) =
vanta_security::verify_file(&dl, &artifact.checksum.algo, &artifact.checksum.value)
{
let _ = fs::remove_file(&dl);
return Err(e);
}
if let (Some(sig), Some(key_text)) = (&artifact.signature, &artifact.signature_key) {
let key = vanta_security::parse_minisign_pubkey(key_text)?;
let bytes = fs::read(&dl).map_err(|e| io(&dl, e))?;
if let Err(e) = vanta_security::minisign_verify(&bytes, sig, &key) {
let _ = fs::remove_file(&dl);
return Err(e);
}
}
reporter.phase("extracting");
let staging = self.store.new_staging()?;
let name = artifact
.bin
.first()
.map(|b| basename(b))
.unwrap_or_else(|| tool.to_string());
extract(
&artifact.archive,
&dl,
&staging,
&name,
artifact.strip,
self.max_decompressed,
)?;
let _ = fs::remove_file(&dl);
let key = self.store.publish_tree(&staging)?;
self.link_bins(&key, &artifact.bin)?;
self.record(tool, version, &key, &artifact.checksum.value)?;
Ok(key)
}
fn link_bins(&self, key: &StoreKey, bins: &[String]) -> VtaResult<()> {
let bin_dir = self.home.join("bin");
fs::create_dir_all(&bin_dir).map_err(|e| io(&bin_dir, e))?;
let entry = self.store.entry_path(key);
for bin in bins {
let src = entry.join(bin);
if src.exists() {
let dst = bin_dir.join(basename(bin));
vanta_store::link_best(&src, &dst)?;
}
}
Ok(())
}
fn record(&self, tool: &str, version: &str, key: &StoreKey, sha256: &str) -> VtaResult<()> {
let platform = Platform::current().token();
self.state.put_store_entry(
key.as_str(),
&StoreEntryMeta {
tool: tool.to_string(),
version: version.to_string(),
platform,
size: 0,
sha256: sha256.to_string(),
},
)?;
let parent = self.state.current()?;
let id = parent.map(|c| c + 1).unwrap_or(1);
self.state.append_generation(&GenerationRecord {
id,
parent,
command: format!("vanta add {tool}@{version}"),
reason: "add".to_string(),
tools: vec![(tool.to_string(), key.as_str().to_string())],
})?;
self.state.set_current(id)?;
Ok(())
}
fn active_store_keys(&self) -> VtaResult<Vec<StoreKey>> {
let mut keys = Vec::new();
if let Some(current) = self.state.current()? {
if let Some(gen) = self.state.get_generation(current)? {
for (_, k) in gen.tools {
if let Ok(sk) = StoreKey::new(k) {
keys.push(sk);
}
}
}
}
Ok(keys)
}
pub fn bundle_current(&self, out: &Path) -> VtaResult<usize> {
let keys = self.active_store_keys()?;
let file = fs::File::create(out).map_err(|e| io(out, e))?;
let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
let mut builder = tar::Builder::new(enc);
let list = keys
.iter()
.map(|k| k.as_str())
.collect::<Vec<_>>()
.join("\n");
let mut header = tar::Header::new_gnu();
header.set_size(list.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "KEYS", list.as_bytes())
.map_err(|e| inst(format!("bundle KEYS: {e}")))?;
for key in &keys {
let dir = self.store.entry_path(key);
if dir.is_dir() {
builder
.append_dir_all(key.as_str(), &dir)
.map_err(|e| inst(format!("bundle {key}: {e}")))?;
}
}
let enc = builder
.into_inner()
.map_err(|e| inst(format!("bundle finalize: {e}")))?;
enc.finish()
.map_err(|e| inst(format!("bundle gzip: {e}")))?;
Ok(keys.len())
}
pub fn restore(&self, bundle: &Path) -> VtaResult<usize> {
let file = fs::File::open(bundle).map_err(|e| io(bundle, e))?;
let gz = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(gz);
let staging = self.store.new_staging()?;
archive
.unpack(&staging)
.map_err(|e| inst(format!("restore unpack: {e}")))?;
let keys_txt =
fs::read_to_string(staging.join("KEYS")).map_err(|e| io(&staging.join("KEYS"), e))?;
let mut restored = 0;
for line in keys_txt.lines() {
let key = line.trim();
if key.is_empty() {
continue;
}
let sk = StoreKey::new(key)?;
let dst = self.store.entry_path(&sk);
if dst.exists() {
continue;
}
let src = staging.join(key);
if !src.is_dir() {
continue;
}
let actual = vanta_store::hash_tree(&src)?;
if actual != sk.as_str() {
let _ = fs::remove_dir_all(&staging);
return Err(VtaError::new(
Area::Vrf,
1,
format!("bundled entry {key} failed integrity verification (content mismatch)"),
));
}
let _ = vanta_store::ensure_writable(&src);
fs::rename(&src, &dst).map_err(|e| io(&dst, e))?;
restored += 1;
}
let _ = fs::remove_dir_all(&staging);
Ok(restored)
}
pub fn remove(&self, tool: &str) -> VtaResult<bool> {
let current = match self.state.current()? {
Some(c) => c,
None => return Ok(false),
};
let gen = match self.state.get_generation(current)? {
Some(g) => g,
None => return Ok(false),
};
if !gen.tools.iter().any(|(t, _)| t == tool) {
return Ok(false);
}
let tools: Vec<(String, String)> = gen
.tools
.iter()
.filter(|(t, _)| t != tool)
.cloned()
.collect();
let id = current + 1;
self.state.append_generation(&GenerationRecord {
id,
parent: Some(current),
command: format!("vanta remove {tool}"),
reason: "remove".to_string(),
tools,
})?;
self.state.set_current(id)?;
let _ = fs::remove_file(self.home.join("bin").join(tool));
Ok(true)
}
}
fn inst(msg: String) -> VtaError {
VtaError::new(Area::Inst, 1, msg)
}
pub fn extract(
archive: &str,
src: &Path,
dest: &Path,
raw_name: &str,
strip: u32,
max_decompressed: u64,
) -> VtaResult<()> {
match archive {
"tar.gz" | "tgz" => extract_targz(src, dest, strip, max_decompressed),
"zip" => extract_zip(src, dest, strip, max_decompressed),
"raw" => {
fs::create_dir_all(dest).map_err(|e| io(dest, e))?;
let out = dest.join(raw_name);
fs::copy(src, &out).map_err(|e| io(&out, e))?;
set_executable(&out);
Ok(())
}
other => Err(VtaError::new(
Area::Inst,
3,
format!("unsupported archive kind `{other}` (supported: tar.gz, tgz, zip, raw)"),
)),
}
}
fn extract_zip(src: &Path, dest: &Path, strip: u32, max_decompressed: u64) -> VtaResult<()> {
use std::path::PathBuf;
let file = fs::File::open(src).map_err(|e| io(src, e))?;
let mut archive = zip::ZipArchive::new(file)
.map_err(|e| VtaError::new(Area::Inst, 1, format!("reading zip archive: {e}")))?;
fs::create_dir_all(dest).map_err(|e| io(dest, e))?;
let dest_canon = dest.canonicalize().map_err(|e| io(dest, e))?;
let mut budget = max_decompressed;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| VtaError::new(Area::Inst, 1, format!("reading zip entry: {e}")))?;
let Some(path) = entry.enclosed_name() else {
return Err(traversal());
};
let stripped: PathBuf = path.components().skip(strip as usize).collect();
if stripped.as_os_str().is_empty() {
continue;
}
if escapes(&stripped) {
return Err(traversal());
}
let out = dest.join(&stripped);
if entry.is_dir() {
fs::create_dir_all(&out).map_err(|e| io(&out, e))?;
continue;
}
if let Some(parent) = out.parent() {
fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
let parent_canon = parent.canonicalize().map_err(|e| io(parent, e))?;
if !parent_canon.starts_with(&dest_canon) {
return Err(traversal());
}
}
let mode = entry.unix_mode();
if mode.is_some_and(|m| m & 0o170000 == 0o120000) {
let mut target = String::new();
LimitReader::new(&mut entry, 4096)
.read_to_string(&mut target)
.map_err(|e| VtaError::new(Area::Inst, 1, format!("zip link target: {e}")))?;
let target_path = Path::new(&target);
if target_path.is_absolute() || escapes(target_path) {
return Err(VtaError::new(
Area::Inst,
1,
format!(
"archive link entry `{}` has an unsafe target `{target}` (rejected)",
stripped.display()
),
));
}
#[cfg(unix)]
std::os::unix::fs::symlink(target_path, &out).map_err(|e| io(&out, e))?;
continue;
}
let mut writer = fs::File::create(&out).map_err(|e| io(&out, e))?;
let mut limited = LimitReader::new(&mut entry, budget);
let copied = std::io::copy(&mut limited, &mut writer)
.map_err(|e| VtaError::new(Area::Inst, 1, format!("unpacking zip entry: {e}")))?;
budget = budget.saturating_sub(copied);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let safe = mode.map(|m| m & 0o777).unwrap_or(0o644);
let _ = fs::set_permissions(&out, fs::Permissions::from_mode(safe));
}
strip_special_bits(&out);
}
Ok(())
}
fn extract_targz(src: &Path, dest: &Path, strip: u32, max_decompressed: u64) -> VtaResult<()> {
use std::path::PathBuf;
let file = fs::File::open(src).map_err(|e| io(src, e))?;
let gz = LimitReader::new(flate2::read::GzDecoder::new(file), max_decompressed);
let mut archive = tar::Archive::new(gz);
archive.set_preserve_permissions(true);
let dest_canon = dest.canonicalize().map_err(|e| io(dest, e))?;
let entries = archive
.entries()
.map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive: {e}")))?;
for entry in entries {
let mut entry = entry
.map_err(|e| VtaError::new(Area::Inst, 1, format!("reading archive entry: {e}")))?;
let entry_type = entry.header().entry_type();
let path = entry
.path()
.map_err(|e| VtaError::new(Area::Inst, 1, format!("entry path: {e}")))?
.into_owned();
let stripped: PathBuf = path.components().skip(strip as usize).collect();
if stripped.as_os_str().is_empty() {
continue;
}
if escapes(&stripped) {
return Err(traversal());
}
if matches!(entry_type, tar::EntryType::Symlink | tar::EntryType::Link) {
let target = entry
.link_name()
.map_err(|e| VtaError::new(Area::Inst, 1, format!("link target: {e}")))?
.map(|c| c.into_owned())
.unwrap_or_default();
if target.is_absolute() || escapes(&target) {
return Err(VtaError::new(
Area::Inst,
1,
format!(
"archive link entry `{}` has an unsafe target `{}` (rejected)",
stripped.display(),
target.display()
),
));
}
}
let out = dest.join(&stripped);
if let Some(parent) = out.parent() {
fs::create_dir_all(parent).map_err(|e| io(parent, e))?;
let parent_canon = parent.canonicalize().map_err(|e| io(parent, e))?;
if !parent_canon.starts_with(&dest_canon) {
return Err(traversal());
}
}
entry
.unpack(&out)
.map_err(|e| VtaError::new(Area::Inst, 1, format!("unpacking entry: {e}")))?;
strip_special_bits(&out);
}
Ok(())
}
fn escapes(p: &Path) -> bool {
use std::path::Component;
p.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
}
fn traversal() -> VtaError {
VtaError::new(
Area::Inst,
1,
"archive entry escapes destination (path traversal rejected)".to_string(),
)
}
struct LimitReader<R> {
inner: R,
remaining: u64,
}
impl<R> LimitReader<R> {
fn new(inner: R, limit: u64) -> Self {
LimitReader {
inner,
remaining: limit,
}
}
}
impl<R: Read> Read for LimitReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
let n64 = n as u64;
if n64 > self.remaining {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"decompressed size exceeds configured maximum (possible decompression bomb)",
));
}
self.remaining -= n64;
Ok(n)
}
}
#[cfg(unix)]
fn strip_special_bits(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
return;
}
let mode = meta.permissions().mode();
let safe = mode & 0o777; if safe != mode {
let mut perms = meta.permissions();
perms.set_mode(safe);
let _ = fs::set_permissions(path, perms);
}
}
}
#[cfg(not(unix))]
fn strip_special_bits(_path: &Path) {}
fn basename(p: &str) -> String {
p.rsplit(['/', '\\']).next().unwrap_or(p).to_string()
}
#[cfg(unix)]
fn set_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(path) {
let mut perms = meta.permissions();
perms.set_mode(perms.mode() | 0o755);
let _ = fs::set_permissions(path, perms);
}
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) {}
fn io(path: &Path, e: std::io::Error) -> VtaError {
VtaError::new(Area::Inst, 2, format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn home(tag: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("vanta-install-{}-{}", tag, std::process::id()));
let _ = fs::remove_dir_all(&p);
p
}
#[test]
fn engine_opens_and_creates_state() {
let h = home("open");
let e = Engine::open(&h).unwrap();
assert_eq!(
e.state().schema_version().unwrap(),
vanta_state::SCHEMA_VERSION
);
let _ = fs::remove_dir_all(&h);
}
#[test]
fn extracts_targz_then_publishes() {
use flate2::write::GzEncoder;
use flate2::Compression;
let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
let mut header = tar::Header::new_gnu();
let payload = b"#!/bin/sh\necho hi\n";
header.set_size(payload.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder
.append_data(&mut header, "bin/tool", &payload[..])
.unwrap();
let gz = builder.into_inner().unwrap();
let bytes = gz.finish().unwrap();
let h = home("targz");
let store = Store::open(&h).unwrap();
let archive_path = store.downloads_dir().join("a.tar.gz");
fs::write(&archive_path, &bytes).unwrap();
let staging = store.new_staging().unwrap();
extract(
"tar.gz",
&archive_path,
&staging,
"tool",
0,
DEFAULT_MAX_DECOMPRESSED,
)
.unwrap();
assert!(staging.join("bin/tool").exists());
let key = store.publish_tree(&staging).unwrap();
assert!(store.has(&key));
assert!(store.verify_entry(&key).unwrap());
let _ = fs::remove_dir_all(&h);
}
fn make_zip(entries: &[(&str, u32, &[u8])]) -> Vec<u8> {
use std::io::Write;
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, mode, payload) in entries {
let opts = zip::write::SimpleFileOptions::default().unix_permissions(*mode);
w.start_file(*name, opts).unwrap();
w.write_all(payload).unwrap();
}
w.finish().unwrap().into_inner()
}
#[test]
fn extracts_zip_with_strip_and_modes() {
let h = home("zip");
let store = Store::open(&h).unwrap();
let bytes = make_zip(&[
("terraform_1.9.0/terraform", 0o755, b"#!/bin/sh\necho tf\n"),
("terraform_1.9.0/README.md", 0o644, b"docs"),
]);
let archive_path = store.downloads_dir().join("a.zip");
fs::write(&archive_path, &bytes).unwrap();
let staging = store.new_staging().unwrap();
extract(
"zip",
&archive_path,
&staging,
"terraform",
1,
DEFAULT_MAX_DECOMPRESSED,
)
.unwrap();
let bin = staging.join("terraform");
assert!(bin.exists());
assert!(staging.join("README.md").exists());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&bin).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755, "exec bit preserved from zip modes");
}
let _ = fs::remove_dir_all(&h);
}
#[test]
fn zip_slip_rejected() {
let h = home("zipslip");
let store = Store::open(&h).unwrap();
let bytes = make_zip(&[("../evil", 0o644, b"pwn")]);
let archive_path = store.downloads_dir().join("evil.zip");
fs::write(&archive_path, &bytes).unwrap();
let staging = store.new_staging().unwrap();
let err = extract(
"zip",
&archive_path,
&staging,
"evil",
0,
DEFAULT_MAX_DECOMPRESSED,
)
.unwrap_err();
assert!(err.to_string().contains("traversal"), "{err}");
let _ = fs::remove_dir_all(&h);
}
#[test]
fn zip_decompression_budget_enforced() {
let h = home("zipbomb");
let store = Store::open(&h).unwrap();
let big = vec![0u8; 64 * 1024];
let bytes = make_zip(&[("big.bin", 0o644, &big[..])]);
let archive_path = store.downloads_dir().join("big.zip");
fs::write(&archive_path, &bytes).unwrap();
let staging = store.new_staging().unwrap();
let err = extract("zip", &archive_path, &staging, "big", 0, 1024).unwrap_err();
assert!(err.to_string().contains("decompress"), "{err}");
let _ = fs::remove_dir_all(&h);
}
#[test]
fn rejects_unsupported_archive() {
let err = extract(
"tar.xz",
Path::new("/x"),
Path::new("/y"),
"t",
0,
DEFAULT_MAX_DECOMPRESSED,
)
.unwrap_err();
assert_eq!(err.area, Area::Inst);
}
#[test]
fn rejects_symlink_escape_archive() {
use flate2::write::GzEncoder;
use flate2::Compression;
let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
let mut link = tar::Header::new_gnu();
link.set_entry_type(tar::EntryType::Symlink);
link.set_size(0);
link.set_mode(0o777);
builder
.append_link(&mut link, "evil", "/tmp/vanta-escape-target")
.unwrap();
let payload = b"pwned";
let mut f = tar::Header::new_gnu();
f.set_size(payload.len() as u64);
f.set_mode(0o644);
f.set_cksum();
builder.append_data(&mut f, "evil", &payload[..]).unwrap();
let bytes = builder.into_inner().unwrap().finish().unwrap();
let h = home("symlink");
let store = Store::open(&h).unwrap();
let archive_path = store.downloads_dir().join("evil.tar.gz");
fs::write(&archive_path, &bytes).unwrap();
let staging = store.new_staging().unwrap();
let err = extract(
"tar.gz",
&archive_path,
&staging,
"tool",
0,
DEFAULT_MAX_DECOMPRESSED,
)
.unwrap_err();
assert_eq!(err.area, Area::Inst);
assert!(!Path::new("/tmp/vanta-escape-target").exists());
let _ = fs::remove_dir_all(&h);
}
#[test]
fn rejects_decompression_bomb() {
use flate2::write::GzEncoder;
use flate2::Compression;
let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
let big = vec![0u8; 1_000_000]; let mut header = tar::Header::new_gnu();
header.set_size(big.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, "big", &big[..]).unwrap();
let bytes = builder.into_inner().unwrap().finish().unwrap();
let h = home("bomb");
let store = Store::open(&h).unwrap();
let archive_path = store.downloads_dir().join("bomb.tar.gz");
fs::write(&archive_path, &bytes).unwrap();
let staging = store.new_staging().unwrap();
let err = extract("tar.gz", &archive_path, &staging, "tool", 0, 4096).unwrap_err();
assert_eq!(err.area, Area::Inst);
let _ = fs::remove_dir_all(&h);
}
}