Gunnar

nordisk/heimdall

crates/heimdall-core/tests/proptests.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! Property tests: round trips, determinism and "no mutation ever verifies".
//! Heimdall is a defensive sandbox.

mod common;
use common::*;
use heimdall_core::*;
use proptest::prelude::*;

fn arb_name() -> impl Strategy<Value = String> {
    "[a-zA-Z0-9._åäö-]{1,40}".prop_filter("valid output name", |s| validate_output_name(s).is_ok())
}

fn arb_verdict() -> impl Strategy<Value = Verdict> {
    prop_oneof![
        Just(Verdict::Accepted),
        ("[a-z0-9-]{0,16}", ".{0,64}").prop_map(|(code, reason)| Verdict::Refused { code, reason }),
    ]
}

fn arb_decision() -> impl Strategy<Value = Decision> {
    prop_oneof![
        Just(Decision::Allowed),
        Just(Decision::Refused),
        Just(Decision::Killed)
    ]
}

fn arb_event() -> impl Strategy<Value = BrokerEvent> {
    (
        any::<u64>(),
        ".{0,16}",
        prop::collection::vec(".{0,16}", 0..4),
        arb_decision(),
    )
        .prop_map(|(at_ms, call, args, decision)| BrokerEvent {
            at_ms,
            call,
            args,
            decision,
        })
}

