Gunnar

nordisk/heimdall

crates/heimdall-core/tests/api.rs

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

//! The milestone 0 job API that Holger builds against (HEIMDALL-EARLY-TRAIT):
//! every public name and field the demand lists, built with struct literals
//! so a rename breaks this file at compile time. Heimdall is a defensive
//! sandbox; these tests exercise only its own API, signing and output check.

mod common;

use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;

use common::*;
use heimdall_core::*;

fn demand_job(input: &Path, output: &Path) -> Job {
    Job {
        id: JobId([3; 16]),
        nonce: Nonce([4; 32]),
        kind: JobKind::Native {
            image: ImageRef {
                name: "docker.io/library/busybox".into(),
                digest: format!("sha256:{}", "ab".repeat(32)),
            },
            argv: vec!["/bin/cp".into(), "/in/a".into(), "/out/a".into()],
        },
        input_dir: input.to_path_buf(),
        output_dir: output.to_path_buf(),
        params: serde_json::json!({"mode": "copy"}),
        policy: Policy {
            read: vec![],
            write: vec![],
            net: None,
            limits: Limits {
                wall: Duration::from_secs(5),
                memory_bytes: 64 << 20,
                cpus: 0.5,
                pids: 32,
                ..Limits::default()
            },
        },
    }
}

type FeatureMut = Box<dyn Fn(&mut Features)>;

fn demand_features() -> Features {
    Features {
        runner: "youki-plain".into(),
        container: true,
        seccomp: Protection::Default,
        landlock: false,
        broker: false,
        wasm: false,
        image_verified_each_start: true,
    }
}

fn demand_result() -> JobResult {
    JobResult {
        job_id: JobId([3; 16]),
        nonce: Nonce([4; 32]),
        verdict: Verdict::Accepted,
        outputs: vec![Output {
            name: "a".into(),
            sha256: Sha256Digest::of(b"hello"),
            bytes: 5,
        }],
        exit: ExitInfo {
            code: Some(0),
            signal: None,
            killed: None,
        },
        started_unix: 1_793_000_000,
        finished_unix: 1_793_000_002,
        runner: demand_features(),
        events: vec![],
    }
}

/// A runner that "copies" by writing the bytes itself: proves the trait is
/// implementable outside the crate and object safe.
struct FakeRunner {
    key: BootKey,
}

impl JobRunner for FakeRunner {
    fn run(&self, job: Job) -> Result<SignedResult, RunError> {
        if matches!(job.kind, JobKind::Wasm { .. }) {
            return Err(RunError::Unsupported {
                what: "wasm jobs".into(),
            });
        }
        let data = std::fs::read(job.input_dir.join("a")).map_err(|e| RunError::Io {
            reason: e.to_string(),
        })?;
        std::fs::write(job.output_dir.join("a"), &data).map_err(|e| RunError::Io {
            reason: e.to_string(),
        })?;
        let r = JobResult {
            job_id: job.id,
            nonce: job.nonce,
            outputs: vec![Output::of("a", &data)],
            ..demand_result()
        };
        self.key.sign(r).map_err(RunError::from)
    }
    fn verifying_key(&self) -> VerifyingKey {
        self.key.verifying_key()
    }
    fn features(&self) -> Features {
        demand_features()
    }
}

fn dirs() -> (PathBuf, PathBuf, PathBuf) {
    let base = std::env::temp_dir().join(format!("heimdall-api-{}", JobId::random()));
    let i = base.join("in");
    let o = base.join("out");
    std::fs::create_dir_all(&i).unwrap();
    std::fs::create_dir_all(&o).unwrap();
    (base, i, o)
}

#[test]
fn job_has_the_demand_fields_including_output_dir() {
    let j = demand_job(Path::new("/i"), Path::new("/o"));
    assert_eq!(j.output_dir, PathBuf::from("/o"));
    assert_eq!(j.input_dir, PathBuf::from("/i"));
    assert_eq!(j.params["mode"], "copy");
}

