Gunnar

nordisk/heimdall

guests/verify-bundle/tests/openzl.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! OpenZL-compressed members (feature `openzl`). Heimdall is a defensive
//! sandbox: the guest decodes znippy's OpenZL frames itself, and only after the
//! frame header declares exactly the size the index declares. Real
//! Holger-packed bundles are accepted with the report Holger's own verifier
//! gives; tampered, truncated, lying and bomb frames are refused, and on every
//! hostile frame the guest and Holger agree on accept versus refuse.
#![cfg(feature = "openzl")]

mod common;
use common::zwriter::{openzl_frame, RawArchive};
use common::*;

use heimdall_guest_verify_bundle::znippy::Archive;
use holger_errcode as ec;

const PAYLOAD: &str = "payload/firmware.bin";

/// A compressible payload big enough for several OpenZL chunks.
fn firmware(len: usize) -> Vec<u8> {
    (0..len)
        .map(|i| b"ACS880 firmware block "[i % 22] ^ ((i / 4096) as u8 & 3))
        .collect()
}

fn big_fixture(len: usize) -> Fixture {
    build(Spec {
        payloads: vec![(
            "firmware.bin".into(),
            firmware(len),
            "application/octet-stream".into(),
        )],
        ..Spec::default()
    })
}

fn report(o: &Outcome) -> serde_json::Value {
    assert!(o.is_accepted(), "refused: {:?}", o.reason());
    serde_json::from_slice(&o.output).unwrap()
}

/// Every reported field equals what Holger's verifier says about the same file.
fn assert_same_report_as_holger(fx: &Fixture, archive: &[u8]) {
    let h = fx.holger(archive).expect("Holger accepts its own bundle");
    let r = report(&verify_bundle(archive, &fx.params()));
    assert_eq!(r["bundle_id"], h.bundle_id);
    assert_eq!(r["sequence"], h.sequence);
    assert_eq!(r["supplier"], h.supplier);
    assert_eq!(r["product"], h.product);
    assert_eq!(r["release_version"], h.release_version);
    assert_eq!(r["candidate_digest"], h.candidate_digest);
    assert_eq!(
        r["release_signer_fingerprint"],
        h.release_signer_fingerprint
    );
    assert_eq!(r["release_signer_subject"], h.release_signer_subject);
    assert_eq!(r["non_production_evidence"], h.non_production_evidence);
    let mut ga: Vec<(String, String)> = r["approvals"]
        .as_array()
        .unwrap()
        .iter()
        .map(|a| {
            (
                a["role"].as_str().unwrap().into(),
                a["fingerprint"].as_str().unwrap().into(),
            )
        })
        .collect();
    let mut ha: Vec<(String, String)> = h
        .approvals
        .iter()
        .map(|a| (a.role.clone(), a.fingerprint.clone()))
        .collect();
    ga.sort();
    ha.sort();
    assert_eq!(ga, ha);
}

/// The guest and Holger agree: both accept (and the guest emits the original
/// bytes) or both refuse (and the guest says why with a stable code).
fn assert_agrees_with_holger(fx: &Fixture, archive: &[u8], what: &str) -> Outcome {
    let o = verify_bundle(archive, &fx.params());
    let h = fx.holger(archive);
    assert_eq!(
        o.is_accepted(),
        h.is_ok(),
        "{what}: guest {:?} vs Holger {:?}",
        o.reason(),
        h.as_ref().err()
    );
    if o.is_accepted() {
        for e in &o.emitted[1..] {
            assert_eq!(&e.bytes, member_for(&fx.members, &e.name), "{what}");
        }
    } else {
        code_of(&o);
    }
    o
}

#[test]
fn a_bundle_holger_packs_with_openzl_members_is_accepted_with_holgers_report() {
    let fx = big_fixture(300_000);
    let archive = pack_holger(&fx.members);
    assert!(
        holger_compressed_rows(&archive) > 0,
        "Holger's packer compressed nothing; the test would prove nothing"
    );
    assert_same_report_as_holger(&fx, &archive);
}

#[test]
fn an_openzl_bundle_emits_exactly_the_original_member_bytes() {
    let fx = big_fixture(1_000_000);
    let o = verify_bundle(&pack_holger(&fx.members), &fx.params());
    assert!(o.is_accepted(), "{:?}", o.reason());
    for e in &o.emitted[1..] {
        assert_eq!(&e.bytes, member_for(&fx.members, &e.name), "{}", e.name);
    }
}

#[test]
fn every_member_in_multi_chunk_openzl_frames_is_accepted_like_holger() {
    let fx = big_fixture(100_000);
    let a = RawArchive::from_members_openzl(&fx.members, 8192);
    assert!(a.rows.iter().filter(|r| r.compressed).count() > 10);
    assert_same_report_as_holger(&fx, &a.encode());
}

