Gunnar

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(_)))
    ));
}