update-bundle/src/tests/bomb.rs
//! Decompression-bomb and load-order tests.
//!
//! The property under test is an ORDER, not a size: the expensive extraction must
//! happen AFTER the release signature is verified, never before. Every test here
//! is therefore a differential between the two loaders — `load_bundle`, which
//! extracts first and authenticates never, and `load_bundle_verified`, which
//! authenticates a few MiB of control members and only then decompresses anything.
//! A test that merely asserted "the verified loader returns an error" would pass
//! just as happily with the gate deleted; what pins the fix is asserting WHICH
//! error, because the wrong one proves the payload was reached.
use std::fs;
use super::testkit::*;
use crate::container::{load_bundle, load_bundle_verified, pack_bundle};
use crate::error::BundleError;
use crate::schema::{
EXPANSION_RATIO_FLOOR_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_CONTROL_BYTES, RELEASE_SIG,
SIGNATURES_PREFIX,
};
/// A marker long and odd enough that finding it in the packed archive is
/// unambiguous. Small members are stored with little or no compression, so the
/// literal survives into the archive and its stored bytes can be corrupted.
const MARKER: &[u8] = b"PAYLOAD-MARKER-MUST-NOT-BE-EXTRACTED";
const STOWAWAY_MARKER: &[u8] = b"STOWAWAY-MARKER-MUST-NOT-BE-EXTRACTED";
/// Flip a bit inside a member's STORED bytes in a packed archive, so that znippy's
/// per-chunk checksum makes extracting that ONE member fatal. Panics if the marker
/// is not present — a silent fallback would make the differentials below prove
/// nothing.
///
/// Corrupting the bytes rather than deleting the member is what turns "was it
/// extracted?" — normally invisible from outside — into an observable verdict: a
/// loader that touches the member says so, and a loader that refuses it earlier
/// returns its own error instead.
fn corrupt_stored_member(path: &std::path::Path, marker: &[u8]) {
let mut bytes = fs::read(path).unwrap();
let pos = bytes
.windows(marker.len())
.position(|w| w == marker)
.expect("marker must be present in the packed archive");
bytes[pos + 5] ^= 0x01;
fs::write(path, &bytes).unwrap();
}
#[test]
fn a_bundle_whose_release_signature_is_invalid_is_refused_before_its_payload_is_extracted() {
let mut b = valid_bundle_with_payload(MARKER.to_vec());
// Break the release signature. `release.sig` is one of the three members the
// release manifest cannot cover, so the archive itself stays self-consistent
// and nothing but the signature check can object to it.
b.members.get_mut(RELEASE_SIG).unwrap()[0] ^= 0x01;
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("bundle.holger.znippy");
pack_bundle(&b.members, &out).unwrap();
corrupt_stored_member(&out, MARKER);
// The payload is now a landmine: ANY loader that extracts it dies on it.
// `load_bundle` extracts first and authenticates never, so it steps on it —
// which is what makes this a differential and not a coincidence.
match load_bundle(&out) {
Err(BundleError::Container(_)) => {}
other => panic!(
"the unauthenticated loader must reach — and fail on — the corrupt payload, got {other:?}"
),
}
// The verified loader must never get there: the signature is checked over a
// few MiB of control members, and the refusal is the SIGNATURE's, not the
// payload's. If the gate were removed this would be `Container` instead.
match load_bundle_verified(&out, &b.policy) {
Err(BundleError::InvalidReleaseSignature) => {}
other => panic!(
"an unsigned bundle must be refused on its signature, before any payload is \
decompressed — got {other:?}"
),
}
}
#[test]
fn a_member_the_signed_manifest_does_not_name_is_refused_without_being_extracted() {
let mut b = valid_bundle_with_payload(MARKER.to_vec());
// A stowaway: in the archive, absent from the signed release manifest. The
// release signature stays valid — the manifest it covers is untouched, so the
// gate passes and this member's fate is decided in phase two.
b.members
.insert("payload/stowaway.bin".into(), STOWAWAY_MARKER.to_vec());
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("bundle.holger.znippy");
pack_bundle(&b.members, &out).unwrap();
// Make the stowaway a landmine. Refusing it by NAME and refusing it after
// decompressing it are two different behaviours, and this is what tells them
// apart: only a loader that never hands it to a decompressor survives.
corrupt_stored_member(&out, STOWAWAY_MARKER);
// The unauthenticated loader decompresses whatever the archive lists, so it
// steps on it.
match load_bundle(&out) {
Err(BundleError::Container(msg)) => assert!(
msg.contains("stowaway"),
"the unauthenticated loader must die on the stowaway itself: {msg:?}"
),
other => {
panic!("the differential needs extracting the stowaway to be fatal, got {other:?}")
}
}
// The verified loader refuses it BY NAME against the now-signed manifest,
// before a decompressor is handed the member at all.
match load_bundle_verified(&out, &b.policy) {
Err(BundleError::UnreferencedMember(path)) => {
assert_eq!(path, "payload/stowaway.bin");
}
other => panic!("a member absent from the signed manifest must be refused, got {other:?}"),
}
}
#[test]
fn an_archive_that_expands_far_beyond_its_own_size_is_refused_as_a_bomb() {
// Just over the ratio guard's floor, all zeros: it compresses to a few KB, so
// the expansion ratio is thousands to one — the bomb SHAPE.
let payload_len = (EXPANSION_RATIO_FLOOR_BYTES + 4 * 1024 * 1024) as usize;
let b = valid_bundle_with_payload(vec![0u8; payload_len]);
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("bomb.holger.znippy");
pack_bundle(&b.members, &out).unwrap();
let on_disk = fs::metadata(&out).unwrap().len();
assert!(
on_disk * 100 < payload_len as u64,
"the test payload must actually compress to a bomb shape (archive {on_disk} B, \
expands to {payload_len} B)"
);
match load_bundle(&out) {
Err(BundleError::LimitExceeded(msg)) => {
assert!(
msg.contains("decompression bomb"),
"the refusal must name the bomb, got {msg:?}"
);
}
// Summarised, never `{other:?}`: on failure that value is the whole
// 68 MiB member map, and a panic message must not become the bomb.
Ok(map) => panic!(
"a {payload_len}-byte expansion from {on_disk} bytes on disk must be refused, \
but {} members loaded",
map.len()
),
Err(e) => panic!("expected a bomb refusal, got {e}"),
}
}
#[test]
fn a_legitimately_large_but_poorly_compressing_bundle_is_not_mistaken_for_a_bomb() {
// The other half of the ratio guard, and the half that decides whether it can
// ship: pseudo-random bytes past the floor compress hardly at all, so the
// ratio stays near 1 and a real firmware bundle must load unharmed.
let payload_len = (EXPANSION_RATIO_FLOOR_BYTES + 4 * 1024 * 1024) as usize;
let mut payload = Vec::with_capacity(payload_len);
let mut x: u64 = 0x2545_F491_4F6C_DD1D;
while payload.len() < payload_len {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
payload.extend_from_slice(&x.to_le_bytes());
}
payload.truncate(payload_len);
let b = valid_bundle_with_payload(payload);
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("firmware.holger.znippy");
pack_bundle(&b.members, &out).unwrap();
// BOTH loaders must accept it, and `load_bundle` is the one that matters here:
// it is the loader carrying the expansion-ratio guard, so it is the only place
// a mis-calibrated ratio could turn a real firmware bundle into a refusal.
let loose = match load_bundle(&out) {
Ok(m) => m,
Err(e) => panic!("the ratio guard must not refuse incompressible payload: {e}"),
};
assert_eq!(loose["payload/firmware.bin"].len(), payload_len);
let loaded = load_bundle_verified(&out, &b.policy)
.map_err(|e| format!("{e}"))
.expect("a large, incompressible bundle is not a bomb and must load");
assert_eq!(loaded["payload/firmware.bin"].len(), payload_len);
}
#[test]
fn an_oversized_archive_is_refused_by_stat_before_it_is_ever_opened() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("huge.holger.znippy");
// A sparse file: the length the `stat` door reads is real, the disk cost is
// not. The bytes are not a znippy archive at all, which is the point — a
// refusal that names the SIZE proves nothing tried to parse it.
let f = fs::File::create(&out).unwrap();
f.set_len(MAX_BUNDLE_FILE_BYTES + 1).unwrap();
drop(f);
match load_bundle(&out) {
Err(BundleError::LimitExceeded(msg)) => {
assert!(
msg.contains("bundle file size"),
"the refusal must come from the stat door, not the parser: {msg:?}"
);
}
other => panic!("an archive over the file cap must be refused unopened, got {other:?}"),
}
}
#[test]
fn a_control_member_over_the_control_cap_cannot_bomb_the_pre_signature_phase() {
let mut b = valid_bundle_with_payload(MARKER.to_vec());
// A bomb disguised as a signature: phase one reads everything under
// `signatures/`, so that prefix is the one an attacker would aim at. Its own
// cap is what keeps phase one from becoming the thing it exists to prevent.
b.members.insert(
format!("{SIGNATURES_PREFIX}bloat.bin"),
vec![0u8; (MAX_CONTROL_BYTES + 1) as usize],
);
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("fat-control.holger.znippy");
pack_bundle(&b.members, &out).unwrap();
match load_bundle_verified(&out, &b.policy) {
Err(BundleError::LimitExceeded(msg)) => {
assert!(
msg.contains("control member"),
"the refusal must come from the control-plane cap: {msg:?}"
);
}
other => panic!("an oversized control member must be refused, got {other:?}"),
}
}