nordisk/heimdall
crates/heimdall-core/tests/codec.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The canonical v1 encoding of a JobResult: deterministic, strict, bounded.
//! Heimdall is a defensive sandbox.
mod common;
use common::*;
use heimdall_core::*;
#[test]
fn encoding_round_trips_the_sample_result() {
let r = sample_result();
assert_eq!(decode_result(&encode_result(&r)).unwrap(), r);
}
#[test]
fn encoding_is_deterministic_for_equal_values() {
assert_eq!(
encode_result(&sample_result()),
encode_result(&sample_result())
);
}
fn min_result() -> JobResult {
JobResult {
job_id: JobId([1; 16]),
nonce: Nonce([2; 32]),
verdict: Verdict::Accepted,
outputs: vec![],
exit: ExitInfo::default(),
started_unix: 5,
finished_unix: 6,
runner: min_features(),
events: vec![],
}
}
/// Encoded `min_features()`: empty runner string, six zero bytes.
fn min_features_bytes() -> Vec<u8> {
let mut w = 0u32.to_be_bytes().to_vec();
w.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
w
}
#[test]
fn encoding_of_a_minimal_accepted_result_is_pinned_byte_for_byte() {
// Golden vector: a change here is a format change and needs a new version.
let r = min_result();
let mut want = Vec::new();
want.extend_from_slice(&[1; 16]);
want.extend_from_slice(&[2; 32]);
want.push(0); // Accepted
want.extend_from_slice(&0u32.to_be_bytes()); // outputs
want.extend_from_slice(&[0, 0, 0]); // exit: no code, no signal, not killed
want.extend_from_slice(&5i64.to_be_bytes());
want.extend_from_slice(&6i64.to_be_bytes());
want.extend_from_slice(&min_features_bytes());
want.extend_from_slice(&0u32.to_be_bytes()); // events
assert_eq!(encode_result(&r), want);
assert_eq!(decode_result(&want).unwrap(), r);
}
#[test]
fn encoding_of_refused_output_exit_features_and_event_is_pinned_byte_for_byte() {
let r = JobResult {
job_id: JobId([0; 16]),
nonce: Nonce([0; 32]),
verdict: Verdict::Refused {
code: "k".into(),
reason: "no".into(),
},
outputs: vec![Output {
name: "a".into(),
sha256: Sha256Digest([3; 32]),
bytes: 7,
}],
exit: ExitInfo {
code: Some(-1),
signal: Some(9),
killed: Some(KillReason::Memory),
},
started_unix: -2,
finished_unix: 0,
runner: Features {
runner: "y".into(),
container: true,
seccomp: Protection::Generated {
profile_digest: "d".into(),
},
landlock: true,
broker: false,
wasm: true,
image_verified_each_start: false,
},
events: vec![BrokerEvent {
at_ms: 1,
call: "c".into(),
args: vec!["x".into()],
decision: Decision::Killed,
}],
};
let mut w = vec![0u8; 48];
w.push(1);
w.extend_from_slice(&1u32.to_be_bytes());
w.push(b'k');
w.extend_from_slice(&2u32.to_be_bytes());
w.extend_from_slice(b"no");
w.extend_from_slice(&1u32.to_be_bytes());
w.extend_from_slice(&1u32.to_be_bytes());
w.push(b'a');
w.extend_from_slice(&[3; 32]);
w.extend_from_slice(&7u64.to_be_bytes());
w.push(1);
w.extend_from_slice(&(-1i32).to_be_bytes());
w.push(1);
w.extend_from_slice(&9i32.to_be_bytes());
w.push(2); // Memory
w.extend_from_slice(&(-2i64).to_be_bytes());
w.extend_from_slice(&0i64.to_be_bytes());
w.extend_from_slice(&1u32.to_be_bytes());
w.push(b'y');
w.push(1); // container
w.push(2); // Generated
w.extend_from_slice(&1u32.to_be_bytes());
w.push(b'd');
w.extend_from_slice(&[1, 0, 1, 0]); // landlock, broker, wasm, image check
w.extend_from_slice(&1u32.to_be_bytes());
w.extend_from_slice(&1u64.to_be_bytes());
w.extend_from_slice(&1u32.to_be_bytes());
w.push(b'c');
w.extend_from_slice(&1u32.to_be_bytes());
w.extend_from_slice(&1u32.to_be_bytes());
w.push(b'x');
w.push(2); // Killed
assert_eq!(encode_result(&r), w);
assert_eq!(decode_result(&w).unwrap(), r);
}
/// Offset of the first feature bool (container) in an encoding of `min_result()`.
const MIN_CONTAINER_AT: usize = 48 + 1 + 4 + 3 + 16 + 4;
#[test]
fn decoder_refuses_a_bool_that_is_not_zero_or_one() {
for (off, what) in [
(0, "container"),
(2, "landlock"),
(3, "broker"),
(4, "wasm"),
(5, "image_verified_each_start"),
] {
let mut b = encode_result(&min_result());
b[MIN_CONTAINER_AT + off] = 2;
assert_eq!(
decode_result(&b).unwrap_err(),
DecodeError::BadTag { what, tag: 2 },
"{what}"
);
}
}
#[test]
fn decoder_refuses_an_unknown_seccomp_tag() {
let mut b = encode_result(&min_result());
b[MIN_CONTAINER_AT + 1] = 3;
assert_eq!(
decode_result(&b).unwrap_err(),
DecodeError::BadTag {
what: "seccomp",
tag: 3
}
);
}
#[test]
fn decoder_refuses_an_unknown_kill_reason() {
let mut b = encode_result(&min_result());
b[48 + 1 + 4 + 2] = 5;
assert_eq!(
decode_result(&b).unwrap_err(),
DecodeError::BadTag {
what: "killed",
tag: 5
}
);
}
#[test]
fn decoder_refuses_an_exit_presence_byte_that_is_not_zero_or_one() {
let mut b = encode_result(&min_result());
b[48 + 1 + 4] = 2;
assert!(matches!(
decode_result(&b).unwrap_err(),
DecodeError::BadTag { tag: 2, .. }
));
}
#[test]
fn negative_unix_times_round_trip() {
let r = JobResult {
started_unix: i64::MIN,
finished_unix: i64::MAX,
..min_result()
};
assert_eq!(decode_result(&encode_result(&r)).unwrap(), r);
}
#[test]
fn golden_signature_for_the_sample_result_is_stable() {
// Ed25519 is deterministic: the same seed, boot and result always give the
// same wire bytes. Pin their sha256 so any accidental format change fails.
let a = signed_wire();
let b = signed_wire();
assert_eq!(a, b);
let s = SignedResult::from_bytes(&a).unwrap();
assert_eq!(s.to_bytes(), a);
assert_eq!(Sha256Digest::of(&a).to_hex(), GOLDEN_WIRE_SHA256);
}
#[test]
fn decoder_refuses_an_unknown_verdict_tag() {
let mut b = encode_result(&sample_result());
b[48] = 7;
assert_eq!(
decode_result(&b).unwrap_err(),
DecodeError::BadTag {
what: "verdict",
tag: 7
}
);
}
#[test]
fn decoder_refuses_an_unknown_decision_tag() {
let r = JobResult {
outputs: vec![],
events: vec![BrokerEvent {
at_ms: 0,
call: "c".into(),
args: vec![],
decision: Decision::Allowed,
}],
..sample_result()
};
let mut b = encode_result(&r);
// The decision is the last byte: events come last.
let pos = b.len() - 1;
assert_eq!(b[pos], 0);
b[pos] = 3;
assert_eq!(
decode_result(&b).unwrap_err(),
DecodeError::BadTag {
what: "decision",
tag: 3
}
);
}
#[test]
fn decoder_refuses_non_utf8_in_an_output_name() {
let r = JobResult {
outputs: vec![Output::of("ab", b"")],
events: vec![],
..sample_result()
};
let mut b = encode_result(&r);
let pos = b.windows(2).position(|w| w == b"ab").unwrap();
b[pos] = 0xff;
assert!(matches!(
decode_result(&b).unwrap_err(),
DecodeError::BadUtf8 { .. }
));
}
#[test]
fn decoder_refuses_non_utf8_in_the_refusal_reason() {
let r = JobResult {
verdict: Verdict::Refused {
code: "c".into(),
reason: "zz".into(),
},
..sample_result()
};
let mut b = encode_result(&r);
b[58] = 0xc0; // first reason byte: 48 ids + tag + 4 + "c" + 4 length
assert!(matches!(
decode_result(&b).unwrap_err(),
DecodeError::BadUtf8 { what: "reason" }
));
}
#[test]
fn decoder_refuses_trailing_bytes() {
let mut b = encode_result(&sample_result());
b.push(0);
assert_eq!(
decode_result(&b).unwrap_err(),
DecodeError::TrailingBytes { count: 1 }
);
}
#[test]
fn decoder_refuses_every_truncation() {
let b = encode_result(&sample_result());
for n in 0..b.len() {
assert!(decode_result(&b[..n]).is_err(), "prefix {n} decoded");
}
}
#[test]
fn decoder_refuses_a_huge_output_count_without_allocating() {
let r = JobResult {
outputs: vec![],
events: vec![],
..sample_result()
};
let mut b = encode_result(&r);
b[49..53].copy_from_slice(&u32::MAX.to_be_bytes());
assert!(matches!(
decode_result(&b).unwrap_err(),
DecodeError::Truncated { .. }
));
}
#[test]
fn decoder_refuses_a_huge_string_length() {
let r = JobResult {
verdict: Verdict::Refused {
code: "c".into(),
reason: "r".into(),
},
..sample_result()
};
let mut b = encode_result(&r);
b[49..53].copy_from_slice(&u32::MAX.to_be_bytes());
assert!(matches!(
decode_result(&b).unwrap_err(),
DecodeError::Truncated { .. }
));
}
#[test]
fn decoder_refuses_a_huge_event_arg_count() {
let r = JobResult {
outputs: vec![],
events: vec![BrokerEvent {
at_ms: 0,
call: "".into(),
args: vec![],
decision: Decision::Allowed,
}],
..sample_result()
};
let r = JobResult {
runner: min_features(),
exit: ExitInfo::default(),
..r
};
let mut b = encode_result(&r);
// 48 ids + 1 tag + 4 outputs + 3 exit + 16 times + 10 features
// + 4 events + 8 at_ms + 4 call len = 98
b[98..102].copy_from_slice(&0x0fff_ffffu32.to_be_bytes());
assert!(decode_result(&b).is_err());
}
#[test]
fn decoder_refuses_an_input_over_the_cap() {
let b = vec![0u8; codec::MAX_ENCODED_RESULT + 1];
assert!(matches!(
decode_result(&b).unwrap_err(),
DecodeError::TooLarge { .. }
));
}
#[test]
fn decoder_refuses_empty_input() {
assert!(matches!(
decode_result(&[]).unwrap_err(),
DecodeError::Truncated { .. }
));
}
#[test]
fn different_verdicts_encode_differently() {
assert_ne!(
encode_result(&sample_result()),
encode_result(&refused_result())
);
}
#[test]
fn moving_a_byte_between_adjacent_strings_changes_the_encoding() {
// Length prefixes make ["ab","c"] and ["a","bc"] distinct.
let mk = |code: &str, reason: &str| JobResult {
verdict: Verdict::Refused {
code: code.into(),
reason: reason.into(),
},
..sample_result()
};
assert_ne!(encode_result(&mk("ab", "c")), encode_result(&mk("a", "bc")));
}