nordisk/heimdall
crates/heimdall-plain/tests/container.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Real youki containers (the milestone 0 demand §3 and more). Heimdall is a
//! defensive sandbox; every job here attacks only its own container: its
//! input, its root, its limits, its network namespace.
//!
//! These need a Linux kernel with user namespaces, cgroup v2 with the memory,
//! pids and cpu controllers delegated, and a static busybox, so they are
//! `#[ignore]`d in plain CI. `scripts/plain-tests.sh` runs them in a
//! privileged toolchain container (see docs/API.md).
mod common;
use std::io::Read;
use std::net::TcpListener;
use std::sync::Arc;
use std::time::{Duration, Instant};
use common::*;
use heimdall_core::*;
use heimdall_plain::*;
const WHY: &str = "needs user namespaces + delegated cgroup v2: scripts/plain-tests.sh";
struct Fx {
base: std::path::PathBuf,
image: ImageRef,
runner: PlainYoukiRunner,
}
fn fx(name: &str) -> Fx {
let _ = WHY;
let base = scratch(name);
let layout = Layout::new(&base.join("layout"));
let image = busybox_image(&layout);
let cfg = PlainConfig::new(&layout.dir, base.join("work"));
Fx {
runner: PlainYoukiRunner::new(cfg),
base,
image,
}
}
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) {
let (d, s) = self.run(script);
let r = std::fs::read_to_string(d.output.join("r")).unwrap_or_default();
(r, s.result)
}
}
// ---- demand §3 ----
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2: scripts/plain-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: scripts/plain-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 = PlainYoukiRunner::new(PlainConfig::new("/x", "/y"));
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: scripts/plain-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: scripts/plain-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();
// Every address of this host, plus loopback and a public address.
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: scripts/plain-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: scripts/plain-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:?}");
// The host still runs jobs.
let (_d, s) = f.run("true");
assert!(s.result.verdict.is_accepted());
}
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2: scripts/plain-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: scripts/plain-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: scripts/plain-tests.sh"]
fn features_report_default_seccomp_no_landlock_no_broker() {
let f = fx("features");
let ft = f.runner.features();
assert_eq!(ft.seccomp, Protection::Default);
assert!(!ft.landlock);
assert!(!ft.broker);
let (_d, s) = f.run("true");
assert_eq!(s.result.runner, ft);
}
// ---- containment, beyond the demand ----
#[test]
#[ignore = "needs user namespaces + delegated cgroup v2: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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: scripts/plain-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..4)
.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());
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: scripts/plain-tests.sh"]
fn the_signing_key_is_not_reachable_from_the_job() {
// The key lives only in the runner process. The job sees its own pid
// namespace, so it cannot read the runner's memory or environment.
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));
}