Gunnar

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