// SPDX-License-Identifier: AGPL-3.0-only
//! The znippy container layer: every hostile index shape is refused before a
//! byte of content is believed — traversal names, duplicates, lying sizes,
//! overlapping or gapped chunks, broken checksums, bomb ratios, odd sections,
//! truncation — and honest re-orderings change nothing.
mod common;
use common::zwriter::{manifest_stream, RawArchive, MAGIC};
use common::*;
use heimdall_guest_verify_bundle::znippy::Archive;
use holger_errcode as ec;
const ARCHIVE: &str = "heimdall-bundle-2";
const CODEC: &str = "heimdall-bundle-3";
fn raw(fx: &Fixture) -> RawArchive {
RawArchive::from_members(&fx.members, 4096)
}
fn run(fx: &Fixture, a: &RawArchive) -> Outcome {
verify_bundle(&a.encode(), &fx.params())
}
#[test]
fn the_test_writer_produces_archives_znippys_own_reader_opens_and_verifies() {
let fx = good();
let bytes = RawArchive::from_members(&fx.members, 7).encode();
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("w.znippy");
std::fs::write(&p, &bytes).unwrap();
let z = znippy_common::ZnippyArchive::open(&p).unwrap();
for (name, want) in &fx.members {
assert_eq!(&z.extract_file_verified(name).unwrap(), want, "{name}");
}
}
#[test]
fn a_bundle_split_into_seven_byte_chunks_is_accepted() {
let fx = good();
let o = verify_bundle(
&RawArchive::from_members(&fx.members, 7).encode(),
&fx.params(),
);
assert!(o.is_accepted(), "{:?}", o.reason());
}
#[test]
fn znippys_real_writer_splits_a_big_member_into_several_chunks_and_the_guest_reassembles_them() {
let big: Vec<u8> = (0..20 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect();
let mut spec = Spec::default();
spec.payloads.push((
"big.bin".into(),
big.clone(),
"application/octet-stream".into(),
));
let fx = build(spec);
let bytes = fx.pack();
let a = Archive::parse(&bytes).unwrap();
let e = a.entry("payload/big.bin").unwrap();
assert!(
e.chunks.len() > 1,
"znippy stored it as {} chunk(s)",
e.chunks.len()
);
assert_eq!(a.read("payload/big.bin", u64::MAX).unwrap(), big);
assert!(verify_bundle(&bytes, &fx.params()).is_accepted());
}
#[test]
fn rows_in_reverse_order_change_nothing() {
let fx = good();
let mut a = raw(&fx);
let honest = run(&fx, &a);
a.rows.reverse();
assert_eq!(run(&fx, &a), honest);
assert!(honest.is_accepted());
}
#[test]
fn blobs_laid_out_in_reverse_order_change_nothing() {
let fx = good();
let honest = run(&fx, &raw(&fx));
let rev: std::collections::BTreeMap<String, Vec<u8>> = fx.members.clone();
// Re-lay the blob region back to front, then point every row at its new place.
let mut a = RawArchive::from_members(&rev, 4096);
let mut blobs = Vec::new();
for r in a.rows.iter_mut().rev() {
let piece = fx.members[&r.path]
[r.fdata_offset as usize..(r.fdata_offset + r.blob_size) as usize]
.to_vec();
r.blob_offset = blobs.len() as u64;
blobs.extend_from_slice(&piece);
}
a.blobs = blobs;
assert_eq!(run(&fx, &a), honest);
}
fn renamed(fx: &Fixture, from: &str, to: &str) -> Outcome {
let mut a = raw(fx);
a.row_mut(from).path = to.into();
// Also break its checksum: the NAME must be refused before the bytes are read.
a.row_mut(to).checksum = vec![0xAA; 32];
run(fx, &a)
}
#[test]
fn every_path_traversal_name_in_the_index_is_refused_before_any_blob_is_read() {
let fx = good();
for evil in [
"../evil",
"payload/../../etc/passwd",
"/etc/passwd",
"payload/./firmware.bin",
"payload//firmware.bin",
"payload\\firmware.bin",
"C:/Windows/system32/x",
"payload/fw\0.bin",
"payload/fw\n.bin",
"payload/",
"",
] {
let o = renamed(&fx, "payload/firmware.bin", evil);
assert_refused(&o, ec::UPDATE_UNSAFE_PATH.code, "");
}
}
#[test]
fn an_overlong_member_name_is_refused() {
let fx = good();
let o = renamed(
&fx,
"payload/firmware.bin",
&format!("payload/{}", "a".repeat(5000)),
);
assert_refused(&o, ec::UPDATE_UNSAFE_PATH.code, "longer than");
}
#[test]
fn a_duplicated_index_entry_is_refused() {
let fx = good();
let mut a = raw(&fx);
let dup = a
.rows
.iter()
.find(|r| r.path == "payload/firmware.bin")
.unwrap()
.clone();
a.rows.push(dup);
assert_refused(
&run(&fx, &a),
ec::UPDATE_UNSAFE_PATH.code,
"duplicate index entry",
);
}
#[test]
fn two_members_that_differ_only_in_case_are_refused() {
let fx = good();
let mut a = raw(&fx);
let mut twin = a
.rows
.iter()
.find(|r| r.path == "payload/firmware.bin")
.unwrap()
.clone();
twin.path = "payload/FIRMWARE.bin".into();
a.rows.push(twin);
assert_refused(&run(&fx, &a), ec::UPDATE_UNSAFE_PATH.code, "case-folding");
}
#[test]
fn overlapping_chunks_of_one_file_are_refused() {
let fx = good();
let mut a = RawArchive::from_members(&fx.members, 4);
let r = a
.rows
.iter_mut()
.find(|r| r.path == "payload/firmware.bin" && r.chunk_seq == 1)
.unwrap();
r.fdata_offset = 2;
assert_refused(&run(&fx, &a), ARCHIVE, "do not tile");
}
#[test]
fn a_gap_between_chunks_is_refused() {
let fx = good();
let mut a = RawArchive::from_members(&fx.members, 4);
let r = a
.rows
.iter_mut()
.find(|r| r.path == "payload/firmware.bin" && r.chunk_seq == 2)
.unwrap();
r.fdata_offset += 1;
assert_refused(&run(&fx, &a), ARCHIVE, "do not tile");
}
#[test]
fn a_chunk_whose_bytes_do_not_match_its_blake3_is_refused() {
let fx = good();
let mut a = raw(&fx);
let off = a.row_mut("payload/firmware.bin").blob_offset as usize;
a.blobs[off] ^= 0x40;
assert_refused(&run(&fx, &a), ec::UPDATE_CONTAINER.code, "blake3");
}
#[test]
fn a_zero_checksum_on_a_non_empty_chunk_is_refused() {
let fx = good();
let mut a = raw(&fx);
a.row_mut("payload/firmware.bin").checksum = vec![0; 32];
assert_refused(&run(&fx, &a), ec::UPDATE_CONTAINER.code, "blake3");
}
#[test]
fn a_tampered_chunk_with_a_recomputed_checksum_is_still_refused_by_the_signed_digest() {
let fx = good();
let mut a = raw(&fx);
let row = a.row_mut("payload/firmware.bin").clone();
let off = row.blob_offset as usize;
a.blobs[off] ^= 0x40;
let piece = a.blobs[off..off + row.blob_size as usize].to_vec();
a.row_mut("payload/firmware.bin").checksum = blake3::hash(&piece).as_bytes().to_vec();
assert_refused(
&run(&fx, &a),
ec::UPDATE_DIGEST_MISMATCH.code,
"payload/firmware.bin",
);
}
#[test]
fn a_compressed_flag_over_stored_bytes_is_refused_by_the_codec_layer() {
// With `openzl` the bytes are not an OpenZL frame (holger-update container
// error, as Holger's extract fails); without it, no decoder (by name).
let fx = good();
let mut a = raw(&fx);
a.row_mut("payload/firmware.bin").compressed = true;
if cfg!(feature = "openzl") {
assert_refused(
&run(&fx, &a),
ec::UPDATE_CONTAINER.code,
"not an OpenZL frame",
);
} else {
assert_refused(&run(&fx, &a), CODEC, "OpenZL");
}
}
#[test]
fn a_blob_pointing_past_the_end_of_the_file_is_refused() {
let fx = good();
let mut a = raw(&fx);
a.row_mut("payload/firmware.bin").blob_offset = 1 << 40;
assert_refused(&run(&fx, &a), ARCHIVE, "outside the blob region");
}
#[test]
fn a_blob_pointing_into_the_index_region_is_refused() {
let fx = good();
let mut a = raw(&fx);
let end = a.blobs.len() as u64;
let r = a.row_mut("payload/firmware.bin");
r.blob_offset = end; // the data sub-index starts here
r.blob_size = 1 << 20;
r.uncompressed_size = 1 << 20;
assert_refused(&run(&fx, &a), ARCHIVE, "outside the blob region");
}
#[test]
fn a_stored_chunk_whose_sizes_disagree_is_refused() {
let fx = good();
let mut a = raw(&fx);
a.row_mut("payload/firmware.bin").uncompressed_size += 1;
assert_refused(&run(&fx, &a), ARCHIVE, "blob_size");
}
#[test]
fn a_bomb_shaped_index_is_refused_from_its_declared_sizes_alone() {
let fx = good();
let mut a = raw(&fx);
let r = a.row_mut("payload/firmware.bin");
r.compressed = true;
r.uncompressed_size = 10 << 30; // 10 GiB from 11 bytes
assert_refused(
&run(&fx, &a),
ec::UPDATE_LIMIT_EXCEEDED.code,
"decompression bomb",
);
}
#[test]
fn a_control_member_declaring_more_than_sixteen_mib_is_refused_before_it_is_read() {
let fx = good();
let mut a = raw(&fx);
let r = a.row_mut("core.json");
r.compressed = true;
r.uncompressed_size = 17 << 20;
assert_refused(&run(&fx, &a), ec::UPDATE_LIMIT_EXCEEDED.code, "core.json");
}
#[test]
fn a_payload_whose_index_size_disagrees_with_the_signed_size_is_refused_before_it_is_read() {
let fx = good();
let mut a = raw(&fx);
let r = a.row_mut("payload/firmware.bin");
r.compressed = true;
r.uncompressed_size = 1 << 20;
assert_refused(&run(&fx, &a), ec::UPDATE_DIGEST_MISMATCH.code, "size");
}
#[test]
fn more_than_one_hundred_thousand_members_are_refused() {
let fx = good();
let mut a = raw(&fx);
let tmpl = a.rows[0].clone();
for i in 0..100_001u32 {
let mut r = tmpl.clone();
r.path = format!("payload/m/{i}");
r.blob_size = 0;
r.uncompressed_size = 0;
r.checksum = vec![0; 32];
a.rows.push(r);
}
assert_refused(
&run(&fx, &a),
ec::UPDATE_LIMIT_EXCEEDED.code,
"member count",
);
}
#[test]
fn an_index_without_a_checksum_column_is_refused() {
let fx = good();
let mut a = raw(&fx);
a.omit_checksum = true;
assert_refused(&run(&fx, &a), ARCHIVE, "missing column checksum");
}
#[test]
fn a_checksum_column_of_the_wrong_width_is_refused() {
let fx = good();
let mut a = raw(&fx);
a.checksum_width = 16;
assert_refused(&run(&fx, &a), ARCHIVE, "not 32");
}
#[test]
fn a_newer_znippy_format_version_is_refused() {
let fx = good();
let mut a = raw(&fx);
a.version = Some("4".into());
assert_refused(&run(&fx, &a), ARCHIVE, "newer than this reader");
a.version = Some("three".into());
assert_refused(&run(&fx, &a), ARCHIVE, "invalid znippy format version");
}
#[test]
fn a_delta_map_section_is_refused_by_name() {
let fx = good();
let mut a = raw(&fx);
a.extra_sections
.push(("__znippy_delta__".into(), b"whatever".to_vec()));
assert_refused(&run(&fx, &a), CODEC, "delta");
}
#[test]
fn reserved_sections_are_skipped_exactly_as_znippy_skips_them() {
let fx = good();
let mut a = raw(&fx);
a.extra_sections
.push(("__znippy_lookup__".into(), b"not arrow at all".to_vec()));
a.extra_sections
.push(("__znippy_trie__".into(), vec![0xFF; 64]));
let o = run(&fx, &a);
assert!(o.is_accepted(), "{:?}", o.reason());
}
#[test]
fn a_data_section_that_is_not_arrow_is_refused() {
let fx = good();
let mut a = raw(&fx);
a.extra_sections
.push(("extra-data".into(), b"not arrow at all".to_vec()));
assert_refused(&run(&fx, &a), ARCHIVE, "data sub-index");
}
#[test]
fn an_archive_without_the_footer_magic_is_refused() {
let fx = good();
let mut b = raw(&fx).encode();
let n = b.len();
b[n - 16] = b'X';
assert_refused(&verify_bundle(&b, &fx.params()), ARCHIVE, "ZNPYMIDX");
}
#[test]
fn a_manifest_offset_past_the_footer_is_refused() {
let fx = good();
let mut b = raw(&fx).encode();
let n = b.len();
b[n - 8..].copy_from_slice(&(n as u64).to_le_bytes());
assert_refused(&verify_bundle(&b, &fx.params()), ARCHIVE, "manifest offset");
}
#[test]
fn a_manifest_that_is_garbage_is_refused() {
let fx = good();
let mut out = raw(&fx).blobs.clone();
let off = out.len() as u64;
out.extend_from_slice(b"garbage manifest bytes");
out.extend_from_slice(MAGIC);
out.extend_from_slice(&off.to_le_bytes());
assert_refused(&verify_bundle(&out, &fx.params()), ARCHIVE, "manifest");
}
#[test]
fn a_manifest_naming_a_section_outside_the_file_is_refused() {
let fx = good();
let mut out = Vec::new();
let manifest = manifest_stream(&[("data".into(), 0, 1 << 40, 1)]);
out.extend_from_slice(&manifest);
out.extend_from_slice(MAGIC);
out.extend_from_slice(&0u64.to_le_bytes());
assert_refused(&verify_bundle(&out, &fx.params()), ARCHIVE, "outside");
}
#[test]
fn every_truncation_of_a_real_archive_is_refused() {
let fx = good();
let b = fx.pack();
let p = fx.params();
for cut in [
0,
1,
8,
15,
16,
17,
b.len() / 2,
b.len() - 17,
b.len() - 16,
b.len() - 1,
] {
let o = verify_bundle(&b[..cut], &p);
assert!(!o.is_accepted(), "a {cut}-byte prefix was accepted");
assert!(o.emitted.is_empty());
}
}
#[test]
fn removing_one_byte_from_the_middle_of_a_real_archive_is_refused() {
let fx = good();
let mut b = fx.pack();
b.remove(b.len() / 3);
assert!(!verify_bundle(&b, &fx.params()).is_accepted());
}
#[test]
fn input_shorter_than_the_footer_is_refused() {
let fx = good();
assert_refused(
&verify_bundle(b"ZNPY", &fx.params()),
ARCHIVE,
"16-byte footer",
);
assert_refused(&verify_bundle(b"", &fx.params()), ARCHIVE, "16-byte footer");
}
/// Regression (found by `props.rs`): arrow-ipc allocates a message's
/// `bodyLength` before reading it, so a header claiming a 1 TiB body aborted
/// the process. The guest now walks the framing first and refuses it.
#[test]
fn an_index_message_claiming_a_terabyte_body_is_refused_without_allocating_it() {
let fx = good();
let mut a = raw(&fx);
let honest = a.encode();
let data_start = a.blobs.len();
// Locate the record-batch message of the data sub-index and patch its bodyLength.
let stream = &honest[data_start..];
let mut pos = 0usize;
let mut patched = None;
while patched.is_none() {
let mut len = u32::from_le_bytes(stream[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if len == 0xFFFF_FFFF {
len = u32::from_le_bytes(stream[pos..pos + 4].try_into().unwrap()) as usize;
pos += 4;
}
let meta = &stream[pos..pos + len];
let msg = arrow_ipc::root_as_message(meta).unwrap();
let body = msg.bodyLength();
if msg.header_type() == arrow_ipc::MessageHeader::RecordBatch {
let needle = body.to_le_bytes();
let at = meta.windows(8).position(|w| w == needle).unwrap();
patched = Some((data_start + pos + at, body));
}
pos += len + body as usize;
}
let (at, _) = patched.unwrap();
let mut evil = honest.clone();
evil[at..at + 8].copy_from_slice(&(1i64 << 40).to_le_bytes());
let o = verify_bundle(&evil, &fx.params());
assert_refused(&o, ARCHIVE, "more than the stream holds");
a.rows.clear(); // keep `a` used
}