crates/heimdall-cli/tests/probe_container.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! `heimdall profile probe-container`: a real program learned inside
//! Heimdall's own protected container while this test drives it from
//! outside. Heimdall is a defensive sandbox; every program here talks only to
//! this test.
//!
//! Kernel tests: they need user namespaces, delegated cgroup v2, Landlock,
//! seccomp USER_NOTIF, a static busybox, rustc with the musl target and the
//! heimdall-container shim (HEIMDALL_CONTAINER_SHIM), so they are
//! `#[ignore]`d in plain CI; `scripts/container-tests.sh` runs them.
#[path = "../../heimdall-plain/tests/common/mod.rs"]
mod common;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use common::*;
use heimdall_core::ImageRef;
use heimdall_probe::observe::RunInfo;
use heimdall_probe::Observations;
/// The static musl test program (tests/fixtures/chan_echo.rs).
fn chan_echo(base: &Path) -> Vec<u8> {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/chan_echo.rs");
let out = base.join("chan-echo");
let target = format!("{}-unknown-linux-musl", std::env::consts::ARCH);
let st = Command::new(std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()))
.args([
"--edition",
"2021",
"-C",
"opt-level=1",
"--target",
&target,
"-o",
])
.arg(&out)
.arg(&src)
.status()
.expect("rustc");
assert!(st.success(), "building the musl test program");
std::fs::read(out).unwrap()
}
/// busybox (static) + /bin/chan-echo (static musl).
fn image(base: &Path, layout: &Layout) -> ImageRef {
let bb = std::fs::read(busybox().expect("a static busybox")).unwrap();
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/chan-echo".into(), chan_echo(base), 0o755),
];
for a in APPLETS {
entries.push(Entry::Symlink(format!("bin/{a}"), "busybox".into()));
}
layout.image(&[(Layout::tar(&entries), true)], &["PATH=/bin"])
}
struct Fx {
base: PathBuf,
layout: Layout,
image: ImageRef,
}
fn fx(name: &str) -> Fx {
let base = scratch(&format!("pc-{name}"));
let layout = Layout::new(&base.join("layout"));
let image = image(&base, &layout);
Fx {
base,
layout,
image,
}
}
/// A running `heimdall profile probe-container` with its stdout lines.
struct Probe {
child: Child,
lines: mpsc::Receiver<String>,
}
impl Fx {
fn out(&self) -> PathBuf {
self.base.join("evidence").join("x86_64-glibc.probe.json")
}
fn log(&self) -> PathBuf {
self.base.join("program.log")
}
fn args(&self, run: &str) -> Vec<String> {
std::fs::create_dir_all(self.base.join("evidence")).unwrap();
vec![
"profile".into(),
"probe-container".into(),
"--workload".into(),
"chan-echo".into(),
"--run".into(),
run.into(),
"--out".into(),
self.out().to_string_lossy().into_owned(),
"--image".into(),
self.layout.dir.to_string_lossy().into_owned(),
"--digest".into(),
self.image.digest.clone(),
"--work-dir".into(),
self.base.join("work").to_string_lossy().into_owned(),
"--log".into(),
self.log().to_string_lossy().into_owned(),
"--flush-secs".into(),
"1".into(),
"--wall-secs".into(),
"120".into(),
"--memory-mib".into(),
"128".into(),
"--pids".into(),
"32".into(),
]
}
fn spawn(&self, args: Vec<String>, argv: &[&str]) -> Probe {
let mut child = Command::new(env!("CARGO_BIN_EXE_heimdall"))
.args(args)
.arg("--")
.args(argv)
.stdout(Stdio::piped())
.spawn()
.unwrap();
let out = child.stdout.take().unwrap();
let (tx, lines) = mpsc::channel();
std::thread::spawn(move || {
for l in BufReader::new(out).lines().map_while(Result::ok) {
let _ = tx.send(l);
}
});
Probe { child, lines }
}
}
impl Probe {
fn event(&self, what: &str, within: Duration) -> serde_json::Value {
let deadline = Instant::now() + within;
loop {
let left = deadline.saturating_duration_since(Instant::now());
let l = self
.lines
.recv_timeout(left)
.unwrap_or_else(|_| panic!("no {what} event within {within:?}"));
let v: serde_json::Value =
serde_json::from_str(&l).unwrap_or_else(|e| panic!("stdout line {l:?}: {e}"));
if v["event"] == what {
return v;
}
}
}
fn signal(&self, sig: i32) {
// SAFETY: kill on our own child.
assert_eq!(unsafe { libc::kill(self.child.id() as i32, sig) }, 0);
}
fn wait(&mut self, within: Duration) -> std::process::ExitStatus {
let deadline = Instant::now() + within;
loop {
if let Some(s) = self.child.try_wait().unwrap() {
return s;
}
if Instant::now() > deadline {
let _ = self.child.kill();
panic!("heimdall did not exit within {within:?}");
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
fn wait_for(what: &str, within: Duration, mut f: impl FnMut() -> bool) {
let deadline = Instant::now() + within;
while !f() {
assert!(Instant::now() < deadline, "{what}: not within {within:?}");
std::thread::sleep(Duration::from_millis(100));
}
}
fn read(p: &Path) -> String {
std::fs::read_to_string(p).unwrap_or_default()
}
fn evidence(p: &Path) -> Observations {
Observations::from_json(&read(p)).unwrap()
}
fn has_call(o: &Observations, name: &str) -> bool {
o.calls.keys().any(|c| c.syscall == name)
}
/// Mount points whose per-mount options are read-write, from mountinfo.
fn rw_mounts(mountinfo: &str) -> Vec<String> {
mountinfo
.lines()
.filter_map(|l| {
let f: Vec<&str> = l.split_whitespace().collect();
(f.len() > 5 && f[5].split(',').any(|o| o == "rw")).then(|| f[4].to_string())
})
.collect()
}
#[test]
#[ignore = "kernel test: scripts/container-tests.sh"]
fn a_program_in_the_container_is_learned_driven_through_the_channel_and_stopped_by_sigterm() {
let f = fx("chan");
let chan = f.base.join("chan");
std::fs::create_dir_all(&chan).unwrap();
let mut args = f.args("kernel:chan");
args.extend(["--channel".into(), chan.to_string_lossy().into_owned()]);
args.extend(["--env".into(), "GREETING=hello-from-env".into()]);
let mut p = f.spawn(
args,
&[
"/bin/sh",
"-c",
"trap 'echo got-term; exit 7' TERM; echo $GREETING; cat /proc/self/mountinfo; \
/bin/chan-echo /run/heimdall-channel/echo.sock & wait",
],
);
let started = p.event("started", Duration::from_secs(60));
assert!(started["pid"].as_i64().unwrap() > 1, "{started}");
assert_eq!(started["seccomp"], "learning", "{started}");
assert_eq!(started["channel"], "/run/heimdall-channel", "{started}");
let bb = std::fs::read(busybox().unwrap()).unwrap();
assert_eq!(
started["libc"],
heimdall_seccomp::Libc::detect(&bb).unwrap().as_str(),
"the libc of /bin/sh inside the image"
);
// The driver, outside the program's process tree, reaches its socket.
let sock = chan.join("echo.sock");
wait_for(
"the program's socket in the channel",
Duration::from_secs(30),
|| sock.exists(),
);
let mut s = UnixStream::connect(&sock).expect("connect from outside");
s.write_all(b"ping\n").unwrap();
let mut answer = String::new();
BufReader::new(&mut s).read_line(&mut answer).unwrap();
assert_eq!(answer.trim(), "pong ping");
// The evidence is current while the program still runs.
wait_for(
"accept4 in the evidence while running",
Duration::from_secs(20),
|| {
let t = read(&f.out());
t.contains("accept4") && t.contains("running")
},
);
p.signal(libc::SIGTERM);
let st = p.wait(Duration::from_secs(30));
assert_eq!(
st.code(),
Some(7),
"the program's exit is heimdall's: {st:?}"
);
let exited = p.event("exited", Duration::from_secs(5));
assert_eq!(exited["exit"], "exit 7", "{exited}");
assert_eq!(exited["out"], f.out().to_string_lossy().as_ref());
assert!(exited["calls"].as_u64().unwrap() > 10, "{exited}");
let o = evidence(&f.out());
for c in ["bind", "listen", "accept4", "execve", "wait4"] {
assert!(has_call(&o, c), "{c} recorded");
}
assert!(
o.calls
.keys()
.any(|c| c.syscall == "socket" && c.arg(0) == Some(libc::AF_UNIX as u64)),
"an AF_UNIX socket recorded with its domain"
);
assert!(o.runs.contains(&RunInfo {
name: "kernel:chan".into(),
error_path: false,
exit: "exit 7".into(),
}));
assert!(!o.runs.iter().any(|r| r.exit == "running"), "{:?}", o.runs);
let log = read(&f.log());
assert!(
log.contains("hello-from-env"),
"--env reaches the program: {log}"
);
assert!(
log.contains("got-term"),
"SIGTERM reached the program: {log}"
);
let rw = rw_mounts(&log);
assert!(rw.iter().any(|m| m == "/run/heimdall-channel"), "{rw:?}");
// Mergeable with the other evidence verbs.
let merged = f.base.join("merged.json");
let st = Command::new(env!("CARGO_BIN_EXE_heimdall"))
.args(["profile", "merge", "--out"])
.arg(&merged)
.arg(f.out())
.status()
.unwrap();
assert!(st.success());
assert_eq!(evidence(&merged).calls, o.calls);
let _ = std::fs::remove_dir_all(&f.base);
}
#[test]
#[ignore = "kernel test: scripts/container-tests.sh"]
fn without_a_channel_there_is_no_read_write_mount_and_a_program_deaf_to_sigterm_is_killed_after_the_grace(
) {
let f = fx("nochan");
let mut args = f.args("kernel:nochan");
args.extend([
"--libc".into(),
"glibc".into(),
"--term-grace-secs".into(),
"1".into(),
]);
// `exec sleep`: pid 1 of its namespace with no handler, SIGTERM never lands.
let mut p = f.spawn(
args,
&[
"/bin/sh",
"-c",
"cat /proc/self/mountinfo; ls -d /run 2>&1; echo ready; exec sleep 600",
],
);
let started = p.event("started", Duration::from_secs(60));
assert!(started["channel"].is_null(), "{started}");
wait_for("the program's output", Duration::from_secs(30), || {
read(&f.log()).contains("ready")
});
let t0 = Instant::now();
p.signal(libc::SIGTERM);
let st = p.wait(Duration::from_secs(30));
assert!(
t0.elapsed() >= Duration::from_millis(900),
"the grace was kept"
);
assert_eq!(
st.signal(),
Some(libc::SIGKILL),
"killed after the grace; heimdall ends the way the program did: {st:?}"
);
let exited = p.event("exited", Duration::from_secs(5));
assert_eq!(exited["exit"], "killed by signal 9", "{exited}");
assert_eq!(exited["term_killed"], true, "{exited}");
let log = read(&f.log());
assert!(!log.contains("heimdall-channel"), "{log}");
assert!(
log.contains("No such file"),
"no /run made without a channel: {log}"
);
// A normal job's: /out, /tmp, and the runtime's /proc and /dev (with its
// device nodes and the masked /proc paths, bound from /dev/null).
let rw = rw_mounts(&log);
assert!(
rw.iter().all(|m| m == "/out"
|| m == "/tmp"
|| m == "/proc"
|| m.starts_with("/proc/")
|| m == "/dev"
|| m.starts_with("/dev/")),
"only the normal job's writable mounts: {rw:?}"
);
let o = evidence(&f.out());
assert!(has_call(&o, "nanosleep") || has_call(&o, "clock_nanosleep"));
assert!(o
.runs
.iter()
.any(|r| r.name == "kernel:nochan" && r.exit == "killed by signal 9"));
let _ = std::fs::remove_dir_all(&f.base);
}
#[test]
#[ignore = "kernel test: scripts/container-tests.sh"]
fn the_program_may_run_as_another_uid_and_an_image_off_its_digest_never_starts() {
let f = fx("uid");
let mut args = f.args("kernel:uid");
args.extend(["--libc".into(), "glibc".into(), "--uid".into(), "70".into()]);
let mut p = f.spawn(
args,
&[
"/bin/sh",
"-c",
"id -u; id -g; grep -E '^Cap(Inh|Prm|Eff|Bnd|Amb)' /proc/self/status",
],
);
p.event("started", Duration::from_secs(60));
let st = p.wait(Duration::from_secs(30));
assert_eq!(st.code(), Some(0), "{st:?}");
let log = read(&f.log());
let lines: Vec<&str> = log.lines().collect();
assert_eq!(lines[..2], ["70", "70"], "{log}");
// The CAP_SETFCAP the maps needed is gone: the program holds nothing.
assert_eq!(lines.len(), 7, "{log}");
for l in &lines[2..] {
assert!(l.ends_with("0000000000000000"), "no capability: {l}");
}
let mut args = f.args("kernel:baddigest");
let i = args.iter().position(|a| a == "--digest").unwrap();
args[i + 1] = format!("sha256:{}", "0".repeat(64));
args.extend(["--libc".into(), "glibc".into()]);
let before = read(&f.out());
let mut p = f.spawn(args, &["/bin/true"]);
let st = p.wait(Duration::from_secs(30));
assert_eq!(
st.code(),
Some(3),
"a host error, not the program's exit: {st:?}"
);
let mut rest = String::new();
while let Ok(l) = p.lines.recv_timeout(Duration::from_millis(200)) {
rest.push_str(&l);
}
assert!(!rest.contains("started"), "{rest}");
assert_eq!(read(&f.out()), before, "no evidence written");
let _ = std::fs::remove_dir_all(&f.base);
}