jobs/runner/src/tests.rs
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use heimdall_core::{
verify, ExitInfo, Features, Job, JobId, JobResult, JobRunner, JobTicket, KillReason, NetAllow,
Nonce, Output, Policy, Protection, RunError, Sha256Digest, Verdict,
};
use holger_sandbox::{Engine, EngineKind, Refusal, Sandbox, SandboxConfig};
use crate::wire::{Answer, Dirs, WireJob, JOB_SCHEMA};
use crate::*;
// ── helpers ─────────────────────────────────────────────────────────────────
/// The host path a `run` argument list mounts at `container` (read-only or not).
fn host_of(args: &[String], container: &str) -> Option<PathBuf> {
args.iter()
.filter_map(|a| a.strip_prefix("--volume="))
.find_map(|v| {
let v = v.strip_suffix(":ro").or_else(|| v.strip_suffix(":rw"))?;
let (h, c) = v.rsplit_once(':')?;
(c == container).then(|| PathBuf::from(h))
})
}
/// An engine that plays the worker: the program `prog` over the mounted
/// directories, its answer on stdout. Records the limits and job it saw.
struct Plays {
prog: Arc<Program>,
seen: Arc<Mutex<Vec<(holger_sandbox::Limits, WireJob)>>>,
}
use crate::wire::Program;
impl Engine for Plays {
fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
Ok(())
}
fn run(
&self,
cfg: &SandboxConfig,
_: &str,
args: &[String],
) -> Result<holger_sandbox::Output, Refusal> {
let job: WireJob =
serde_json::from_slice(&std::fs::read(host_of(args, "/in/job.json").unwrap()).unwrap())
.unwrap();
self.seen
.lock()
.unwrap()
.push((cfg.limits.clone(), job.clone()));
let dirs = Dirs {
input: host_of(args, "/in/i").expect("the input at /in/i"),
read: (0..job.reads)
.map(|n| host_of(args, &format!("/in/r{n}")).unwrap())
.collect(),
out: PathBuf::from(holger_sandbox::out_of(args).expect("the output at /out")),
};
let a = (self.prog)(&job, &dirs);
Ok(holger_sandbox::Output {
report: serde_json::to_vec(&a).unwrap(),
elapsed: std::time::Duration::ZERO,
})
}
}
/// An engine that refuses every run with `r`.
struct Fails(Refusal);
impl Engine for Fails {
fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
Ok(())
}
fn run(
&self,
_: &SandboxConfig,
_: &str,
_: &[String],
) -> Result<holger_sandbox::Output, Refusal> {
Err(self.0.clone())
}
}
type Seen = Arc<Mutex<Vec<(holger_sandbox::Limits, WireJob)>>>;
fn runner(prog: Arc<Program>) -> (ContainerRunner, Seen) {
let seen = Seen::default();
let sb = Sandbox::with_engine(
SandboxConfig::default(),
Box::new(Plays {
prog,
seen: seen.clone(),
}),
)
.unwrap();
(
ContainerRunner::with_key(sb, ResultKey::from_seed([9; 32])),
seen,
)
}
/// The copy program: `in/a` → `out/a`.
fn copy_a() -> Arc<Program> {
Arc::new(|_job: &WireJob, d: &Dirs| {
std::fs::copy(d.input.join("a"), d.out.join("a")).unwrap();
Answer::accepted(vec!["a".into()])
})
}
struct Dirs2 {
_root: tempfile::TempDir,
input: PathBuf,
out: PathBuf,
}
fn dirs(a: &[u8]) -> Dirs2 {
let root = tempfile::tempdir().unwrap();
let input = root.path().join("in");
let out = root.path().join("out");
std::fs::create_dir(&input).unwrap();
std::fs::create_dir(&out).unwrap();
std::fs::write(input.join("a"), a).unwrap();
Dirs2 {
_root: root,
input,
out,
}
}
fn job(d: &Dirs2) -> Job {
Job::new(
worker(&["copy"]),
d.input.clone(),
d.out.clone(),
serde_json::json!({"x": 1}),
Policy::default(),
)
}
// ── keys and ids ────────────────────────────────────────────────────────────
#[test]
fn every_boot_and_every_job_gets_its_own() {
let (a, b) = (ResultKey::per_boot(), ResultKey::per_boot());
assert_ne!(a.verifying_key(), b.verifying_key());
assert_ne!(JobId::random(), JobId::random());
assert_ne!(Nonce::random(), Nonce::random());
let w = key_words(&a.verifying_key());
assert!(
w.starts_with("ed25519:") && w.len() == "ed25519:".len() + 32,
"{w}"
);
}
// ── the output check ────────────────────────────────────────────────────────
#[test]
fn the_declared_files_are_hashed_and_listed() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("a"), b"alpha").unwrap();
std::fs::write(d.path().join("b"), b"").unwrap();
let (v, outs) = collect::collect(&Answer::accepted(vec!["b".into(), "a".into()]), d.path());
assert_eq!(v, Verdict::Accepted);
assert_eq!(outs, vec![Output::of("a", b"alpha"), Output::of("b", b"")]);
}
fn refused_code(v: &Verdict) -> &str {
match v {
Verdict::Refused { code, .. } => code,
Verdict::Accepted => "accepted",
}
}
#[test]
fn an_undeclared_a_linked_or_a_missing_output_refuses_and_hands_over_nothing() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("a"), b"alpha").unwrap();
std::fs::write(d.path().join("planted"), b"x").unwrap();
let (v, outs) = collect::collect(&Answer::accepted(vec!["a".into()]), d.path());
assert_eq!(refused_code(&v), "holger-sandbox-16", "{v:?}");
assert!(outs.is_empty());
assert_eq!(
std::fs::read_dir(d.path()).unwrap().count(),
0,
"a refused job hands over nothing"
);
std::os::unix::fs::symlink("/etc/passwd", d.path().join("a")).unwrap();
let (v, _) = collect::collect(&Answer::accepted(vec!["a".into()]), d.path());
assert_eq!(refused_code(&v), "holger-sandbox-16", "{v:?}");
let (v, _) = collect::collect(&Answer::accepted(vec!["gone".into()]), d.path());
assert_eq!(refused_code(&v), "holger-sandbox-17", "{v:?}");
let (v, _) = collect::collect(&Answer::accepted(vec!["../x".into()]), d.path());
assert_eq!(refused_code(&v), "holger-sandbox-17", "{v:?}");
}
#[test]
fn a_jobs_own_refusal_stands_and_hands_over_nothing() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("half"), b"x").unwrap();
let (v, outs) = collect::collect(&Answer::refused("holger-import-5", "tampered"), d.path());
assert_eq!(
v,
Verdict::Refused {
code: "holger-import-5".into(),
reason: "tampered".into()
}
);
assert!(outs.is_empty());
assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 0);
}
// ── the caller's check ──────────────────────────────────────────────────────
fn signed_copy() -> (
ContainerRunner,
Dirs2,
heimdall_core::SignedResult,
JobTicket,
) {
let (r, _) = runner(copy_a());
let d = dirs(b"alpha");
let j = job(&d);
let t = j.ticket();
let s = r.run(j).unwrap();
(r, d, s, t)
}
#[test]
fn a_good_run_is_accepted_with_its_outputs() {
let (r, d, s, t) = signed_copy();
let res = accept(&s, &r.verifying_key(), &t, &d.out).unwrap();
assert_eq!(res.verdict, Verdict::Accepted);
assert_eq!(res.outputs, vec![Output::of("a", b"alpha")]);
assert_eq!(res.runner, r.features());
assert!(res.finished_unix >= res.started_unix && res.started_unix > 1_700_000_000);
assert_eq!(std::fs::read(d.out.join("a")).unwrap(), b"alpha");
}
#[test]
fn a_forged_a_foreign_or_a_replayed_result_is_refused() {
let (r, d, s, t) = signed_copy();
let pinned = r.verifying_key();
let mut forged = s.clone();
forged.result.outputs[0].sha256 = Sha256Digest::of(b"evil");
let e = accept(&forged, &pinned, &t, &d.out).unwrap_err();
assert_eq!(e.code.code, "holger-sandbox-18", "{e}");
let theirs = ResultKey::from_seed([1; 32])
.sign(s.result.clone())
.unwrap();
assert_eq!(
accept(&theirs, &pinned, &t, &d.out).unwrap_err().code.code,
"holger-sandbox-18"
);
// The genuine result of one job, offered as the answer to another.
let d2 = dirs(b"alpha");
let other = job(&d2).ticket();
let e = accept(&s, &pinned, &other, &d.out).unwrap_err();
assert_eq!(e.code.code, "holger-sandbox-18");
assert!(
e.why.contains(&t.job_id.to_string()) && e.why.contains("replayed"),
"{e}"
);
// The same id with another nonce is another run too.
let e = accept(
&s,
&pinned,
&JobTicket {
job_id: t.job_id,
nonce: Nonce::random(),
},
&d.out,
)
.unwrap_err();
assert_eq!(e.code.code, "holger-sandbox-18");
}
#[test]
fn an_output_changed_after_the_run_is_refused() {
let (r, d, s, t) = signed_copy();
// The copy kept the input's read-only mode; the attacker here may write anyway.
std::fs::remove_file(d.out.join("a")).unwrap();
std::fs::write(d.out.join("a"), b"alphA").unwrap();
let e = accept(&s, &r.verifying_key(), &t, &d.out).unwrap_err();
assert_eq!(e.code.code, "holger-sandbox-19", "{e}");
std::fs::remove_file(d.out.join("a")).unwrap();
std::fs::write(d.out.join("a"), b"alpha").unwrap();
std::fs::write(d.out.join("b"), b"planted").unwrap();
assert_eq!(
accept(&s, &r.verifying_key(), &t, &d.out)
.unwrap_err()
.code
.code,
"holger-sandbox-19"
);
}
// ── the container runner ────────────────────────────────────────────────────
#[test]
fn the_job_reaches_the_worker_as_its_wire_document_under_its_own_limits() {
let (r, seen) = runner(copy_a());
let d = dirs(b"alpha");
let mut j = job(&d);
let id = j.id;
j.policy.limits = heimdall_core::Limits {
wall: std::time::Duration::from_secs(9),
memory_bytes: 512 << 20,
cpus: 0.5,
pids: 32,
..Default::default()
};
let rules = tempfile::tempdir().unwrap();
j.policy.read = vec![rules.path().to_path_buf()];
r.run(j).unwrap();
let (l, w) = seen.lock().unwrap()[0].clone();
assert_eq!(
(l.timeout_secs, l.memory_mib, l.cpu_millis, l.pids),
(9, 512, 500, 32)
);
assert!(l.scratch_mib < l.memory_mib);
assert_eq!(w.schema, JOB_SCHEMA);
assert_eq!(w.job_id, id.to_hex());
assert_eq!(w.argv, ["copy"]);
assert_eq!(w.params, serde_json::json!({"x": 1}));
assert_eq!(w.reads, 1);
}
#[test]
fn a_job_killed_by_a_limit_is_a_signed_refusal_and_no_engine_is_an_error() {
let d = dirs(b"alpha");
let sb = Sandbox::with_engine(
SandboxConfig::default(),
Box::new(Fails(Refusal::Timeout { secs: 3 })),
)
.unwrap();
let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
let s = r.run(job(&d)).unwrap();
let res = verify(&s, &r.verifying_key()).unwrap();
assert_eq!(refused_code(&res.verdict), "holger-sandbox-2");
assert!(res.exit.killed == Some(KillReason::Wall) && res.outputs.is_empty());
let sb = Sandbox::with_engine(
SandboxConfig::default(),
Box::new(Fails(Refusal::Killed {
exit: Some(137),
oom: true,
stderr: String::new(),
})),
)
.unwrap();
let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
let res = r.run(job(&dirs(b"a"))).unwrap().result;
assert_eq!(refused_code(&res.verdict), "holger-sandbox-3");
assert_eq!(
res.exit,
ExitInfo {
code: Some(137),
signal: None,
killed: Some(KillReason::Memory)
}
);
let sb = Sandbox::with_engine(
SandboxConfig::default(),
Box::new(Fails(Refusal::WorkerFailed {
exit: Some(101),
stderr: "panicked at the index".into(),
})),
)
.unwrap();
let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
let res = r.run(job(&dirs(b"a"))).unwrap().result;
assert_eq!(
refused_code(&res.verdict),
"holger-sandbox-9",
"a crashed worker fails only its job: {res:?}"
);
let sb = Sandbox::with_engine(
SandboxConfig::default(),
Box::new(Fails(Refusal::NoEngine("no podman".into()))),
)
.unwrap();
let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
assert!(matches!(
r.run(job(&dirs(b"a"))),
Err(RunError::Setup { .. })
));
}
#[test]
fn a_wasm_job_a_network_a_foreign_image_or_a_used_output_is_refused_before_it_runs() {
let (r, seen) = runner(copy_a());
let d = dirs(b"a");
let mut w = job(&d);
w.kind = heimdall_core::JobKind::wasm(heimdall_core::GuestRef {
path: "/g.wasm".into(),
blake3: "00".into(),
});
assert!(matches!(r.run(w), Err(RunError::Unsupported { .. })));
let mut n = job(&d);
n.policy.net = Some(NetAllow {
peers: vec![heimdall_core::NetPeer::new(
"upstream.test",
"10.0.0.1",
443,
)],
});
assert!(matches!(r.run(n), Err(RunError::Unsupported { .. })));
let mut i = job(&d);
i.kind = heimdall_core::JobKind::Native {
image: heimdall_core::ImageRef {
name: "docker.io/evil".into(),
digest: String::new(),
},
argv: vec!["copy".into()],
};
assert!(matches!(r.run(i), Err(RunError::Unsupported { .. })));
let mut wr = job(&d);
wr.policy.write = vec!["/etc".into()];
assert!(matches!(r.run(wr), Err(RunError::Unsupported { .. })));
std::fs::write(d.out.join("old"), b"x").unwrap();
assert!(matches!(r.run(job(&d)), Err(RunError::InvalidJob { .. })));
assert!(seen.lock().unwrap().is_empty(), "nothing ran");
}
#[test]
fn the_interim_runner_says_what_it_does_not_have() {
for (engine, runner, container, seccomp) in [
(
EngineKind::Podman,
"holger-podman",
true,
Protection::Default,
),
(EngineKind::Crun, "holger-crun", true, Protection::Default),
(
EngineKind::Remote,
"holger-sidecar",
false,
Protection::None,
),
] {
let f = ContainerRunner::features_of(engine);
assert_eq!(
f,
Features {
runner: runner.into(),
container,
seccomp,
landlock: false,
broker: false,
wasm: false,
image_verified_each_start: false
},
"{engine:?}"
);
let rows = feature_rows(&f);
assert!(rows[0].2.contains("not Heimdall"), "{rows:?}");
assert!(rows
.iter()
.any(|(k, has, w)| *k == "broker" && !has && w.contains("no syscall broker")));
assert!(rows.iter().any(|(k, has, w)| *k == "seccomp"
&& !has
&& (w.contains("not one generated") || w.contains("no seccomp"))));
}
}
/// Lane HOL-HEIMDALL-02: the producer (this runner's signing) and the verifier (`accept`)
/// are Heimdall 0.2 in ONE build: every result is the canonical wire v2
/// (`heimdall.signed-result.v2`), and a v1 result (Heimdall 0.1's) is refused.
#[test]
fn the_signed_result_is_heimdalls_wire_v2_and_a_v1_result_is_refused() {
let (r, d, s, t) = signed_copy();
assert_eq!(s.version, 2, "the producer signs Heimdall 0.2's wire v2");
let bytes = s.to_bytes();
assert_eq!(&bytes[..8], b"HMDL-SR\0");
assert_eq!(
&bytes[8..10],
&2u16.to_be_bytes(),
"the wire's version field"
);
assert!(accept(&s, &r.verifying_key(), &t, &d.out).is_ok());
let mut v1 = s.clone();
v1.version = 1;
let refused = accept(&v1, &r.verifying_key(), &t, &d.out).unwrap_err();
assert!(refused.why.contains('1'), "{refused}");
}
#[test]
fn a_result_names_the_job_and_the_features_that_protected_it() {
let (r, d, s, t) = signed_copy();
let JobResult {
job_id,
nonce,
runner,
..
} = verify(&s, &r.verifying_key()).unwrap().clone();
assert_eq!((job_id, nonce), (t.job_id, t.nonce));
assert_eq!(runner, ContainerRunner::features_of(EngineKind::Podman));
let _ = d;
}
// ── the test runner ─────────────────────────────────────────────────────────
#[cfg(feature = "test-runner")]
#[test]
fn the_test_runner_runs_the_program_in_process_and_survives_its_panic() {
let r = InProcessRunner::new(copy_a());
let d = dirs(b"alpha");
let j = job(&d);
let t = j.ticket();
let s = r.run(j).unwrap();
let res = accept(&s, &r.verifying_key(), &t, &d.out).unwrap();
assert_eq!(res.verdict, Verdict::Accepted);
assert_eq!(res.runner.runner, "holger-in-process-test");
assert!(!res.runner.container && res.runner.seccomp == Protection::None);
let boom = InProcessRunner::new(Arc::new(|_: &WireJob, _: &Dirs| -> Answer {
panic!("a malformed index")
}));
let d = dirs(b"alpha");
let res = boom.run(job(&d)).unwrap().result;
assert_eq!(refused_code(&res.verdict), "holger-sandbox-9", "{res:?}");
assert!(
matches!(&res.verdict, Verdict::Refused { reason, .. } if reason.contains("a malformed index"))
);
}
#[test]
fn a_dirs_paths_follow_the_engine() {
let c = Dirs::under(Path::new("/in"), 2);
assert_eq!(c.input, Path::new("/in/i"));
assert_eq!(c.read, [PathBuf::from("/in/r0"), PathBuf::from("/in/r1")]);
assert_eq!(c.out, Path::new("/out"));
let s = Dirs::under(Path::new("/run/holger-scan/holger-scan-x"), 0);
assert_eq!(s.out, Path::new("/run/holger-scan/holger-scan-x/out"));
}