#[test]
fn job_new_makes_fresh_id_and_nonce_and_keeps_output_dir() {
    let a = Job::new(
        JobKind::Wasm {
            module: GuestRef {
                path: "/g.wasm".into(),
                blake3: "00".repeat(32),
            },
        },
        "/i".into(),
        "/o".into(),
        serde_json::Value::Null,
        Policy::default(),
    );
    let b = Job::new(
        a.kind.clone(),
        "/i".into(),
        "/o".into(),
        serde_json::Value::Null,
        Policy::default(),
    );
    assert_ne!(a.id, b.id);
    assert_ne!(a.nonce, b.nonce);
    assert_eq!(a.output_dir, PathBuf::from("/o"));
}

#[test]
fn job_round_trips_through_json() {
    let j = demand_job(Path::new("/i"), Path::new("/o"));
    let s = serde_json::to_string(&j).unwrap();
    let back: Job = serde_json::from_str(&s).unwrap();
    assert_eq!(back, j);
}

#[test]
fn job_kind_native_serializes_image_and_argv() {
    let j = demand_job(Path::new("/i"), Path::new("/o"));
    let v = serde_json::to_value(&j.kind).unwrap();
    assert_eq!(v["Native"]["image"]["name"], "docker.io/library/busybox");
    assert_eq!(v["Native"]["argv"][0], "/bin/cp");
}

#[test]
fn image_ref_sha256_parses_a_pinned_digest() {
    let r = ImageRef {
        name: "x".into(),
        digest: format!("sha256:{}", "ab".repeat(32)),
    };
    assert_eq!(r.sha256(), Some(Sha256Digest([0xab; 32])));
}

#[test]
fn image_ref_sha256_refuses_unpinned_or_malformed_digests() {
    for d in [
        "latest".to_string(),
        "ab".repeat(32),
        format!("sha512:{}", "ab".repeat(32)),
        format!("sha256:{}", "ab".repeat(31)),
        format!("sha256:{}", "AB".repeat(32)),
        format!("sha256:{}z", "ab".repeat(31)),
        String::new(),
    ] {
        let r = ImageRef {
            name: "x".into(),
            digest: d.clone(),
        };
        assert_eq!(r.sha256(), None, "{d}");
    }
}

#[test]
fn guest_ref_carries_path_and_blake3() {
    let g = GuestRef {
        path: "/g/verify-bundle.wasm".into(),
        blake3: "11".repeat(32),
    };
    let v = serde_json::to_value(&g).unwrap();
    assert_eq!(v["path"], "/g/verify-bundle.wasm");
    assert_eq!(v["blake3"], "11".repeat(32));
}

#[test]
fn limits_default_has_container_limits() {
    let l = Limits::default();
    assert!(l.wall > Duration::ZERO);
    assert!(l.memory_bytes > 0);
    assert!(l.cpus > 0.0);
    assert!(l.pids > 0);
}

#[test]
fn net_allow_is_exactly_one_peer() {
    let n = NetAllow {
        host: "pypi.org".into(),
        port: 443,
    };
    assert_eq!(serde_json::to_value(&n).unwrap()["port"], 443);
}

#[test]
fn verdict_refused_carries_code_and_reason() {
    let v = Verdict::Refused {
        code: "heimdall-out-1".into(),
        reason: "extra file".into(),
    };
    assert!(!v.is_accepted());
    assert_eq!(v.code(), Some("heimdall-out-1"));
    assert_eq!(Verdict::Accepted.code(), None);
}

#[test]
fn output_of_records_sha256_and_bytes() {
    let o = Output::of("a", b"abc");
    assert_eq!(o.bytes, 3);
    assert_eq!(o.sha256, Sha256Digest::of(b"abc"));
}

#[test]
fn protection_serializes_all_three_shapes() {
    assert_eq!(serde_json::to_value(Protection::None).unwrap(), "None");
    assert_eq!(
        serde_json::to_value(Protection::Default).unwrap(),
        "Default"
    );
    let g = Protection::Generated {
        profile_digest: "sha256:00".into(),
    };
    let v = serde_json::to_value(&g).unwrap();
    assert_eq!(v["Generated"]["profile_digest"], "sha256:00");
    assert_eq!(serde_json::from_value::<Protection>(v).unwrap(), g);
}

