update-bundle/src/container.rs
//! The bundle container: a znippy archive holding the member layout. No new binary
//! archive format is invented — znippy is used purely as an offline container.
//!
//! All verification logic operates over an in-memory [`MemberMap`] (path → exact
//! bytes), so the security core is testable without znippy and, per the threat
//! model, a bundle is verified against the same in-memory bytes it is later
//! extracted from — there is no re-read window between verify and extract.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use znippy_common::{ZnippyArchive, ZnippyReader};
use crate::error::{BundleError, Result};
use crate::pathsafe::{validate_member_path, PathGuard};
use crate::policy::TrustPolicy;
use crate::schema::{
is_control_member, EXPANSION_RATIO_FLOOR_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_CONTROL_BYTES,
MAX_CONTROL_TOTAL_BYTES, MAX_EXPANSION_RATIO, MAX_MEMBERS, MAX_MEMBER_BYTES, MAX_TOTAL_BYTES,
};
use crate::verify::{verify_control_plane, ControlVerdict};
/// A bundle as a map of member path → exact bytes. `BTreeMap` keeps a deterministic
/// order (matters for the release manifest and for reproducible packing).
pub type MemberMap = BTreeMap<String, Vec<u8>>;
fn tmp_sibling(out: &Path) -> PathBuf {
let mut name = out
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_default();
name.push(".partial.tmp");
out.with_file_name(name)
}
/// Write `members` to a `.holger.znippy` archive at `out` atomically: pack into a
/// temp sibling, then rename into place. A partial archive never appears under the
/// requested final filename; the temp file is removed on failure.
pub fn pack_bundle(members: &MemberMap, out: &Path) -> Result<()> {
let staging = tempfile::TempDir::new().map_err(|e| BundleError::io("create staging dir", e))?;
let list: Vec<(&String, &Vec<u8>)> = members.iter().collect();
for (rel, _) in &list {
validate_member_path(rel)?; // defensive: never stage a hostile path
}
// The members are staged on one gatling fan-out (stinky 30): independent
// files, so the writes overlap instead of queueing one after another.
let staged = gatling::gatling_forkjoin::gatling_for_each(list.len(), 0, |i| {
let (rel, bytes) = list[i];
let dest = staging.path().join(rel);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| BundleError::io("mkdir staging", e))?;
}
fs::write(&dest, bytes).map_err(|e| BundleError::io("write staging member", e))
});
staged.into_iter().collect::<Result<Vec<()>>>()?;
let staging_pb: PathBuf = staging.path().to_path_buf();
// `compress_dir` forces the output name to `<path>.with_extension("znippy")`, so
// the file it actually creates is `written`, not the raw temp path we pass.
let tmp_out = tmp_sibling(out);
let written = tmp_out.with_extension("znippy");
let _ = fs::remove_file(&written);
match znippy_compress::compress_dir(&staging_pb, &tmp_out, false, None, None, None) {
Ok(_report) => {}
Err(e) => {
let _ = fs::remove_file(&written);
return Err(BundleError::Container(format!("znippy pack failed: {e}")));
}
}
fs::rename(&written, out).map_err(|e| {
let _ = fs::remove_file(&written);
BundleError::io("rename bundle into place", e)
})?;
Ok(())
}
/// `stat` the bundle file and refuse an over-large archive BEFORE it is opened.
///
/// The cheapest door in the whole pipeline: one syscall, no allocation, no
/// decompressor touched. Returns the opened archive and its on-disk length (the
/// denominator of the expansion-ratio guard).
/// **Every call into the archive reader, with its panics refused as a malformed bundle.**
///
/// The reader parses the untrusted index (an Arrow IPC schema and table) before anything is
/// authenticated, and Arrow answers some schemas with a panic, not an error: holger-fuzz's
/// `bundle_verify` found a field of type NONE (arrow-ipc 59 `unimplemented!`, 2026-10-30).
/// A panic would take down the process opening the bundle (holger-verify, the inbox
/// import); a refusal by name is the answer. Holger builds unwind, so the panic is caught
/// here; the reader's state is dropped with the refusal.
fn reader<T>(what: &str, f: impl FnOnce() -> T) -> Result<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|p| {
let why = p
.downcast_ref::<&str>()
.map(|s| (*s).to_owned())
.or_else(|| p.downcast_ref::<String>().cloned())
.unwrap_or_default();
BundleError::Container(format!(
"the bundle archive is malformed: its reader failed to {what} ({why})"
))
})
}
fn open_archive(path: &Path) -> Result<(ZnippyArchive, u64)> {
let meta = fs::metadata(path).map_err(|e| BundleError::io("stat bundle", e))?;
if !meta.is_file() {
return Err(BundleError::Container(
"bundle path is not a regular file".into(),
));
}
let file_len = meta.len();
if file_len > MAX_BUNDLE_FILE_BYTES {
return Err(BundleError::LimitExceeded(format!(
"bundle file size {file_len} exceeds {MAX_BUNDLE_FILE_BYTES}"
)));
}
let archive = reader("open it", || ZnippyArchive::open(path))?
.map_err(|e| BundleError::Container(format!("cannot open bundle archive: {e}")))?;
Ok((archive, file_len))
}
/// List the members, cap the count, and run the path guard over EVERY name before
/// a single byte is extracted. Names are cheap; bytes are not.
fn safe_member_names(archive: &ZnippyArchive) -> Result<Vec<String>> {
let mut files = reader("list its members", || archive.list_files())?
.map_err(|e| BundleError::Container(format!("cannot list bundle members: {e}")))?;
if files.len() > MAX_MEMBERS {
return Err(BundleError::LimitExceeded(format!(
"member count {} exceeds {}",
files.len(),
MAX_MEMBERS
)));
}
// `list_files` walks a HashMap, so fix an order before anything depends on it.
files.sort();
let mut guard = PathGuard::new();
for rel in &files {
guard.check(rel)?;
}
Ok(files)
}
/// Extract one member, re-checking every chunk's stored checksum.
///
/// `extract_file_verified` recomputes each chunk's blake3, so archive-layer
/// tampering (corrupted data OR corrupted stored checksums) is caught here, in
/// addition to the manifest-layer digest check done during verification.
fn extract_member(archive: &ZnippyArchive, rel: &str) -> Result<Vec<u8>> {
reader("extract a member", || archive.extract_file_verified(rel))?
.map_err(|e| BundleError::Container(format!("cannot extract bundle member: {e}")))
}
/// Load and validate every member from a `.holger.znippy` archive, with NO
/// authentication whatsoever.
///
/// # What this does NOT protect against
///
/// **This function decompresses attacker-chosen bytes before anything is
/// verified.** It is bounded by shape, not by trust:
///
/// * the file-size `stat` ([`MAX_BUNDLE_FILE_BYTES`]) refuses an oversized
/// container before it is opened;
/// * the per-member and aggregate caps on ACTUAL decompressed bytes
/// ([`MAX_MEMBER_BYTES`], [`MAX_TOTAL_BYTES`]) bound the total;
/// * the expansion-ratio guard ([`MAX_EXPANSION_RATIO`]) refuses the classic bomb
/// SHAPE — a small archive expanding enormously — while a legitimately large,
/// poorly-compressing firmware bundle (ratio near 1) passes untouched.
///
/// What none of that gives you is an upper bound anywhere near an appliance's
/// RAM: an archive at the file cap may still legitimately expand to gigabytes,
/// and a single member is only capped AFTER it has been decompressed into memory
/// (znippy's random-access read returns a whole `Vec<u8>`; there is no incremental
/// cap hook to stop it halfway). Nothing here checks a signature, so every byte it
/// returns is unauthenticated input.
///
/// # Use [`load_bundle_verified`] instead
///
/// Any caller holding a [`TrustPolicy`] MUST use [`load_bundle_verified`], which
/// authenticates the release manifest from a few MiB of control members and then
/// budgets the rest against SIGNED sizes. This function remains only for the
/// genuinely unauthenticated inspection path — `holger-verify`, which must read
/// `core.json` to look up anti-rollback state before it can complete a
/// verification, and tooling that inspects a bundle it has no policy for.
pub fn load_bundle(path: &Path) -> Result<MemberMap> {
let (archive, file_len) = open_archive(path)?;
let files = safe_member_names(&archive)?;
let mut declared_total: u64 = 0;
let mut actual_total: u64 = 0;
let mut map = MemberMap::new();
for rel in &files {
// Early decompression-bomb reject on the DECLARED size — a hint from the
// untrusted index. Cheap, but not authoritative (the index can lie small).
if let Some(sz) = reader("read a member's size", || archive.file_size(rel))? {
if sz > MAX_MEMBER_BYTES {
return Err(BundleError::LimitExceeded(format!(
"member {rel:?} declared size {sz} exceeds {MAX_MEMBER_BYTES}"
)));
}
declared_total = declared_total
.checked_add(sz)
.ok_or_else(|| BundleError::LimitExceeded("aggregate size overflow".into()))?;
if declared_total > MAX_TOTAL_BYTES {
return Err(BundleError::LimitExceeded(format!(
"aggregate declared size exceeds {MAX_TOTAL_BYTES}"
)));
}
}
let bytes = extract_member(&archive, rel)?;
// AUTHORITATIVE caps on the ACTUAL decompressed size. znippy grows the buffer
// from verified bytes (it never pre-sizes from the untrusted index), so a
// lying-small `file_size` cannot smuggle bytes past these bounds — a bomb is
// capped per-member and, crucially, in aggregate against ACTUAL bytes.
if bytes.len() as u64 > MAX_MEMBER_BYTES {
return Err(BundleError::LimitExceeded(format!(
"member {rel:?} extracted size exceeds {MAX_MEMBER_BYTES}"
)));
}
actual_total = actual_total
.checked_add(bytes.len() as u64)
.ok_or_else(|| BundleError::LimitExceeded("aggregate size overflow".into()))?;
if actual_total > MAX_TOTAL_BYTES {
return Err(BundleError::LimitExceeded(format!(
"aggregate uncompressed size exceeds {MAX_TOTAL_BYTES}"
)));
}
// Expansion-ratio guard: refuse the bomb SHAPE. Only consulted once the
// extraction is large enough to matter, so a tiny archive of highly
// compressible JSON — a real and perfectly innocent ratio of hundreds — is
// never punished for it.
if actual_total > EXPANSION_RATIO_FLOOR_BYTES {
let allowed = file_len
.saturating_mul(MAX_EXPANSION_RATIO)
.max(EXPANSION_RATIO_FLOOR_BYTES);
if actual_total > allowed {
return Err(BundleError::LimitExceeded(format!(
"archive expands {actual_total} bytes from {file_len} on disk, \
over the {MAX_EXPANSION_RATIO}:1 limit — refusing a decompression bomb"
)));
}
}
map.insert(rel.clone(), bytes);
}
Ok(map)
}
/// Load a bundle **authenticating before extracting**: the expensive
/// decompression happens only after the release signature has been verified
/// against `policy`, and every member is then sized against the SIGNED manifest.
///
/// This is the loader every caller with a policy in hand must use.
///
/// # Why two phases
///
/// [`load_bundle`] reads the whole archive into memory and only then hands it to
/// a verifier — so an attacker who can put a file in front of the importer spends
/// nothing and makes the appliance allocate up to [`MAX_TOTAL_BYTES`] of bytes
/// nobody has vouched for. The caps were real but they were the wrong order of
/// magnitude, and, more to the point, in the wrong ORDER: the allocation came
/// first and the trust decision second.
///
/// 1. **Control members only.** `core.json`, the envelope, `signatures/**` and
/// `approvals/**` are extracted under their own tight caps
/// ([`MAX_CONTROL_BYTES`] each, [`MAX_CONTROL_TOTAL_BYTES`] combined), so
/// phase one cannot itself be the bomb. Everything a bundle *delivers* stays
/// compressed on disk.
/// 2. **The gate.** [`verify_control_plane`] checks that the present members match
/// the release manifest, that the release signature over that manifest is valid
/// and made by a signer the policy names and that chains to a trusted root, that
/// the candidate digest matches `core.json`, and that the approvals satisfy the
/// two-person rule. A failure here costs the attacker everything and this
/// process a few MiB.
/// 3. **Budget, then extract.** The manifest is now signed, so its `size_bytes`
/// list may be believed. A member the signed manifest does not name is refused
/// WITHOUT being extracted; the signed sizes are summed against
/// [`MAX_TOTAL_BYTES`] before the first payload byte is decompressed; and each
/// extracted member's actual length must equal its signed size, so an index
/// that lied small is caught on the one member it lied about rather than after
/// the whole archive has landed.
///
/// The returned map is *authenticated*, not *verified*: the caller still runs
/// [`verify_member_map`](crate::verify::verify_member_map) over it for the checks
/// that need the whole bundle and the caller's context (core's content list,
/// evidence, target, expiry, anti-rollback sequence).
pub fn load_bundle_verified(path: &Path, policy: &TrustPolicy) -> Result<MemberMap> {
let (archive, _file_len) = open_archive(path)?;
let files = safe_member_names(&archive)?;
// ── phase one: the control members, and nothing else ──────────────────────
let mut control = MemberMap::new();
let mut control_total: u64 = 0;
for rel in files.iter().filter(|r| is_control_member(r)) {
// Declared size first (free, and usually honest), actual size after.
if let Some(sz) = reader("read a member's size", || archive.file_size(rel))? {
if sz > MAX_CONTROL_BYTES {
return Err(BundleError::LimitExceeded(format!(
"control member {rel:?} declared size {sz} exceeds {MAX_CONTROL_BYTES}"
)));
}
}
let bytes = extract_member(&archive, rel)?;
if bytes.len() as u64 > MAX_CONTROL_BYTES {
return Err(BundleError::LimitExceeded(format!(
"control member {rel:?} extracted size exceeds {MAX_CONTROL_BYTES}"
)));
}
control_total = control_total
.checked_add(bytes.len() as u64)
.ok_or_else(|| BundleError::LimitExceeded("control size overflow".into()))?;
if control_total > MAX_CONTROL_TOTAL_BYTES {
return Err(BundleError::LimitExceeded(format!(
"control members exceed {MAX_CONTROL_TOTAL_BYTES} before anything is authenticated"
)));
}
control.insert(rel.clone(), bytes);
}
// ── the gate: nothing below this line runs on an unauthenticated bundle ───
let verdict: ControlVerdict = verify_control_plane(&control, policy)?;
let signed = verdict.manifest.index();
// ── phase two, step 1: budget from the SIGNED sizes, extracting nothing ───
let mut budget = control_total;
for rel in files.iter().filter(|r| !is_control_member(r)) {
let entry = signed
.get(rel.as_str())
// Refused by NAME: an unreferenced member never reaches a decompressor.
.ok_or_else(|| BundleError::UnreferencedMember(rel.clone()))?;
if entry.size_bytes > MAX_MEMBER_BYTES {
return Err(BundleError::LimitExceeded(format!(
"member {rel:?} signed size {} exceeds {MAX_MEMBER_BYTES}",
entry.size_bytes
)));
}
budget = budget
.checked_add(entry.size_bytes)
.ok_or_else(|| BundleError::LimitExceeded("aggregate size overflow".into()))?;
if budget > MAX_TOTAL_BYTES {
return Err(BundleError::LimitExceeded(format!(
"signed member sizes total {budget}, over {MAX_TOTAL_BYTES} — refusing before extraction"
)));
}
}
// ── phase two, step 2: extract, each member held to its signed size ───────
let mut map = control;
for rel in files.iter().filter(|r| !is_control_member(r)) {
let entry = signed
.get(rel.as_str())
.ok_or_else(|| BundleError::UnreferencedMember(rel.clone()))?;
let bytes = extract_member(&archive, rel)?;
if bytes.len() as u64 != entry.size_bytes {
// The index promised one size and the decompressor produced another.
// The signed number is the truth; the archive is lying either way.
return Err(BundleError::DigestMismatch {
path: rel.clone(),
which: "size",
});
}
map.insert(rel.clone(), bytes);
}
Ok(map)
}
/// Atomically write `bytes` to `path` (temp sibling + rename), creating parents.
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| BundleError::io("mkdir", e))?;
}
let tmp = tmp_sibling(path);
fs::write(&tmp, bytes).map_err(|e| BundleError::io("write temp", e))?;
fs::rename(&tmp, path).map_err(|e| {
let _ = fs::remove_file(&tmp);
BundleError::io("rename", e)
})?;
Ok(())
}