From 589f4eea4b464251e0104e32423ddfdfcff84293 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 17:35:33 -0500 Subject: [PATCH] feat(cli): validate bounded scanned snapshot archive plans Signed-off-by: Tee8z --- Cargo.lock | 3 + crates/buzz-cli/Cargo.toml | 4 + crates/buzz-cli/src/sandbox_archive.rs | 8 +- crates/buzz-cli/src/sandbox_archive/format.rs | 619 ++++++++++++++++++ .../src/sandbox_archive/format/tests.rs | 342 ++++++++++ docs/sandbox-snapshot-archive.md | 54 ++ 6 files changed, 1029 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-cli/src/sandbox_archive/format.rs create mode 100644 crates/buzz-cli/src/sandbox_archive/format/tests.rs create mode 100644 docs/sandbox-snapshot-archive.md diff --git a/Cargo.lock b/Cargo.lock index 02d40134cbe..4b261a9031b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1664,6 +1664,7 @@ dependencies = [ "crossterm", "diffy", "dirs", + "flate2", "hex", "infer", "nix 0.31.3", @@ -1674,9 +1675,11 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tar", "tempfile", "thiserror 2.0.18", "tokio", + "tokio-util", "url", "uuid", ] diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 910ddaf0eae..c60f9c08a8f 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -83,6 +83,10 @@ buzz-ws-client = { path = "../buzz-ws-client" } # auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency. rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +tar = "0.4.46" +flate2 = "1" +tokio-util = { workspace = true } + # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } diff --git a/crates/buzz-cli/src/sandbox_archive.rs b/crates/buzz-cli/src/sandbox_archive.rs index d21e45b77eb..dc819de6f6e 100644 --- a/crates/buzz-cli/src/sandbox_archive.rs +++ b/crates/buzz-cli/src/sandbox_archive.rs @@ -1,4 +1,4 @@ -//! Local-only bounded archive preflight. No identity, relay or Git process is used. +//! Local snapshot preflight and cancellation-aware read-only restoration. use std::fs::OpenOptions; use std::io::Read; #[cfg(unix)] @@ -10,9 +10,15 @@ use buzz_sdk::sandbox_attach::snapshot::{ }; use serde::{Deserialize, Serialize}; +mod format; +pub use format::{inspect_archive, ArchivePlan}; + /// Local archive refusal; file contents and scanner diagnostics are never included. #[derive(Debug, thiserror::Error)] pub enum ArchiveError { + /// Cancellation completed after staging and child cleanup. + #[error("snapshot extraction cancelled")] + Cancelled, /// Filesystem access failed. #[error("archive file access failed: {0}")] Io(#[from] std::io::Error), diff --git a/crates/buzz-cli/src/sandbox_archive/format.rs b/crates/buzz-cli/src/sandbox_archive/format.rs new file mode 100644 index 00000000000..d6ea416a1f5 --- /dev/null +++ b/crates/buzz-cli/src/sandbox_archive/format.rs @@ -0,0 +1,619 @@ +//! Raw tar iteration bounds extension payloads before the tar crate can expand them. +use crate::sandbox_archive::ArchiveError as Error; +use buzz_sdk::sandbox_attach::snapshot::{ + archive::{inspect_bundle, Inventory}, + MAX_BLOB_BYTES, MAX_FILES, MAX_SOURCE_BYTES, +}; +use flate2::bufread::GzDecoder; +use serde::Deserialize; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +const MAX_ENTRIES: usize = MAX_FILES * 3; +const MAX_PAX: usize = 16384; +const MAX_META: usize = 1024 * 1024; +fn invalid(message: &'static str) -> Error { + Error::Invalid(message) +} + +fn check(cancel: &CancellationToken, start: Instant) -> Result<(), Error> { + if cancel.is_cancelled() { + return Err(Error::Cancelled); + } + if start.elapsed() > Duration::from_secs(120) { + return Err(invalid("snapshot preflight timed out")); + } + Ok(()) +} + +pub(super) fn safe(value: &str) -> Result { + if value.is_empty() + || value.len() > 4096 + || value.split('/').count() > 128 + || value + .chars() + .any(|c| c.is_control() || matches!(c, '\\' | ':')) + || value + .split('/') + .any(|s| s.is_empty() || matches!(s, "." | "..")) + { + return Err(invalid("unsafe snapshot path")); + } + Ok(PathBuf::from(value)) +} +pub(super) fn excluded(path: &Path) -> bool { + const NAMES: &[&str] = &[ + ".git", + ".aws", + ".ssh", + ".gnupg", + ".config", + ".codex", + ".claude", + ".cache", + ".local", + ".npm", + ".cargo", + ".rustup", + ".venv", + "venv", + "node_modules", + "target", + "__pycache__", + "dist", + "build", + ".terraform", + "credentials", + "credentials.json", + "auth.json", + "id_rsa", + "id_ed25519", + ".npmrc", + ".netrc", + ".pypirc", + ".git-credentials", + ]; + path.iter().any(|s| { + s.to_str().is_none_or(|s| { + s.eq_ignore_ascii_case(".git") + || NAMES.contains(&s) + || s.starts_with(".env") + || [ + ".pem", + ".key", + ".p12", + ".pfx", + ".tfstate", + ".tfstate.backup", + ] + .iter() + .any(|suffix| s.ends_with(suffix)) + }) + }) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Manifest { + pub version: u8, + pub snapshot: bool, + pub scanned: bool, + pub repositories: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Repo { + pub path: String, + pub head: String, + pub base: String, + pub branch: String, + pub shallow: Vec, + pub(super) files: Vec, + pub excluded: Vec, + pub deleted: Vec, +} +#[derive(Clone, PartialEq, Eq)] +pub(super) enum Kind { + File, + Dir, + Link(String), +} +#[derive(Clone)] +pub(super) struct Item { + pub path: String, + pub size: u64, + pub kind: Kind, +} +/// Validated snapshot structure; parsing performs no filesystem writes or Git execution. +/// Consumers must still validate imported Git objects and enforce local capacity limits. +pub struct ArchivePlan { + pub(super) manifest: Manifest, + pub(super) items: Vec, + pub(super) inventory: HashMap, + pub(super) files: HashMap, + pub(super) links: HashMap, + pub(super) bytes: u64, +} + +struct Bounded { + inner: R, + read: u64, + start: Instant, + cancel: CancellationToken, +} +impl Read for Bounded { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.cancel.is_cancelled() || self.start.elapsed() > Duration::from_secs(120) { + return Err(std::io::Error::other( + "snapshot read cancelled or timed out", + )); + } + let n = self.inner.read(buf)?; + self.read = self.read.saturating_add(n as u64); + if self.read > MAX_SOURCE_BYTES + MAX_ENTRIES as u64 * 1024 { + return Err(std::io::Error::other("snapshot tar expansion exceeded")); + } + Ok(n) + } +} +fn pax(bytes: &[u8]) -> Result, Error> { + let mut values = HashMap::new(); + let mut offset = 0; + while offset < bytes.len() { + let tail = &bytes[offset..]; + let space = tail + .iter() + .position(|b| *b == b' ') + .ok_or_else(|| invalid("invalid PAX record"))?; + let count = std::str::from_utf8(&tail[..space]) + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|n| *n > space + 2 && *n <= tail.len()) + .ok_or_else(|| invalid("invalid PAX length"))?; + let record = &tail[space + 1..count]; + if record.last() != Some(&b'\n') { + return Err(invalid("invalid PAX terminator")); + } + let value = std::str::from_utf8(&record[..record.len() - 1]) + .map_err(|_| invalid("non-UTF8 PAX record"))?; + let (key, value) = value + .split_once('=') + .ok_or_else(|| invalid("invalid PAX field"))?; + if !matches!(key, "path" | "linkpath") + || value.len() > 4096 + || values.insert(key.to_owned(), value.to_owned()).is_some() + { + return Err(invalid("unsupported or duplicate PAX field")); + } + offset += count; + } + Ok(values) +} + +pub(super) fn walk(bytes: &[u8], cancel: &CancellationToken, mut visit: F) -> Result<(), Error> +where + F: FnMut(Item, &mut dyn Read) -> Result<(), Error>, +{ + if bytes.len() > MAX_BLOB_BYTES { + return Err(invalid("snapshot archive exceeds byte budget")); + } + let input = Bounded { + inner: GzDecoder::new(bytes), + read: 0, + start: Instant::now(), + cancel: cancel.clone(), + }; + let mut archive = tar::Archive::new(input); + let mut pending = None; + let mut metadata = 0usize; + let mut entries = 0; + let mut last_end = 0u64; + // raw(true) prevents tar from calling read_all on GNU/PAX extension bodies. + for entry in archive.entries()?.raw(true) { + let mut entry = entry?; + entries += 1; + if entries > MAX_ENTRIES { + return Err(invalid("too many snapshot archive entries")); + } + let ty = entry.header().entry_type(); + let size = entry.size(); + last_end = entry + .raw_file_position() + .checked_add(size.div_ceil(512) * 512) + .ok_or_else(|| invalid("snapshot tar offset overflow"))?; + if ty.is_pax_local_extensions() { + if pending.is_some() || size > MAX_PAX as u64 { + return Err(invalid("snapshot PAX payload exceeds budget")); + } + metadata = metadata + .checked_add(512 + size.div_ceil(512) as usize * 512) + .ok_or_else(|| invalid("snapshot metadata overflow"))?; + if metadata > MAX_META { + return Err(invalid("snapshot PAX metadata exceeds budget")); + } + let mut data = Vec::new(); + entry.read_to_end(&mut data)?; + pending = Some(pax(&data)?); + continue; + } + if !(ty.is_file() || ty.is_dir() || ty.is_symlink()) || size > MAX_SOURCE_BYTES { + return Err(invalid("unsupported snapshot archive entry")); + } + let mut fields = pending.take().unwrap_or_default(); + let path = match fields.remove("path") { + Some(path) => path, + None => std::str::from_utf8(&entry.path_bytes()) + .map_err(|_| invalid("non-UTF8 snapshot path"))? + .to_owned(), + }; + let path = if ty.is_dir() { + path.strip_suffix('/').unwrap_or(&path).to_owned() + } else { + path + }; + safe(&path)?; + let kind = if ty.is_symlink() { + if size != 0 { + return Err(invalid("snapshot link has data")); + } + let link = match fields.remove("linkpath") { + Some(link) => link, + None => entry + .link_name_bytes() + .and_then(|v| std::str::from_utf8(&v).ok().map(str::to_owned)) + .ok_or_else(|| invalid("missing snapshot link"))?, + }; + Kind::Link(link) + } else if ty.is_dir() { + if size != 0 { + return Err(invalid("snapshot directory has data")); + } + Kind::Dir + } else { + Kind::File + }; + if !fields.is_empty() { + return Err(invalid("PAX link metadata on non-link")); + } + let item = Item { path, size, kind }; + visit(item, &mut entry)?; + std::io::copy(&mut entry, &mut std::io::sink())?; + } + if pending.is_some() { + return Err(invalid("dangling snapshot PAX metadata")); + } + let mut reader = archive.into_inner(); + let mut tail = Vec::new(); + reader.by_ref().take(10241).read_to_end(&mut tail)?; + if tail.len() > 10240 || tail.iter().any(|b| *b != 0) { + return Err(invalid("snapshot tar trailing data")); + } + // Finish gzip CRC/trailer checks and reject concatenated gzip members. + let mut extra = [0u8; 1]; + if reader.read(&mut extra)? != 0 + || reader.read.saturating_sub(last_end) < 1024 + || !reader.inner.get_ref().is_empty() + { + return Err(invalid("snapshot gzip trailing data")); + } + Ok(()) +} +fn location(path: &str) -> Option<(usize, &str)> { + let tail = path.strip_prefix("snapshot/repositories/")?; + let (index, rest) = tail.split_once('/')?; + let parsed = index.parse::().ok()?; + (index == parsed.to_string()).then_some((parsed, rest)) +} +fn resolve_link<'a>( + source: &Path, + root: &Path, + target: &'a str, + links: &'a HashMap, +) -> Result { + let mut current = source + .parent() + .ok_or_else(|| invalid("missing link parent"))? + .to_path_buf(); + let mut pending: VecDeque<_> = target.split('/').collect(); + let mut expansions = 0; + while let Some(part) = pending.pop_front() { + match part { + "" | "." => {} + ".." => { + if current == root || !current.pop() { + return Err(invalid("snapshot link escapes repository")); + } + } + name => { + current.push(name); + if let Some((_, next)) = links.get(¤t) { + expansions += 1; + if expansions > 128 { + return Err(invalid("snapshot link chain exceeded")); + } + current.pop(); + for part in next.split('/').rev() { + pending.push_front(part); + } + } + } + } + if pending.len() > 256 + || current.components().count() > 256 + || current.as_os_str().len() > 4096 + { + return Err(invalid("snapshot link expansion exceeded")); + } + } + Ok(current) +} + +/// Validate an authenticated snapshot before allocating storage or importing its packs. +/// Tar/PAX metadata, paths, object expansion, repository/file counts and symlink chains +/// are bounded; unscanned archives, external links and unsupported types are refused. +pub fn inspect_archive(bytes: &[u8], cancel: &CancellationToken) -> Result { + let started = Instant::now(); + let mut items = Vec::new(); + let mut seen = HashSet::new(); + let mut manifest = None; + let mut inventory = HashMap::new(); + let mut total = 0u64; + walk(bytes, cancel, |item, reader| { + if !seen.insert(item.path.clone()) { + return Err(invalid("duplicate snapshot archive path")); + } + total = total + .checked_add(item.size) + .filter(|n| *n <= MAX_SOURCE_BYTES) + .ok_or_else(|| invalid("snapshot source budget exceeded"))?; + if item.path == "snapshot/manifest.json" { + if item.kind != Kind::File || item.size > 8 * 1024 * 1024 { + return Err(invalid("invalid snapshot manifest")); + } + let mut data = Vec::new(); + reader.read_to_end(&mut data)?; + manifest = Some( + serde_json::from_slice::(&data) + .map_err(|_| invalid("invalid snapshot manifest"))?, + ); + } else if let Some((index, "history.bundle")) = location(&item.path) { + if index >= 64 || item.kind != Kind::File || item.size > MAX_BLOB_BYTES as u64 { + return Err(invalid("invalid snapshot history entry")); + } + let mut data = Vec::new(); + reader.read_to_end(&mut data)?; + inventory.insert(index, inspect_bundle(&data, MAX_SOURCE_BYTES)?); + } + items.push(item); + Ok(()) + }) + .map_err(|e| { + if cancel.is_cancelled() { + Error::Cancelled + } else { + e + } + })?; + let manifest = manifest.ok_or_else(|| invalid("missing snapshot manifest"))?; + if manifest.version != 1 + || !manifest.snapshot + || !manifest.scanned + || manifest.repositories.is_empty() + || manifest.repositories.len() > 64 + || inventory.len() != manifest.repositories.len() + { + return Err(invalid("unsupported or unscanned snapshot manifest")); + } + let mut files = HashMap::new(); + let mut links = HashMap::new(); + let mut roots = HashSet::new(); + let mut declared = HashSet::new(); + let mut count = 0usize; + let mut expanded = 0u64; + let mut objects = 0u32; + let mut refs = 0usize; + for (index, repo) in manifest.repositories.iter().enumerate() { + check(cancel, started)?; + let root = safe(&repo.path)?; + let stats = inventory + .get(&index) + .ok_or_else(|| invalid("missing snapshot bundle"))?; + if !roots.insert(root.clone()) + || excluded(&root) + || repo.base != "bundle" + || repo.head.len() != stats.hash_bytes * 2 + || !repo.head.bytes().all(|b| b.is_ascii_hexdigit()) + || repo.branch.len() > 1024 + || repo.shallow.len() > MAX_FILES + || !repo + .shallow + .iter() + .all(|s| s.len() == repo.head.len() && s.bytes().all(|b| b.is_ascii_hexdigit())) + { + return Err(invalid("invalid snapshot repository metadata")); + } + expanded = expanded + .checked_add(stats.expanded_bytes) + .filter(|n| *n <= MAX_SOURCE_BYTES) + .ok_or_else(|| invalid("snapshot aggregate expansion exceeded"))?; + objects = objects + .checked_add(stats.objects) + .filter(|n| *n <= 200000) + .ok_or_else(|| invalid("snapshot aggregate object count exceeded"))?; + refs += stats.refs.len(); + let mut ref_names = HashSet::new(); + for (_, name) in &stats.refs { + safe(name)?; + if !ref_names.insert(name) || name.to_ascii_lowercase().starts_with("refs/replace/") { + return Err(invalid("duplicate or unsafe snapshot reference")); + } + } + count += repo.files.len() + repo.excluded.len() + repo.deleted.len(); + if count > MAX_FILES || refs > MAX_FILES { + return Err(invalid("snapshot path count exceeded")); + } + for value in repo.excluded.iter().chain(repo.deleted.iter()) { + safe(value)?; + } + for file in &repo.files { + let relative = safe(file)?; + if excluded(&relative) + || !declared.insert(format!("snapshot/repositories/{index}/files/{file}")) + { + return Err(invalid("invalid copied snapshot path")); + } + } + } + for item in &items { + check(cancel, started)?; + if item.kind == Kind::Dir { + continue; + } + if item.path == "snapshot/manifest.json" { + continue; + } + let (index, tail) = + location(&item.path).ok_or_else(|| invalid("unknown snapshot archive entry"))?; + let repo = manifest + .repositories + .get(index) + .ok_or_else(|| invalid("unknown snapshot repository"))?; + if tail == "history.bundle" { + continue; + } + let relative = tail + .strip_prefix("files/") + .ok_or_else(|| invalid("unknown snapshot archive entry"))?; + if !declared.remove(&item.path) { + return Err(invalid("undeclared copied snapshot file")); + } + let full = Path::new(&repo.path).join(safe(relative)?); + // Nested repositories may both name the same physical working file. + // The deepest repository owns its final copied view. + let replace = files.get(&full).is_none_or(|old: &String| { + location(old) + .is_some_and(|(old, _)| manifest.repositories[old].path.len() < repo.path.len()) + }); + if replace { + files.insert(full, item.path.clone()); + } + } + if !declared.is_empty() { + return Err(invalid("snapshot manifest file is missing")); + } + let mut directories = HashSet::new(); + for item in &items { + if item.kind != Kind::Dir { + for parent in Path::new(&item.path).ancestors().skip(1) { + directories.insert(parent.to_path_buf()); + } + } + } + for index in 0..manifest.repositories.len() { + directories.insert(PathBuf::from(format!( + "snapshot/repositories/{index}/files" + ))); + } + if items + .iter() + .any(|item| item.kind == Kind::Dir && !directories.contains(Path::new(&item.path))) + { + return Err(invalid("undeclared snapshot directory")); + } + let by_name: HashMap<_, _> = items + .iter() + .map(|item| (item.path.as_str(), item)) + .collect(); + let mut raw_links = HashMap::new(); + for (full, name) in &files { + check(cancel, started)?; + let item = by_name + .get(name.as_str()) + .ok_or_else(|| invalid("missing selected snapshot item"))?; + if let Kind::Link(target) = &item.kind { + if target.is_empty() + || target.len() > 4096 + || target.starts_with('/') + || target.split('/').count() > 128 + || target + .chars() + .any(|c| c.is_control() || matches!(c, '\\' | ':')) + { + return Err(invalid("unsafe snapshot link target")); + } + let (index, _) = location(name).ok_or_else(|| invalid("invalid link scope"))?; + raw_links.insert( + full.clone(), + ( + PathBuf::from(&manifest.repositories[index].path), + target.clone(), + ), + ); + } + } + for file in files.keys() { + check(cancel, started)?; + if file + .ancestors() + .skip(1) + .any(|parent| files.contains_key(parent)) + { + return Err(invalid("snapshot file is an ancestor")); + } + } + for root in &roots { + if root.ancestors().any(|parent| files.contains_key(parent)) { + return Err(invalid("snapshot repository crosses a file")); + } + } + for (source, (root, target)) in &raw_links { + check(cancel, started)?; + links.insert( + source.clone(), + resolve_link(source, root, target, &raw_links)?, + ); + } + Ok(ArchivePlan { + manifest, + items, + inventory, + files, + links, + bytes: total, + }) +} + +impl ArchivePlan { + /// Repository paths relative to the eventual mirror directory. + pub fn repositories(&self) -> impl Iterator { + self.manifest.repositories.iter().map(|r| r.path.as_str()) + } + /// Total packed object count across all repositories. + pub fn objects(&self) -> u32 { + self.inventory.values().map(|s| s.objects).sum() + } + /// Total reconstructed Git object bytes, including delta targets. + pub fn expanded_bytes(&self) -> u64 { + self.inventory.values().map(|s| s.expanded_bytes).sum() + } + /// Number of unique copied working files, including symlinks. + pub fn files(&self) -> usize { + self.files.len() + } + /// Number of selected safe symlinks. + pub fn symlinks(&self) -> usize { + self.links.len() + } + /// Number of bounded tar entries, excluding PAX metadata headers. + pub fn entries(&self) -> usize { + self.items.len() + } + /// Declared regular payload bytes, including copied bundles and manifest. + pub fn uncompressed_bytes(&self) -> u64 { + self.bytes + } +} +#[cfg(test)] +mod tests; diff --git a/crates/buzz-cli/src/sandbox_archive/format/tests.rs b/crates/buzz-cli/src/sandbox_archive/format/tests.rs new file mode 100644 index 00000000000..e1f3c313b09 --- /dev/null +++ b/crates/buzz-cli/src/sandbox_archive/format/tests.rs @@ -0,0 +1,342 @@ +use super::*; +use flate2::{write::GzEncoder, Compression}; +use serde_json::{json, Value}; +use std::{fs, io::Write, process::Command}; + +fn git(repo: &Path, args: &[&str]) -> Vec { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "Fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") + .env("GIT_COMMITTER_NAME", "Fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") + .output() + .unwrap(); + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + output.stdout +} +fn fixture(sha256: bool) -> (tempfile::TempDir, Value, Vec) { + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path(); + git( + repo, + &[ + "init", + "--template=", + if sha256 { + "--object-format=sha256" + } else { + "--object-format=sha1" + }, + ], + ); + fs::write(repo.join("tracked.txt"), "clean\n").unwrap(); + fs::write(repo.join(".env.example"), "TEMPLATE=public\n").unwrap(); + git(repo, &["add", "."]); + git( + repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], + ); + git(repo, &["branch", "local-only"]); + let bundle = repo.join("history.bundle"); + git( + repo, + &[ + "bundle", + "create", + bundle.to_str().unwrap(), + "--all", + "HEAD", + ], + ); + let head = String::from_utf8(git(repo, &["rev-parse", "HEAD"])) + .unwrap() + .trim() + .to_owned(); + let manifest = json!({"version":1,"snapshot":true,"scanned":true,"repositories":[{"path":"workspace","head":head,"base":"bundle","branch":"main","shallow":[],"files":["tracked.txt"],"excluded":[".env.example"],"deleted":[]}]}); + let bytes = fs::read(bundle).unwrap(); + (temp, manifest, bytes) +} +fn header(path: &str, kind: u8, size: u64) -> tar::Header { + let mut h = tar::Header::new_ustar(); + h.set_size(size); + h.set_mode(0o644); + h.set_entry_type(tar::EntryType::new(kind)); + assert!(path.len() <= 100); + h.as_mut_bytes()[..path.len()].copy_from_slice(path.as_bytes()); + h.set_cksum(); + h +} +fn packed(entries: Vec<(tar::Header, Vec)>) -> Vec { + let gzip = GzEncoder::new(Vec::new(), Compression::fast()); + let mut tar = tar::Builder::new(gzip); + for (h, data) in entries { + tar.append(&h, data.as_slice()).unwrap(); + } + tar.into_inner().unwrap().finish().unwrap() +} +fn entries(manifest: &Value, bundle: &[u8]) -> Vec<(tar::Header, Vec)> { + let data = serde_json::to_vec(manifest).unwrap(); + vec![ + ( + header("snapshot/manifest.json", b'0', data.len() as u64), + data, + ), + ( + header( + "snapshot/repositories/0/history.bundle", + b'0', + bundle.len() as u64, + ), + bundle.to_vec(), + ), + ( + header("snapshot/repositories/0/files/tracked.txt", b'0', 6), + b"dirty\n".to_vec(), + ), + ] +} +fn inspect(data: &[u8]) -> Result { + inspect_archive(data, &CancellationToken::new()) +} +fn pax_record(key: &str, value: &str) -> Vec { + let body = format!(" {key}={value}\n"); + let mut n = body.len() + 1; + loop { + let next = body.len() + n.to_string().len(); + if next == n { + return format!("{n}{body}").into_bytes(); + } + n = next; + } +} +#[test] +fn real_bundles_and_policy_fixture_cover_full_history_and_copied_exclusions() { + for sha256 in [false, true] { + let (_temp, manifest, bundle) = fixture(sha256); + let plan = inspect(&packed(entries(&manifest, &bundle))).unwrap(); + assert_eq!(plan.repositories().collect::>(), ["workspace"]); + assert_eq!(plan.files(), 1); + assert_eq!(plan.symlinks(), 0); + assert!( + plan.objects() >= 4 + && plan.expanded_bytes() > 20 + && plan.uncompressed_bytes() > bundle.len() as u64 + ); + assert!(plan.inventory[&0] + .refs + .iter() + .any(|(_, name)| name == "refs/heads/local-only")); + } + let cases: Vec<(String, bool)> = serde_json::from_str(include_str!( + "../../../../../voltage/images/buzz-agent/snapshot-policy.json" + )) + .unwrap(); + for (path, expected) in cases { + assert_eq!(excluded(&safe(&path).unwrap()), expected, "{path}"); + } +} +#[test] +fn metadata_types_duplicates_and_path_guards_are_bound_to_real_archive_iteration() { + let (_temp, manifest, bundle) = fixture(false); + let good = entries(&manifest, &bundle); + for path in ["../outside", "/outside", "a/../b", "a\\b", "a//b", "a:b"] { + let mut bad = good.clone(); + bad[2] = ( + header(&format!("snapshot/repositories/0/files/{path}"), b'0', 0), + vec![], + ); + let mut altered = manifest.clone(); + altered["repositories"][0]["files"] = json!([path]); + bad[0].1 = serde_json::to_vec(&altered).unwrap(); + let size = bad[0].1.len() as u64; + bad[0].0.set_size(size); + bad[0].0.set_cksum(); + assert!(inspect(&packed(bad)).is_err(), "{path}"); + } + for ty in *b"1346SLKg" { + let mut bad = good.clone(); + bad[2] = ( + header("snapshot/repositories/0/files/tracked.txt", ty, 0), + vec![], + ); + assert!(inspect(&packed(bad)).is_err(), "type{ty}"); + } + let mut duplicate = good.clone(); + duplicate.push(good[2].clone()); + assert!(inspect(&packed(duplicate)).is_err()); + let mut unscanned = manifest.clone(); + unscanned["scanned"] = json!(false); + assert!(inspect(&packed(entries(&unscanned, &bundle))).is_err()); + let mut private = manifest.clone(); + private["repositories"][0]["files"] = json!([".Git/config"]); + let mut private_entries = entries(&private, &bundle); + private_entries[2].0 = header("snapshot/repositories/0/files/.Git/config", b'0', 6); + assert!(inspect(&packed(private_entries)).is_err()); + let mut undeclared = good.clone(); + undeclared.push((header("snapshot/unused", b'5', 0), vec![])); + assert!(inspect(&packed(undeclared)).is_err()); +} +#[test] +fn pax_is_bounded_before_payload_allocation_and_accepts_python_long_paths() { + let (_temp, mut manifest, bundle) = fixture(false); + let name = format!("{}file.txt", "long/".repeat(24)); + manifest["repositories"][0]["files"] = json!([name]); + let mut items = entries(&manifest, &bundle); + items.pop(); + let path = format!("snapshot/repositories/0/files/{name}"); + let extension = pax_record("path", &path); + items.push(( + header("././@PaxHeader", b'x', extension.len() as u64), + extension, + )); + items.push((header("placeholder", b'0', 6), b"dirty\n".to_vec())); + assert_eq!(inspect(&packed(items)).unwrap().files(), 1); + // A sparse compressed 1MiB extension must be refused at the 16KiB declaration. + let mut bad = entries(&manifest, &bundle); + bad.push((header("pax", b'x', 1024 * 1024), vec![0; 1024 * 1024])); + assert!(matches!( + inspect(&packed(bad)), + Err(Error::Invalid("snapshot PAX payload exceeds budget")) + )); + let mut bad = entries(&manifest, &bundle); + let metadata = pax_record("SCHILY.xattr.security.capability", "bad"); + bad.push((header("pax", b'x', metadata.len() as u64), metadata)); + assert!(inspect(&packed(bad)).is_err()); +} +#[test] +fn symlinks_are_scoped_and_cycles_and_file_ancestors_are_refused() { + let (_temp, mut manifest, bundle) = fixture(false); + manifest["repositories"][0]["files"] = json!(["tracked.txt", "link"]); + for target in ["tracked.txt", "../outside", "/outside", "link"] { + let mut items = entries(&manifest, &bundle); + let mut h = header("snapshot/repositories/0/files/link", b'2', 0); + h.set_link_name(target).unwrap(); + h.set_cksum(); + items.push((h, vec![])); + assert_eq!( + inspect(&packed(items)).is_ok(), + target == "tracked.txt", + "{target}" + ); + } + manifest["repositories"][0]["files"] = json!(["tracked.txt", "tracked.txt/child"]); + let mut items = entries(&manifest, &bundle); + items.push(( + header("snapshot/repositories/0/files/tracked.txt/child", b'0', 0), + vec![], + )); + assert!(inspect(&packed(items)).is_err()); +} +#[test] +fn gzip_tail_truncation_and_cancellation_refuse_without_filesystem_side_effects() { + let (_temp, manifest, bundle) = fixture(false); + let good = packed(entries(&manifest, &bundle)); + let mut concatenated = good.clone(); + concatenated.extend_from_slice(&good); + assert!(inspect(&concatenated).is_err()); + for length in [0, 10, good.len() - 1] { + assert!(inspect(&good[..length]).is_err()); + } + let token = CancellationToken::new(); + token.cancel(); + assert!(matches!( + inspect_archive(&good, &token), + Err(Error::Cancelled) + )); + let mut tar = Vec::new(); + { + let mut decoder = flate2::read::GzDecoder::new(good.as_slice()); + decoder.read_to_end(&mut tar).unwrap(); + } + tar.extend_from_slice(b"not-zero"); + let mut gzip = GzEncoder::new(Vec::new(), Compression::fast()); + gzip.write_all(&tar).unwrap(); + assert!(inspect(&gzip.finish().unwrap()).is_err()); +} + +#[test] +fn aggregate_expansion_counts_repeated_compressed_bundles() { + use sha2::{Digest, Sha256}; + let size = 129 * 1024 * 1024usize; + let mut pack = b"PACK\0\0\0\x02\0\0\0\x01".to_vec(); + let mut remainder = size >> 4; + pack.push(0xb0 | (size as u8 & 15)); + while remainder > 0 { + let part = (remainder & 127) as u8; + remainder >>= 7; + pack.push(part | if remainder > 0 { 128 } else { 0 }); + } + let mut zlib = flate2::write::ZlibEncoder::new(Vec::new(), Compression::fast()); + for _ in 0..size / 1024 { + zlib.write_all(&[b'x'; 1024]).unwrap(); + } + pack.extend(zlib.finish().unwrap()); + pack.extend_from_slice(&Sha256::digest(&pack)); + let head = "0".repeat(64); + let mut bundle = + format!("# v3 git bundle\n@object-format=sha256\n{head} HEAD\n\n").into_bytes(); + bundle.extend(pack); + let record = json!({"path":"one","head":head,"base":"bundle","branch":"","shallow":[],"files":[],"excluded":[],"deleted":[]}); + let mut second = record.clone(); + second["path"] = json!("two"); + let manifest = + json!({"version":1,"snapshot":true,"scanned":true,"repositories":[record,second]}); + let mut items = entries(&manifest, &bundle); + items.pop(); + items.push(( + header( + "snapshot/repositories/1/history.bundle", + b'0', + bundle.len() as u64, + ), + bundle, + )); + assert!(matches!( + inspect(&packed(items)), + Err(Error::Invalid("snapshot aggregate expansion exceeded")) + )); +} + +#[test] +fn dotdot_is_resolved_after_symlink_expansion() { + let (_temp, mut manifest, bundle) = fixture(false); + manifest["repositories"][0]["files"] = json!(["tracked.txt", "pivot", "up", "escape"]); + let mut items = entries(&manifest, &bundle); + for (name, target) in [ + ("pivot", "."), + ("up", "pivot/.."), + ("escape", "up/../outside"), + ] { + let mut h = header(&format!("snapshot/repositories/0/files/{name}"), b'2', 0); + h.set_link_name(target).unwrap(); + h.set_cksum(); + items.push((h, vec![])); + } + assert!(matches!( + inspect(&packed(items)), + Err(Error::Invalid("snapshot link escapes repository")) + )); +} + +#[test] +#[ignore = "requires exact packaged producer archive in BUZZ_SNAPSHOT_ARCHIVE_FIXTURE"] +fn exact_packaged_producer_archive() { + let path = std::env::var("BUZZ_SNAPSHOT_ARCHIVE_FIXTURE").unwrap(); + let plan = inspect(&fs::read(path).unwrap()).unwrap(); + assert_eq!(plan.repositories().collect::>(), ["workspace"]); + assert_eq!(plan.files(), 3); + assert_eq!(plan.symlinks(), 1); + assert!(plan.inventory[&0] + .refs + .iter() + .any(|(_, name)| name == "refs/heads/local-only")); +} diff --git a/docs/sandbox-snapshot-archive.md b/docs/sandbox-snapshot-archive.md new file mode 100644 index 00000000000..e525cc1ae76 --- /dev/null +++ b/docs/sandbox-snapshot-archive.md @@ -0,0 +1,54 @@ +# Snapshot archive validation + +The snapshot archive reader validates authenticated plaintext before a consumer +writes files or imports Git objects. The producer format and scan policy are +specified in [sandbox-snapshot-producer.md](sandbox-snapshot-producer.md). +The transport uses [NIP-SA](nips/NIP-SA.md). + +`buzz_cli::sandbox_archive::inspect_archive` returns an opaque `ArchivePlan`. +It performs no filesystem writes, Git execution or network access. A successful +plan proves the archive structure fits the fixed limits below. A restore +consumer must also verify Git integrity, account for local storage, and publish +the mirror atomically. This foundation does not expose a user restore command. + +| Resource | Limit | +| --- | --- | +| Compressed archive | 100 MiB | +| Declared regular payloads | 256 MiB total | +| Expanded Git objects | 256 MiB total, including delta targets | +| Git objects | 200,000 total | +| Repositories | 64 | +| Manifest | 8 MiB | +| Copied, omitted and deleted paths | 50,000 total | +| Advertised Git refs | 50,000 total | +| Tar entries | 150,000 | +| Local PAX payload | 16 KiB per header, 1 MiB total padded metadata | +| Relative path | 4,096 UTF-8 bytes and 128 components | +| Symlink resolution | 128 substitutions | +| Parsing | 120 seconds, with cancellation checks | + +The reader iterates raw tar headers. It checks extension type and declared size +before reading PAX payloads, so the tar library cannot allocate an unbounded +extension body. Only local `path` and `linkpath` PAX fields are accepted. GNU +extensions, sparse files, hard links, devices, FIFOs, global PAX fields and +unknown fields are refused. Gzip CRC, end markers and trailing bytes are checked; +concatenated gzip members are refused. + +Paths must be canonical relative UTF-8 paths. Duplicate tar entries, missing or +undeclared files, file ancestors and repository paths crossing files are +refused. Working files follow the checkpoint exclusion policy. Versioned +excluded paths can remain in the scanned bundle history. The validator does +not check out that history. Case variants of `.git` are also refused as copied +paths, to protect repository metadata on case-insensitive filesystems. + +The reader preserves full locally available history and explicit shallow +boundaries. It requires a scanned snapshot manifest and uses the SDK pack +preflight for each bundle before accounting for aggregate object counts and +expanded sizes. The restore consumer must still run Git integrity checks with +replacement refs, hooks, lazy fetch and external configuration disabled. + +Relative symlinks must stay within their repository. The validator resolves +symlink ancestors to reject cycles and excessive chains. A nested repository +owns a copied file when both its own bundle and an outer repository name the +same physical path. Consumers must defer creating links until all regular files +and Git metadata have been written.