nordisk/heimdall
crates/heimdall-plain/tests/runner_fake_shim.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! PlainYoukiRunner end to end with a FAKE shim (a shell script that plays
//! the container and answers a scripted response): runs in plain CI, no
//! namespaces needed. Proves the job checks, image verification each start,
//! output listing, verdict mapping, signing and features. Heimdall is a
//! defensive sandbox; these tests exercise only its own logic.
mod common;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use common::*;
use heimdall_core::*;
use heimdall_plain::*;
struct Fx {
base: PathBuf,
layout: Layout,
image: ImageRef,
}
fn fx(name: &str) -> Fx {
let base = scratch(name);
let layout = Layout::new(&base.join("layout"));
let image = layout.image(
&[(
Layout::tar(&[
Entry::Dir("bin"),
Entry::File("bin/tool".into(), b"#!/bin/sh\n".to_vec(), 0o755),
]),
true,
)],
&["PATH=/bin"],
);
Fx {
base,
layout,
image,
}
}
/// A runner whose shim is `sh <script>`; the script first drains stdin, then
/// runs `body` (which may write into the job's output dir) and prints `resp`.
fn runner(f: &Fx, body: &str, resp: &str) -> PlainYoukiRunner {
let script = f.base.join(format!("shim-{}.sh", JobId::random()));
std::fs::write(
&script,
format!("cat > /dev/null\n{body}\ncat <<'EOF'\n{resp}\nEOF\n"),
)
.unwrap();
let mut cfg = PlainConfig::new(&f.layout.dir, f.base.join("work"));
cfg.shim = ShimCommand {
program: "/bin/sh".into(),
args: vec![script.display().to_string()],
};
PlainYoukiRunner::with_key(cfg, BootKey::from_seed([7; 32], BootId([1; 16])))
}
const OK: &str = r#"{"error":null,"exit_code":0,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":3}"#;
fn job(f: &Fx, d: &JobDirs) -> Job {
native_job(&f.image, &["/bin/tool"], d)
}
fn run_ok(f: &Fx, body: &str, resp: &str) -> (PlainYoukiRunner, JobDirs, SignedResult) {
let d = job_dirs(&f.base);
let body = body.replace("OUT", &d.output.display().to_string());
let r = runner(f, &body, resp);
let s = r.run(job(f, &d)).unwrap();
(r, d, s)
}
#[test]
fn an_accepted_run_lists_and_hashes_the_outputs_and_verifies() {
let f = fx("ok");
let (r, d, s) = run_ok(&f, "printf hello > OUT/a; printf x > OUT/b", OK);
let res = verify(&s, &r.verifying_key()).unwrap();
assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
assert_eq!(
res.outputs,
vec![Output::of("a", b"hello"), Output::of("b", b"x")]
);
assert_eq!(res.exit, ExitInfo::exited(0));
check_outputs(res, &d.output).unwrap();
}
#[test]
fn every_result_states_the_plain_features() {
let f = fx("feat");
let (r, _d, s) = run_ok(&f, "", OK);
assert_eq!(s.result.runner, plain_features());
assert_eq!(r.features(), plain_features());
let ft = r.features();
assert_eq!(ft.runner, "youki-plain");
assert!(ft.container);
assert_eq!(ft.seccomp, Protection::Default);
assert!(!ft.landlock && !ft.broker && !ft.wasm);
assert!(ft.image_verified_each_start);
}
#[test]
fn a_wall_kill_is_refused_with_the_wall_code() {
let f = fx("wall");
let (_r, _d, s) = run_ok(
&f,
"",
r#"{"error":null,"exit_code":null,"signal":9,"wall_killed":true,"oom_kills":0,"pids_max":0,"ran_ms":20000}"#,
);
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_WALL));
assert_eq!(s.result.exit.killed, Some(KillReason::Wall));
assert_eq!(s.result.exit.signal, Some(9));
}
#[test]
fn an_oom_kill_is_refused_with_the_memory_code() {
let f = fx("oom");
let (_r, _d, s) = run_ok(
&f,
"",
r#"{"error":null,"exit_code":null,"signal":9,"wall_killed":false,"oom_kills":1,"pids_max":0,"ran_ms":5}"#,
);
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_MEMORY));
assert_eq!(s.result.exit.killed, Some(KillReason::Memory));
}
#[test]
fn a_nonzero_exit_is_refused_and_outputs_are_still_listed() {
let f = fx("exit");
let (_r, _d, s) = run_ok(
&f,
"printf r > OUT/report",
r#"{"error":null,"exit_code":3,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":5}"#,
);
assert_eq!(s.result.verdict.code(), Some(codes::EXIT_NONZERO));
assert_eq!(s.result.exit.code, Some(3));
assert_eq!(s.result.outputs, vec![Output::of("report", b"r")]);
}
#[test]
fn a_pids_limit_hit_is_named_in_the_reason() {
let f = fx("pids");
let (_r, _d, s) = run_ok(
&f,
"",
r#"{"error":null,"exit_code":2,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":4,"ran_ms":5}"#,
);
let Verdict::Refused { reason, .. } = &s.result.verdict else {
panic!()
};
assert!(reason.contains("process limit"), "{reason}");
}
#[test]
fn a_signal_death_is_refused_with_the_signal_code() {
let f = fx("sig");
let (_r, _d, s) = run_ok(
&f,
"",
r#"{"error":null,"exit_code":null,"signal":11,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":5}"#,
);
assert_eq!(s.result.verdict.code(), Some(codes::EXIT_SIGNAL));
}
#[test]
fn an_extra_directory_in_output_dir_is_refused_out_1() {
let f = fx("xdir");
let (_r, _d, s) = run_ok(&f, "mkdir OUT/sub", OK);
assert_eq!(s.result.verdict.code(), Some(codes::OUT_EXTRA));
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
assert!(s.result.outputs.is_empty());
}
#[test]
fn an_extra_symlink_in_output_dir_is_refused_out_1() {
let f = fx("xlink");
let (_r, _d, s) = run_ok(&f, "ln -s /etc/passwd OUT/passwd", OK);
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}
#[test]
fn an_extra_fifo_in_output_dir_is_refused_out_1() {
let f = fx("xfifo");
let (_r, _d, s) = run_ok(&f, "mkfifo OUT/pipe", OK);
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}
#[test]
fn a_hidden_or_bad_name_is_not_a_plain_listing_problem_unless_invalid() {
// Dot files are valid output names in heimdall-core; a control character is not.
let f = fx("xname");
let (_r, _d, s) = run_ok(&f, "printf x > \"OUT/$(printf 'a\\tb')\"", OK);
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}
#[test]
fn more_files_than_max_output_files_is_refused_out_1() {
let f = fx("xmany");
let d = job_dirs(&f.base);
let body = format!("for i in 1 2 3 4; do : > {}/f$i; done", d.output.display());
let r = runner(&f, &body, OK);
let mut j = job(&f, &d);
j.policy.limits.max_output_files = 3;
let s = r.run(j).unwrap();
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}
#[test]
fn an_output_over_max_output_file_bytes_is_refused_out_2() {
let f = fx("xbig");
let d = job_dirs(&f.base);
let body = format!("printf 0123456789 > {}/big", d.output.display());
let r = runner(&f, &body, OK);
let mut j = job(&f, &d);
j.policy.limits.max_output_file_bytes = 9;
let s = r.run(j).unwrap();
assert_eq!(s.result.verdict.code(), Some(codes::OUT_TOO_LARGE));
}
#[test]
fn outputs_over_max_output_total_bytes_are_refused_out_2() {
let f = fx("xtotal");
let d = job_dirs(&f.base);
let body = format!(
"printf 01234 > {o}/a; printf 56789 > {o}/b",
o = d.output.display()
);
let r = runner(&f, &body, OK);
let mut j = job(&f, &d);
j.policy.limits.max_output_total_bytes = 9;
j.policy.limits.max_output_file_bytes = 9;
let s = r.run(j).unwrap();
assert_eq!(s.result.verdict.code(), Some(codes::OUT_TOO_LARGE));
}
#[test]
fn a_file_added_after_the_run_fails_check_outputs() {
let f = fx("late");
let (r, d, s) = run_ok(&f, "printf a > OUT/a", OK);
std::fs::write(d.output.join("late"), b"x").unwrap();
let res = verify(&s, &r.verifying_key()).unwrap();
assert_eq!(
check_outputs(res, &d.output),
Err(VerifyError::Output(OutputError::Unlisted("late".into())))
);
}
#[test]
fn a_changed_output_after_the_run_fails_check_outputs() {
let f = fx("chg");
let (r, d, s) = run_ok(&f, "printf hello > OUT/a", OK);
std::fs::write(d.output.join("a"), b"hellO").unwrap();
assert!(matches!(
check_outputs(verify(&s, &r.verifying_key()).unwrap(), &d.output),
Err(VerifyError::Output(OutputError::HashMismatch { .. }))
));
}
#[test]
fn a_tampered_signature_and_a_different_key_fail_verify() {
let f = fx("sig2");
let (r, _d, s) = run_ok(&f, "", OK);
let mut t = s.clone();
t.sig.0[10] ^= 4;
assert_eq!(
verify(&t, &r.verifying_key()),
Err(VerifyError::BadSignature)
);
let other = BootKey::from_seed([8; 32], BootId([2; 16]));
assert!(matches!(
verify(&s, &other.verifying_key()),
Err(VerifyError::WrongKey { .. })
));
}
#[test]
fn a_shim_error_is_a_setup_error_and_nothing_is_signed() {
let f = fx("shimerr");
let d = job_dirs(&f.base);
let r = runner(
&f,
"",
r#"{"error":"create: no user namespaces","exit_code":null,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":0}"#,
);
assert!(matches!(r.run(job(&f, &d)), Err(RunError::Setup { .. })));
}
#[test]
fn a_missing_shim_is_a_setup_error() {
let f = fx("noshim");
let d = job_dirs(&f.base);
let mut cfg = PlainConfig::new(&f.layout.dir, f.base.join("work"));
cfg.shim = ShimCommand {
program: f.base.join("does-not-exist"),
args: vec![],
};
let r = PlainYoukiRunner::new(cfg);
assert!(matches!(r.run(job(&f, &d)), Err(RunError::Setup { .. })));
}
#[test]
fn a_garbled_shim_response_is_a_setup_error() {
let f = fx("garble");
let d = job_dirs(&f.base);
let r = runner(&f, "", "not json");
assert!(matches!(r.run(job(&f, &d)), Err(RunError::Setup { .. })));
}
#[test]
fn a_wasm_job_is_unsupported() {
let f = fx("wasm");
let d = job_dirs(&f.base);
let r = runner(&f, "", OK);
let mut j = job(&f, &d);
j.kind = JobKind::Wasm {
module: GuestRef {
path: "/g.wasm".into(),
blake3: "00".repeat(32),
},
};
assert!(matches!(r.run(j), Err(RunError::Unsupported { .. })));
}
#[test]
fn a_network_peer_is_unsupported() {
let f = fx("net");
let d = job_dirs(&f.base);
let r = runner(&f, "", OK);
let mut j = job(&f, &d);
j.policy.net = Some(NetAllow {
host: "pypi.org".into(),
port: 443,
});
assert!(matches!(r.run(j), Err(RunError::Unsupported { .. })));
}
type JobMut = Box<dyn Fn(&mut Job)>;
#[test]
fn bad_jobs_are_invalid_before_anything_runs() {
let f = fx("bad");
let marker = f.base.join("ran");
let r = runner(&f, &format!(": > {}", marker.display()), OK);
let d = job_dirs(&f.base);
let muts: Vec<(&str, JobMut)> = vec![
(
"unpinned digest",
Box::new(|j| {
if let JobKind::Native { image, .. } = &mut j.kind {
image.digest = "latest".into();
}
}),
),
(
"empty argv",
Box::new(|j| {
if let JobKind::Native { argv, .. } = &mut j.kind {
argv.clear();
}
}),
),
(
"zero wall",
Box::new(|j| j.policy.limits.wall = std::time::Duration::ZERO),
),
("nan cpus", Box::new(|j| j.policy.limits.cpus = f32::NAN)),
("negative cpus", Box::new(|j| j.policy.limits.cpus = -1.0)),
("zero pids", Box::new(|j| j.policy.limits.pids = 0)),
(
"tiny memory",
Box::new(|j| j.policy.limits.memory_bytes = 1024),
),
(
"relative read path",
Box::new(|j| j.policy.read = vec!["rel".into()]),
),
(
"dotdot write path",
Box::new(|j| j.policy.write = vec!["/a/../b".into()]),
),
(
"missing input",
Box::new(|j| j.input_dir = "/nonexistent-heimdall".into()),
),
(
"missing read path",
Box::new(|j| j.policy.read = vec!["/nonexistent-heimdall".into()]),
),
];
for (what, m) in muts {
let mut j = job(&f, &d);
m(&mut j);
assert!(
matches!(r.run(j), Err(RunError::InvalidJob { .. })),
"{what}"
);
}
assert!(!marker.exists(), "the shim must not have run");
}
#[test]
fn a_non_empty_output_dir_is_invalid() {
let f = fx("dirty");
let d = job_dirs(&f.base);
std::fs::write(d.output.join("stale"), b"").unwrap();
let r = runner(&f, "", OK);
assert!(matches!(
r.run(job(&f, &d)),
Err(RunError::InvalidJob { .. })
));
}
#[test]
fn a_reused_job_id_while_its_bundle_exists_is_invalid() {
let f = fx("reuse");
let d = job_dirs(&f.base);
let r = runner(&f, "", OK);
let j = job(&f, &d);
std::fs::create_dir_all(r.config().work_dir.join("jobs").join(j.id.to_hex())).unwrap();
assert!(matches!(r.run(j), Err(RunError::InvalidJob { .. })));
}
#[test]
fn the_bundle_is_removed_after_every_run() {
let f = fx("clean");
let (r, _d, _s) = run_ok(&f, "", OK);
let jobs = r.config().work_dir.join("jobs");
assert_eq!(std::fs::read_dir(jobs).unwrap().count(), 0);
}
#[test]
fn the_image_is_verified_again_on_every_start() {
let f = fx("reverify");
let r = runner(&f, "", OK);
let d1 = job_dirs(&f.base);
assert!(r.run(job(&f, &d1)).is_ok());
// Corrupt the layer blob between two jobs: the second start must notice.
let m = f.layout.manifest(&f.image);
let layer = m["layers"][0]["digest"].as_str().unwrap().to_string();
let p = f.layout.blob_path(&layer);
let mut b = std::fs::read(&p).unwrap();
let n = b.len();
b[n - 9] ^= 0xff;
std::fs::write(&p, b).unwrap();
let d2 = job_dirs(&f.base);
assert!(matches!(r.run(job(&f, &d2)), Err(RunError::Image { .. })));
}
#[test]
fn an_image_missing_from_the_layout_is_an_image_error() {
let f = fx("missing");
let r = runner(&f, "", OK);
let d = job_dirs(&f.base);
let mut j = job(&f, &d);
if let JobKind::Native { image, .. } = &mut j.kind {
image.digest = format!("sha256:{}", "ab".repeat(32));
}
assert!(matches!(r.run(j), Err(RunError::Image { .. })));
}
#[test]
fn the_config_given_to_the_shim_is_the_plain_spec() {
let f = fx("cfg");
let d = job_dirs(&f.base);
let dump = f.base.join("config-copy.json");
// The shim copies the bundle's config.json so the test can read it.
let script = f.base.join("dump.sh");
std::fs::write(
&script,
format!(
"req=$(cat)\nb=$(printf '%s' \"$req\" | sed 's/.*\"bundle\":\"\\([^\"]*\\)\".*/\\1/')\ncp \"$b/config.json\" {}\necho '{}'\n",
dump.display(),
OK
),
)
.unwrap();
let mut cfg = PlainConfig::new(&f.layout.dir, f.base.join("work"));
cfg.shim = ShimCommand {
program: "/bin/sh".into(),
args: vec![script.display().to_string()],
};
let r = PlainYoukiRunner::new(cfg);
r.run(job(&f, &d)).unwrap();
let c: serde_json::Value = serde_json::from_slice(&std::fs::read(&dump).unwrap()).unwrap();
assert_eq!(c["root"]["readonly"], true);
assert_eq!(c["process"]["args"][0], "/bin/tool");
assert_eq!(c["process"]["env"][0], "PATH=/bin");
let mounts = c["mounts"].as_array().unwrap();
let in_m = mounts.iter().find(|m| m["destination"] == "/in").unwrap();
assert_eq!(
Path::new(in_m["source"].as_str().unwrap()),
d.input.canonicalize().unwrap()
);
}
#[test]
fn two_jobs_run_in_parallel_through_a_shared_trait_object() {
let f = fx("par");
let r: Arc<dyn JobRunner> = Arc::new(runner(&f, "", OK));
let hs: Vec<_> = (0..4)
.map(|_| {
let r = Arc::clone(&r);
let d = job_dirs(&f.base);
let j = job(&f, &d);
std::thread::spawn(move || r.run(j))
})
.collect();
for h in hs {
let s = h.join().unwrap().unwrap();
assert!(verify(&s, &r.verifying_key()).is_ok());
}
}
#[test]
fn the_shim_is_found_through_the_environment_variable() {
// Only checks the lookup order; no process is started.
let c = ShimCommand::locate();
assert!(
c.program.ends_with("heimdall-plain-shim")
|| std::env::var_os("HEIMDALL_PLAIN_SHIM").is_some()
);
}