#[test]
fn a_frame_in_openzls_newest_format_decodes_too() {
    let fx = good();
    let mut a = RawArchive::from_members_openzl(&fx.members, 4096);
    let plain = member_for(&fx.members, "payload+2ffirmware.bin").to_vec();
    let frame = latest_format_frame(&plain);
    let r = a.row_mut(PAYLOAD).clone();
    let off = a.blobs.len() as u64;
    a.blobs.extend_from_slice(&frame);
    let row = a.row_mut(PAYLOAD);
    row.blob_offset = off;
    row.blob_size = frame.len() as u64;
    assert_eq!(row.uncompressed_size, r.uncompressed_size);
    assert!(verify_bundle(&a.encode(), &fx.params()).is_accepted());
}

#[test]
fn a_bit_flip_anywhere_in_an_openzl_frame_never_makes_the_guest_and_holger_disagree() {
    let fx = big_fixture(20_000);
    let clean = RawArchive::from_members_openzl(&fx.members, 1 << 20);
    let len = clean.clone().blob_mut(PAYLOAD).len();
    let mut refused = 0;
    for i in 0..len {
        let mut a = clean.clone();
        a.blob_mut(PAYLOAD)[i] ^= 0x01 << (i % 8);
        let o = assert_agrees_with_holger(&fx, &a.encode(), &format!("flip at {i}"));
        if !o.is_accepted() {
            refused += 1;
            assert_eq!(code_of(&o), ec::UPDATE_CONTAINER.code, "{:?}", o.reason());
        }
    }
    assert!(
        refused * 10 >= len * 9,
        "only {refused} of {len} flips refused"
    );
}

#[test]
fn a_truncated_openzl_frame_is_refused_like_holger() {
    let fx = big_fixture(20_000);
    let clean = RawArchive::from_members_openzl(&fx.members, 1 << 20);
    let len = clean.clone().row_mut(PAYLOAD).blob_size;
    for keep in [0, 1, 4, 8, 16, 32, len / 2, len - 1] {
        let mut a = clean.clone();
        a.row_mut(PAYLOAD).blob_size = keep;
        let o = assert_agrees_with_holger(&fx, &a.encode(), &format!("keep {keep}"));
        assert_eq!(code_of(&o), ec::UPDATE_CONTAINER.code, "{:?}", o.reason());
    }
}

#[test]
fn a_frame_declaring_more_or_fewer_bytes_than_the_index_is_refused_before_decoding() {
    // The reader alone (the pipeline already stops a member-size lie at the
    // release manifest's size, holger-update-7, before reading it).
    let fx = good();
    let clean = RawArchive::from_members_openzl(&fx.members, 1 << 20);
    let real = clean.clone().row_mut(PAYLOAD).uncompressed_size;
    for lie in [1, real - 1, real + 1, real * 2] {
        let mut a = clean.clone();
        a.row_mut(PAYLOAD).uncompressed_size = lie;
        let bytes = a.encode();
        let z = Archive::parse(&bytes).unwrap();
        let e = z.read(PAYLOAD, u64::MAX).unwrap_err();
        assert_eq!(e.code().as_string(), ec::UPDATE_CONTAINER.code, "{e}");
        assert!(e.to_string().contains("but the index declares"), "{e}");
        let o = verify_bundle(&bytes, &fx.params());
        assert_refused(&o, ec::UPDATE_DIGEST_MISMATCH.code, "size digest mismatch");
    }
}

#[test]
fn a_bomb_frame_behind_a_small_declared_size_is_refused_without_allocating_it() {
    // 256 MiB of zeros in a frame of a few KiB; the index says 11 bytes.
    let fx = good();
    let bomb = openzl_frame(&vec![0u8; 256 << 20]);
    assert!(bomb.len() < 1 << 20, "{}", bomb.len());
    let mut a = RawArchive::from_members_openzl(&fx.members, 1 << 20);
    let off = a.blobs.len() as u64;
    a.blobs.extend_from_slice(&bomb);
    let row = a.row_mut(PAYLOAD);
    row.blob_offset = off;
    row.blob_size = bomb.len() as u64;
    let o = verify_bundle(&a.encode(), &fx.params());
    assert_refused(
        &o,
        ec::UPDATE_CONTAINER.code,
        "declares 268435456 bytes but the index declares",
    );
}

#[test]
fn an_honest_bomb_is_refused_by_the_expansion_ratio_before_anything_is_decoded() {
    let fx = good();
    let bomb = openzl_frame(&vec![0u8; 256 << 20]);
    let mut a = RawArchive::from_members_openzl(&fx.members, 1 << 20);
    let off = a.blobs.len() as u64;
    a.blobs.extend_from_slice(&bomb);
    let row = a.row_mut(PAYLOAD);
    row.blob_offset = off;
    row.blob_size = bomb.len() as u64;
    row.uncompressed_size = 256 << 20;
    let o = verify_bundle(&a.encode(), &fx.params());
    assert_refused(&o, ec::UPDATE_LIMIT_EXCEEDED.code, "decompression bomb");
}

