crates/heimdall-container/tests/spec.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The protected job's OCI config, seccomp profile and Landlock plan, as
//! data (no kernel needed). Heimdall is a defensive sandbox; these tests
//! check only its own containment settings.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use heimdall_container::confine::{self, rights, LandlockPlan};
use heimdall_container::{profile, spec, UnlistedMount};
use heimdall_core::Limits;
use landlock::AccessFs;
use serde_json::Value;
fn limits() -> Limits {
Limits {
memory_bytes: 64 << 20,
pids: 32,
max_output_file_bytes: 5 << 20,
..Limits::default()
}
}
fn config_with(unlisted: &[UnlistedMount]) -> Value {
config_full(unlisted, false)
}
fn config_full(unlisted: &[UnlistedMount], generated: bool) -> Value {
let l = limits();
let argv = vec!["/bin/sh".to_string()];
let env = vec!["PATH=/bin".to_string()];
let read = vec![PathBuf::from("/data/ro")];
let write = vec![PathBuf::from("/data/rw")];
spec::build(
&heimdall_plain::spec::SpecInput {
argv: &argv,
env: &env,
input_dir: Path::new("/host/in"),
output_dir: Path::new("/host/out"),
read: &read,
write: &write,
limits: &l,
uid: 1000,
gid: 1000,
cgroup_path: "/heimdall/job-x",
},
unlisted,
generated,
)
}
fn config() -> Value {
config_with(&[])
}
fn mount<'a>(c: &'a Value, dst: &str) -> Option<&'a Value> {
c["mounts"]
.as_array()
.unwrap()
.iter()
.find(|m| m["destination"] == dst)
}
fn rlimit(c: &Value, t: &str) -> (u64, u64) {
let r = c["process"]["rlimits"]
.as_array()
.unwrap()
.iter()
.find(|r| r["type"] == t)
.unwrap_or_else(|| panic!("no {t}"));
(r["soft"].as_u64().unwrap(), r["hard"].as_u64().unwrap())
}
// ---- the container ----
#[test]
fn the_job_gets_user_mount_pid_ipc_uts_cgroup_and_an_empty_network_namespace() {
let c = config();
let ns: BTreeSet<&str> = c["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.map(|n| n["type"].as_str().unwrap())
.collect();
let want: BTreeSet<&str> = ["user", "mount", "pid", "ipc", "uts", "network", "cgroup"].into();
assert_eq!(ns, want);
// An empty netns: no namespace path joins an existing one.
assert!(c["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.all(|n| n.get("path").is_none()));
}
#[test]
fn the_root_is_read_only_tmp_is_a_tmpfs_in_is_read_only_and_out_is_bound() {
let c = config();
assert_eq!(c["root"]["readonly"], true);
assert_eq!(mount(&c, "/tmp").unwrap()["type"], "tmpfs");
let i = mount(&c, "/in").unwrap();
assert_eq!(i["source"], "/host/in");
assert!(i["options"].as_array().unwrap().contains(&"ro".into()));
assert_eq!(mount(&c, "/out").unwrap()["source"], "/host/out");
}
#[test]
fn there_is_no_mqueue_mount_since_the_profile_refuses_mq_calls() {
assert!(mount(&config(), "/dev/mqueue").is_none());
}
#[test]
fn rlimits_are_nofile_nproc_from_pids_fsize_from_the_output_cap_and_core_zero() {
let c = config();
assert_eq!(rlimit(&c, "RLIMIT_NOFILE"), (spec::NOFILE, spec::NOFILE));
assert_eq!(rlimit(&c, "RLIMIT_NPROC"), (33, 33));
assert_eq!(rlimit(&c, "RLIMIT_FSIZE"), (5 << 20, 5 << 20));
assert_eq!(rlimit(&c, "RLIMIT_CORE"), (0, 0));
}
#[test]
fn every_capability_is_dropped_and_no_new_privs_is_set() {
let c = config();
for set in [
"bounding",
"effective",
"inheritable",
"permitted",
"ambient",
] {
assert_eq!(
c["process"]["capabilities"][set],
Value::Array(vec![]),
"{set}"
);
}
assert_eq!(c["process"]["noNewPrivileges"], true);
}
#[test]
fn cgroup_limits_memory_with_no_swap_pids_and_cpu() {
let c = config();
let r = &c["linux"]["resources"];
assert_eq!(r["memory"]["limit"], 64 << 20);
assert_eq!(r["memory"]["swap"], 64 << 20, "swap == limit means no swap");
assert_eq!(r["pids"]["limit"], 32);
assert!(r["cpu"]["quota"].as_i64().unwrap() > 0);
}
#[test]
fn the_io_weight_is_never_in_the_oci_config() {
// libcgroups 0.7.0 converts an OCI blockIO.weight to io.weight in u16 and
// overflows for every weight above 16 (a panic in a debug shim, a wrong
// io.weight in a release one): the shim writes io.weight itself.
assert!(config()["linux"]["resources"]["blockIO"]
.get("weight")
.is_none());
}
#[test]
fn the_seccomp_profile_in_the_config_is_heimdalls_profile() {
assert_eq!(config()["linux"]["seccomp"], profile::seccomp_json());
}
#[test]
fn unlisted_test_mounts_are_mounted_where_asked() {
let c = config_with(&[UnlistedMount {
source: "/host/secret".into(),
destination: "/secret".into(),
writable: true,
}]);
let m = mount(&c, "/secret").unwrap();
assert_eq!(m["source"], "/host/secret");
assert!(m["options"].as_array().unwrap().contains(&"rw".into()));
}
// ---- the seccomp profile ----
#[test]
fn the_profile_allows_only_calls_heimdall_plain_allows() {
let plain: BTreeSet<&str> = heimdall_plain::default_profile::ALLOW
.iter()
.copied()
.collect();
for n in profile::allowed() {
assert!(plain.contains(n), "{n} is not in heimdall-plain's profile");
}
}
#[test]
fn the_profile_is_strictly_stricter_than_heimdall_plains() {
let allowed: BTreeSet<&str> = profile::allowed().into_iter().collect();
assert!(profile::REMOVED.len() >= 30, "{}", profile::REMOVED.len());
for (n, why) in profile::REMOVED {
assert!(!allowed.contains(n), "{n} is removed but allowed");
assert!(why.len() > 10, "{n}: say why");
assert!(
heimdall_plain::default_profile::ALLOW.contains(n),
"{n} was not allowed by plain anyway"
);
}
assert_eq!(
allowed.len() + profile::REMOVED.len(),
heimdall_plain::default_profile::ALLOW.len()
);
}
#[test]
fn the_dangerous_calls_kill_the_job() {
let j = profile::seccomp_json();
let kill: BTreeSet<&str> = j["syscalls"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["action"] == "SCMP_ACT_KILL_PROCESS")
.flat_map(|r| r["names"].as_array().unwrap())
.map(|n| n.as_str().unwrap())
.collect();
for n in [
"ptrace",
"process_vm_readv",
"process_vm_writev",
"bpf",
"perf_event_open",
"userfaultfd",
"io_uring_enter",
"io_uring_register",
"keyctl",
"add_key",
"mount",
"unshare",
"setns",
"open_by_handle_at",
"init_module",
"kexec_load",
] {
assert!(kill.contains(n), "{n} must kill");
}
let allowed: BTreeSet<&str> = profile::allowed().into_iter().collect();
assert!(kill.is_disjoint(&allowed));
}
/// node 22's libuv calls io_uring_setup at start whatever UV_USE_IO_URING
/// says; refused with EPERM (the default), it carries on without a ring. With
/// no ring fd, io_uring_enter and io_uring_register stay KILL.
#[test]
fn io_uring_setup_is_refused_with_eperm_not_killed_and_enter_and_register_still_kill() {
let s = profile::seccomp_json();
let rules = s["syscalls"].as_array().unwrap();
let named = |n: &str| {
rules
.iter()
.filter(|r| r["names"].as_array().unwrap().iter().any(|x| x == n))
.map(|r| r["action"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
};
assert!(
named("io_uring_setup").is_empty(),
"{:?}",
named("io_uring_setup")
);
assert_eq!(s["defaultAction"], "SCMP_ACT_ERRNO");
for n in ["io_uring_enter", "io_uring_register"] {
assert_eq!(named(n), vec!["SCMP_ACT_KILL_PROCESS".to_string()], "{n}");
}
}
#[test]
fn ptrace_aio_sysv_ipc_mqueue_memfd_execveat_and_sync_are_not_allowed() {
let allowed: BTreeSet<&str> = profile::allowed().into_iter().collect();
for n in [
"ptrace",
"io_setup",
"io_submit",
"msgget",
"semget",
"shmget",
"mq_open",
"memfd_create",
"execveat",
"sync",
"vmsplice",
"modify_ldt",
"name_to_handle_at",
"listmount",
] {
assert!(!allowed.contains(n), "{n}");
}
for n in [
"read",
"write",
"openat",
"execve",
"landlock_restrict_self",
"exit_group",
] {
assert!(allowed.contains(n), "{n} is needed");
}
}
#[test]
fn socket_is_allowed_only_for_unix_inet_and_inet6() {
assert_eq!(profile::SOCKET_DOMAINS, &[1, 2, 10]);
let j = profile::seccomp_json();
let sock: Vec<&Value> = j["syscalls"]
.as_array()
.unwrap()
.iter()
.filter(|r| r["names"].as_array().unwrap().contains(&"socket".into()))
.collect();
assert_eq!(sock.len(), 3);
for r in sock {
assert_eq!(r["args"][0]["op"], "SCMP_CMP_EQ");
}
}
#[test]
fn unknown_calls_fail_with_eperm_and_clone3_with_enosys() {
let j = profile::seccomp_json();
assert_eq!(j["defaultAction"], "SCMP_ACT_ERRNO");
assert_eq!(j["defaultErrnoRet"], 1);
assert_eq!(j["architectures"], serde_json::json!(["SCMP_ARCH_X86_64"]));
assert!(j["syscalls"]
.as_array()
.unwrap()
.iter()
.any(|r| r["names"] == serde_json::json!(["clone3"]) && r["errnoRet"] == 38));
}
// ---- the Landlock plan ----
fn rootfs(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!(
"heimdall-container-plan-{name}-{}",
heimdall_core::JobId::random()
));
for sub in [
"bin", "etc", "usr/lib", "in", "out", "tmp", "proc", "dev", "sys",
] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
std::fs::write(d.join("marker"), b"x").unwrap();
std::os::unix::fs::symlink("usr/lib", d.join("lib")).unwrap();
std::os::unix::fs::symlink("/", d.join("root-link")).unwrap();
std::os::unix::fs::symlink("..", d.join("up-link")).unwrap();
std::os::unix::fs::symlink("/proc/self", d.join("proc-link")).unwrap();
std::os::unix::fs::symlink("./out", d.join("out-link")).unwrap();
d
}
fn paths(v: &[PathBuf]) -> Vec<&str> {
v.iter().map(|p| p.to_str().unwrap()).collect()
}
#[test]
fn the_plan_gives_the_image_entries_but_never_the_root_itself() {
let d = rootfs("image");
let p = confine::plan(&d, &[], &[]).unwrap();
assert_eq!(
paths(&p.read_exec),
vec!["/bin", "/etc", "/lib", "/marker", "/usr"]
);
for g in [&p.read_exec, &p.read, &p.read_write, &p.write_only] {
assert!(!g.iter().any(|x| x == Path::new("/")), "{p:?}");
}
}
#[test]
fn the_plan_skips_symlinks_that_escape_to_the_root_up_proc_or_out() {
let d = rootfs("links");
let p = confine::plan(&d, &[], &[]).unwrap();
for bad in ["/root-link", "/up-link", "/proc-link", "/out-link"] {
assert!(!p.read_exec.iter().any(|x| x == Path::new(bad)), "{bad}");
}
}
#[test]
fn the_plan_reads_in_and_proc_writes_out_only_and_tmp_read_write() {
let d = rootfs("groups");
let p = confine::plan(&d, &["/data/ro".into()], &["/data/rw".into()]).unwrap();
assert_eq!(
p,
LandlockPlan {
read_exec: p.read_exec.clone(),
read: vec!["/in".into(), "/proc".into(), "/data/ro".into()],
read_write: vec!["/tmp".into(), "/data/rw".into()],
read_write_exec: vec![],
channel: vec![],
write_only: vec!["/out".into()],
dev_rw: vec!["/dev/null".into()],
dev_read: vec![
"/dev/zero".into(),
"/dev/full".into(),
"/dev/random".into(),
"/dev/urandom".into()
],
}
);
}
#[test]
fn out_is_write_only_tmp_is_not_executable_and_in_is_not_writable() {
let w = rights::write_only();
for r in [AccessFs::ReadFile, AccessFs::ReadDir, AccessFs::Execute] {
assert!(!w.contains(r), "/out must not allow {r:?}");
}
for r in [AccessFs::WriteFile, AccessFs::MakeReg, AccessFs::Truncate] {
assert!(w.contains(r), "/out must allow {r:?}");
}
assert!(!rights::read_write().contains(AccessFs::Execute));
assert!(rights::read_write().contains(AccessFs::ReadFile));
let rd = rights::read();
for r in [
AccessFs::WriteFile,
AccessFs::MakeReg,
AccessFs::Execute,
AccessFs::RemoveFile,
] {
assert!(!rd.contains(r), "/in must not allow {r:?}");
}
assert!(rights::image().contains(AccessFs::Execute));
assert!(!rights::image().contains(AccessFs::WriteFile));
}
#[test]
fn no_group_may_make_devices_or_ioctl_devices() {
for g in [
rights::image(),
rights::read(),
rights::read_write(),
rights::write_only(),
rights::dev_rw(),
rights::dev_read(),
] {
for r in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::IoctlDev] {
assert!(!g.contains(r), "{r:?}");
}
}
}
#[test]
fn the_plan_serializes_and_round_trips() {
let d = rootfs("serde");
let p = confine::plan(&d, &[], &[]).unwrap();
let j = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<LandlockPlan>(&j).unwrap(), p);
}
// ---- generated profiles (lane genprof) ----
fn execveat_rules(c: &Value) -> Vec<Value> {
c["linux"]["seccomp"]["syscalls"]
.as_array()
.unwrap()
.iter()
.filter(|r| {
r["names"]
.as_array()
.unwrap()
.iter()
.any(|n| n == "execveat")
})
.cloned()
.collect()
}
#[test]
fn without_a_generated_profile_the_fixed_profile_allows_no_execveat_at_all() {
let c = config();
for r in execveat_rules(&c) {
assert_ne!(r["action"], "SCMP_ACT_ALLOW", "{r}");
}
}
#[test]
fn with_a_generated_profile_the_fixed_profile_allows_execveat_only_on_exec_fd_with_at_empty_path() {
let c = config_full(&[], true);
let allowed: Vec<Value> = execveat_rules(&c)
.into_iter()
.filter(|r| r["action"] == "SCMP_ACT_ALLOW")
.collect();
assert_eq!(allowed.len(), 1, "{allowed:?}");
let args = allowed[0]["args"].as_array().unwrap();
assert_eq!(args.len(), 2);
assert_eq!(args[0]["index"], 0);
assert_eq!(args[0]["value"], heimdall_seccomp::launch::EXEC_FD);
assert_eq!(args[0]["op"], "SCMP_CMP_EQ");
assert_eq!(args[1]["index"], 4);
assert_eq!(args[1]["value"], 0x1000);
assert_eq!(args[1]["op"], "SCMP_CMP_EQ");
// Nothing else of the fixed profile changes.
let mut a = config();
let mut b = c.clone();
a["linux"]["seccomp"]["syscalls"]
.as_array_mut()
.unwrap()
.retain(|r| r["names"][0] != "execveat" || r["action"] != "SCMP_ACT_ALLOW");
b["linux"]["seccomp"]["syscalls"]
.as_array_mut()
.unwrap()
.retain(|r| r["names"][0] != "execveat" || r["action"] != "SCMP_ACT_ALLOW");
assert_eq!(a, b);
}
#[test]
fn set_nofile_sets_soft_and_hard_rlimit_nofile_and_nothing_else() {
let mut c = config();
let before = c["process"]["rlimits"].clone();
spec::set_nofile(&mut c, 4096);
assert_eq!(rlimit(&c, "RLIMIT_NOFILE"), (4096, 4096));
let others = |v: &serde_json::Value| -> Vec<serde_json::Value> {
v.as_array()
.unwrap()
.iter()
.filter(|r| r["type"] != "RLIMIT_NOFILE")
.cloned()
.collect()
};
assert_eq!(others(&c["process"]["rlimits"]), others(&before));
}
// ---- learning inside the container (heimdall profile probe-container) ----
use heimdall_container::learn::{self, LearnSpec, CHANNEL_DIR};
fn learning(s: &LearnSpec) -> Value {
let mut c = config();
learn::learning_config(&mut c, s);
c
}
#[test]
fn a_learning_job_leaves_seccomp_to_the_executor_and_keeps_every_other_isolation() {
let normal = config();
let c = learning(&LearnSpec::default());
assert!(
c["linux"].get("seccomp").is_none(),
"the runtime installs no profile: the executor installs the learning filter"
);
for k in ["namespaces", "resources", "maskedPaths", "readonlyPaths"] {
assert_eq!(c["linux"][k], normal["linux"][k], "linux.{k}");
}
assert_eq!(c["root"], normal["root"]);
assert_eq!(
c["process"]["capabilities"],
normal["process"]["capabilities"]
);
assert_eq!(c["process"]["noNewPrivileges"], true);
assert_eq!(c["process"]["rlimits"], normal["process"]["rlimits"]);
assert_eq!(
c["mounts"], normal["mounts"],
"no channel: not one mount more (no read-write channel)"
);
assert!(mount(&c, CHANNEL_DIR).is_none());
}
#[test]
fn the_channel_is_one_bind_mount_read_write_nosuid_nodev_noexec_at_run_heimdall_channel() {
let c = learning(&LearnSpec {
channel: Some("/host/chan".into()),
..LearnSpec::default()
});
let m = mount(&c, CHANNEL_DIR).expect("the channel mount");
assert_eq!(m["type"], "bind");
assert_eq!(m["source"], "/host/chan");
let opts: BTreeSet<&str> = m["options"]
.as_array()
.unwrap()
.iter()
.map(|o| o.as_str().unwrap())
.collect();
for o in ["rbind", "rw", "nosuid", "nodev", "noexec"] {
assert!(opts.contains(o), "{o} in {opts:?}");
}
assert!(!opts.contains("ro"));
assert_eq!(
c["mounts"].as_array().unwrap().len(),
config()["mounts"].as_array().unwrap().len() + 1
);
}
#[test]
fn a_learning_job_may_get_a_larger_tmp_and_keeps_root_mapped_for_the_runtime() {
let c = learning(&LearnSpec {
tmp_bytes: Some(512 << 20),
..LearnSpec::default()
});
let tmp = mount(&c, "/tmp").unwrap();
assert!(
tmp["options"]
.as_array()
.unwrap()
.iter()
.any(|o| o == &format!("size={}", 512u64 << 20)),
"{tmp}"
);
// youki cannot start a rootless job without root mapped: another uid is
// the executor's nested user namespace (LearnPlan::uid), not the config.
assert_eq!(c["process"]["user"], config()["process"]["user"]);
assert_eq!(
c["linux"]["uidMappings"],
serde_json::json!([{"containerID": 0, "hostID": 1000, "size": 1}])
);
assert_eq!(
c["process"]["capabilities"],
config()["process"]["capabilities"]
);
// Another uid: CAP_SETFCAP for the maps only, never where execve keeps it.
let u = learning(&LearnSpec {
uid: 70,
..LearnSpec::default()
});
let caps = &u["process"]["capabilities"];
assert_eq!(caps["effective"], serde_json::json!(["CAP_SETFCAP"]));
assert_eq!(caps["permitted"], serde_json::json!(["CAP_SETFCAP"]));
for k in ["bounding", "inheritable", "ambient"] {
assert_eq!(caps[k], serde_json::json!([]), "{k}");
}
assert_eq!(u["process"]["user"], config()["process"]["user"]);
}
#[test]
fn the_channel_rights_are_tmps_plus_making_unix_sockets_never_execute() {
let ch = rights::channel();
assert!(ch.contains(AccessFs::MakeSock));
assert!(ch.contains(rights::read_write()));
for r in [AccessFs::Execute, AccessFs::MakeChar, AccessFs::MakeBlock] {
assert!(!ch.contains(r), "the channel must not allow {r:?}");
}
assert!(
!rights::read_write().contains(AccessFs::MakeSock),
"a normal job makes no socket file anywhere"
);
}
/// A minimal 64-bit ELF with a PT_INTERP naming `interp`.
fn elf_with_interp(interp: &str) -> Vec<u8> {
let mut f = vec![0u8; 0x40 + 0x38];
f[0..4].copy_from_slice(b"\x7fELF");
f[4] = 2; // 64-bit
f[5] = 1; // little endian
f[6] = 1;
f[0x20..0x28].copy_from_slice(&0x40u64.to_le_bytes()); // e_phoff
f[0x36..0x38].copy_from_slice(&0x38u16.to_le_bytes()); // e_phentsize
f[0x38..0x3a].copy_from_slice(&1u16.to_le_bytes()); // e_phnum
let off = f.len() as u64;
let ph = 0x40;
f[ph..ph + 4].copy_from_slice(&3u32.to_le_bytes()); // PT_INTERP
f[ph + 8..ph + 16].copy_from_slice(&off.to_le_bytes());
f[ph + 32..ph + 40].copy_from_slice(&(interp.len() as u64 + 1).to_le_bytes());
f.extend_from_slice(interp.as_bytes());
f.push(0);
f
}
#[test]
fn the_libc_is_read_from_the_programs_elf_inside_the_image_never_from_the_host() {
let d = std::env::temp_dir().join(format!("hc-libc-{}", heimdall_core::JobId::random()));
std::fs::create_dir_all(d.join("usr/bin")).unwrap();
std::fs::create_dir_all(d.join("usr/lib/postgresql/bin")).unwrap();
std::os::unix::fs::symlink("usr/bin", d.join("bin")).unwrap();
std::fs::write(
d.join("usr/bin/m"),
elf_with_interp("/lib/ld-musl-x86_64.so.1"),
)
.unwrap();
std::fs::write(
d.join("usr/lib/postgresql/bin/postgres"),
elf_with_interp("/lib64/ld-linux-x86-64.so.2"),
)
.unwrap();
// An absolute link is resolved inside the image, not on the host.
std::os::unix::fs::symlink("/usr/lib/postgresql/bin/postgres", d.join("usr/bin/pg")).unwrap();
std::fs::write(d.join("usr/bin/script"), b"#!/bin/m -x\necho hi\n").unwrap();
let env = vec!["PATH=/usr/local/bin:/bin".to_string()];
assert_eq!(
learn::detect_libc(&d, "/bin/m", &env),
Some(heimdall_seccomp::Libc::Musl)
);
assert_eq!(
learn::detect_libc(&d, "m", &env),
Some(heimdall_seccomp::Libc::Musl)
);
assert_eq!(
learn::detect_libc(&d, "pg", &env),
Some(heimdall_seccomp::Libc::Glibc)
);
assert_eq!(
learn::detect_libc(&d, "script", &env),
Some(heimdall_seccomp::Libc::Musl)
);
assert_eq!(learn::detect_libc(&d, "nothere", &env), None);
assert_eq!(
learn::resolve_in(&d, Path::new("/bin/pg")),
Some(d.join("usr/lib/postgresql/bin/postgres"))
);
std::os::unix::fs::symlink("../../../../../../etc/passwd", d.join("usr/bin/up")).unwrap();
let up = learn::resolve_in(&d, Path::new("/bin/up"));
assert!(
up.as_ref().is_none_or(|p| p.starts_with(&d)),
"a link up and out stays inside the image: {up:?}"
);
let _ = std::fs::remove_dir_all(&d);
}