#[test]
fn features_round_trip_through_json() {
    let f = demand_features();
    let back: Features = serde_json::from_str(&serde_json::to_string(&f).unwrap()).unwrap();
    assert_eq!(back, f);
}

#[test]
fn result_with_every_new_field_signs_and_verifies() {
    let k = key_a();
    let s = k.sign(demand_result()).unwrap();
    let r = verify(&s, &k.verifying_key()).unwrap();
    assert_eq!(r, &demand_result());
}

#[test]
fn result_wire_round_trips_every_new_field() {
    let mut r = demand_result();
    r.verdict = Verdict::Refused {
        code: "heimdall-limit-memory".into(),
        reason: "memory limit".into(),
    };
    r.exit = ExitInfo {
        code: None,
        signal: Some(9),
        killed: Some(KillReason::Memory),
    };
    r.runner.seccomp = Protection::Generated {
        profile_digest: "sha256:abc".into(),
    };
    r.runner.landlock = true;
    r.started_unix = -5;
    r.finished_unix = -5;
    let s = key_a().sign(r.clone()).unwrap();
    let back = SignedResult::from_bytes(&s.to_bytes()).unwrap();
    assert_eq!(back.result, r);
    assert_eq!(decode_result(&encode_result(&r)).unwrap(), r);
}

#[test]
fn every_kill_reason_round_trips_on_the_wire() {
    for k in [
        KillReason::Wall,
        KillReason::Memory,
        KillReason::Pids,
        KillReason::Fuel,
    ] {
        let mut r = demand_result();
        r.exit.killed = Some(k);
        assert_eq!(
            decode_result(&encode_result(&r)).unwrap().exit.killed,
            Some(k)
        );
    }
}

#[test]
fn changing_any_feature_changes_the_signed_bytes() {
    let base = encode_result(&demand_result());
    let muts: Vec<FeatureMut> = vec![
        Box::new(|f| f.runner = "heimdall".into()),
        Box::new(|f| f.container = false),
        Box::new(|f| f.seccomp = Protection::None),
        Box::new(|f| f.landlock = true),
        Box::new(|f| f.broker = true),
        Box::new(|f| f.wasm = true),
        Box::new(|f| f.image_verified_each_start = false),
    ];
    for m in muts {
        let mut r = demand_result();
        m(&mut r.runner);
        assert_ne!(encode_result(&r), base);
    }
}

#[test]
fn changing_exit_info_changes_the_signed_bytes() {
    let base = encode_result(&demand_result());
    let mut r = demand_result();
    r.exit.code = Some(1);
    assert_ne!(encode_result(&r), base);
    let mut r = demand_result();
    r.exit.signal = Some(9);
    assert_ne!(encode_result(&r), base);
}

#[test]
fn verify_refuses_a_tampered_signature() {
    let k = key_a();
    let mut s = k.sign(demand_result()).unwrap();
    s.sig.0[0] ^= 1;
    assert_eq!(
        verify(&s, &k.verifying_key()),
        Err(VerifyError::BadSignature)
    );
}

#[test]
fn verify_refuses_a_different_key() {
    let s = key_a().sign(demand_result()).unwrap();
    assert!(matches!(
        verify(&s, &key_b().verifying_key()),
        Err(VerifyError::WrongKey { .. })
    ));
}

#[test]
fn verify_refuses_a_different_key_even_with_the_key_id_swapped_in() {
    let mut s = key_a().sign(demand_result()).unwrap();
    s.key_id = key_b().key_id();
    assert_eq!(
        verify(&s, &key_b().verifying_key()),
        Err(VerifyError::BadSignature)
    );
}

#[test]
fn verify_refuses_an_altered_result() {
    let k = key_a();
    let mut s = k.sign(demand_result()).unwrap();
    s.result.outputs[0].bytes = 6;
    assert_eq!(
        verify(&s, &k.verifying_key()),
        Err(VerifyError::BadSignature)
    );
    let mut s = k.sign(demand_result()).unwrap();
    s.result.runner.seccomp = Protection::Generated {
        profile_digest: "x".into(),
    };
    assert_eq!(
        verify(&s, &k.verifying_key()),
        Err(VerifyError::BadSignature)
    );
}