#[test]
fn stored_bytes_flagged_compressed_are_refused_as_not_an_openzl_frame() {
    let fx = good();
    let mut a = RawArchive::from_members(&fx.members, 4096);
    a.row_mut(PAYLOAD).compressed = true;
    let o = verify_bundle(&a.encode(), &fx.params());
    assert_refused(&o, ec::UPDATE_CONTAINER.code, "not an OpenZL frame");
}

#[test]
fn garbage_frames_of_every_small_length_are_refused_never_panic() {
    let fx = good();
    let clean = RawArchive::from_members_openzl(&fx.members, 1 << 20);
    let mut seed = 0x9e37_79b9_7f4a_7c15u64;
    for n in 0..200usize {
        let mut a = clean.clone();
        let mut g: Vec<u8> = (0..n)
            .map(|_| {
                seed ^= seed << 13;
                seed ^= seed >> 7;
                seed ^= seed << 17;
                seed as u8
            })
            .collect();
        // Half of them keep a real frame's first bytes, to get past the magic.
        if n % 2 == 0 {
            let real = clean.clone().blob_mut(PAYLOAD).to_vec();
            let k = n.min(real.len()).min(12);
            g[..k].copy_from_slice(&real[..k]);
        }
        let off = a.blobs.len() as u64;
        a.blobs.extend_from_slice(&g);
        let row = a.row_mut(PAYLOAD);
        row.blob_offset = off;
        row.blob_size = n as u64;
        let o = verify_bundle(&a.encode(), &fx.params());
        assert!(!o.is_accepted(), "garbage of {n} bytes accepted");
        assert_eq!(code_of(&o), ec::UPDATE_CONTAINER.code, "{:?}", o.reason());
    }
}

/// How many chunk rows of a Holger-packed archive are compressed (znippy's own
/// reader is the judge).
fn holger_compressed_rows(archive: &[u8]) -> usize {
    let a = Archive::parse(archive).unwrap();
    a.names()
        .filter_map(|n| a.entry(n))
        .flat_map(|e| e.chunks.iter())
        .filter(|c| c.compressed)
        .count()
}

/// A frame in the newest format OpenZL 0.3.0 writes (27), not znippy's pin.
fn latest_format_frame(input: &[u8]) -> Vec<u8> {
    use openzl_sys_rs::*;
    let latest = unsafe { ZL_getDefaultEncodingVersion() } as i32;
    let mut cctx = ZlCCtx::new().unwrap();
    cctx.set_parameter(ZL_CParam_ZL_CParam_formatVersion, latest)
        .unwrap();
    cctx.set_parameter(ZL_CParam_ZL_CParam_compressionLevel, 3)
        .unwrap();
    let mut frame = vec![0u8; zl_compress_bound(input.len())];
    let n = cctx.compress(&mut frame, input).unwrap();
    frame.truncate(n);
    let fmt = unsafe { ZL_getFormatVersionFromFrame(frame.as_ptr().cast(), frame.len()) };
    assert_eq!(zl_report_value(fmt), latest as usize);
    frame
}

/// The fuzz find (oden, 11 s): a 36-byte frame whose "constant" codec asks
/// OpenZL for a ~2^64-byte stream (DI_constant_typed -> STREAM_reserve) — an
/// allocation that does not come from the header size the reader checks.
/// malloc returns NULL, OpenZL reports an error, the member is refused.
const CONSTANT_STREAM_ALLOC: &[u8] =
    include_bytes!("../fuzz/seeds/openzl_frame/regress-constant-stream-alloc");

#[test]
fn the_fuzz_found_constant_stream_allocation_is_an_openzl_error_not_a_crash() {
    // The header declares 0 bytes, so the size check passes; the allocation
    // OpenZL then asks for fails (NULL) and it reports an error.
    assert_eq!(
        heimdall_guest_verify_bundle::zl::frame_size(CONSTANT_STREAM_ALLOC),
        Ok(0)
    );
    let e = heimdall_guest_verify_bundle::zl::decode(CONSTANT_STREAM_ALLOC, &mut []).unwrap_err();
    assert!(e.0.starts_with("Allocation"), "{e:?}");
    heimdall_guest_verify_bundle::fuzz_api::openzl_frame(CONSTANT_STREAM_ALLOC);
}

#[test]
fn the_fuzz_found_frame_as_a_zero_length_tail_chunk_is_refused_like_holger() {
    let fx = good();
    let mut a = RawArchive::from_members(&fx.members, 1 << 20);
    a.append_compressed_tail(PAYLOAD, CONSTANT_STREAM_ALLOC);
    let o = assert_agrees_with_holger(&fx, &a.encode(), "constant-stream frame");
    assert_refused(&o, ec::UPDATE_CONTAINER.code, "does not decode");
}