crates/heimdall-container/tests/runner_fake_shim.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! HeimdallRunner 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 at every
//! start, output listing, verdict mapping, signing, the truthful features,
//! and that a job whose protections were not really applied is never signed.
//! Heimdall is a defensive sandbox; these tests exercise only its own logic.
#[path = "../../heimdall-plain/tests/common/mod.rs"]
mod common;
use std::path::PathBuf;
use std::sync::Arc;
use common::*;
use heimdall_container::kernel::KernelReport;
use heimdall_container::shim::ShimRequest;
use heimdall_container::*;
use heimdall_core::*;
struct Fx {
base: PathBuf,
layout: Layout,
image: ImageRef,
}
fn fx(name: &str) -> Fx {
let base = scratch(&format!("hc-{name}"));
let layout = Layout::new(&base.join("layout"));
let image = layout.image(
&[(
Layout::tar(&[
Entry::Dir("bin"),
Entry::Dir("etc"),
Entry::File("bin/tool".into(), b"#!/bin/sh\n".to_vec(), 0o755),
]),
true,
)],
&["PATH=/bin"],
);
Fx {
base,
layout,
image,
}
}
fn kernel() -> KernelReport {
KernelReport {
release: "7.0.0".into(),
seccomp_filter: true,
user_notif: true,
notif_addfd: true,
landlock_abi: Some(8),
cgroup_v2: true,
cgroup_controllers: vec!["cpu".into(), "memory".into(), "pids".into()],
user_namespaces: true,
}
}
const SEED: [u8; 32] = [7; 32];
/// A config whose shim is `sh <script>`; the script saves its request to
/// `<base>/req-<n>.json`, runs `body`, and prints `resp`.
fn cfg_with(f: &Fx, body: &str, resp: &str) -> HeimdallConfig {
let script = f.base.join(format!("shim-{}.sh", JobId::random()));
std::fs::write(
&script,
format!(
"cat > {}/req-$$.json\n{body}\ncat <<'EOF'\n{resp}\nEOF\n",
f.base.display()
),
)
.unwrap();
let mut cfg = HeimdallConfig::new(&f.layout.dir, f.base.join("work"));
cfg.shim = ShimCommand {
program: "/bin/sh".into(),
args: vec![script.display().to_string()],
};
cfg
}
fn runner_cfg(cfg: HeimdallConfig) -> HeimdallRunner {
HeimdallRunner::with_kernel(cfg, BootKey::from_seed(SEED, BootId([1; 16])), kernel()).unwrap()
}
fn runner(f: &Fx, body: &str, resp: &str) -> HeimdallRunner {
runner_cfg(cfg_with(f, body, resp))
}
fn requests(f: &Fx) -> Vec<String> {
std::fs::read_dir(&f.base)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with("req-"))
.map(|e| std::fs::read_to_string(e.path()).unwrap())
.collect()
}
const INSIDE: &str = r#"{"no_new_privs":true,"seccomp_mode":2,"landlock":"Full","error":null}"#;
/// The broker's report of a full-mode run with one allowed open.
const BROKER: &str = r#"{"job_id":"00000000000000000000000000000000","mode":"full","events":[{"at_ms":1,"call":"openat","args":["path=/in/a","flags=O_RDONLY","fd=3"],"decision":"Allowed"}],"dropped_events":0,"warnings":[]}"#;
/// A shim response. Unless the test says otherwise, the inside report says
/// the broker filter was installed (full) and the shim served it.
fn resp(exit: &str, extra: &str, inside: &str) -> String {
let inside = if inside.starts_with('{') && !inside.contains(r#""broker""#) {
inside.replacen('{', r#"{"broker":"full","#, 1)
} else {
inside.to_string()
};
let extra = if extra.contains(r#""broker""#) {
extra.to_string()
} else {
format!(r#"{extra},"broker":{BROKER}"#)
};
format!(
r#"{{"error":null,{exit},"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":3,"swap_max":"0","inside":{inside}{extra}}}"#
)
}
fn ok() -> String {
resp(r#""exit_code":0,"signal":null"#, "", INSIDE)
}
fn job(f: &Fx, d: &JobDirs) -> Job {
native_job(&f.image, &["/bin/tool"], d)
}
fn run_ok(f: &Fx, body: &str, resp: &str) -> (HeimdallRunner, 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)
}
fn setup_err(r: Result<SignedResult, RunError>) -> String {
match r {
Err(RunError::Setup { reason }) => reason,
other => panic!("expected a setup error, nothing signed: {other:?}"),
}
}
#[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_what_the_job_really_got() {
let f = fx("feat");
let (r, _d, s) = run_ok(&f, "", &ok());
assert_eq!(s.result.runner, r.features());
assert_eq!(s.result.runner.runner, "heimdall");
assert!(s.result.runner.landlock);
assert_eq!(s.result.runner.seccomp, Protection::Default);
assert!(s.result.runner.broker && !s.result.runner.wasm);
assert!(s.result.runner.image_verified_each_start);
}
#[test]
fn every_signed_result_names_the_kernel_features_the_runner_got() {
let f = fx("kernelfeat");
let (r, _d, s) = run_ok(&f, "", &ok());
let k = s
.result
.kernel
.clone()
.expect("the result names its kernel");
assert_eq!(k, r.kernel().features());
assert_eq!(k, kernel().features());
assert_eq!(k.release, "7.0.0");
assert_eq!(k.landlock_abi, Some(8));
assert!(k.seccomp_filter && k.user_notif && k.notif_addfd);
assert_eq!(k.cgroup_controllers, ["cpu", "memory", "pids"]);
// and it is inside the signature
let mut forged = s.clone();
forged.result.kernel.as_mut().unwrap().landlock_abi = None;
assert!(verify(&forged, &r.verifying_key()).is_err());
assert!(verify(&s, &r.verifying_key()).is_ok());
}
#[test]
fn a_partially_enforced_landlock_still_counts_as_landlock() {
let f = fx("partial");
let inside = INSIDE.replace("Full", "Partial");
let (_r, _d, s) = run_ok(&f, "", &resp(r#""exit_code":0,"signal":null"#, "", &inside));
assert!(s.result.runner.landlock);
}
#[test]
fn no_inside_report_when_landlock_was_expected_is_a_setup_error() {
let f = fx("noinside");
let d = job_dirs(&f.base);
let r = runner(&f, "", &resp(r#""exit_code":0,"signal":null"#, "", "null"));
let why = setup_err(r.run(job(&f, &d)));
assert!(why.contains("inside"), "{why}");
}
#[test]
fn landlock_not_applied_inside_the_job_is_a_setup_error_never_a_signed_result() {
let f = fx("nolandlock");
let d = job_dirs(&f.base);
let inside = r#"{"no_new_privs":true,"seccomp_mode":2,"landlock":null,"error":null}"#;
let r = runner(&f, "", &resp(r#""exit_code":0,"signal":null"#, "", inside));
let why = setup_err(r.run(job(&f, &d)));
assert!(why.contains("Landlock"), "{why}");
}
#[test]
fn an_executor_error_inside_the_job_is_a_setup_error() {
let f = fx("execerr");
let d = job_dirs(&f.base);
let inside =
r#"{"no_new_privs":true,"seccomp_mode":2,"landlock":null,"error":"landlock: EINVAL"}"#;
let r = runner(&f, "", &resp(r#""exit_code":1,"signal":null"#, "", inside));
let why = setup_err(r.run(job(&f, &d)));
assert!(why.contains("EINVAL"), "{why}");
}
#[test]
fn no_seccomp_filter_or_no_new_privs_inside_is_a_setup_error() {
for inside in [
INSIDE.replace(r#""seccomp_mode":2"#, r#""seccomp_mode":0"#),
INSIDE.replace(r#""no_new_privs":true"#, r#""no_new_privs":false"#),
] {
let f = fx("noseccomp");
let d = job_dirs(&f.base);
let r = runner(&f, "", &resp(r#""exit_code":0,"signal":null"#, "", &inside));
setup_err(r.run(job(&f, &d)));
}
}
#[test]
fn swap_not_zero_is_a_setup_error() {
let f = fx("swap");
let d = job_dirs(&f.base);
let body = ok().replace(r#""swap_max":"0""#, r#""swap_max":"max""#);
let r = runner(&f, "", &body);
let why = setup_err(r.run(job(&f, &d)));
assert!(why.contains("swap"), "{why}");
}
#[test]
fn with_landlock_off_the_request_has_no_plan_and_the_result_says_landlock_false() {
let f = fx("off");
let mut cfg = cfg_with(
&f,
"",
&resp(
r#""exit_code":0,"signal":null"#,
"",
r#"{"no_new_privs":true,"seccomp_mode":2,"landlock":null,"error":null}"#,
),
);
cfg.strict = false;
cfg.landlock = LandlockMode::Off;
let r = runner_cfg(cfg);
let d = job_dirs(&f.base);
let s = r.run(job(&f, &d)).unwrap();
assert!(!s.result.runner.landlock);
assert!(!r.features().landlock);
let req: ShimRequest = serde_json::from_str(&requests(&f)[0]).unwrap();
assert_eq!(req.landlock, None);
}
#[test]
fn the_request_carries_the_landlock_plan_and_no_key_material() {
let f = fx("req");
let (r, _d, _s) = run_ok(&f, "", &ok());
let raw = &requests(&f)[0];
let req: ShimRequest = serde_json::from_str(raw).unwrap();
let plan = req.landlock.expect("a plan");
assert_eq!(plan.write_only, vec![PathBuf::from("/out")]);
assert!(plan.read.contains(&"/in".into()));
assert_eq!(
plan.read_exec,
vec![PathBuf::from("/bin"), PathBuf::from("/etc")]
);
let seed_hex = heimdall_core::hex::encode(&SEED);
assert!(!raw.contains(&seed_hex));
let _ = r.pinned();
}
#[test]
fn a_wall_kill_is_refused_with_the_wall_code() {
let f = fx("wall");
let body = resp(r#""exit_code":null,"signal":9"#, "", INSIDE)
.replace(r#""wall_killed":false"#, r#""wall_killed":true"#);
let (_r, _d, s) = run_ok(&f, "", &body);
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_WALL));
assert_eq!(s.result.exit.killed, Some(KillReason::Wall));
}
#[test]
fn an_oom_kill_is_refused_with_the_memory_code() {
let f = fx("oom");
let body = resp(r#""exit_code":null,"signal":9"#, "", INSIDE)
.replace(r#""oom_kills":0"#, r#""oom_kills":1"#);
let (_r, _d, s) = run_ok(&f, "", &body);
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_MEMORY));
assert_eq!(s.result.exit.killed, Some(KillReason::Memory));
}
#[test]
fn a_nonzero_exit_and_a_signal_death_are_refused_with_their_codes() {
let f = fx("exit");
let (_r, _d, s) = run_ok(
&f,
"printf r > OUT/report",
&resp(r#""exit_code":3,"signal":null"#, "", INSIDE),
);
assert_eq!(s.result.verdict.code(), Some(codes::EXIT_NONZERO));
assert_eq!(s.result.outputs, vec![Output::of("report", b"r")]);
let (_r, _d, s) = run_ok(&f, "", &resp(r#""exit_code":null,"signal":9"#, "", INSIDE));
assert_eq!(s.result.verdict.code(), Some(codes::EXIT_SIGNAL));
assert_eq!(s.result.exit.signal, Some(9));
// SIGSYS = the seccomp profile killed it: its own stable code.
let (_r, _d, s) = run_ok(&f, "", &resp(r#""exit_code":null,"signal":31"#, "", INSIDE));
assert_eq!(s.result.verdict.code(), Some(codes::SECCOMP_KILL));
assert_eq!(s.result.exit.signal, Some(31));
}
#[test]
fn an_extra_directory_or_symlink_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));
let (_r, _d, s) = run_ok(&f, "ln -s /etc/passwd OUT/passwd", &ok());
assert_eq!(s.result.verdict.code(), Some(codes::OUT_EXTRA));
}
#[test]
fn a_shim_error_a_missing_shim_or_a_garbled_response_is_a_setup_error() {
let f = fx("shimerr");
let d = job_dirs(&f.base);
let r = runner(&f, "", r#"{"error":"create: no user namespaces"}"#);
setup_err(r.run(job(&f, &d)));
let d = job_dirs(&f.base);
let r = runner(&f, "", "not json");
setup_err(r.run(job(&f, &d)));
let mut cfg = HeimdallConfig::new(&f.layout.dir, f.base.join("work"));
cfg.shim = ShimCommand {
program: f.base.join("does-not-exist"),
args: vec![],
};
let d = job_dirs(&f.base);
setup_err(runner_cfg(cfg).run(job(&f, &d)));
}
#[test]
fn a_wasm_job_is_unsupported_and_a_peer_named_not_addressed_is_invalid() {
let f = fx("unsup");
let r = runner(&f, "", &ok());
let d = job_dirs(&f.base);
let mut j = job(&f, &d);
j.kind = JobKind::Wasm {
module: GuestRef {
path: "/g.wasm".into(),
blake3: "00".repeat(32),
},
decoder: None,
};
assert!(matches!(r.run(j), Err(RunError::Unsupported { .. })));
let mut j = job(&f, &d);
j.policy.net = Some(NetAllow::one("pypi.org", "pypi.org", 443));
match r.run(j) {
Err(RunError::InvalidJob { reason }) => {
assert!(reason.contains("not an IP literal"), "{reason}")
}
other => panic!("{other:?}"),
}
}
#[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);
type JobMut = Box<dyn Fn(&mut Job)>;
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)),
("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()),
),
(
"a policy path over /in",
Box::new(|j| j.policy.read = vec!["/in".into()]),
),
(
"a policy path over /proc",
Box::new(|j| j.policy.write = vec!["/proc/x".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_or_a_reused_job_id_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 { .. })
));
let d = job_dirs(&f.base);
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 a_changed_image_byte_is_refused_at_the_next_start() {
let f = fx("reverify");
let r = runner(&f, "", &ok());
let d1 = job_dirs(&f.base);
assert!(r.run(job(&f, &d1)).is_ok());
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 the_config_given_to_the_shim_is_heimdalls_spec() {
let f = fx("cfg");
let d = job_dirs(&f.base);
let dump = f.base.join("config-copy.json");
let body = format!(
"b=$(sed 's/.*\"bundle\":\"\\([^\"]*\\)\".*/\\1/' {}/req-$$.json)\ncp \"$b/config.json\" {}",
f.base.display(),
dump.display()
);
let r = runner(&f, &body, &ok());
r.run(job(&f, &d)).unwrap();
let c: serde_json::Value = serde_json::from_slice(&std::fs::read(&dump).unwrap()).unwrap();
assert_eq!(c["linux"]["seccomp"], profile::seccomp_json());
assert_eq!(c["root"]["readonly"], true);
assert!(c["process"]["rlimits"]
.as_array()
.unwrap()
.iter()
.any(|r| r["type"] == "RLIMIT_NPROC"));
let seed_hex = heimdall_core::hex::encode(&SEED);
assert!(!std::fs::read_to_string(&dump).unwrap().contains(&seed_hex));
}
#[test]
fn 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..8)
.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_or_next_to_the_executable() {
let c = locate_shim();
assert!(
c.program.ends_with("heimdall-container-shim")
|| std::env::var_os("HEIMDALL_CONTAINER_SHIM").is_some()
);
}
// ==== generated seccomp profiles (lane genprof) ====
/// The checked-in wasm-host profile for this arch, as a job's reference.
fn builtin_ref() -> (SeccompProfileRef, String) {
let e = heimdall_profiles::ALL
.iter()
.find(|e| Some(e.profile.arch) == heimdall_seccomp::Arch::native())
.expect("a checked-in profile for this arch");
let d = Sha256Digest::of(e.oci_json.as_bytes()).to_prefixed();
(
SeccompProfileRef {
source: ProfileSource::Workload(e.profile.workload.into()),
sha256: d.clone(),
fallback_to_default: false,
},
d,
)
}
fn inside_generated(d: &str) -> String {
format!(
r#"{{"no_new_privs":true,"seccomp_mode":2,"landlock":"Full","error":null,"generated":"{d}"}}"#
)
}
fn run_job_with(
f: &Fx,
body: &str,
resp: &str,
m: impl FnOnce(&mut Job),
) -> (HeimdallRunner, Result<SignedResult, RunError>) {
let d = job_dirs(&f.base);
let r = runner(f, body, resp);
let mut j = job(f, &d);
m(&mut j);
let s = r.run(j);
(r, s)
}
#[test]
fn a_job_naming_a_generated_profile_sends_it_to_the_shim_and_its_result_says_generated_with_the_digest(
) {
let f = fx("gen-ok");
let (pr, d) = builtin_ref();
let resp = resp(r#""exit_code":0,"signal":null"#, "", &inside_generated(&d));
let (r, s) = run_job_with(&f, "", &resp, |j| j.policy.seccomp = Some(pr));
let s = s.unwrap();
let res = verify(&s, &r.verifying_key()).unwrap();
assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
assert_eq!(
res.runner.seccomp,
Protection::Generated {
profile_digest: d.clone()
}
);
// The runner's own features still say what a job WITHOUT a profile gets.
assert_eq!(r.features().seccomp, Protection::Default);
let req: ShimRequest = serde_json::from_str(&requests(&f)[0]).unwrap();
let g = req.generated.expect("the shim was asked to apply it");
assert_eq!(g.digest, d);
assert!(!g.profile.rules.is_empty());
}
#[test]
fn a_generated_profile_the_executor_did_not_confirm_is_never_signed() {
let f = fx("gen-unconfirmed");
let (pr, _d) = builtin_ref();
// The executor's report has no `generated`: it was not installed.
let (_r, s) = run_job_with(&f, "", &ok(), |j| j.policy.seccomp = Some(pr.clone()));
assert!(setup_err(s).contains("not confirmed"));
// A report naming ANOTHER digest is no confirmation either.
let other = format!("sha256:{}", "0".repeat(64));
let resp = resp(
r#""exit_code":0,"signal":null"#,
"",
&inside_generated(&other),
);
let (_r, s) = run_job_with(&f, "", &resp, |j| j.policy.seccomp = Some(pr));
assert!(setup_err(s).contains("not confirmed"));
}
#[test]
fn a_tampered_or_missing_profile_refuses_the_job_before_the_shim_runs() {
let f = fx("gen-tamper");
let (mut pr, d) = builtin_ref();
let mut bad = d.clone().into_bytes();
let last = bad.len() - 1;
bad[last] = if bad[last] == b'0' { b'1' } else { b'0' };
pr.sha256 = String::from_utf8(bad).unwrap();
let (_r, s) = run_job_with(&f, "", &ok(), |j| j.policy.seccomp = Some(pr.clone()));
match s {
Err(RunError::InvalidJob { reason }) => {
assert!(reason.contains("digest mismatch"), "{reason}")
}
other => panic!("expected a refusal: {other:?}"),
}
pr.source = ProfileSource::Workload("no-such-workload".into());
pr.sha256 = d;
let (_r, s) = run_job_with(&f, "", &ok(), |j| j.policy.seccomp = Some(pr.clone()));
match s {
Err(RunError::InvalidJob { reason }) => assert!(reason.contains("missing"), "{reason}"),
other => panic!("expected a refusal: {other:?}"),
}
pr.sha256 = "sha256:not-hex".into();
assert!(matches!(
run_job_with(&f, "", &ok(), |j| j.policy.seccomp = Some(pr)).1,
Err(RunError::InvalidJob { .. })
));
assert!(requests(&f).is_empty(), "the shim never ran");
}
#[test]
fn with_fallback_a_tampered_profile_runs_under_the_fixed_one_and_says_default() {
let f = fx("gen-fallback");
let (mut pr, _d) = builtin_ref();
pr.sha256 = format!("sha256:{}", "ab".repeat(32));
pr.fallback_to_default = true;
let (_r, s) = run_job_with(&f, "", &ok(), |j| j.policy.seccomp = Some(pr));
let s = s.unwrap();
assert_eq!(s.result.runner.seccomp, Protection::Default);
let req: ShimRequest = serde_json::from_str(&requests(&f)[0]).unwrap();
assert!(req.generated.is_none());
}
#[test]
fn a_runner_configured_with_a_generated_profile_says_generated_in_features_and_every_result() {
let f = fx("gen-cfg");
let (pr, d) = builtin_ref();
let resp = resp(r#""exit_code":0,"signal":null"#, "", &inside_generated(&d));
let mut cfg = cfg_with(&f, "", &resp);
cfg.seccomp = Some(pr);
let r = runner_cfg(cfg);
let want = Protection::Generated { profile_digest: d };
assert_eq!(r.features().seccomp, want);
let dirs = job_dirs(&f.base);
let s = r.run(job(&f, &dirs)).unwrap();
assert_eq!(s.result.runner.seccomp, want);
assert_eq!(s.result.runner, r.features());
}
#[test]
fn a_runner_configured_with_a_tampered_profile_refuses_to_start() {
let f = fx("gen-cfg-bad");
let (mut pr, _d) = builtin_ref();
pr.sha256 = format!("sha256:{}", "cd".repeat(32));
let mut cfg = cfg_with(&f, "", &ok());
cfg.seccomp = Some(pr.clone());
let e = HeimdallRunner::with_kernel(cfg, BootKey::from_seed(SEED, BootId([1; 16])), kernel())
.unwrap_err();
assert!(e.to_string().contains("digest mismatch"), "{e}");
// With the fallback it starts and says Default.
pr.fallback_to_default = true;
let mut cfg = cfg_with(&f, "", &ok());
cfg.seccomp = Some(pr);
assert_eq!(runner_cfg(cfg).features().seccomp, Protection::Default);
}
#[test]
fn a_sigsys_death_under_a_generated_profile_is_a_signed_refusal_naming_the_digest() {
let f = fx("gen-sigsys");
let (pr, d) = builtin_ref();
let resp = resp(r#""exit_code":null,"signal":31"#, "", &inside_generated(&d));
let (r, s) = run_job_with(&f, "", &resp, |j| j.policy.seccomp = Some(pr));
let res = verify(&s.unwrap(), &r.verifying_key()).unwrap().clone();
assert_eq!(res.verdict.code(), Some(codes::SECCOMP_KILL));
match &res.verdict {
Verdict::Refused { reason, .. } => assert!(reason.contains(&d), "{reason}"),
v => panic!("{v:?}"),
}
assert_eq!(
res.runner.seccomp,
Protection::Generated { profile_digest: d }
);
}
// ==== the pids limit and self-consistent output refusals ====
fn run_pids(f: &Fx, exit: &str, pids_max: u64, body: &str) -> (JobDirs, SignedResult) {
let d = job_dirs(&f.base);
let body = body.replace("OUT", &d.output.display().to_string());
let resp =
resp(exit, "", INSIDE).replace(r#""pids_max":0"#, &format!(r#""pids_max":{pids_max}"#));
let r = runner(f, &body, &resp);
let s = r.run(job(f, &d)).unwrap();
verify(&s, &r.verifying_key()).unwrap();
(d, s)
}
#[test]
fn a_job_that_hit_the_pids_limit_and_still_exited_zero_is_refused_heimdall_limit_pids() {
let f = fx("pids0");
let (d, s) = run_pids(
&f,
r#""exit_code":0,"signal":null"#,
3,
"printf r > OUT/report",
);
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_PIDS));
match &s.result.verdict {
Verdict::Refused { reason, .. } => assert!(reason.contains("process limit"), "{reason}"),
v => panic!("{v:?}"),
}
// The job's own outputs stay listed (like a non-zero exit) and check.
assert_eq!(s.result.outputs, vec![Output::of("report", b"r")]);
check_outputs(&s.result, &d.output).unwrap();
}
#[test]
fn a_nonzero_exit_after_hitting_the_pids_limit_is_also_heimdall_limit_pids() {
let f = fx("pids1");
let (_d, s) = run_pids(&f, r#""exit_code":2,"signal":null"#, 1, "");
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_PIDS));
let (_d, s) = run_pids(&f, r#""exit_code":0,"signal":null"#, 0, "");
assert!(s.result.verdict.is_accepted());
}
#[test]
fn an_out_1_or_out_2_refusal_leaves_output_dir_empty_so_the_signed_empty_list_checks() {
let f = fx("outcons");
for body in [
"printf a > OUT/a; mkdir OUT/sub; printf x > OUT/sub/hidden",
"printf a > OUT/a; ln -s /etc/passwd OUT/passwd",
] {
let (_r, d, s) = run_ok(&f, body, &ok());
assert_eq!(s.result.verdict.code(), Some(codes::OUT_EXTRA));
assert!(s.result.outputs.is_empty());
assert_eq!(std::fs::read_dir(&d.output).unwrap().count(), 0, "{body}");
check_outputs(&s.result, &d.output).unwrap();
}
// out-2: one file over max_output_file_bytes.
let d = job_dirs(&f.base);
let body = format!(
"head -c 2048 /dev/zero > {}/big; printf a > {}/a",
d.output.display(),
d.output.display()
);
let r = runner(&f, &body, &ok());
let mut j = job(&f, &d);
j.policy.limits.max_output_file_bytes = 1024;
let s = r.run(j).unwrap();
assert_eq!(s.result.verdict.code(), Some(codes::OUT_TOO_LARGE));
assert!(s.result.outputs.is_empty());
assert_eq!(std::fs::read_dir(&d.output).unwrap().count(), 0);
check_outputs(&s.result, &d.output).unwrap();
}
// ---- the syscall broker (spec §4, §10) ----
fn broker_req(f: &Fx) -> serde_json::Value {
let r = requests(f);
assert_eq!(r.len(), 1);
let v: serde_json::Value = serde_json::from_str(&r[0]).unwrap();
v["broker"].clone()
}
#[test]
fn the_shim_is_asked_for_the_broker_in_full_mode_with_the_jobs_policy() {
let f = fx("brokerreq");
let d = job_dirs(&f.base);
let r = runner(&f, "", &ok());
let mut j = job(&f, &d);
j.policy.read = vec![f.base.clone()];
r.run(j).unwrap();
let b = broker_req(&f);
assert_eq!(b["mode"], "full");
let pre: Vec<(String, String)> = b["policy"]["prefixes"]
.as_array()
.unwrap()
.iter()
.map(|p| {
(
p["path"].as_str().unwrap().to_string(),
p["access"].as_str().unwrap().to_string(),
)
})
.collect();
let has = |p: &str, a: &str| pre.iter().any(|(x, y)| x == p && y == a);
assert!(
has("/", "read") && has("/in", "read") && has("/out", "write_only"),
"{pre:?}"
);
assert!(
has("/tmp", "read_write") && has("/proc", "read") && has("/dev/null", "read_write"),
"{pre:?}"
);
assert!(
has(f.base.to_str().unwrap(), "read"),
"policy.read is a read prefix: {pre:?}"
);
assert_eq!(b["policy"]["fetch"], serde_json::json!([]), "no network");
assert_eq!(b["policy"]["exec"], "allow");
}
#[test]
fn the_brokers_decisions_are_signed_into_the_result_after_a_mode_event() {
let f = fx("brokerev");
let (r, _d, s) = run_ok(&f, "", &ok());
let res = verify(&s, &r.verifying_key()).unwrap();
assert!(res.runner.broker);
assert_eq!(res.events.len(), 2, "{:?}", res.events);
assert_eq!(res.events[0].call, "broker");
assert_eq!(res.events[0].args, vec!["mode=full".to_string()]);
assert_eq!(res.events[1].call, "openat");
assert_eq!(res.events[1].args[0], "path=/in/a");
}
#[test]
fn a_broker_filter_missing_inside_the_job_is_a_setup_error_never_signed() {
let f = fx("brokermissing");
let d = job_dirs(&f.base);
let inside =
r#"{"broker":null,"no_new_privs":true,"seccomp_mode":2,"landlock":"Full","error":null}"#;
let r = runner(&f, "", &resp(r#""exit_code":0,"signal":null"#, "", inside));
let why = setup_err(r.run(job(&f, &d)));
assert!(why.contains("broker filter"), "{why}");
}
#[test]
fn a_broker_that_never_served_or_ran_weaker_than_promised_is_a_setup_error() {
let f = fx("brokernone");
let d = job_dirs(&f.base);
let r = runner(
&f,
"",
&resp(
r#""exit_code":0,"signal":null"#,
r#","broker":null"#,
INSIDE,
),
);
let why = setup_err(r.run(job(&f, &d)));
assert!(why.contains("never served"), "{why}");
let weaker = BROKER.replace(r#""mode":"full""#, r#""mode":"check_only""#);
let r = runner(
&f,
"",
&resp(
r#""exit_code":0,"signal":null"#,
&format!(r#","broker":{weaker}"#),
INSIDE,
),
);
let why = setup_err(r.run(job(&f, &job_dirs(&f.base))));
assert!(why.contains("CheckOnly"), "{why}");
}
#[test]
fn on_a_kernel_without_addfd_jobs_get_check_only_a_warning_event_and_no_broker_feature() {
let f = fx("brokercheck");
let mut k = kernel();
k.notif_addfd = false;
let inside = INSIDE.replacen('{', r#"{"broker":"check_only","#, 1);
let rep = BROKER.replace(r#""mode":"full""#, r#""mode":"check_only""#);
let cfg = cfg_with(
&f,
"",
&resp(
r#""exit_code":0,"signal":null"#,
&format!(r#","broker":{rep}"#),
&inside,
),
);
let r = HeimdallRunner::with_kernel(cfg, BootKey::from_seed(SEED, BootId([1; 16])), k).unwrap();
assert!(!r.features().broker);
let d = job_dirs(&f.base);
let s = r.run(job(&f, &d)).unwrap();
assert!(!s.result.runner.broker);
assert_eq!(s.result.events[0].args[0], "mode=check-only");
assert!(
s.result.events[0].args[1].contains("not TOCTOU-safe"),
"{:?}",
s.result.events[0]
);
assert_eq!(broker_req(&f)["mode"], "check_only");
}
#[test]
fn on_a_kernel_without_user_notif_jobs_get_deny_only_and_no_broker_process() {
let f = fx("brokerdeny");
let mut k = kernel();
k.user_notif = false;
k.notif_addfd = false;
let inside = INSIDE.replacen('{', r#"{"broker":"deny_only","#, 1);
let cfg = cfg_with(
&f,
"",
&resp(
r#""exit_code":0,"signal":null"#,
r#","broker":null"#,
&inside,
),
);
let r = HeimdallRunner::with_kernel(cfg, BootKey::from_seed(SEED, BootId([1; 16])), k).unwrap();
assert!(!r.features().broker);
let d = job_dirs(&f.base);
let s = r.run(job(&f, &d)).unwrap();
assert!(!s.result.runner.broker);
assert_eq!(s.result.events.len(), 1);
assert_eq!(s.result.events[0].args[0], "mode=deny-only");
assert!(s.result.events[0].args[1].contains("fail closed"));
}
#[test]
fn with_the_broker_switched_off_the_shim_gets_no_broker_and_features_say_so() {
let f = fx("brokeroff");
let inside = INSIDE.replacen('{', r#"{"broker":null,"#, 1);
let mut cfg = cfg_with(
&f,
"",
&resp(
r#""exit_code":0,"signal":null"#,
r#","broker":null"#,
&inside,
),
);
cfg.broker = BrokerSetting::Off;
let r = runner_cfg(cfg);
assert!(!r.features().broker);
let s = r.run(job(&f, &job_dirs(&f.base))).unwrap();
assert!(!s.result.runner.broker && s.result.events.is_empty());
assert!(broker_req(&f).is_null());
}
// ==== host_ids: container root mapped to a host id range (0.2.1) ====
fn whoami() -> (u32, u32) {
use std::os::unix::fs::MetadataExt;
let m = std::fs::metadata("/proc/self").unwrap();
(m.uid(), m.gid())
}
/// Root with ids 10000..11000 mapped (the ownership half needs it).
fn can_map_10000() -> bool {
let p = scratch("hc-idmap-probe").join("f");
std::fs::write(&p, b"").unwrap();
whoami().0 == 0 && std::os::unix::fs::lchown(&p, Some(10_999), Some(10_999)).is_ok()
}
/// Run one job whose shim copies config.json to `<base>/config.json` and the
/// owners of the rootfs, a file in it, /in, /in/a and /out to `<base>/owners`.
fn run_dumping(f: &Fx, ids: Option<(u32, u32)>) -> (serde_json::Value, String) {
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"in").unwrap();
let body = format!(
"b=$(sed 's/.*\"bundle\":\"\\([^\"]*\\)\".*/\\1/' {base}/req-$$.json)\n\
cp \"$b/config.json\" {base}/config.json\n\
stat -c %u:%g \"$b/rootfs\" \"$b/rootfs/bin/tool\" {i} {i}/a {o} > {base}/owners",
base = f.base.display(),
i = d.input.display(),
o = d.output.display()
);
let mut r = runner(f, &body, &ok());
if let Some((b, c)) = ids {
r = r.with_host_ids(b, c).unwrap();
}
let s = r.run(job(f, &d)).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
(
serde_json::from_slice(&std::fs::read(f.base.join("config.json")).unwrap()).unwrap(),
std::fs::read_to_string(f.base.join("owners")).unwrap(),
)
}
#[test]
fn without_host_ids_heimdall_maps_container_root_to_its_own_uid_and_gid_as_in_0_2_0() {
let f = fx("idmap-off");
assert_eq!(runner(&f, "", &ok()).host_ids(), None);
let (c, owners) = run_dumping(&f, None);
let (uid, gid) = whoami();
assert_eq!(
c["linux"]["uidMappings"],
serde_json::json!([{"containerID": 0, "hostID": uid, "size": 1}])
);
assert_eq!(
c["linux"]["gidMappings"],
serde_json::json!([{"containerID": 0, "hostID": gid, "size": 1}])
);
for l in owners.lines() {
assert_eq!(l, format!("{uid}:{gid}"), "{owners}");
}
}
#[test]
fn with_host_ids_the_job_root_is_the_range_and_owns_its_root_input_and_output() {
let f = fx("idmap-on");
if whoami().0 != 0 {
let e = runner(&f, "", &ok())
.with_host_ids(10_000, 1000)
.unwrap_err();
assert!(
matches!(&e, RunError::Unsupported { what } if what.contains("host_ids")),
"{e}"
);
eprintln!("not root: refused as expected; the mapping half needs root");
return;
}
if !can_map_10000() {
eprintln!("skipped: ids 10000..11000 are not mapped here");
return;
}
let (c, owners) = run_dumping(&f, Some((10_000, 1000)));
let want = serde_json::json!([{"containerID": 0, "hostID": 10_000, "size": 1000}]);
assert_eq!(c["linux"]["uidMappings"], want);
assert_eq!(c["linux"]["gidMappings"], want);
// The rest of Heimdall's spec is unchanged.
assert_eq!(c["linux"]["seccomp"], profile::seccomp_json());
let lines: Vec<&str> = owners.lines().collect();
assert_eq!(lines, ["10000:10000"; 5], "{owners}");
}
#[test]
fn heimdall_with_host_ids_refuses_an_invalid_range_or_a_non_root_runner_by_name() {
let f = fx("idmap-refuse");
let mut cases = vec![(0, 10), (10_000, 0), (u32::MAX, 1), (u32::MAX - 5, 10)];
let (uid, gid) = whoami();
if uid != 0 {
cases.extend([(uid, 1), (gid, 1), (100_000, 65_536)]);
}
for (b, c) in cases {
let e = runner(&f, "", &ok()).with_host_ids(b, c).unwrap_err();
assert!(
matches!(&e, RunError::Unsupported { what } if what.contains("host_ids")),
"{b} {c}: {e}"
);
}
}
// ==== io limits (io.max) ====
/// A fake sysfs with one disk, 7:3.
fn io_sysfs(f: &Fx) -> PathBuf {
let s = f.base.join("sysfs");
let disk = s.join("devices/virtual/block/loop3");
std::fs::create_dir_all(&disk).unwrap();
std::fs::write(disk.join("dev"), "7:3\n").unwrap();
std::fs::create_dir_all(s.join("dev/block")).unwrap();
std::os::unix::fs::symlink(&disk, s.join("dev/block/7:3")).unwrap();
s
}
fn io_runner(f: &Fx, resp: &str) -> HeimdallRunner {
let mut cfg = cfg_with(f, "", resp);
cfg.sysfs_root = io_sysfs(f);
cfg.io_max = vec![iolimit::IoMax {
device: iolimit::IoDevice::Number(iolimit::DevNum { major: 7, minor: 3 }),
rates: iolimit::IoRates {
rbps: Some(2 << 20),
..Default::default()
},
}];
let mut k = kernel();
k.cgroup_controllers.push("io".into());
HeimdallRunner::with_kernel(cfg, BootKey::from_seed(SEED, BootId([1; 16])), k).unwrap()
}
#[test]
fn io_limits_not_read_back_from_the_job_cgroup_are_a_setup_error() {
let f = fx("io-none");
let d = job_dirs(&f.base);
let why = setup_err(io_runner(&f, &ok()).run(job(&f, &d)));
assert!(why.contains("io.max"), "{why}");
}
#[test]
fn io_limits_read_back_wrong_are_a_setup_error() {
let f = fx("io-wrong");
let d = job_dirs(&f.base);
let body = ok().replace(
r#""swap_max":"0""#,
r#""swap_max":"0","io_max":"7:3 rbps=max wbps=max riops=max wiops=max""#,
);
let why = setup_err(io_runner(&f, &body).run(job(&f, &d)));
assert!(why.contains("rbps=max"), "{why}");
}
#[test]
fn io_limits_read_back_right_are_signed() {
let f = fx("io-ok");
let d = job_dirs(&f.base);
let body = ok().replace(
r#""swap_max":"0""#,
r#""swap_max":"0","io_weight":"default 50","io_max":"7:3 rbps=2097152 wbps=max riops=max wiops=max\n""#,
);
let r = io_runner(&f, &body);
let s = r.run(job(&f, &d)).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
}
#[test]
fn the_shim_is_asked_for_io_weight_and_a_weight_not_read_back_is_a_setup_error() {
let f = fx("ioweight");
let mut k = kernel();
k.cgroup_controllers.push("io".into());
let mk = |resp: &str| {
HeimdallRunner::with_kernel(
cfg_with(&f, "", resp),
BootKey::from_seed(SEED, BootId([1; 16])),
k.clone(),
)
.unwrap()
};
let d = job_dirs(&f.base);
let why = setup_err(mk(&ok()).run(job(&f, &d)));
assert!(why.contains("io.weight"), "{why}");
assert!(
requests(&f).iter().all(|r| r.contains(r#""io_weight":50"#)),
"{:?}",
requests(&f)
);
let d = job_dirs(&f.base);
let wrong = ok().replace(
r#""swap_max":"0""#,
r#""swap_max":"0","io_weight":"default 7""#,
);
let why = setup_err(mk(&wrong).run(job(&f, &d)));
assert!(why.contains("default 7"), "{why}");
let d = job_dirs(&f.base);
let right = ok().replace(
r#""swap_max":"0""#,
r#""swap_max":"0","io_weight":"default 50""#,
);
let s = mk(&right).run(job(&f, &d)).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
}
#[test]
fn without_the_io_controller_no_io_weight_is_asked_or_checked() {
let f = fx("noioweight");
let (_r, _d, s) = run_ok(&f, "", &ok());
assert!(s.result.verdict.is_accepted());
assert!(requests(&f).iter().all(|r| !r.contains("io_weight")));
}
// ==== the job workspace (owner decision 2026-11-01) ====
fn ws_job(f: &Fx, d: &JobDirs, bytes: u64, exec: bool) -> Job {
let mut j = job(f, d);
j.policy.workspace = Some(Workspace { bytes, exec });
j
}
#[test]
fn an_exec_workspace_is_refused_unless_the_runner_allows_it() {
let f = fx("ws-exec");
let d = job_dirs(&f.base);
match runner(&f, "", &ok()).run(ws_job(&f, &d, 1 << 20, true)) {
Err(RunError::Unsupported { what }) => assert!(what.contains("exec"), "{what}"),
other => panic!("expected a refusal, got {other:?}"),
}
let mut cfg = cfg_with(&f, "", &ok());
cfg.allow_workspace_exec = true;
let d = job_dirs(&f.base);
let s = runner_cfg(cfg).run(ws_job(&f, &d, 1 << 20, true)).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
}
#[test]
fn a_tmpfs_workspace_is_in_the_landlock_plan_and_needs_no_host_folder() {
let f = fx("ws-tmpfs");
let d = job_dirs(&f.base);
let s = runner(&f, "", &ok())
.run(ws_job(&f, &d, 1 << 20, false))
.unwrap();
assert!(s.result.verdict.is_accepted());
let reqs = requests(&f);
let req: serde_json::Value = serde_json::from_str(&reqs[0]).unwrap();
assert!(
req["landlock"]["read_write"]
.as_array()
.unwrap()
.contains(&serde_json::json!("/work")),
"{req}"
);
assert!(req.get("workspace").is_none(), "{req}");
}
#[test]
fn a_host_workspace_exists_while_the_job_runs_and_is_deleted_after() {
let f = fx("ws-host");
let d = job_dirs(&f.base);
// The fake shim proves the folder is there (and writable) while it runs.
let body = format!(
"dir=$(sed -n 's/.*\"dir\":\"\\([^\"]*\\)\".*/\\1/p' {}/req-$$.json); test -d \"$dir\" && touch \"$dir/x\" && echo \"$dir\" > {}/ws-seen",
f.base.display(),
f.base.display()
);
let r = runner(&f, &body, &ok());
let s = r.run(ws_job(&f, &d, 2 << 30, false)).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let seen = std::fs::read_to_string(f.base.join("ws-seen")).expect("the shim saw the folder");
let seen = std::path::PathBuf::from(seen.trim());
assert!(
seen.starts_with(f.base.join("work/workspaces")),
"{}",
seen.display()
);
assert!(!seen.exists(), "the workspace outlived its job");
let req: serde_json::Value = serde_json::from_str(&requests(&f)[0]).unwrap();
assert_eq!(req["workspace"]["cap"], 2u64 << 30, "{req}");
}
#[test]
fn a_workspace_over_its_cap_is_refused_and_killed_for_it() {
let f = fx("ws-cap");
let d = job_dirs(&f.base);
let body = ok().replace(
r#""swap_max":"0""#,
r#""swap_max":"0","workspace_killed":true,"workspace_bytes":3221225472"#,
);
let s = runner(&f, "", &body)
.run(ws_job(&f, &d, 2 << 30, false))
.unwrap();
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_WORKSPACE));
assert_eq!(s.result.exit.killed, Some(KillReason::Workspace));
}
// ==== the jobs' parent cgroup exists before any shim starts ====
/// libcgroups 0.7.0 creates each missing path component with a plain
/// mkdir after an exists() check, so two parallel jobs that both find
/// `<root>/heimdall` missing race and one fails EEXIST ("failed to create
/// dir /sys/fs/cgroup/heimdall: File exists", 1 of 12 container-test runs
/// on oden). The runner creates the parent itself, race-free, first.
#[test]
fn the_runner_creates_the_jobs_parent_cgroup_before_the_shim_runs() {
let f = fx("cg-parent");
let d = job_dirs(&f.base);
let root = f.base.join("cgroot");
std::fs::create_dir_all(&root).unwrap();
let body = format!(
"test -d {}/heimdall && echo yes > {}/parent-seen",
root.display(),
f.base.display()
);
let mut cfg = cfg_with(&f, &body, &ok());
cfg.cgroup_root = root.clone();
let _ = runner_cfg(cfg).run(job(&f, &d));
assert!(
f.base.join("parent-seen").exists(),
"the shim started before <cgroup_root>/heimdall existed"
);
}
// ==== fetch jobs ====
#[test]
fn a_fetch_job_gets_its_declared_peers_in_the_broker_policy() {
let f = fx("fetchreq");
let d = job_dirs(&f.base);
let r = runner(&f, "", &ok());
let mut j = job(&f, &d);
j.policy.net = Some(NetAllow {
peers: vec![
NetPeer::new("pypi.org", "10.1.2.3", 443),
NetPeer::new("files.pythonhosted.org", "10.1.2.4", 443),
],
});
r.run(j).unwrap();
let req: serde_json::Value = serde_json::from_str(&requests(&f)[0]).unwrap();
assert_eq!(
req["broker"]["policy"]["fetch"],
serde_json::json!(["10.1.2.3:443", "10.1.2.4:443"])
);
assert_eq!(req["broker"]["mode"], "full");
}
#[test]
fn a_fetch_job_needs_the_broker_in_full_mode_and_a_valid_peer_set() {
let f = fx("fetchmode");
let peer = || Some(NetAllow::one("a.example", "10.1.2.3", 443));
let mut k = kernel();
k.notif_addfd = false;
let r = HeimdallRunner::with_kernel(cfg_with(&f, "", &ok()), BootKey::generate(), k).unwrap();
let mut j = job(&f, &job_dirs(&f.base));
j.policy.net = peer();
assert!(
matches!(r.run(j), Err(RunError::Unsupported { .. })),
"check-only cannot fetch"
);
let mut c = cfg_with(&f, "", &ok());
c.broker = BrokerSetting::Off;
let r = runner_cfg(c);
let mut j = job(&f, &job_dirs(&f.base));
j.policy.net = peer();
assert!(
matches!(r.run(j), Err(RunError::Unsupported { .. })),
"no broker cannot fetch"
);
let r = runner(&f, "", &ok());
let bad: Vec<(NetAllow, &str)> = vec![
(NetAllow { peers: vec![] }, "no peers"),
(
NetAllow {
peers: (0..17)
.map(|i| NetPeer::new(&format!("h{i}.example"), "10.0.0.1", 1000 + i))
.collect(),
},
"at most 16",
),
(
NetAllow::one("pypi.org", "pypi.org", 443),
"not an IP literal",
),
(NetAllow::one("a.example", "10.1.2.3", 0), "port 0"),
];
for (n, why) in bad {
let mut j = job(&f, &job_dirs(&f.base));
j.policy.net = Some(n);
match r.run(j) {
Err(RunError::InvalidJob { reason }) => assert!(reason.contains(why), "{reason}"),
other => panic!("{why}: {other:?}"),
}
}
assert!(requests(&f).is_empty(), "nothing ran");
}
// ==== RLIMIT_NOFILE set by the runner (0.3.1) ====
#[test]
fn with_nofile_is_kept_and_the_default_is_1024() {
let f = fx("nofile-ok");
assert_eq!(runner(&f, "", &ok()).nofile(), spec::NOFILE);
assert_eq!(
runner(&f, "", &ok()).with_nofile(4096).unwrap().nofile(),
4096
);
}
#[test]
fn with_nofile_refuses_too_few_too_many_or_more_than_the_runner_has_by_name() {
let f = fx("nofile-refuse");
let mut hard = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
assert_eq!(
unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut hard) },
0
);
for n in [
0,
spec::NOFILE_MIN - 1,
spec::NOFILE_MAX + 1,
u64::MAX,
hard.rlim_max + 1,
] {
if (spec::NOFILE_MIN..=spec::NOFILE_MAX).contains(&n) && n <= hard.rlim_max {
continue;
}
let e = runner(&f, "", &ok()).with_nofile(n).unwrap_err();
assert!(
matches!(&e, RunError::Unsupported { what } if what.contains("nofile")),
"{n}: {e}"
);
}
}
#[test]
fn with_nofile_reaches_the_jobs_config() {
let f = fx("nofile-cfg");
let d = job_dirs(&f.base);
let body = format!(
r#"b=$(sed 's/.*"bundle":"\([^"]*\)".*/\1/' {base}/req-$$.json)
cp "$b/config.json" {base}/config.json"#,
base = f.base.display()
);
let r = runner(&f, &body, &ok()).with_nofile(2048).unwrap();
let s = r.run(job(&f, &d)).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let c: serde_json::Value =
serde_json::from_slice(&std::fs::read(f.base.join("config.json")).unwrap()).unwrap();
let n = c["process"]["rlimits"]
.as_array()
.unwrap()
.iter()
.find(|r| r["type"] == "RLIMIT_NOFILE")
.cloned()
.unwrap();
assert_eq!(
(n["soft"].as_u64(), n["hard"].as_u64()),
(Some(2048), Some(2048))
);
}
// ── learning runs (probe-container) next to the broker ─────────────────────
fn learn_job(f: &Fx, d: &JobDirs) -> heimdall_container::learn::LearnJob {
heimdall_container::learn::LearnJob {
job: job(f, d),
env: vec![],
spec: heimdall_container::learn::LearnSpec::default(),
libc: Some(heimdall_seccomp::Libc::Musl),
term_grace: std::time::Duration::from_secs(1),
log: None,
}
}
/// A learning job declares no network: its job has an empty netns and no
/// broker to inject a peer, so `policy.net` is refused before anything runs.
#[test]
fn a_learning_job_with_network_peers_is_refused_before_anything_runs() {
let f = fx("learn-net");
let marker = f.base.join("ran");
let r = runner(&f, &format!(": > {}", marker.display()), &ok());
let d = job_dirs(&f.base);
let mut lj = learn_job(&f, &d);
lj.job.policy.net = Some(NetAllow::one("a.example", "10.1.2.3", 443));
let signals = std::sync::atomic::AtomicU64::new(0);
match r.learn(lj, &signals, |_| panic!("nothing starts")) {
Err(RunError::InvalidJob { reason }) => {
assert!(reason.contains("no network"), "{reason}")
}
other => panic!("{other:?}"),
}
assert!(!marker.exists(), "the shim never ran");
}
/// The learning filter holds the job's one USER_NOTIF listener: a learning
/// request never asks the shim for a broker, even on a broker-capable runner.
#[test]
fn a_learning_request_carries_the_learning_plan_and_no_broker() {
let f = fx("learn-nobroker");
let r = runner(&f, "", &ok());
assert!(r.broker_mode().is_some(), "this runner brokers normal jobs");
let d = job_dirs(&f.base);
let signals = std::sync::atomic::AtomicU64::new(0);
// The canned response is a normal job's: the learning run is refused
// after the shim answered, which is all this test needs.
let _ = r.learn(learn_job(&f, &d), &signals, |_| {});
let reqs = requests(&f);
assert_eq!(reqs.len(), 1, "{reqs:?}");
let req: serde_json::Value = serde_json::from_str(&reqs[0]).unwrap();
assert!(req.get("learning").is_some_and(|l| !l.is_null()), "{req}");
assert!(req.get("broker").is_none_or(|b| b.is_null()), "{req}");
}
/// A runner's `with_nofile` covers its learning jobs too: the program is
/// probed with the RLIMIT_NOFILE it will run with.
#[test]
fn a_learning_job_gets_the_runners_nofile() {
let f = fx("learn-nofile");
let cfg_copy = f.base.join("learn-config.json");
let body = format!(
"cp {}/work/jobs/*/config.json {}",
f.base.display(),
cfg_copy.display()
);
let r = runner(&f, &body, &ok()).with_nofile(64).unwrap();
let d = job_dirs(&f.base);
let signals = std::sync::atomic::AtomicU64::new(0);
let _ = r.learn(learn_job(&f, &d), &signals, |_| {});
let c: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&cfg_copy).unwrap()).unwrap();
let nofile = c["process"]["rlimits"]
.as_array()
.unwrap()
.iter()
.find(|r| r["type"] == "RLIMIT_NOFILE")
.cloned()
.unwrap();
assert_eq!((nofile["soft"].as_u64(), nofile["hard"].as_u64()), (Some(64), Some(64)), "{nofile}");
}