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 { .. })
));
}