Gunnar

nordisk/heimdall

guests/verify-bundle/tests/props.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Property tests: no mutation of a real bundle, of its members, or of the
//! parameters can make the guest accept content other than what Holger signed,
//! and no input at all can make it panic past its own guard.

mod common;
use common::zwriter::RawArchive;
use common::*;

use std::sync::OnceLock;

use heimdall_guest_verify_bundle::pathsafe::validate_member_path;
use heimdall_guest_verify_bundle::znippy::Archive;
use proptest::prelude::*;

struct Golden {
    fx: Fixture,
    archive: Vec<u8>,
    params: Vec<u8>,
    accepted: Outcome,
}

fn golden() -> &'static Golden {
    static G: OnceLock<Golden> = OnceLock::new();
    G.get_or_init(|| {
        let fx = good();
        let archive = fx.pack();
        let params = fx.params();
        let accepted = verify_bundle(&archive, &params);
        assert!(accepted.is_accepted());
        Golden {
            fx,
            archive,
            params,
            accepted,
        }
    })
}

/// The integrity property: refused with nothing emitted, or accepted with
/// exactly the signed content.
fn assert_integrity(o: &Outcome) {
    let g = golden();
    if o.is_accepted() {
        assert_eq!(o.emitted.len(), g.accepted.emitted.len());
        for e in &o.emitted[1..] {
            assert_eq!(
                &e.bytes,
                member_for(&g.fx.members, &e.name),
                "accepted ALTERED content in {}",
                e.name
            );
        }
    } else {
        assert!(o.emitted.is_empty(), "a refusal emitted outputs");
        assert!(
            o.reason().unwrap().starts_with('['),
            "refusal without a code"
        );
    }
}

proptest! {
    #![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]

    #[test]
    fn any_single_byte_change_of_the_archive_never_yields_altered_content(pos in any::<prop::sample::Index>(), x in 1u8..=255) {
        let g = golden();
        let mut b = g.archive.clone();
        let i = pos.index(b.len());
        b[i] ^= x;
        assert_integrity(&verify_bundle(&b, &g.params));
    }

    #[test]
    fn several_random_byte_changes_never_yield_altered_content(edits in prop::collection::vec((any::<prop::sample::Index>(), 1u8..=255), 1..16)) {
        let g = golden();
        let mut b = g.archive.clone();
        for (pos, x) in edits {
            let i = pos.index(b.len());
            b[i] ^= x;
        }
        assert_integrity(&verify_bundle(&b, &g.params));
    }

    #[test]
    fn every_proper_prefix_of_the_archive_is_refused(cut in any::<prop::sample::Index>()) {
        let g = golden();
        let n = cut.index(g.archive.len());
        let o = verify_bundle(&g.archive[..n], &g.params);
        prop_assert!(!o.is_accepted());
    }

    #[test]
    fn bytes_inserted_anywhere_never_yield_altered_content(pos in any::<prop::sample::Index>(), ins in prop::collection::vec(any::<u8>(), 1..64)) {
        let g = golden();
        let mut b = g.archive.clone();
        let i = pos.index(b.len() + 1);
        b.splice(i..i, ins);
        assert_integrity(&verify_bundle(&b, &g.params));
    }

    #[test]
    fn arbitrary_bytes_are_never_accepted(b in prop::collection::vec(any::<u8>(), 0..4096)) {
        let g = golden();
        let o = verify_bundle(&b, &g.params);
        prop_assert!(!o.is_accepted());
        prop_assert!(o.emitted.is_empty());
    }

    #[test]
    fn arbitrary_bytes_behind_a_valid_footer_are_never_accepted(b in prop::collection::vec(any::<u8>(), 0..4096), off in any::<u64>()) {
        let g = golden();
        let mut v = b.clone();
        v.extend_from_slice(b"ZNPYMIDX");
        v.extend_from_slice(&(off % (b.len() as u64 + 1)).to_le_bytes());
        prop_assert!(!verify_bundle(&v, &g.params).is_accepted());
    }

    #[test]
    fn changing_any_member_byte_without_a_key_is_always_refused(which in any::<prop::sample::Index>(), pos in any::<prop::sample::Index>(), x in 1u8..=255) {
        let g = golden();
        let names: Vec<&String> = g.fx.members.keys().filter(|k| !g.fx.members[*k].is_empty()).collect();
        let name = names[which.index(names.len())].clone();
        let mut members = g.fx.members.clone();
        let m = members.get_mut(&name).unwrap();
        let i = pos.index(m.len());
        m[i] ^= x;
        // Rebuilt honestly at the container layer: the archive itself is sound,
        // only the signed content changed.
        let bytes = RawArchive::from_members(&members, 4096).encode();
        let o = verify_bundle(&bytes, &g.params);
        prop_assert!(!o.is_accepted(), "a change in {} was accepted", name);
    }

    #[test]
    fn a_stowaway_member_under_any_name_is_refused(name in "[a-zA-Z0-9_./\\\\-]{1,40}", body in prop::collection::vec(any::<u8>(), 0..64)) {
        let g = golden();
        let mut members = g.fx.members.clone();
        prop_assume!(!members.contains_key(&name));
        members.insert(name.clone(), body);
        let bytes = RawArchive::from_members(&members, 4096).encode();
        let o = verify_bundle(&bytes, &g.params);
        prop_assert!(!o.is_accepted(), "stowaway {:?} accepted", name);
    }

    #[test]
    fn a_mutated_parameter_document_never_yields_altered_content(pos in any::<prop::sample::Index>(), x in 1u8..=255) {
        let g = golden();
        let mut p = g.params.clone();
        let i = pos.index(p.len());
        p[i] ^= x;
        assert_integrity(&verify_bundle(&g.archive, &p));
    }

    #[test]
    fn the_index_parser_never_panics_on_arbitrary_input(b in prop::collection::vec(any::<u8>(), 0..2048)) {
        let _ = Archive::parse(&b);
    }

    #[test]
    fn an_accepted_member_path_never_escapes_its_root(s in "\\PC{0,24}") {
        if validate_member_path(&s).is_ok() {
            prop_assert!(!s.starts_with('/'));
            prop_assert!(!s.contains('\\'));
            for seg in s.split('/') {
                prop_assert!(!seg.is_empty() && seg != "." && seg != "..");
            }
            prop_assert!(!s.chars().any(|c| c.is_control()));
        }
    }
}