Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

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 v2 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![],
        kernel: None,
    }
}

/// 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
    want.push(0); // kernel: none
    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,
        }],
        kernel: None,
    };
    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
    w.push(0); // kernel: none
    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] = 6;
    assert_eq!(
        decode_result(&b).unwrap_err(),
        DecodeError::BadTag {
            what: "killed",
            tag: 6
        }
    );
}

#[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 before the kernel tag (0: none).
    assert_eq!(b[b.len() - 1], 0);
    let pos = b.len() - 2;
    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")));
}

// ---- kernel (v2) ----

fn kf() -> KernelFeatures {
    KernelFeatures {
        release: "7.0".into(),
        seccomp_filter: true,
        user_notif: false,
        notif_addfd: true,
        landlock_abi: Some(4),
        cgroup_v2: true,
        cgroup_controllers: vec!["cpu".into(), "memory".into()],
        user_namespaces: false,
    }
}

#[test]
fn the_kernel_features_are_pinned_byte_for_byte_after_the_events() {
    let r = JobResult {
        kernel: Some(kf()),
        ..min_result()
    };
    let base = encode_result(&min_result());
    let mut want = base[..base.len() - 1].to_vec();
    want.push(1); // kernel: present
    want.extend_from_slice(&3u32.to_be_bytes());
    want.extend_from_slice(b"7.0");
    want.extend_from_slice(&[1, 0, 1]); // seccomp_filter, user_notif, notif_addfd
    want.extend_from_slice(&[1]);
    want.extend_from_slice(&4u32.to_be_bytes()); // landlock_abi
    want.push(1); // cgroup_v2
    want.extend_from_slice(&2u32.to_be_bytes());
    want.extend_from_slice(&3u32.to_be_bytes());
    want.extend_from_slice(b"cpu");
    want.extend_from_slice(&6u32.to_be_bytes());
    want.extend_from_slice(b"memory");
    want.push(0); // user_namespaces
    assert_eq!(encode_result(&r), want);
    assert_eq!(decode_result(&want).unwrap(), r);
}

#[test]
fn kernel_features_without_landlock_round_trip() {
    let mut k = kf();
    k.landlock_abi = None;
    k.cgroup_controllers.clear();
    let r = JobResult {
        kernel: Some(k),
        ..min_result()
    };
    assert_eq!(decode_result(&encode_result(&r)).unwrap(), r);
}

#[test]
fn a_bad_kernel_presence_tag_is_refused() {
    let mut b = encode_result(&min_result());
    *b.last_mut().unwrap() = 2;
    assert_eq!(
        decode_result(&b).unwrap_err(),
        DecodeError::BadTag {
            what: "kernel",
            tag: 2
        }
    );
}

#[test]
fn a_v1_encoding_without_the_kernel_byte_is_truncated() {
    let b = encode_result(&min_result());
    assert!(matches!(
        decode_result(&b[..b.len() - 1]).unwrap_err(),
        DecodeError::Truncated { .. }
    ));
}

#[test]
fn a_forged_huge_cgroup_controller_count_is_refused_without_allocating() {
    let r = JobResult {
        kernel: Some(kf()),
        ..min_result()
    };
    let b = encode_result(&r);
    // The count sits after kernel tag, release, 3 bools, landlock opt u32, cgroup_v2.
    let base = encode_result(&min_result()).len() - 1;
    let at = base + 1 + 4 + 3 + 3 + 1 + 4 + 1;
    let mut f = b.clone();
    f[at..at + 4].copy_from_slice(&u32::MAX.to_be_bytes());
    assert!(matches!(
        decode_result(&f).unwrap_err(),
        DecodeError::Truncated { .. }
    ));
}

#[test]
fn a_json_result_from_0_1_without_kernel_reads_as_kernel_none() {
    let mut v = serde_json::to_value(min_result()).unwrap();
    v.as_object_mut().unwrap().remove("kernel");
    let r: JobResult = serde_json::from_value(v).unwrap();
    assert_eq!(r.kernel, None);
}