Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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(())
}