#[test]
fn verify_refuses_a_weak_key() {
    let s = key_a().sign(demand_result()).unwrap();
    // The identity point is a valid encoding but of small order.
    let mut id = [0u8; 32];
    id[0] = 1;
    let weak = VerifyingKey::from_bytes(&id).unwrap();
    assert_eq!(verify(&s, &weak), Err(VerifyError::WeakKey));
}

#[test]
fn check_outputs_passes_for_matching_files_and_names_the_failure_otherwise() {
    let (base, _i, o) = dirs();
    std::fs::write(o.join("a"), b"hello").unwrap();
    let r = demand_result();
    assert_eq!(check_outputs(&r, &o), Ok(()));
    std::fs::write(o.join("a"), b"hellO").unwrap();
    assert!(matches!(
        check_outputs(&r, &o),
        Err(VerifyError::Output(OutputError::HashMismatch { .. }))
    ));
    std::fs::write(o.join("a"), b"hello").unwrap();
    std::fs::write(o.join("extra"), b"").unwrap();
    assert_eq!(
        check_outputs(&r, &o),
        Err(VerifyError::Output(OutputError::Unlisted("extra".into())))
    );
    std::fs::remove_dir_all(base).unwrap();
}

#[test]
fn a_job_runner_is_object_safe_send_and_sync() {
    fn assert_send_sync<T: Send + Sync + ?Sized>() {}
    assert_send_sync::<dyn JobRunner>();
    let r: Arc<dyn JobRunner> = Arc::new(FakeRunner { key: key_a() });
    let r2 = Arc::clone(&r);
    let h = std::thread::spawn(move || r2.features());
    assert_eq!(h.join().unwrap(), demand_features());
    assert_eq!(r.verifying_key(), key_a().verifying_key());
}

#[test]
fn the_holger_flow_run_verify_check_outputs() {
    let (base, i, o) = dirs();
    std::fs::write(i.join("a"), b"hello").unwrap();
    let runner: Box<dyn JobRunner> = Box::new(FakeRunner { key: key_a() });
    let pinned = runner.verifying_key();
    let job = demand_job(&i, &o);
    let id = job.id;
    let signed = runner.run(job).unwrap();
    let r = verify(&signed, &pinned).unwrap();
    assert_eq!(r.job_id, id);
    assert!(r.verdict.is_accepted());
    assert_eq!(r.outputs[0].sha256, Sha256Digest::of(b"hello"));
    check_outputs(r, &o).unwrap();
    std::fs::remove_dir_all(base).unwrap();
}

#[test]
fn run_error_unsupported_is_named_and_serializable() {
    let runner = FakeRunner { key: key_a() };
    let job = Job {
        kind: JobKind::Wasm {
            module: GuestRef {
                path: "/g.wasm".into(),
                blake3: "00".repeat(32),
            },
        },
        ..demand_job(Path::new("/i"), Path::new("/o"))
    };
    let e = runner.run(job).unwrap_err();
    assert!(matches!(e, RunError::Unsupported { .. }));
    let back: RunError = serde_json::from_str(&serde_json::to_string(&e).unwrap()).unwrap();
    assert_eq!(back, e);
}

#[test]
fn boot_key_verifying_key_matches_the_pinned_record() {
    let k = key_a();
    assert_eq!(k.verifying_key().to_bytes(), k.pinned().public_key.0);
    assert_eq!(k.pinned().verifying_key().unwrap(), k.verifying_key());
}

#[test]
fn signed_result_json_round_trip_still_verifies() {
    let k = key_a();
    let s = k.sign(demand_result()).unwrap();
    let back: SignedResult = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
    assert!(verify(&back, &k.verifying_key()).is_ok());
}

#[test]
fn time_reversed_results_are_refused_on_sign() {
    let mut r = demand_result();
    r.finished_unix = r.started_unix - 1;
    assert!(matches!(
        key_a().sign(r),
        Err(ResultError::TimeReversed { .. })
    ));
}