nordisk/heimdall
crates/heimdall-run/tests/job.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! One job end to end with scripted guests: input in, files out, signed
//! result, caller-side verification. Heimdall is a defensive sandbox; these
//! tests exercise its own containment inside temporary directories only.
use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf};
use heimdall_core::{
check_outputs, BootId, BootKey, Decision, OutputError, Params, SignedResult, Verdict, Verifier,
VerifyError,
};
use heimdall_run::outdir::{FILES_DIR, RESULT_FILE};
use heimdall_run::{
run_job, GuestRun, GuestRunner, GuestVerdict, JobError, JobSpec, OutDirError, PolicyFile,
RunLimits,
};
use sha2::{Digest, Sha256};
/// A guest that answers from a script and records what it was given.
struct Script {
verdict: GuestVerdict,
files: Vec<(String, Vec<u8>)>,
fuel: u64,
calls: Cell<u32>,
seen_input: RefCell<Vec<u8>>,
seen_params: RefCell<Params>,
seen_limits: Cell<Option<RunLimits>>,
}
impl Script {
fn new(verdict: GuestVerdict, files: &[(&str, &[u8])]) -> Script {
Script {
verdict,
files: files
.iter()
.map(|(n, b)| (n.to_string(), b.to_vec()))
.collect(),
fuel: 1234,
calls: Cell::new(0),
seen_input: RefCell::new(Vec::new()),
seen_params: RefCell::new(Params::new()),
seen_limits: Cell::new(None),
}
}
fn accept(files: &[(&str, &[u8])]) -> Script {
Self::new(GuestVerdict::Accepted { note: "ok".into() }, files)
}
fn refuse(reason: &str, files: &[(&str, &[u8])]) -> Script {
Self::new(
GuestVerdict::Refused {
code: "test-guest".into(),
reason: reason.into(),
},
files,
)
}
}
impl GuestRunner for Script {
fn run_guest(&self, input: &[u8], params: &Params, limits: &RunLimits) -> GuestRun {
self.calls.set(self.calls.get() + 1);
*self.seen_input.borrow_mut() = input.to_vec();
*self.seen_params.borrow_mut() = params.clone();
self.seen_limits.set(Some(*limits));
GuestRun {
verdict: self.verdict.clone(),
files: self.files.clone(),
fuel_used: self.fuel,
}
}
}
struct Fixture {
_tmp: tempfile::TempDir,
root: PathBuf,
spec: JobSpec,
key: BootKey,
}
impl Fixture {
fn new() -> Fixture {
Self::with_policy(PolicyFile::default())
}
fn with_policy(policy: PolicyFile) -> Fixture {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let inp = root.join("in");
std::fs::create_dir(&inp).unwrap();
std::fs::write(inp.join("bundle.bin"), b"untrusted input bytes").unwrap();
let spec = JobSpec::new(&inp, &root.join("out"), policy, Params::new());
Fixture {
_tmp: tmp,
root,
spec,
key: BootKey::generate(),
}
}
fn out(&self) -> PathBuf {
self.root.join("out")
}
fn files(&self) -> PathBuf {
self.out().join(FILES_DIR)
}
fn verifier(&self) -> Verifier {
Verifier::new(self.key.pinned()).unwrap()
}
}
fn names_in(p: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(p)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
v.sort();
v
}
fn refused_reason(o: &heimdall_run::JobOutcome) -> String {
match &o.signed.result.verdict {
Verdict::Refused { reason, .. } => reason.clone(),
Verdict::Accepted => panic!("expected Refused"),
}
}
// ---- the good path ----
#[test]
fn accepted_guest_gives_a_signed_accepted_that_verifies_with_the_pinned_key() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("report.json", b"{}")]), &f.key).unwrap();
assert!(o.is_accepted());
let mut v = f.verifier();
let r = v
.accept(&std::fs::read(&o.result_path).unwrap(), &f.spec.ticket())
.unwrap();
assert_eq!(r.verdict, Verdict::Accepted);
}
#[test]
fn result_file_holds_exactly_the_returned_wire_bytes() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
assert_eq!(o.result_path, f.out().join(RESULT_FILE));
assert_eq!(std::fs::read(&o.result_path).unwrap(), o.wire);
assert_eq!(SignedResult::from_bytes(&o.wire).unwrap(), o.signed);
}
#[test]
fn every_listed_output_matches_its_file_on_disk() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::accept(&[("a.txt", b"A"), ("b.txt", b"BB")]),
&f.key,
)
.unwrap();
let paths = heimdall_core::check_output_files(&o.signed.result, &f.files()).unwrap();
assert_eq!(paths.len(), 2);
}
#[test]
fn output_hashes_are_the_sha256_of_the_emitted_bytes() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("a", b"hello")]), &f.key).unwrap();
let e = &o.signed.result.outputs[0];
let want: [u8; 32] = Sha256::digest(b"hello").into();
assert_eq!(e.sha256.0, want);
assert_eq!(e.bytes, 5);
assert_eq!(e.name, "a");
}
#[test]
fn outputs_are_listed_in_emit_order() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::accept(&[("z", b"1"), ("a", b"2"), ("m", b"3")]),
&f.key,
)
.unwrap();
let names: Vec<_> = o
.signed
.result
.outputs
.iter()
.map(|e| e.name.as_str())
.collect();
assert_eq!(names, ["z", "a", "m"]);
}
#[test]
fn the_guest_gets_the_input_file_bytes_exactly() {
let f = Fixture::new();
let g = Script::accept(&[]);
run_job(&f.spec, &g, &f.key).unwrap();
assert_eq!(&*g.seen_input.borrow(), b"untrusted input bytes");
assert_eq!(g.calls.get(), 1);
}
#[test]
fn the_guest_gets_the_job_params_exactly() {
let mut f = Fixture::new();
f.spec.params.insert("mode".into(), "strict".into());
f.spec.params.insert("a".into(), "".into());
let g = Script::accept(&[]);
run_job(&f.spec, &g, &f.key).unwrap();
assert_eq!(*g.seen_params.borrow(), f.spec.params);
}
#[test]
fn the_guest_runs_under_the_policy_limits() {
let p = PolicyFile::parse("version = 1\n[limits]\nfuel = 77\nwall_time_ms = 55\n").unwrap();
let f = Fixture::with_policy(p.clone());
let g = Script::accept(&[]);
run_job(&f.spec, &g, &f.key).unwrap();
assert_eq!(g.seen_limits.get(), Some(p.limits));
}
#[test]
fn job_id_and_nonce_are_echoed_in_the_signed_result() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
assert_eq!(o.signed.result.job_id, f.spec.job_id);
assert_eq!(o.signed.result.nonce, f.spec.nonce);
}
#[test]
fn fuel_event_and_one_emit_event_per_file_are_logged() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::accept(&[("a", b"1"), ("b", b"22")]),
&f.key,
)
.unwrap();
let ev = &o.signed.result.events;
assert_eq!(ev[0].call, "fuel");
assert_eq!(
ev[0].args,
vec!["1234".to_string(), RunLimits::default().fuel.to_string()]
);
let emits: Vec<_> = ev.iter().filter(|e| e.call == "emit-file").collect();
assert_eq!(emits.len(), 2);
assert_eq!(emits[1].args, vec!["b".to_string(), "2".to_string()]);
assert!(emits.iter().all(|e| e.decision == Decision::Allowed));
}
#[test]
fn features_state_a_wasm_run_truthfully() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
let feats = &o.signed.result.runner;
assert_eq!(*feats, heimdall_run::job::wasm_features());
assert_eq!(feats.runner, "heimdall-wasm");
assert!(feats.wasm);
assert!(!feats.container && !feats.landlock && !feats.broker);
assert_eq!(feats.seccomp, heimdall_core::Protection::None);
}
#[test]
fn started_is_not_after_finished() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
assert!(o.signed.result.started_unix <= o.signed.result.finished_unix);
assert!(o.signed.result.started_unix > 1_700_000_000);
}
#[test]
fn input_dir_is_unchanged_after_the_run() {
let f = Fixture::new();
run_job(&f.spec, &Script::accept(&[("x", b"y")]), &f.key).unwrap();
assert_eq!(names_in(&f.spec.input_dir), ["bundle.bin"]);
assert_eq!(
std::fs::read(f.spec.input_dir.join("bundle.bin")).unwrap(),
b"untrusted input bytes"
);
}
#[test]
fn out_dir_holds_only_files_dir_and_the_result() {
let f = Fixture::new();
run_job(&f.spec, &Script::accept(&[("x", b"y")]), &f.key).unwrap();
assert_eq!(names_in(&f.out()), [FILES_DIR, RESULT_FILE]);
assert_eq!(names_in(&f.root), ["in", "out"]);
}
#[test]
fn same_input_twice_gives_the_same_output_hashes_but_new_job_ids() {
let f = Fixture::new();
let g = Script::accept(&[("a", b"same")]);
let o1 = run_job(&f.spec, &g, &f.key).unwrap();
let mut spec2 = JobSpec::new(
&f.spec.input_dir,
&f.root.join("out2"),
PolicyFile::default(),
Params::new(),
);
spec2.params = f.spec.params.clone();
let o2 = run_job(&spec2, &g, &f.key).unwrap();
assert_eq!(o1.signed.result.outputs, o2.signed.result.outputs);
assert_ne!(o1.signed.result.job_id, o2.signed.result.job_id);
assert_ne!(o1.signed.result.nonce, o2.signed.result.nonce);
}
// ---- refusals ----
#[test]
fn guest_refusal_gives_a_signed_refused_with_the_guests_reason() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::refuse("hash mismatch in index", &[]),
&f.key,
)
.unwrap();
assert!(!o.is_accepted());
assert_eq!(refused_reason(&o), "hash mismatch in index");
f.verifier().verify(&o.wire).unwrap();
}
#[test]
fn guest_refusal_keeps_the_refusal_report_it_emitted() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::refuse("bad", &[("why.txt", b"details")]),
&f.key,
)
.unwrap();
assert_eq!(o.signed.result.outputs.len(), 1);
check_outputs(&o.signed.result, &f.files()).unwrap();
}
#[test]
fn trapped_guest_gives_a_signed_refused_and_no_outputs() {
let f = Fixture::new();
let g = Script::refuse("guest trapped (fuel exhausted)", &[("partial", b"half")]);
let o = run_job(&f.spec, &g, &f.key).unwrap();
assert_eq!(refused_reason(&o), "guest trapped (fuel exhausted)");
assert!(o.signed.result.outputs.is_empty());
assert!(names_in(&f.files()).is_empty());
assert_eq!(o.signed.result.events[0].decision, Decision::Killed);
}
#[test]
fn traversal_name_from_the_guest_gives_refused_and_nothing_escapes() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("../../escaped", b"x")]), &f.key).unwrap();
assert!(refused_reason(&o).starts_with("output refused"));
assert!(!f.root.join("escaped").exists());
assert!(!f.root.parent().unwrap().join("escaped").exists());
assert!(names_in(&f.files()).is_empty());
}
#[test]
fn absolute_name_from_the_guest_gives_refused() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("/etc/x", b"x")]), &f.key).unwrap();
assert!(!o.is_accepted());
}
#[test]
fn hidden_name_from_the_guest_gives_refused() {
let f = Fixture::new();
assert!(
!run_job(&f.spec, &Script::accept(&[(".profile", b"x")]), &f.key)
.unwrap()
.is_accepted()
);
}
#[test]
fn non_ascii_name_from_the_guest_gives_refused() {
let f = Fixture::new();
assert!(
!run_job(&f.spec, &Script::accept(&[("rapport-å.txt", b"x")]), &f.key)
.unwrap()
.is_accepted()
);
}
#[test]
fn refused_output_is_logged_as_a_refused_emit_event() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::accept(&[("ok", b"1"), ("a/b", b"2")]),
&f.key,
)
.unwrap();
let last = o.signed.result.events.last().unwrap();
assert_eq!(last.call, "emit-file");
assert_eq!(last.decision, Decision::Refused);
}
#[test]
fn one_bad_name_discards_the_good_outputs_written_before_it() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::accept(&[("good", b"1"), ("../bad", b"2")]),
&f.key,
)
.unwrap();
assert!(!o.is_accepted());
assert!(o.signed.result.outputs.is_empty());
assert!(names_in(&f.files()).is_empty());
}
#[test]
fn duplicate_name_from_the_guest_gives_refused_and_no_outputs() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::accept(&[("x", b"1"), ("x", b"2")]),
&f.key,
)
.unwrap();
assert!(refused_reason(&o).contains("never overwritten"));
assert!(names_in(&f.files()).is_empty());
}
#[test]
fn too_many_files_gives_refused_and_no_outputs() {
let p = PolicyFile::parse("version = 1\n[limits]\nmax_output_files = 2\n").unwrap();
let f = Fixture::with_policy(p);
let o = run_job(
&f.spec,
&Script::accept(&[("a", b""), ("b", b""), ("c", b"")]),
&f.key,
)
.unwrap();
assert!(refused_reason(&o).contains("too many output files"));
assert!(names_in(&f.files()).is_empty());
}
#[test]
fn oversized_file_gives_refused() {
let p = PolicyFile::parse("version = 1\n[limits]\nmax_output_file_bytes = 4\n").unwrap();
let f = Fixture::with_policy(p);
let o = run_job(&f.spec, &Script::accept(&[("big", b"12345")]), &f.key).unwrap();
assert!(refused_reason(&o).contains("larger than the limit"));
}
#[test]
fn total_output_cap_gives_refused() {
let p = PolicyFile::parse(
"version = 1\n[limits]\nmax_output_file_bytes = 4\nmax_output_total_bytes = 6\n",
)
.unwrap();
let f = Fixture::with_policy(p);
let o = run_job(
&f.spec,
&Script::accept(&[("a", b"1234"), ("b", b"123")]),
&f.key,
)
.unwrap();
assert!(refused_reason(&o).contains("together are larger"));
assert!(names_in(&f.files()).is_empty());
}
#[test]
fn guest_output_named_result_signed_never_replaces_the_real_result() {
let f = Fixture::new();
let o = run_job(
&f.spec,
&Script::accept(&[(RESULT_FILE, b"forged")]),
&f.key,
)
.unwrap();
assert_eq!(std::fs::read(f.out().join(RESULT_FILE)).unwrap(), o.wire);
assert_eq!(
std::fs::read(f.files().join(RESULT_FILE)).unwrap(),
b"forged"
);
}
// ---- the job does not start ----
#[test]
fn empty_input_dir_stops_the_job_before_the_guest_runs_and_no_out_dir_is_made() {
let f = Fixture::new();
std::fs::remove_file(f.spec.input_dir.join("bundle.bin")).unwrap();
let g = Script::accept(&[]);
assert!(matches!(
run_job(&f.spec, &g, &f.key),
Err(JobError::Input(_))
));
assert_eq!(g.calls.get(), 0);
assert!(!f.out().exists());
}
#[test]
fn input_above_max_input_bytes_stops_the_job() {
let p = PolicyFile::parse("version = 1\n[limits]\nmax_input_bytes = 5\n").unwrap();
let f = Fixture::with_policy(p);
let g = Script::accept(&[]);
assert!(matches!(
run_job(&f.spec, &g, &f.key),
Err(JobError::Input(_))
));
assert_eq!(g.calls.get(), 0);
}
#[test]
fn existing_out_dir_stops_the_job_and_is_left_untouched() {
let f = Fixture::new();
std::fs::create_dir(f.out()).unwrap();
std::fs::write(f.out().join("keep"), b"mine").unwrap();
let g = Script::accept(&[("keep", b"theirs")]);
let e = run_job(&f.spec, &g, &f.key).unwrap_err();
assert!(matches!(e, JobError::OutDir(OutDirError::AlreadyExists(_))));
assert_eq!(g.calls.get(), 0);
assert_eq!(std::fs::read(f.out().join("keep")).unwrap(), b"mine");
}
#[test]
fn out_dir_symlink_stops_the_job_and_its_target_gets_nothing() {
let f = Fixture::new();
let target = f.root.join("target");
std::fs::create_dir(&target).unwrap();
std::os::unix::fs::symlink(&target, f.out()).unwrap();
assert!(run_job(&f.spec, &Script::accept(&[("x", b"y")]), &f.key).is_err());
assert!(names_in(&target).is_empty());
}
// ---- the caller's side ----
#[test]
fn result_signed_by_another_boot_key_is_refused() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
let other = Verifier::new(BootKey::generate().pinned()).unwrap();
assert!(matches!(
other.verify(&o.wire),
Err(VerifyError::WrongKey { .. })
));
}
#[test]
fn result_from_a_key_with_the_same_seed_but_another_boot_is_refused() {
let f = Fixture::new();
let k1 = BootKey::from_seed([7; 32], BootId([1; 16]));
let k2 = BootKey::from_seed([7; 32], BootId([2; 16]));
let o = run_job(&f.spec, &Script::accept(&[]), &k1).unwrap();
let v = Verifier::new(k2.pinned()).unwrap();
assert!(matches!(
v.verify(&o.wire),
Err(VerifyError::WrongBoot { .. })
));
}
#[test]
fn replayed_result_is_refused_the_second_time() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
let mut v = f.verifier();
v.accept(&o.wire, &f.spec.ticket()).unwrap();
assert!(matches!(
v.accept(&o.wire, &f.spec.ticket()),
Err(VerifyError::Replayed(_))
));
}
#[test]
fn result_for_another_job_ticket_is_refused() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
let mut ticket = f.spec.ticket();
ticket.nonce = heimdall_core::Nonce::random();
assert!(matches!(
f.verifier().accept(&o.wire, &ticket),
Err(VerifyError::NonceMismatch)
));
let mut ticket = f.spec.ticket();
ticket.job_id = heimdall_core::JobId::random();
assert!(matches!(
f.verifier().accept(&o.wire, &ticket),
Err(VerifyError::JobMismatch { .. })
));
}
#[test]
fn every_single_bit_flip_in_the_result_file_is_refused() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("a", b"1")]), &f.key).unwrap();
let v = f.verifier();
for i in 0..o.wire.len() {
for bit in [0u8, 3, 7] {
let mut w = o.wire.clone();
w[i] ^= 1 << bit;
assert!(v.verify(&w).is_err(), "flip byte {i} bit {bit} accepted");
}
}
}
#[test]
fn every_truncation_of_the_result_file_is_refused() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("a", b"1")]), &f.key).unwrap();
let v = f.verifier();
for n in 0..o.wire.len() {
assert!(v.verify(&o.wire[..n]).is_err(), "truncated to {n} accepted");
}
}
#[test]
fn trailing_bytes_after_the_result_are_refused() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[]), &f.key).unwrap();
let mut w = o.wire.clone();
w.push(0);
assert!(f.verifier().verify(&w).is_err());
}
#[test]
fn a_changed_output_file_is_caught_by_the_output_check() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("a", b"good")]), &f.key).unwrap();
std::fs::write(f.files().join("a"), b"evil").unwrap();
assert!(matches!(
check_outputs(&o.signed.result, &f.files()),
Err(VerifyError::Output(OutputError::HashMismatch { .. }))
));
}
#[test]
fn a_file_planted_beside_the_outputs_is_caught_by_the_output_check() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("a", b"good")]), &f.key).unwrap();
std::fs::write(f.files().join("extra"), b"x").unwrap();
assert!(matches!(
check_outputs(&o.signed.result, &f.files()),
Err(VerifyError::Output(OutputError::Unlisted(_)))
));
}
#[test]
fn a_removed_output_file_is_caught_by_the_output_check() {
let f = Fixture::new();
let o = run_job(&f.spec, &Script::accept(&[("a", b"good")]), &f.key).unwrap();
std::fs::remove_file(f.files().join("a")).unwrap();
assert!(matches!(
check_outputs(&o.signed.result, &f.files()),
Err(VerifyError::Output(OutputError::Missing(_)))
));
}