fn arb_result() -> impl Strategy<Value = JobResult> {
    (
        any::<[u8; 16]>(),
        any::<[u8; 32]>(),
        arb_verdict(),
        prop::collection::btree_map(arb_name(), (any::<[u8; 32]>(), any::<u64>()), 0..6),
        prop::collection::vec(arb_event(), 0..6),
        (
            "[a-z0-9-]{0,12}",
            any::<[bool; 5]>(),
            0u8..3,
            any::<(Option<i32>, Option<i32>)>(),
            0u8..5,
        ),
        any::<i64>(),
        any::<u32>(),
    )
        .prop_map(
            |(j, n, verdict, outs, events, features, started, dur)| JobResult {
                job_id: JobId(j),
                nonce: Nonce(n),
                verdict,
                outputs: outs
                    .into_iter()
                    .map(|(name, (h, bytes))| Output {
                        name,
                        sha256: Sha256Digest(h),
                        bytes,
                    })
                    .collect(),
                exit: ExitInfo {
                    code: features.3 .0,
                    signal: features.3 .1,
                    killed: [
                        None,
                        Some(KillReason::Wall),
                        Some(KillReason::Memory),
                        Some(KillReason::Pids),
                        Some(KillReason::Fuel),
                    ][features.4 as usize],
                },
                started_unix: started / 2,
                finished_unix: started / 2 + dur as i64,
                runner: Features {
                    runner: features.0.clone(),
                    container: features.1[0],
                    seccomp: match features.2 {
                        0 => Protection::None,
                        1 => Protection::Default,
                        _ => Protection::Generated {
                            profile_digest: features.0,
                        },
                    },
                    landlock: features.1[1],
                    broker: features.1[2],
                    wasm: features.1[3],
                    image_verified_each_start: features.1[4],
                },
                events,
            },
        )
}

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

    #[test]
    fn prop_encode_then_decode_is_identity(r in arb_result()) {
        prop_assert_eq!(decode_result(&encode_result(&r)).unwrap(), r);
    }

    #[test]
    fn prop_encoding_is_deterministic(r in arb_result()) {
        prop_assert_eq!(encode_result(&r), encode_result(&r.clone()));
    }

    #[test]
    fn prop_distinct_results_have_distinct_encodings(a in arb_result(), b in arb_result()) {
        prop_assume!(a != b);
        prop_assert_ne!(encode_result(&a), encode_result(&b));
    }

    #[test]
    fn prop_sign_then_verify_round_trips(r in arb_result()) {
        let wire = key_a().sign(r.clone()).unwrap().to_bytes();
        prop_assert_eq!(verifier_a().verify(&wire).unwrap().result, r);
    }

    #[test]
    fn prop_wire_from_bytes_to_bytes_is_identity(r in arb_result()) {
        let s = key_a().sign(r).unwrap();
        let w = s.to_bytes();
        prop_assert_eq!(SignedResult::from_bytes(&w).unwrap().to_bytes(), w);
    }

    #[test]
    fn prop_json_round_trip_still_verifies(r in arb_result()) {
        let s = key_a().sign(r).unwrap();
        let back: SignedResult = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
        prop_assert!(verifier_a().verify_signed(&back).is_ok());
    }

    #[test]
    fn prop_any_single_bit_flip_is_refused(r in arb_result(), pos in any::<prop::sample::Index>(), bit in 0u8..8) {
        let mut w = key_a().sign(r).unwrap().to_bytes();
        let i = pos.index(w.len());
        w[i] ^= 1 << bit;
        prop_assert!(verifier_a().verify(&w).is_err());
    }

    #[test]
    fn prop_any_byte_overwrite_is_refused(r in arb_result(), pos in any::<prop::sample::Index>(), val in any::<u8>()) {
        let mut w = key_a().sign(r).unwrap().to_bytes();
        let i = pos.index(w.len());
        prop_assume!(w[i] != val);
        w[i] = val;
        prop_assert!(verifier_a().verify(&w).is_err());
    }

    #[test]
    fn prop_any_truncation_is_refused(r in arb_result(), cut in any::<prop::sample::Index>()) {
        let w = key_a().sign(r).unwrap().to_bytes();
        let n = cut.index(w.len());
        prop_assert!(verifier_a().verify(&w[..n]).is_err());
    }

    #[test]
    fn prop_any_appended_bytes_are_refused(r in arb_result(), tail in prop::collection::vec(any::<u8>(), 1..16)) {
        let mut w = key_a().sign(r).unwrap().to_bytes();
        w.extend_from_slice(&tail);
        prop_assert!(verifier_a().verify(&w).is_err());
    }

    #[test]
    fn prop_removing_one_inner_byte_is_refused(r in arb_result(), pos in any::<prop::sample::Index>()) {
        let mut w = key_a().sign(r).unwrap().to_bytes();
        let i = pos.index(w.len());
        w.remove(i);
        prop_assert!(verifier_a().verify(&w).is_err());
    }

    #[test]
    fn prop_other_key_is_always_refused(r in arb_result()) {
        let w = key_b().sign(r).unwrap().to_bytes();
        prop_assert!(
            matches!(
                verifier_a().verify(&w),
                Err(VerifyError::WrongKey { .. })
            ),
            "other key not refused as wrong key"
        );
    }

    #[test]
    fn prop_accept_then_replay_is_refused(r in arb_result()) {
        let t = JobTicket { job_id: r.job_id, nonce: r.nonce };
        let w = key_a().sign(r).unwrap().to_bytes();
        let mut v = verifier_a();
        prop_assert!(v.accept(&w, &t).is_ok());
        prop_assert!(matches!(v.accept(&w, &t), Err(VerifyError::Replayed(_))));
    }

    #[test]
    fn prop_decoder_never_panics_on_random_bytes(b in prop::collection::vec(any::<u8>(), 0..512)) {
        let _ = decode_result(&b);
        let _ = SignedResult::from_bytes(&b);
        let _ = verifier_a().verify(&b);
    }

    #[test]
    fn prop_decoder_never_panics_on_magic_prefixed_random_bytes(b in prop::collection::vec(any::<u8>(), 0..512)) {
        let mut w = WIRE_MAGIC.to_vec();
        w.extend_from_slice(&1u16.to_be_bytes());
        w.extend_from_slice(&b);
        let _ = SignedResult::from_bytes(&w);
        prop_assert!(verifier_a().verify(&w).is_err());
    }

    #[test]
    fn prop_pinned_key_text_never_panics(s in ".{0,200}") {
        let _ = PinnedKey::from_text(&s);
    }

    #[test]
    fn prop_generated_names_pass_and_slashed_names_fail(a in arb_name(), b in arb_name()) {
        prop_assert!(validate_output_name(&a).is_ok());
        let joined = format!("{a}/{b}");
        prop_assert!(validate_output_name(&joined).is_err());
    }

    #[test]
    fn prop_check_output_bytes_accepts_own_and_refuses_changed(
        data in prop::collection::vec(any::<u8>(), 0..256),
        pos in any::<prop::sample::Index>(),
    ) {
        let e = Output::of("f", &data);
        prop_assert!(check_output_bytes(&e, &data).is_ok());
        if !data.is_empty() {
            let mut d = data.clone();
            let i = pos.index(d.len());
            d[i] ^= 0x01;
            prop_assert!(check_output_bytes(&e, &d).is_err());
        }
    }
}