Gunnar

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));
}