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());
}
}
}