crates/heimdall-container/tests/container.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Real protected containers. Heimdall is a defensive sandbox; every job here
//! attacks only its own container: its input, its root, its limits, its
//! network namespace, its Landlock domain, its seccomp profile.
//!
//! Part one repeats every heimdall-plain container test on HeimdallRunner;
//! part two proves what heimdall-container adds. These need user namespaces,
//! cgroup v2 with memory, pids and cpu delegated, Landlock, and a static
//! busybox, so they are `#[ignore]`d in plain CI: `scripts/container-tests.sh`
//! runs them in a privileged rootless podman container.
#[path = "../../heimdall-plain/tests/common/mod.rs"]
mod common;
use std::io::Read;
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use common::*;
use heimdall_container::*;
use heimdall_core::*;
struct Fx {
base: PathBuf,
image: ImageRef,
layout: Layout,
runner: HeimdallRunner,
}
fn cfg(base: &std::path::Path, layout: &Layout) -> HeimdallConfig {
HeimdallConfig::new(&layout.dir, base.join("work"))
}
fn fx_with(name: &str, m: impl FnOnce(&mut HeimdallConfig, &std::path::Path)) -> Fx {
let base = scratch(&format!("hc-{name}"));
let layout = Layout::new(&base.join("layout"));
let image = busybox_image(&layout);
let mut c = cfg(&base, &layout);
m(&mut c, &base);
Fx {
runner: HeimdallRunner::new(c).expect("this kernel has every feature (strict)"),
base,
image,
layout,
}
}
fn fx(name: &str) -> Fx {
fx_with(name, |_, _| {})
}
impl Fx {
fn run(&self, script: &str) -> (JobDirs, SignedResult) {
self.run_with(script, |_| {})
}
fn run_with(&self, script: &str, m: impl FnOnce(&mut Job)) -> (JobDirs, SignedResult) {
let d = job_dirs(&self.base);
std::fs::write(d.input.join("a"), b"untrusted input\n").unwrap();
let mut j = native_job(&self.image, &sh(script), &d);
m(&mut j);
let s = self
.runner
.run(j)
.expect("the runner returns a signed result");
verify(&s, &self.runner.verifying_key()).expect("it verifies");
(d, s)
}
/// Run `script` (which writes a report to /out/r) and return the report.
fn report(&self, script: &str) -> (String, JobResult) {
self.report_with(script, |_| {})
}
fn report_with(&self, script: &str, m: impl FnOnce(&mut Job)) -> (String, JobResult) {
let (d, s) = self.run_with(script, m);
let r = std::fs::read_to_string(d.output.join("r")).unwrap_or_default();
(r, s.result)
}
}
// ==== part one: every heimdall-plain container test, on HeimdallRunner ====
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn run_copies_in_a_to_out_a_with_the_right_sha256_and_verify_and_check_outputs_pass() {
let f = fx("copy");
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"hello heimdall").unwrap();
let job = native_job(&f.image, &["/bin/cp", "/in/a", "/out/a"], &d);
let id = job.id;
let s = f.runner.run(job).unwrap();
let r = verify(&s, &f.runner.verifying_key()).unwrap();
assert_eq!(r.job_id, id);
assert!(r.verdict.is_accepted(), "{:?}", r.verdict);
assert_eq!(r.outputs, vec![Output::of("a", b"hello heimdall")]);
assert_eq!(r.exit, ExitInfo::exited(0));
check_outputs(r, &d.output).unwrap();
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_tampered_signature_a_different_key_or_an_altered_output_each_fail_with_a_named_error() {
let f = fx("sigs");
let (d, s) = f.run("cp /in/a /out/a");
let mut t = s.clone();
t.sig.0[0] ^= 1;
assert_eq!(
verify(&t, &f.runner.verifying_key()),
Err(VerifyError::BadSignature)
);
let other = BootKey::generate();
assert!(matches!(
verify(&s, &other.verifying_key()),
Err(VerifyError::WrongKey { .. })
));
std::fs::write(d.output.join("a"), b"altered").unwrap();
assert!(matches!(
check_outputs(&s.result, &d.output),
Err(VerifyError::Output(OutputError::LengthMismatch { .. }))
));
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn writing_into_the_input_fails_and_the_input_is_unchanged() {
let f = fx("roin");
let (d, s) = f.run("echo pwned > /in/a && echo pwned > /in/new");
assert_eq!(s.result.verdict.code(), Some(codes::EXIT_NONZERO));
assert_eq!(
std::fs::read(d.input.join("a")).unwrap(),
b"untrusted input\n"
);
assert!(!d.input.join("new").exists());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn with_no_network_a_job_cannot_open_a_tcp_connection() {
let f = fx("nonet");
let l = TcpListener::bind("0.0.0.0:0").unwrap();
l.set_nonblocking(true).unwrap();
let port = l.local_addr().unwrap().port();
let mut targets = vec!["127.0.0.1".to_string(), "1.1.1.1".to_string()];
if let Ok(o) = std::process::Command::new("hostname").arg("-i").output() {
targets.extend(
String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.map(String::from),
);
}
let mut script = String::new();
for t in &targets {
script.push_str(&format!(
"if echo x | nc -w 2 {t} {port}; then echo CONNECTED {t} >> /out/r; fi; "
));
}
script.push_str("cat /proc/net/dev >> /out/dev; true");
let (d, s) = f.run(&script);
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
assert!(!d.output.join("r").exists(), "a connection got out");
assert!(l.accept().is_err(), "the host listener saw a connection");
let dev = std::fs::read_to_string(d.output.join("dev")).unwrap();
let ifaces: Vec<&str> = dev
.lines()
.skip(2)
.filter_map(|l| l.split(':').next())
.map(str::trim)
.collect();
assert_eq!(ifaces, vec!["lo"], "only a loopback in the job's netns");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_memory_limit_kills_the_job_refused_with_a_reason() {
let f = fx("mem");
let (_d, s) = f.run_with(
"x=$(head -c 200000000 /dev/zero | tr '\\0' a); echo survived ${#x} > /out/r",
|j| j.policy.limits.memory_bytes = 32 << 20,
);
let r = &s.result;
assert_eq!(
r.verdict.code(),
Some(codes::LIMIT_MEMORY),
"{:?} {:?}",
r.verdict,
r.exit
);
assert_eq!(r.exit.killed, Some(KillReason::Memory));
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_wall_limit_kills_the_job_refused_with_a_reason_and_the_host_stays_fine() {
let f = fx("wall");
let t0 = Instant::now();
let (_d, s) = f.run_with("sleep 600", |j| {
j.policy.limits.wall = Duration::from_secs(2)
});
let took = t0.elapsed();
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_WALL));
assert_eq!(s.result.exit.killed, Some(KillReason::Wall));
assert!(took < Duration::from_secs(20), "took {took:?}");
let (_d, s) = f.run("true");
assert!(s.result.verdict.is_accepted());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_wall_limit_kills_every_process_of_the_job() {
let f = fx("wallall");
let (_d, s) = f.run_with("(sleep 600 &) ; (sleep 600 &) ; sleep 600", |j| {
j.policy.limits.wall = Duration::from_secs(2)
});
assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_WALL));
let ps = std::process::Command::new("ps")
.args(["-eo", "args"])
.output();
if let Ok(o) = ps {
let text = String::from_utf8_lossy(&o.stdout);
assert!(!text.lines().any(|l| l.trim() == "sleep 600"), "{text}");
}
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn an_extra_file_in_output_dir_is_refused_out_1() {
let f = fx("extra");
let (_d, s) = f.run("cp /in/a /out/a; mkdir /out/sub; echo x > /out/sub/hidden");
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
let (_d, s) = f.run("ln -s /etc/marker /out/link");
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
let (_d, s) = f.run_with("for i in 1 2 3; do echo $i > /out/f$i; done", |j| {
j.policy.limits.max_output_files = 2
});
assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn features_report_default_seccomp_landlock_and_the_broker_in_full_mode() {
// heimdall-plain's features test, with Heimdall's truthful answers.
let f = fx("features");
let ft = f.runner.features();
assert_eq!(ft.runner, "heimdall");
assert_eq!(ft.seccomp, Protection::Default);
assert!(ft.landlock);
assert!(
ft.broker,
"kernel 5.9+ with USER_NOTIF and ADDFD: the broker in full mode"
);
assert!(ft.image_verified_each_start);
let (_d, s) = f.run("true");
assert_eq!(s.result.runner, ft);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_root_is_read_only_and_tmp_is_writable() {
let f = fx("rofs");
let (r, _) = f.report(
"touch /bin/x 2>/dev/null && echo ROOT-WRITABLE >> /out/r; \
echo t > /tmp/t && echo tmp-ok >> /out/r; cat /etc/marker >> /out/r",
);
assert!(!r.contains("ROOT-WRITABLE"), "{r}");
assert!(r.contains("tmp-ok"), "{r}");
assert!(r.contains("from-the-image"), "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn every_capability_is_dropped_no_new_privs_and_seccomp_filter_are_on() {
let f = fx("caps");
let (r, _) = f.report(
"grep -E '^(Cap(Inh|Prm|Eff|Bnd|Amb)|NoNewPrivs|Seccomp):' /proc/self/status > /out/r",
);
for cap in ["CapInh", "CapPrm", "CapEff", "CapBnd", "CapAmb"] {
let line = r
.lines()
.find(|l| l.starts_with(cap))
.unwrap_or_else(|| panic!("{r}"));
assert!(line.ends_with("0000000000000000"), "{line}");
}
assert!(
r.lines()
.any(|l| l.split_whitespace().collect::<Vec<_>>() == ["NoNewPrivs:", "1"]),
"{r}"
);
assert!(
r.lines()
.any(|l| l.split_whitespace().collect::<Vec<_>>() == ["Seccomp:", "2"]),
"{r}"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_default_seccomp_profile_blocks_new_namespaces_and_mount() {
let f = fx("seccomp");
let (r, _) = f.report(
"unshare -U true 2>/dev/null && echo UNSHARE >> /out/r; \
unshare -n true 2>/dev/null && echo NETNS >> /out/r; \
mount -t tmpfs none /tmp 2>/dev/null && echo MOUNT >> /out/r; echo done >> /out/r",
);
assert_eq!(r.trim(), "done", "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_job_is_pid_1_in_its_own_pid_namespace_and_sees_no_host_process() {
let f = fx("pidns");
let (r, _) = f.report("echo $$ > /out/r; ls /proc | grep -c '^[0-9]' >> /out/r");
let mut l = r.lines();
assert_eq!(l.next(), Some("1"), "{r}");
let n: usize = l.next().unwrap().trim().parse().unwrap();
assert!(n <= 4, "{n} processes visible");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn container_root_is_one_mapped_id_and_the_hostname_is_its_own() {
let f = fx("userns");
let (r, _) = f.report("cat /proc/self/uid_map > /out/r; hostname >> /out/r; id -u >> /out/r");
let lines: Vec<&str> = r.lines().collect();
let map: Vec<&str> = lines[0].split_whitespace().collect();
assert_eq!(map[0], "0");
assert_eq!(map[2], "1", "exactly one id mapped: {r}");
assert_eq!(lines[1], "heimdall-job");
assert_eq!(lines[2], "0");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_pids_limit_stops_a_fork_bomb_and_the_host_stays_fine() {
let f = fx("pids");
let (_d, s) = f.run_with(
"i=0; while [ $i -lt 200 ]; do sleep 30 & i=$((i+1)); done; wait",
|j| {
j.policy.limits.pids = 16;
j.policy.limits.wall = Duration::from_secs(10);
},
);
assert!(!s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let (_d, s) = f.run("true");
assert!(s.result.verdict.is_accepted());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn every_job_gets_a_fresh_root_and_tmp() {
let f = fx("fresh");
let (_d, s) = f.run("echo leak > /tmp/leak");
assert!(s.result.verdict.is_accepted());
let (r, _) = f.report("test -e /tmp/leak && echo LEAKED > /out/r; echo ok >> /out/r");
assert_eq!(r.trim(), "ok");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_image_env_is_the_job_env_and_nothing_of_the_host_leaks() {
std::env::set_var("HEIMDALL_HOST_SECRET", "s3cret");
let f = fx("env");
let (r, _) = f.report("env > /out/r");
assert!(r.contains("HEIMDALL_TEST_ENV=from-image"), "{r}");
assert!(!r.contains("s3cret"), "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn proc_kcore_is_masked_and_proc_sys_is_read_only() {
let f = fx("proc");
let (r, _) = f.report(
"head -c 1 /proc/kcore 2>/dev/null | grep -q . && echo KCORE >> /out/r; \
echo 1 > /proc/sys/kernel/printk 2>/dev/null && echo SYSRW >> /out/r; echo ok >> /out/r",
);
assert_eq!(r.trim(), "ok", "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_nonzero_exit_is_refused_and_its_outputs_are_listed() {
let f = fx("exit");
let (_d, s) = f.run("echo report > /out/report; exit 7");
assert_eq!(s.result.verdict.code(), Some(codes::EXIT_NONZERO));
assert_eq!(s.result.exit.code, Some(7));
assert_eq!(s.result.outputs.len(), 1);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn extra_read_and_write_paths_are_honoured_and_nothing_else_of_the_host_is_visible() {
let f = fx("paths");
let ro = f.base.join("ro-data");
let rw = f.base.join("rw-data");
std::fs::create_dir_all(&ro).unwrap();
std::fs::create_dir_all(&rw).unwrap();
std::fs::write(ro.join("x"), b"ro").unwrap();
let script = format!(
"cat {ro}/x > /out/r; echo y > {ro}/y 2>/dev/null && echo RO-WRITABLE >> /out/r; \
echo w > {rw}/w; test -e {layout} && echo LAYOUT-VISIBLE >> /out/r; true",
ro = ro.display(),
rw = rw.display(),
layout = f.base.join("layout").display()
);
let (d, s) = f.run_with(&script, |j| {
j.policy.read = vec![ro.clone()];
j.policy.write = vec![rw.clone()];
});
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let r = std::fs::read_to_string(d.output.join("r")).unwrap();
assert_eq!(r.trim(), "ro", "{r}");
assert_eq!(std::fs::read(rw.join("w")).unwrap(), b"w\n");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn jobs_run_in_parallel_through_a_shared_trait_object() {
let f = fx("par");
let image = f.image.clone();
let base = f.base.clone();
let r: Arc<dyn JobRunner> = Arc::new(f.runner);
let hs: Vec<_> = (0..8)
.map(|i| {
let r = Arc::clone(&r);
let d = job_dirs(&base);
let j = native_job(&image, &sh(&format!("echo {i} > /out/n")), &d);
std::thread::spawn(move || (r.run(j), d))
})
.collect();
for (i, h) in hs.into_iter().enumerate() {
let (s, d) = h.join().unwrap();
let s = s.unwrap();
let res = verify(&s, &r.verifying_key()).unwrap();
assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
check_outputs(res, &d.output).unwrap();
let mut n = String::new();
std::fs::File::open(d.output.join("n"))
.unwrap()
.read_to_string(&mut n)
.unwrap();
assert_eq!(n.trim(), i.to_string());
}
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_signing_key_is_not_reachable_from_the_job() {
let f = fx("key");
let pk = f.runner.pinned().public_key.to_hex();
let (r, _) = f.report("cat /proc/*/environ /proc/*/cmdline 2>/dev/null > /out/r; true");
assert!(!r.contains(&pk));
}
// ==== part two: what heimdall-container adds ====
/// A host dir with a secret file, given to the job as a mount Landlock does
/// not list (`writable` decides the mount's own mode). The broker is off:
/// these prove what Landlock ALONE refuses.
fn with_unlisted(name: &str, writable: bool, landlock: LandlockMode) -> Fx {
fx_with(name, |c, base| {
c.broker = BrokerSetting::Off;
let src = base.join("unlisted");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("secret"), b"host-secret").unwrap();
if landlock == LandlockMode::Off {
c.strict = false;
c.landlock = LandlockMode::Off;
}
c.unlisted_mounts = vec![UnlistedMount {
source: src,
destination: "/unlisted".into(),
writable,
}];
})
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn landlock_refuses_reading_outside_in_even_through_a_readable_mount() {
let script = "cat /unlisted/secret >> /out/r 2>/dev/null; ls /unlisted >> /out/r 2>/dev/null; \
ls /dev >> /out/r 2>/dev/null; cat /in/a >> /out/r";
let f = with_unlisted("llread", false, LandlockMode::Auto);
let (r, res) = f.report(script);
assert!(res.runner.landlock);
assert!(!r.contains("host-secret"), "{r}");
assert!(!r.contains("secret"), "the dir listing got out: {r}");
assert!(!r.contains("null"), "/dev was listed: {r}");
assert!(r.contains("untrusted input"), "/in must stay readable: {r}");
// The control: the same job without Landlock reads it (so Landlock refused it).
let f = with_unlisted("llread-off", false, LandlockMode::Off);
let (r, res) = f.report(script);
assert!(!res.runner.landlock);
assert!(r.contains("host-secret"), "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn landlock_refuses_writing_outside_out_even_into_a_writable_mount() {
let script = "echo x > /unlisted/new 2>/dev/null && echo WROTE-UNLISTED >> /out/r; \
echo x > /dev/shm/new 2>/dev/null && echo WROTE-SHM >> /out/r; \
echo x > /dev/new 2>/dev/null && echo WROTE-DEV >> /out/r; \
echo x > /out/ok && echo out-ok >> /out/r";
let f = with_unlisted("llwrite", true, LandlockMode::Auto);
let (r, _) = f.report(script);
assert_eq!(r.trim(), "out-ok", "{r}");
assert!(!f.base.join("unlisted/new").exists());
let f = with_unlisted("llwrite-off", true, LandlockMode::Off);
let (r, _) = f.report(script);
assert!(
r.contains("WROTE-UNLISTED") && r.contains("WROTE-SHM"),
"{r}"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn out_is_write_only_the_job_cannot_read_back_or_list_it() {
let f = fx("wo");
let (d, s) = f.run(
"echo data > /out/a; cat /out/a > /out/b 2>/dev/null; ls /out > /out/c 2>/dev/null; true",
);
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
assert_eq!(std::fs::read(d.output.join("a")).unwrap(), b"data\n");
assert_eq!(std::fs::read(d.output.join("b")).unwrap_or_default(), b"");
assert_eq!(std::fs::read(d.output.join("c")).unwrap_or_default(), b"");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn nothing_written_into_tmp_can_be_executed() {
let f = fx("noexec");
let (r, _) = f.report(
"cp /bin/busybox /tmp/busybox && chmod 755 /tmp/busybox && echo copied >> /out/r; \
/tmp/busybox true 2>/dev/null && echo RAN-FROM-TMP >> /out/r; echo done >> /out/r",
);
assert!(r.contains("copied"), "{r}");
assert!(!r.contains("RAN-FROM-TMP"), "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_changed_image_byte_is_refused_at_the_next_start() {
let f = fx("image");
let (_d, s) = f.run("true");
assert!(s.result.verdict.is_accepted());
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 / 2] ^= 0x01;
std::fs::write(&p, b).unwrap();
let d = job_dirs(&f.base);
let j = native_job(&f.image, &sh("true"), &d);
assert!(matches!(f.runner.run(j), Err(RunError::Image { .. })));
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn features_are_truthful_when_landlock_is_switched_off() {
let f = fx_with("off", |c, _| {
c.strict = false;
c.landlock = LandlockMode::Off;
c.broker = BrokerSetting::Off;
});
assert!(!f.runner.features().landlock);
let (r, res) = f.report("ls /dev > /out/r");
assert!(!res.runner.landlock);
assert_eq!(res.runner, f.runner.features());
assert!(r.contains("null"), "without Landlock /dev lists: {r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_runner_reports_the_kernel_features_it_got() {
let f = fx("kernel");
let k = f.runner.kernel();
assert!(
k.seccomp_filter && k.cgroup_v2 && k.user_namespaces,
"{k:?}"
);
assert!(k.landlock_abi.unwrap_or(0) >= 1, "{k:?}");
for c in ["memory", "pids", "cpu"] {
assert!(k.has_controller(c), "{c}: {k:?}");
}
eprintln!("kernel report: {k:?}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_killed_syscall_kills_the_calling_process_with_sigsys() {
let f = fx("kill");
let (r, _) = f.report("unshare -U true 2>/dev/null; echo $? > /out/r");
// 128 + SIGSYS (31): the profile's KILL_PROCESS, not an EPERM.
assert_eq!(r.trim(), "159", "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn rlimits_inside_are_nofile_nproc_fsize_and_core_zero() {
let f = fx("rlimits");
let (r, _) = f.report_with("cat /proc/self/limits > /out/r", |j| {
j.policy.limits.pids = 40;
j.policy.limits.max_output_file_bytes = 3 << 20;
});
let row = |name: &str| -> Vec<String> {
let l = r
.lines()
.find(|l| l.starts_with(name))
.unwrap_or_else(|| panic!("{r}"));
l[name.len()..]
.split_whitespace()
.map(String::from)
.collect()
};
assert_eq!(row("Max processes")[..2], ["41", "41"]);
assert_eq!(row("Max open files")[..2], ["1024", "1024"]);
assert_eq!(row("Max core file size")[..2], ["0", "0"]);
let fsize = (3u64 << 20).to_string();
assert_eq!(row("Max file size")[..2], [fsize.clone(), fsize]);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_file_over_the_fsize_limit_is_cut_and_the_job_is_refused() {
let f = fx("fsize");
let (d, s) = f.run_with("head -c 4000000 /dev/zero > /out/big", |j| {
j.policy.limits.max_output_file_bytes = 1 << 20;
});
assert!(!s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let n = std::fs::metadata(d.output.join("big"))
.map(|m| m.len())
.unwrap_or(0);
assert!(n <= 1 << 20, "{n}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_recursive_fork_bomb_is_stopped_and_the_host_stays_fine() {
let f = fx("bomb");
let t0 = Instant::now();
let (r, res) = f.report_with(
"b() { b | b & }; b; sleep 2; ls /proc | grep -c '^[0-9]' > /out/r",
|j| {
j.policy.limits.pids = 32;
j.policy.limits.wall = Duration::from_secs(8);
},
);
assert!(t0.elapsed() < Duration::from_secs(45));
// Never more processes than the limit, whatever the verdict.
if let Ok(n) = r.trim().parse::<u32>() {
assert!(n <= 32, "{n} processes: {:?}", res.verdict);
}
let (_d, s) = f.run("true");
assert!(s.result.verdict.is_accepted());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn memory_swap_max_is_zero_while_the_job_runs() {
let f = fx("swap");
let d = job_dirs(&f.base);
let j = native_job(&f.image, &sh("sleep 3"), &d);
let cg = f
.runner
.config()
.cgroup_root
.join(&f.runner.config().cgroup_parent)
.join(format!("job-{}", j.id));
let seen = std::thread::spawn(move || {
// Wait for the job's cgroup, give the shim time to start the job,
// then read what the running job has.
for _ in 0..1000 {
if cg.join("memory.swap.max").exists() {
std::thread::sleep(Duration::from_millis(1000));
return std::fs::read_to_string(cg.join("memory.swap.max"))
.ok()
.map(|s| s.trim().to_string());
}
std::thread::sleep(Duration::from_millis(5));
}
None
});
let s = f.runner.run(j).unwrap();
assert!(s.result.verdict.is_accepted());
assert_eq!(seen.join().unwrap().as_deref(), Some("0"));
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn landlock_refuses_tcp_connect_before_routing_abi_4() {
// The broker off: this proves Landlock alone (the broker refuses the
// socket before it; see the broker tests).
let f = fx_with("llnet", |c, _| c.broker = BrokerSetting::Off);
assert!(f.runner.kernel().landlock_net(), "{:?}", f.runner.kernel());
// In the empty netns the loopback is down, so a plain container answers
// "Network is unreachable"; Landlock ABI 4 refuses the connect first.
let script = "nc -w 1 127.0.0.1 1 2>> /out/r; true";
let (r, _) = f.report(script);
assert!(r.contains("Permission denied"), "{r}");
let f = fx_with("llnet-off", |c, _| {
c.strict = false;
c.landlock = LandlockMode::Off;
c.broker = BrokerSetting::Off;
});
let (r, _) = f.report(script);
assert!(!r.contains("Permission denied"), "{r}");
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn many_jobs_in_parallel_each_get_their_own_container_and_landlock() {
let f = fx("par16");
let image = f.image.clone();
let base = f.base.clone();
let r: Arc<dyn JobRunner> = Arc::new(f.runner);
let hs: Vec<_> = (0..16)
.map(|i| {
let r = Arc::clone(&r);
let d = job_dirs(&base);
let j = native_job(
&image,
&sh(&format!(
"echo {i} > /tmp/me; cat /tmp/me > /out/n; hostname >> /out/n"
)),
&d,
);
std::thread::spawn(move || (r.run(j), d))
})
.collect();
for (i, h) in hs.into_iter().enumerate() {
let (s, d) = h.join().unwrap();
let s = s.unwrap();
assert!(s.result.runner.landlock);
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let n = std::fs::read_to_string(d.output.join("n")).unwrap();
assert_eq!(n, format!("{i}\nheimdall-job\n"));
}
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_runner_process_its_key_and_the_work_dir_are_not_visible_to_the_job() {
let f = fx("keyfs");
let pk = f.runner.pinned().public_key.to_hex();
let work = f.runner.config().work_dir.display().to_string();
let (r, _) = f.report(&format!(
"test -e {work} && echo WORK-VIS''IBLE >> /out/r; \
ls /proc | grep -c '^[0-9]' >> /out/r; \
cat /proc/1/cmdline >> /out/r; echo >> /out/r; true"
));
assert!(!r.contains("WORK-VISIBLE"), "{r}");
assert!(!r.contains(&pk));
assert!(!r.contains("heimdall-container-shim"), "{r}");
}
// ==== part three: GENERATED seccomp profiles (lane genprof) ====
//
// The profile of a busybox applet is generated the real way: heimdall-probe
// learns the applet's calls (USER_NOTIF, log and allow) outside the
// container, `heimdall profile gen`'s minimiser makes the profile, and its OCI
// seccomp JSON is pinned by sha256 in the job's policy.
/// Probe static busybox running each of `runs` (applet + args, `{d}` = a
/// scratch dir holding `a`), generate the profile and write its OCI JSON.
/// Returns the job policy's reference to it.
fn generated_busybox(base: &std::path::Path, name: &str, runs: &[&[&str]]) -> SeccompProfileRef {
use heimdall_probe::{generate, learn, GenOptions, Observations, ProbeRun};
let bb = busybox().expect("a static busybox");
let arch = heimdall_seccomp::Arch::native().unwrap();
let mut obs = Observations::new(name, arch, heimdall_seccomp::Libc::Glibc);
for (i, args) in runs.iter().enumerate() {
let d = base.join(format!("probe-{name}-{i}"));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("a"), b"probe input\n").unwrap();
let mut run = ProbeRun::new(&format!("{name}-{i}"), &bb);
run.args = args
.iter()
.map(|a| a.replace("{d}", &d.display().to_string()).into())
.collect();
run.env = vec![("PATH".into(), "/bin".into())];
run.cwd = Some(d.clone());
let out = learn(&run, &mut obs).expect("the probe runs");
assert!(out.exit.success(), "probe run {args:?}: {:?}", out.exit);
}
let g = generate(&obs, GenOptions::default());
let oci = heimdall_probe::codegen::to_oci_json(&g);
let path = base.join(name).join("x86_64-glibc.oci.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, &oci).unwrap();
SeccompProfileRef {
source: ProfileSource::OciFile(path),
sha256: Sha256Digest::of(oci.as_bytes()).to_prefixed(),
fallback_to_default: false,
}
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_job_runs_under_its_generated_profile_and_the_signed_result_names_the_digest() {
let f = fx("gen-run");
let pr = generated_busybox(&f.base, "bb-cp", &[&["cp", "{d}/a", "{d}/b"]]);
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"hello generated").unwrap();
let mut job = native_job(&f.image, &["/bin/cp", "/in/a", "/out/a"], &d);
job.policy.seccomp = Some(pr.clone());
let s = f.runner.run(job).unwrap();
let r = verify(&s, &f.runner.verifying_key()).unwrap();
assert!(r.verdict.is_accepted(), "{:?} {:?}", r.verdict, r.exit);
assert_eq!(r.outputs, vec![Output::of("a", b"hello generated")]);
assert_eq!(
r.runner.seccomp,
Protection::Generated {
profile_digest: pr.sha256.clone()
}
);
assert!(r.runner.landlock);
check_outputs(r, &d.output).unwrap();
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_call_outside_the_generated_profile_kills_the_job_with_sigsys_and_a_signed_seccomp_refusal() {
let f = fx("gen-kill");
// The profile of `true`: no open, no read, no write.
let pr = generated_busybox(&f.base, "bb-true", &[&["true"]]);
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"x").unwrap();
let mut job = native_job(&f.image, &["/bin/cp", "/in/a", "/out/a"], &d);
job.policy.seccomp = Some(pr.clone());
let s = f.runner.run(job).unwrap();
let r = verify(&s, &f.runner.verifying_key()).unwrap();
assert_eq!(
r.verdict.code(),
Some(codes::SECCOMP_KILL),
"{:?}",
r.verdict
);
assert_eq!(r.exit.signal, Some(libc::SIGSYS));
assert_eq!(
r.runner.seccomp,
Protection::Generated {
profile_digest: pr.sha256
}
);
// The same job under `true`'s profile but running `true` is fine.
let d = job_dirs(&f.base);
let mut job = native_job(&f.image, &["/bin/true"], &d);
job.policy.seccomp = Some(generated_busybox(&f.base, "bb-true2", &[&["true"]]));
let r = f.runner.run(job).unwrap().result;
assert!(r.verdict.is_accepted(), "{:?} {:?}", r.verdict, r.exit);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_tampered_generated_profile_refuses_the_job_and_nothing_runs() {
let f = fx("gen-tamper");
let pr = generated_busybox(&f.base, "bb-cp2", &[&["cp", "{d}/a", "{d}/b"]]);
let ProfileSource::OciFile(p) = &pr.source else {
unreachable!()
};
// The attacker's widening: one more allowed call after the pin was taken.
let mut j: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap();
j["syscalls"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"names": ["ptrace"], "action": "SCMP_ACT_ALLOW"}));
std::fs::write(p, serde_json::to_string_pretty(&j).unwrap()).unwrap();
let d = job_dirs(&f.base);
let mut job = native_job(&f.image, &["/bin/sh", "-c", "echo ran > /out/r"], &d);
job.policy.seccomp = Some(pr);
match f.runner.run(job) {
Err(RunError::InvalidJob { reason }) => {
assert!(reason.contains("digest mismatch"), "{reason}")
}
other => panic!("expected a refusal: {other:?}"),
}
assert!(!d.output.join("r").exists());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_runner_configured_with_a_generated_profile_says_so_in_features_and_applies_it_to_every_job() {
let base = scratch("hc-gen-cfg");
let pr = generated_busybox(&base, "bb-cfg", &[&["cp", "{d}/a", "{d}/b"]]);
let f = fx_with("gen-cfg", |c, _| c.seccomp = Some(pr.clone()));
let want = Protection::Generated {
profile_digest: pr.sha256.clone(),
};
assert_eq!(f.runner.features().seccomp, want);
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"cfg").unwrap();
let s = f
.runner
.run(native_job(&f.image, &["/bin/cp", "/in/a", "/out/a"], &d))
.unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
assert_eq!(s.result.runner, f.runner.features());
// A shell is outside cp's profile: killed, not run unfiltered.
let (_d, s) = f.run("echo hi > /out/r");
assert_eq!(s.result.verdict.code(), Some(codes::SECCOMP_KILL));
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_job_that_survives_failed_forks_and_exits_zero_is_refused_heimdall_limit_pids() {
let f = fx("pids-zero");
let (_d, s) = f.run_with(
"i=0; while [ $i -lt 40 ]; do (sleep 5 &) 2>/dev/null; i=$((i+1)); done; exit 0",
|j| {
j.policy.limits.pids = 8;
j.policy.limits.wall = Duration::from_secs(15);
},
);
assert_eq!(
s.result.verdict.code(),
Some(codes::LIMIT_PIDS),
"{:?}",
s.result
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn an_out_1_refusal_leaves_output_dir_empty_and_check_outputs_passes() {
let f = fx("out1-empty");
let (d, s) =
f.run("cp /in/a /out/a; mkdir /out/sub; echo x > /out/sub/hidden; ln -s /in/a /out/l");
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);
check_outputs(&s.result, &d.output).unwrap();
}
// ==== part three: the syscall broker (spec §4) and Landlock on its own ====
fn broker_events(r: &JobResult) -> String {
r.events
.iter()
.map(|e| format!("{} {:?} {}", e.call, e.decision, e.args.join(" ")))
.collect::<Vec<_>>()
.join("\n")
}
fn has_event(r: &JobResult, call: &str, d: Decision, needle: &str) -> bool {
r.events
.iter()
.any(|e| e.call == call && e.decision == d && e.args.iter().any(|a| a.contains(needle)))
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_job_reads_in_and_writes_out_through_the_broker_and_every_open_is_a_signed_event() {
let f = fx("broker-cp");
let (d, s) = f.run("cp /in/a /out/a");
let r = verify(&s, &f.runner.verifying_key()).unwrap();
assert!(r.verdict.is_accepted(), "{:?}", r.verdict);
assert!(r.runner.broker, "full mode on this kernel");
assert_eq!(
std::fs::read(d.output.join("a")).unwrap(),
b"untrusted input\n"
);
assert_eq!(r.events[0].call, "broker");
assert_eq!(r.events[0].args, vec!["mode=full".to_string()]);
let ev = broker_events(r);
assert!(
has_event(r, "openat", Decision::Allowed, "path=/in/a"),
"{ev}"
);
assert!(
has_event(r, "openat", Decision::Allowed, "path=/out/a"),
"{ev}"
);
assert!(
has_event(r, "execve", Decision::Allowed, "path=/bin/sh"),
"{ev}"
);
// The events are inside the signature.
let mut forged = s.clone();
forged.result.events.pop();
assert!(verify(&forged, &f.runner.verifying_key()).is_err());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_broker_alone_refuses_an_unlisted_mount_with_landlock_off_and_logs_it() {
let f = fx_with("broker-unlisted", |c, base| {
let src = base.join("unlisted");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("secret"), b"host-secret").unwrap();
c.strict = false;
c.landlock = LandlockMode::Off;
c.unlisted_mounts = vec![UnlistedMount {
source: src,
destination: "/unlisted".into(),
writable: true,
}];
});
let (r, res) = f.report(
"cat /unlisted/secret >> /out/r 2>/dev/null; echo x > /unlisted/new 2>/dev/null && echo WROTE >> /out/r; \
cat /in/a >> /out/r",
);
assert!(!res.runner.landlock && res.runner.broker);
assert!(!r.contains("host-secret") && !r.contains("WROTE"), "{r}");
assert!(r.contains("untrusted input"), "{r}");
assert!(!f.base.join("unlisted/new").exists());
let ev = broker_events(&res);
assert!(
has_event(&res, "openat", Decision::Refused, "path=/unlisted/secret"),
"{ev}"
);
assert!(
has_event(&res, "openat", Decision::Refused, "crosses a mount"),
"{ev}"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn path_tricks_are_refused_by_the_broker_dotdot_symlink_to_out_and_proc_self_fd() {
let f = fx("broker-tricks");
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"untrusted input\n").unwrap();
std::os::unix::fs::symlink("/out/a", d.input.join("to-out")).unwrap();
std::os::unix::fs::symlink("../etc/marker", d.input.join("up")).unwrap();
let script = "echo secret-out > /out/a; \
cat /in/../out/a >> /out/r 2>/dev/null; \
cat /in/to-out >> /out/r 2>/dev/null; \
cat /in/up >> /out/r 2>/dev/null; \
exec 3>/out/x; cat /proc/self/fd/3 >> /out/r 2>/dev/null; \
cat /proc/self/root/out/a >> /out/r 2>/dev/null; \
cat /proc/self/status | grep -c ^Pid: >> /out/r; \
cd /in && cat a >> /out/r; cd /out && cat a >> /out/r 2>/dev/null; true";
let j = native_job(&f.image, &sh(script), &d);
let s = f.runner.run(j).unwrap();
let res = &s.result;
let r = std::fs::read_to_string(d.output.join("r")).unwrap_or_default();
assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
assert!(!r.contains("secret-out"), "read back /out: {r}");
assert!(
!r.contains("from-the-image"),
"a link climbing out of /in: {r}"
);
assert_eq!(
r.lines().collect::<Vec<_>>(),
vec!["1", "untrusted input"],
"{r}"
);
let ev = broker_events(res);
assert!(
has_event(res, "openat", Decision::Refused, "`..` in path"),
"{ev}"
);
assert!(
has_event(res, "openat", Decision::Refused, "path=/in/to-out"),
"{ev}"
);
assert!(
has_event(res, "openat", Decision::Refused, "path=/proc/self/fd/3"),
"{ev}"
);
assert!(
has_event(res, "openat", Decision::Allowed, "resolved=/in/a"),
"relative open from cwd /in: {ev}"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn with_the_broker_on_a_job_cannot_open_any_socket_and_the_refusal_is_signed() {
let f = fx("broker-net");
let (r, res) = f.report("nc -w 1 127.0.0.1 1 2>> /out/r; true");
assert!(r.contains("Permission denied"), "{r}");
let ev = broker_events(&res);
assert!(
has_event(&res, "socket", Decision::Refused, "no network for this job"),
"{ev}"
);
}
/// The broker's selftest worker (static, musl), when the test script built it.
fn selftest() -> Option<Vec<u8>> {
std::env::var_os("HEIMDALL_BROKER_SELFTEST").and_then(|p| std::fs::read(p).ok())
}
fn selftest_image(layout: &Layout) -> ImageRef {
let bb = std::fs::read(busybox().expect("a static busybox")).unwrap();
let st = selftest().expect("HEIMDALL_BROKER_SELFTEST: a static heimdall-broker-selftest");
let mut entries = vec![
Entry::Dir("bin"),
Entry::Dir("tmp"),
Entry::Dir("in"),
Entry::Dir("out"),
Entry::Dir("proc"),
Entry::Dir("dev"),
Entry::Dir("etc"),
Entry::File("bin/busybox".into(), bb, 0o755),
Entry::File("bin/selftest".into(), st, 0o755),
];
for a in APPLETS {
entries.push(Entry::Symlink(format!("bin/{a}"), "busybox".into()));
}
layout.image(&[(Layout::tar(&entries), true)], &["PATH=/bin"])
}
/// Runs `/bin/selftest seq <args>` in a protected job; stdout goes to
/// /out/r through the shell.
fn selftest_job(name: &str, args: &str, prep: impl FnOnce(&JobDirs)) -> (String, JobResult) {
let base = scratch(&format!("hc-{name}"));
let layout = Layout::new(&base.join("layout"));
let image = selftest_image(&layout);
let runner = HeimdallRunner::new(cfg(&base, &layout)).unwrap();
let d = job_dirs(&base);
prep(&d);
let script = format!("printf BAD > /out/g; /bin/selftest seq {args} > /out/r");
let mut j = native_job(&image, &sh(&script), &d);
j.policy.limits.wall = Duration::from_secs(60);
let s = runner.run(j).unwrap();
let r = std::fs::read_to_string(d.output.join("r")).unwrap_or_default();
(r, s.result)
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn in_a_container_a_racing_thread_flipping_the_path_never_gets_out_read_back() {
if selftest().is_none() {
eprintln!("skipped: HEIMDALL_BROKER_SELFTEST not set");
return;
}
// /in/gg (GOOD) is allowed; /out/g (BAD, written first by the job) is
// write-only: same length, flipped by a second thread while the broker
// holds each call.
let (r, res) = selftest_job("broker-race", "race /in/gg /out/g 3000", |d| {
std::fs::write(d.input.join("gg"), b"GOOD").unwrap()
});
let lines: Vec<&str> = r.lines().collect();
assert_eq!(lines.len(), 2, "{r} {}", broker_events(&res));
assert!(
lines[0].contains(" bad=0 ") && !lines[0].contains("good=0 "),
"{r}"
);
assert!(
!lines[0].contains("refused=0 "),
"the race flipped both ways: {r}"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn in_a_container_fd_exhaustion_openat2_resolve_and_o_path_behave() {
if selftest().is_none() {
eprintln!("skipped: HEIMDALL_BROKER_SELFTEST not set");
return;
}
let (r, res) = selftest_job(
"broker-adv",
"fd-exhaust /in/g -- openat2-resolve /in/g 16 -- openat2-resolve /in/g 4 -- \
dirfd /in g -- proc-fd /out/x -- open /out/x r -- socket tcp4 -- exec /tmp/x",
|d| std::fs::write(d.input.join("g"), b"GOOD").unwrap(),
);
let lines: Vec<&str> = r.lines().collect();
let ev = broker_events(&res);
assert_eq!(lines.len(), 9, "{r} {ev}");
assert!(
lines[0].contains(&format!("errno={}", libc::EMFILE)) && lines[0].ends_with("again=true"),
"{r}"
);
assert_eq!(
lines[1],
format!("err {}", libc::EACCES),
"RESOLVE_IN_ROOT from the worker"
);
assert!(
lines[2].ends_with("GOOD"),
"RESOLVE_NO_SYMLINKS on a plain file: {r}"
);
assert!(lines[3].ends_with("GOOD"), "dirfd-relative open: {r}");
assert_eq!(
lines[4],
format!("err {}", libc::EACCES),
"/proc/self/fd reopen of /out"
);
assert_eq!(
lines[5],
format!("err {}", libc::EACCES),
"read-back of /out"
);
assert_eq!(
lines[6],
format!("err {}", libc::EACCES),
"a socket without a fetch peer"
);
// exec of a file that is not there: exec is allowed (and logged); the
// kernel answers ENOENT.
assert_eq!(lines[7], format!("err {}", libc::ENOENT), "{r}");
assert!(
has_event(&res, "execve", Decision::Allowed, "path=/tmp/x"),
"{ev}"
);
}
/// This kernel, with USER_NOTIF or ADDFD taken away (spec §10 fallbacks).
fn degraded(name: &str, user_notif: bool, addfd: bool) -> Fx {
let base = scratch(&format!("hc-{name}"));
let layout = Layout::new(&base.join("layout"));
let image = busybox_image(&layout);
let c = cfg(&base, &layout);
let mut k = heimdall_container::kernel::KernelReport::probe(&c.cgroup_root, &c.cgroup_parent);
k.user_notif = user_notif;
k.notif_addfd = addfd;
Fx {
runner: HeimdallRunner::with_kernel(c, BootKey::generate(), k).unwrap(),
base,
image,
layout,
}
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn without_addfd_the_broker_checks_only_warns_and_features_say_no_broker() {
let f = degraded("broker-checkonly", true, false);
assert!(!f.runner.features().broker);
let (r, res) = f.report(
"cat /in/a > /out/r; cat /etc/marker >> /out/r; cat /out/r >> /out/s 2>/dev/null; true",
);
assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
assert!(!res.runner.broker);
assert!(
r.contains("untrusted input") && r.contains("from-the-image"),
"{r}"
);
assert_eq!(res.events[0].args[0], "mode=check-only");
assert!(
res.events[0]
.args
.iter()
.any(|a| a.contains("not TOCTOU-safe")),
"{:?}",
res.events[0]
);
assert!(
has_event(
&res,
"openat",
Decision::Refused,
"read under a write-only prefix"
),
"{}",
broker_events(&res)
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn without_user_notif_every_brokered_call_fails_closed_and_the_result_says_so() {
let f = degraded("broker-denyonly", false, false);
assert!(!f.runner.features().broker);
let (d, s) = f.run("cat /in/a > /out/r");
let res = &s.result;
assert!(!res.runner.broker);
assert_eq!(
res.verdict.code(),
Some(codes::EXIT_NONZERO),
"{:?}",
res.verdict
);
assert!(
!d.output.join("r").exists(),
"the open of /out/r was refused too"
);
assert_eq!(res.events.len(), 1);
assert_eq!(res.events[0].args[0], "mode=deny-only");
}
// ---- Landlock alone (broker off): /in read-only, /out write-only, /tmp no exec,
// each refused even where the mount allows it ----
fn landlock_only(name: &str, landlock: LandlockMode, unlisted: Vec<UnlistedMount>) -> Fx {
fx_with(name, move |c, _| {
c.broker = BrokerSetting::Off;
if landlock == LandlockMode::Off {
c.strict = false;
c.landlock = LandlockMode::Off;
}
c.unlisted_mounts = unlisted;
})
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn landlock_alone_refuses_reading_back_and_listing_out_which_the_mount_allows() {
let script =
"echo data > /out/a; cat /out/a > /out/b 2>/dev/null; ls /out > /out/c 2>/dev/null; true";
let f = landlock_only("ll-wo", LandlockMode::Auto, vec![]);
let (d, s) = f.run(script);
assert!(s.result.runner.landlock && !s.result.runner.broker);
assert_eq!(std::fs::read(d.output.join("b")).unwrap_or_default(), b"");
assert_eq!(std::fs::read(d.output.join("c")).unwrap_or_default(), b"");
// Control: the same job with Landlock off reads /out back and lists it.
let f = landlock_only("ll-wo-off", LandlockMode::Off, vec![]);
let (d, _) = f.run(script);
assert_eq!(std::fs::read(d.output.join("b")).unwrap(), b"data\n");
assert!(std::fs::read_to_string(d.output.join("c"))
.unwrap()
.contains('a'));
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn landlock_alone_refuses_writing_into_in_even_through_a_writable_in_mount() {
let run = |name: &str, ll: LandlockMode| {
let base = scratch(&format!("hc-{name}"));
let layout = Layout::new(&base.join("layout"));
let image = busybox_image(&layout);
let d = job_dirs(&base);
std::fs::write(d.input.join("a"), b"untrusted input\n").unwrap();
// The job's own input dir again at /in, but writable.
let mut c = cfg(&base, &layout);
c.broker = BrokerSetting::Off;
if ll == LandlockMode::Off {
c.strict = false;
c.landlock = LandlockMode::Off;
}
c.unlisted_mounts = vec![UnlistedMount {
source: d.input.clone(),
destination: "/in".into(),
writable: true,
}];
let runner = HeimdallRunner::new(c).unwrap();
let j = native_job(
&image,
&sh("echo pwned > /in/a; echo pwned > /in/new; true"),
&d,
);
let s = runner.run(j).unwrap();
(d, s)
};
let (d, s) = run("ll-rwin", LandlockMode::Auto);
assert!(s.result.runner.landlock);
assert_eq!(
std::fs::read(d.input.join("a")).unwrap(),
b"untrusted input\n"
);
assert!(!d.input.join("new").exists());
let (d, _) = run("ll-rwin-off", LandlockMode::Off);
assert_eq!(
std::fs::read(d.input.join("a")).unwrap(),
b"pwned\n",
"control: the mount allows it"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn landlock_alone_refuses_exec_from_tmp_which_the_mount_allows() {
// Named busybox: called as anything else it looks for that applet.
let script =
"cp /bin/busybox /tmp/busybox && chmod 755 /tmp/busybox && echo copied >> /out/r; \
/tmp/busybox true 2>> /out/r && echo RAN-FROM-TMP >> /out/r; echo done >> /out/r";
let f = landlock_only("ll-tmpx", LandlockMode::Auto, vec![]);
let (r, _) = f.report(script);
assert!(r.contains("copied") && !r.contains("RAN-FROM-TMP"), "{r}");
let f = landlock_only("ll-tmpx-off", LandlockMode::Off, vec![]);
let (r, _) = f.report(script);
assert!(
r.contains("RAN-FROM-TMP"),
"control: /tmp is not noexec: {r}"
);
}
// ==== host_ids: container root mapped to a host id range (0.2.1) ====
const IDS_BASE: u32 = 10_000;
const IDS_COUNT: u32 = 1000;
/// The `Uid:` and `Gid:` lines of the job's program (a process in the job's
/// cgroup past its execve), read from the HOST side while the job runs.
fn job_ids_from_host(cgroup_root: &std::path::Path, job: JobId) -> (String, String) {
let procs = cgroup_root.join(format!("heimdall/job-{job}/cgroup.procs"));
let t0 = Instant::now();
let mut last = None;
while t0.elapsed() < Duration::from_secs(15) {
let pids = std::fs::read_to_string(&procs).unwrap_or_default();
for pid in pids.lines() {
// Only the job's program, after its execve: before it the process
// is still Heimdall's executor (the shim binary), whose creds are
// host root until it sets its ids inside the mapped namespace.
let exe = std::fs::read_link(format!("/proc/{pid}/exe")).unwrap_or_default();
if exe.as_os_str().is_empty()
|| exe.to_string_lossy().contains("heimdall-container-shim")
{
continue;
}
let Ok(st) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
continue;
};
let line = |k: &str| {
st.lines()
.find(|l| l.starts_with(k))
.map(|l| {
l[k.len()..]
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
})
.unwrap_or_default()
};
let ids = (line("Uid:"), line("Gid:"));
// Host root may still be a moment before the ids are set: keep
// looking while the job runs (it sleeps 2 s); a job that really
// runs as host root stays root and is returned at the end.
if ids.0 != "0 0 0 0" {
return ids;
}
last = Some(ids);
}
if last.is_some() && t0.elapsed() > Duration::from_secs(4) {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
last.unwrap_or_else(|| panic!("no process appeared in {}", procs.display()))
}
fn idmap_run(runner: Arc<HeimdallRunner>, f: &Fx) -> (JobDirs, SignedResult, (String, String)) {
use std::os::unix::fs::PermissionsExt;
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"root-only input\n").unwrap();
std::fs::set_permissions(d.input.join("a"), std::fs::Permissions::from_mode(0o600)).unwrap();
let job = native_job(
&f.image,
&sh("cp /in/a /out/a; echo w > /out/w; cat /proc/self/uid_map > /out/m; sleep 2"),
&d,
);
let id = job.id;
let r = runner.clone();
let h = std::thread::spawn(move || r.run(job));
let ids = job_ids_from_host(std::path::Path::new("/sys/fs/cgroup"), id);
let s = h.join().unwrap().expect("a signed result");
verify(&s, &runner.verifying_key()).unwrap();
(d, s, ids)
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn with_host_ids_a_root_runners_job_root_is_not_host_root_seen_from_the_host() {
use std::os::unix::fs::MetadataExt;
let f = fx("idmap-k");
assert_eq!(
std::fs::metadata("/proc/self").unwrap().uid(),
0,
"the kernel tests run the runner as root"
);
let runner = HeimdallRunner::new(cfg(&f.base, &f.layout))
.unwrap()
.with_host_ids(IDS_BASE, IDS_COUNT)
.expect("a root runner takes the range");
let (d, s, (uid, gid)) = idmap_run(Arc::new(runner), &f);
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
// The job's root, from the host: every Uid/Gid field is the range's base.
assert_eq!(uid, "10000 10000 10000 10000");
assert_eq!(gid, "10000 10000 10000 10000");
// A file the job wrote belongs to the base, not to host root.
let m = std::fs::metadata(d.output.join("w")).unwrap();
assert_eq!((m.uid(), m.gid()), (IDS_BASE, IDS_BASE));
// The job read its 0600 input (given to it) and saw the one-line map.
assert_eq!(
std::fs::read(d.output.join("a")).unwrap(),
b"root-only input\n"
);
let map = std::fs::read_to_string(d.output.join("m")).unwrap();
assert_eq!(
map.split_whitespace().collect::<Vec<_>>(),
["0", "10000", "1000"],
"{map}"
);
check_outputs(&s.result, &d.output).unwrap();
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn with_host_ids_a_job_creates_files_in_its_tmp_through_the_broker() {
// MEASURED on Holger's appliance (lane lsm-shim, 2026-11-03): a root runner with a host id
// range, the broker in full mode; the job's `open(/tmp/x, O_CREAT)` answered EOVERFLOW,
// because the broker created the file as host root, which the job's tmpfs (mounted in the
// job's user namespace) cannot map.
let f = fx("idmap-tmp");
let runner = HeimdallRunner::new(cfg(&f.base, &f.layout))
.unwrap()
.with_host_ids(IDS_BASE, IDS_COUNT)
.expect("a root runner takes the range");
let d = job_dirs(&f.base);
std::fs::write(d.input.join("a"), b"untrusted input\n").unwrap();
let j = native_job(
&f.image,
&sh(
"echo t > /tmp/t && cat /tmp/t >> /out/r && echo tmp-ok >> /out/r; \
ls -ln /tmp/t >> /out/r",
),
&d,
);
let s = runner.run(j).expect("a signed result");
verify(&s, &runner.verifying_key()).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let r = std::fs::read_to_string(d.output.join("r")).unwrap_or_default();
assert!(
r.contains("tmp-ok"),
"the job could not create /tmp/t: {r} {:?}",
s.result.events
);
// The job made it: it is its own root's file, seen from inside.
assert!(
r.lines()
.any(|l| l.contains(" 0 ") && l.ends_with("/tmp/t")),
"{r}"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn without_host_ids_a_root_runners_job_root_is_the_runners_uid_as_in_0_2_0() {
use std::os::unix::fs::MetadataExt;
let f = fx("idmap-k-off");
let runner = HeimdallRunner::new(cfg(&f.base, &f.layout)).unwrap();
assert_eq!(runner.host_ids(), None);
let (d, s, (uid, _gid)) = idmap_run(Arc::new(runner), &f);
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
assert_eq!(uid, "0 0 0 0");
assert_eq!(std::fs::metadata(d.output.join("w")).unwrap().uid(), 0);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn many_rounds_of_parallel_jobs_never_fail_with_a_busy_program() {
// ETXTBSY: while one job's root is unpacked, another thread's spawn holds
// a copy of the write descriptor until its exec. Measured: 1 run in 3 of
// the parallel tests failed "Text file busy" under load before the retry.
let f = fx("busy");
let image = f.image.clone();
let base = f.base.clone();
let r: Arc<dyn JobRunner> = Arc::new(f.runner);
for round in 0..4 {
let hs: Vec<_> = (0..16)
.map(|i| {
let r = Arc::clone(&r);
let d = job_dirs(&base);
let j = native_job(&image, &sh(&format!("echo {round}-{i} > /out/n")), &d);
std::thread::spawn(move || r.run(j))
})
.collect();
for h in hs {
let s = h
.join()
.unwrap()
.unwrap_or_else(|e| panic!("round {round}: {e:?}"));
assert!(
s.result.verdict.is_accepted(),
"round {round}: {:?}",
s.result.verdict
);
}
}
}
// ---- fetch jobs: a declared set of peers, each connected by the broker ----
fn listen_on(ip: &str, tag: &'static [u8]) -> u16 {
let l = TcpListener::bind(format!("{ip}:0")).unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
for mut c in l.incoming().flatten() {
use std::io::Write;
let _ = c.write_all(tag);
}
});
port
}
fn listen(tag: &'static [u8]) -> u16 {
listen_on("127.0.0.1", tag)
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_fetch_job_reaches_both_declared_peers_and_nothing_undeclared() {
let one = listen(b"ONE\n");
let two = listen(b"TWO\n");
let other = listen(b"FORBIDDEN\n");
// The same port as a declared peer, on another loopback address.
let other_addr = listen_on("127.0.0.2", b"FORBIDDEN-ADDR\n");
let f = fx("fetch2");
let script = format!(
"nc -w 3 127.0.0.1 {one} < /dev/null >> /out/r 2>&1; \
nc -w 3 127.0.0.1 {two} < /dev/null >> /out/r 2>&1; \
nc -w 3 127.0.0.1 {other} < /dev/null >> /out/r 2>&1; \
nc -w 3 127.0.0.2 {other_addr} < /dev/null >> /out/r 2>&1; \
nc -l -p 9999 -w 1 >> /out/r 2>&1; true"
);
let peers = vec![
NetPeer::new("one.example", "127.0.0.1", one),
NetPeer::new("two.example", "127.0.0.1", two),
];
let declared = peers.clone();
let (r, res) = f.report_with(&script, move |j| {
j.policy.net = Some(NetAllow { peers: declared })
});
let ev = broker_events(&res);
assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
assert!(res.runner.broker);
assert!(r.contains("ONE") && r.contains("TWO"), "{r}\n{ev}");
assert!(!r.contains("FORBIDDEN"), "{r}\n{ev}");
for p in [one, two] {
assert!(
has_event(
&res,
"connect",
Decision::Allowed,
&format!("addr=127.0.0.1:{p}")
),
"{ev}"
);
}
assert!(
has_event(
&res,
"connect",
Decision::Refused,
&format!("addr=127.0.0.1:{other}")
),
"{ev}"
);
assert!(
has_event(
&res,
"connect",
Decision::Refused,
&format!("addr=127.0.0.2:{other_addr}")
),
"{ev}"
);
assert!(
has_event(&res, "bind", Decision::Refused, "bind is never allowed")
|| has_event(&res, "listen", Decision::Refused, "no listening sockets"),
"{ev}"
);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn the_job_reads_its_declared_peers_with_their_host_names() {
let one = listen(b"ONE\n");
let f = fx("fetch-peers");
// The job sees every declared peer with its host name (for SNI and the
// certificate check) as JSON in HEIMDALL_NET_PEERS.
let n = NetAllow {
peers: vec![
NetPeer::new("pypi.org", "127.0.0.1", one),
NetPeer::new("files.pythonhosted.org", "127.0.0.3", one),
],
};
let want = n.peers.clone();
let (r, res) = f.report_with("printf '%s' \"$HEIMDALL_NET_PEERS\" > /out/r", move |j| {
j.policy.net = Some(n)
});
assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
let got: Vec<NetPeer> = serde_json::from_str(&r).unwrap_or_else(|e| panic!("{e}: {r:?}"));
assert_eq!(got, want);
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn more_than_sixteen_peers_are_refused_before_anything_runs() {
let f = fx("fetch-cap");
let d = job_dirs(&f.base);
let mut j = native_job(&f.image, &sh("echo ran > /out/r"), &d);
j.policy.net = Some(NetAllow {
peers: (0..17u16)
.map(|i| NetPeer::new(&format!("h{i}.example"), "127.0.0.1", 2000 + i))
.collect(),
});
match f.runner.run(j) {
Err(RunError::InvalidJob { reason }) => assert!(reason.contains("at most 16"), "{reason}"),
other => panic!("{other:?}"),
}
assert!(!d.output.join("r").exists());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn a_fetch_jobs_own_network_namespace_stays_empty_and_a_raw_socket_is_refused() {
let allowed = listen(b"ALLOWED\n");
if selftest().is_none() {
eprintln!("skipped: HEIMDALL_BROKER_SELFTEST not set");
return;
}
let base = scratch("hc-fetch-raw");
let layout = Layout::new(&base.join("layout"));
let image = selftest_image(&layout);
let runner = HeimdallRunner::new(cfg(&base, &layout)).unwrap();
let d = job_dirs(&base);
let script = format!(
"/bin/selftest seq connect 127.0.0.1:{allowed} -- socket raw -- socket udp -- socket tcp6 -- socket unix -- bind 127.0.0.1:0 > /out/r; \
cat /proc/net/dev > /out/dev"
);
let mut j = native_job(&image, &sh(&script), &d);
j.policy.net = Some(NetAllow::one("allowed.example", "127.0.0.1", allowed));
let s = runner.run(j).unwrap();
let r = std::fs::read_to_string(d.output.join("r")).unwrap_or_default();
let lines: Vec<&str> = r.lines().collect();
let ev = broker_events(&s.result);
assert_eq!(lines.len(), 7, "{r}\n{ev}");
assert_eq!(lines[0], "ok connected ALLOWED", "{ev}");
for l in &lines[1..6] {
assert_eq!(*l, format!("err {}", libc::EACCES), "{r}");
}
let dev = std::fs::read_to_string(d.output.join("dev")).unwrap();
let ifaces: Vec<&str> = dev
.lines()
.skip(2)
.filter_map(|l| l.split(':').next())
.map(str::trim)
.collect();
assert_eq!(ifaces, vec!["lo"], "the job's own netns is still empty");
}
// ==== RLIMIT_NOFILE set by the runner (0.3.1) ====
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2 + Landlock: scripts/container-tests.sh"]
fn with_nofile_the_job_gets_that_many_open_files_and_no_more() {
let base = scratch("hc-nofile");
let layout = Layout::new(&base.join("layout"));
let image = busybox_image(&layout);
let runner = HeimdallRunner::new(cfg(&base, &layout))
.unwrap()
.with_nofile(4096)
.expect("4096 is within this runner's own limit");
let d = job_dirs(&base);
let job = native_job(
&image,
&sh("grep 'Max open files' /proc/self/limits > /out/r"),
&d,
);
let s = runner.run(job).expect("a signed result");
verify(&s, &runner.verifying_key()).unwrap();
assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
let r = std::fs::read_to_string(d.output.join("r")).unwrap();
let v: Vec<&str> = r["Max open files".len()..].split_whitespace().collect();
assert_eq!(v[..2], ["4096", "4096"], "{r}");
}