nordisk/heimdall
crates/heimdall-plain/tests/spec.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The plain container config, the default seccomp profile, the output
//! listing and the shim protocol. Heimdall is a defensive sandbox; these
//! tests exercise only its own configuration.
mod common;
use std::path::{Path, PathBuf};
use std::time::Duration;
use heimdall_core::*;
use heimdall_plain::{default_profile, outputs, shim, spec};
use serde_json::Value;
fn cfg_with(read: &[PathBuf], write: &[PathBuf]) -> Value {
let limits = Limits {
wall: Duration::from_secs(3),
memory_bytes: 64 << 20,
cpus: 0.5,
pids: 32,
..Limits::default()
};
let argv = vec!["/bin/sh".to_string(), "-c".into(), "true".into()];
let env = vec!["PATH=/bin".to_string()];
spec::build(&spec::SpecInput {
argv: &argv,
env: &env,
input_dir: Path::new("/jobs/in"),
output_dir: Path::new("/jobs/out"),
read,
write,
limits: &limits,
uid: 1001,
gid: 1002,
cgroup_path: "/heimdall/job-x",
})
}
fn cfg() -> Value {
cfg_with(&[], &[])
}
fn mount<'a>(c: &'a Value, dst: &str) -> &'a Value {
c["mounts"]
.as_array()
.unwrap()
.iter()
.find(|m| m["destination"] == dst)
.unwrap_or_else(|| panic!("no mount at {dst}"))
}
fn opts(m: &Value) -> Vec<String> {
m["options"]
.as_array()
.unwrap()
.iter()
.map(|o| o.as_str().unwrap().to_string())
.collect()
}
#[test]
fn every_demanded_namespace_is_there_including_an_empty_network() {
let c = cfg();
let ns: Vec<&str> = c["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.map(|n| n["type"].as_str().unwrap())
.collect();
for want in ["user", "mount", "pid", "ipc", "uts", "network", "cgroup"] {
assert!(ns.contains(&want), "{want} missing in {ns:?}");
}
// A namespace with a path would join an existing one; none may.
assert!(c["linux"]["namespaces"]
.as_array()
.unwrap()
.iter()
.all(|n| n.get("path").is_none()));
}
#[test]
fn root_is_read_only() {
assert_eq!(cfg()["root"]["readonly"], true);
}
#[test]
fn tmp_is_a_size_capped_tmpfs() {
let c = cfg();
let t = mount(&c, "/tmp");
assert_eq!(t["type"], "tmpfs");
assert!(opts(t).iter().any(|o| o.starts_with("size=")));
}
#[test]
fn input_is_bound_read_only_at_in_and_output_read_write_at_out() {
let c = cfg();
let i = mount(&c, "/in");
assert_eq!(i["source"], "/jobs/in");
assert!(opts(i).contains(&"ro".to_string()));
assert!(!opts(i).contains(&"rw".to_string()));
let o = mount(&c, "/out");
assert_eq!(o["source"], "/jobs/out");
assert!(opts(o).contains(&"rw".to_string()));
for m in [i, o] {
assert!(opts(m).contains(&"nosuid".to_string()));
assert!(opts(m).contains(&"nodev".to_string()));
}
}
#[test]
fn extra_policy_paths_are_bound_at_the_same_path_with_the_right_mode() {
let c = cfg_with(&["/data/ro".into()], &["/data/rw".into()]);
assert!(opts(mount(&c, "/data/ro")).contains(&"ro".to_string()));
assert!(opts(mount(&c, "/data/rw")).contains(&"rw".to_string()));
}
#[test]
fn no_host_sys_and_no_host_path_except_the_job_dirs() {
let c = cfg();
for m in c["mounts"].as_array().unwrap() {
if m["type"] == "bind" {
let s = m["source"].as_str().unwrap();
assert!(s.starts_with("/jobs/"), "unexpected host bind {s}");
}
assert_ne!(m["destination"], "/sys");
}
}
#[test]
fn every_capability_is_dropped_and_no_new_privs_is_set() {
let c = cfg();
let caps = &c["process"]["capabilities"];
for set in [
"bounding",
"effective",
"inheritable",
"permitted",
"ambient",
] {
assert_eq!(caps[set], serde_json::json!([]), "{set}");
}
assert_eq!(c["process"]["noNewPrivileges"], true);
assert_eq!(c["process"]["terminal"], false);
}
#[test]
fn container_root_maps_to_exactly_one_host_id() {
let c = cfg();
assert_eq!(
c["linux"]["uidMappings"],
serde_json::json!([{"containerID": 0, "hostID": 1001, "size": 1}])
);
assert_eq!(
c["linux"]["gidMappings"],
serde_json::json!([{"containerID": 0, "hostID": 1002, "size": 1}])
);
}
#[test]
fn cgroup_limits_come_from_limits() {
let c = cfg();
let r = &c["linux"]["resources"];
assert_eq!(r["memory"]["limit"], 64 << 20);
assert_eq!(r["memory"]["swap"], 64 << 20, "no swap beyond the limit");
assert_eq!(r["pids"]["limit"], 32);
assert_eq!(r["cpu"]["quota"], 50_000);
assert_eq!(r["cpu"]["period"], 100_000);
assert_eq!(c["linux"]["cgroupsPath"], "/heimdall/job-x");
}
#[test]
fn cpu_quota_has_a_floor_and_scales() {
assert_eq!(spec::cpu_quota(0.0001), 1000);
assert_eq!(spec::cpu_quota(2.0), 200_000);
}
#[test]
fn core_dumps_are_off_and_files_are_capped() {
let c = cfg();
let rl = c["process"]["rlimits"].as_array().unwrap();
assert!(rl
.iter()
.any(|r| r["type"] == "RLIMIT_CORE" && r["hard"] == 0));
assert!(rl.iter().any(|r| r["type"] == "RLIMIT_NOFILE"));
}
#[test]
fn the_seccomp_profile_denies_by_default_with_eperm() {
let s = spec::seccomp_json();
assert_eq!(s["defaultAction"], "SCMP_ACT_ERRNO");
assert_eq!(s["defaultErrnoRet"], 1);
assert_eq!(s["architectures"], serde_json::json!(["SCMP_ARCH_X86_64"]));
assert_eq!(cfg()["linux"]["seccomp"], s);
}
#[test]
fn the_default_profile_does_not_allow_escape_hatches() {
for bad in [
"mount",
"umount2",
"unshare",
"setns",
"pivot_root",
"bpf",
"keyctl",
"add_key",
"request_key",
"init_module",
"finit_module",
"delete_module",
"kexec_load",
"kexec_file_load",
"open_by_handle_at",
"perf_event_open",
"reboot",
"swapon",
"swapoff",
"acct",
"settimeofday",
"clock_settime",
"iopl",
"ioperm",
"syslog",
"fanotify_init",
"lookup_dcookie",
"quotactl",
"userfaultfd",
"vhangup",
"chroot",
] {
assert!(
!default_profile::ALLOW.contains(&bad),
"{bad} must not be in the default allow list"
);
}
}
#[test]
fn the_default_profile_allows_what_ordinary_programs_need() {
for need in [
"read",
"write",
"openat",
"close",
"execve",
"exit_group",
"mmap",
"brk",
"fstat",
"newfstatat",
"clock_gettime",
"futex",
"rt_sigaction",
"wait4",
"arch_prctl",
"getdents64",
] {
assert!(default_profile::ALLOW.contains(&need), "{need}");
}
}
#[test]
fn clone_with_namespace_flags_and_clone3_are_restricted() {
let s = spec::seccomp_json();
let rules = s["syscalls"].as_array().unwrap();
let clone = rules
.iter()
.find(|r| r["names"] == serde_json::json!(["clone"]))
.unwrap();
assert_eq!(clone["args"][0]["op"], "SCMP_CMP_MASKED_EQ");
assert_eq!(clone["args"][0]["value"], default_profile::CLONE_NS_MASK);
// CLONE_NEWUSER and CLONE_NEWNS are inside the mask.
assert_ne!(default_profile::CLONE_NS_MASK & 0x1000_0000, 0);
assert_ne!(default_profile::CLONE_NS_MASK & 0x0002_0000, 0);
let c3 = rules
.iter()
.find(|r| r["names"] == serde_json::json!(["clone3"]))
.unwrap();
assert_eq!(c3["action"], "SCMP_ACT_ERRNO");
assert_eq!(c3["errnoRet"], 38);
assert!(!default_profile::ALLOW.contains(&"clone"));
assert!(!default_profile::ALLOW.contains(&"clone3"));
}
#[test]
fn sockets_are_limited_to_the_listed_domains() {
// AF_UNIX(1) and AF_INET(2) are below 3; AF_INET6 is 10; AF_VSOCK(40) is not allowed.
assert_eq!(default_profile::SOCKET_DOMAIN_BELOW, 3);
assert!(default_profile::SOCKET_DOMAINS.contains(&10));
assert!(!default_profile::SOCKET_DOMAINS.contains(&40));
assert!(!default_profile::ALLOW.contains(&"socket"));
}
#[test]
fn the_allow_list_is_sorted_and_unique() {
let mut v = default_profile::ALLOW.to_vec();
v.sort();
v.dedup();
assert_eq!(v, default_profile::ALLOW);
}
// ---- output listing ----
fn out_dir(name: &str) -> PathBuf {
common::scratch(name)
}
#[test]
fn outputs_are_listed_sorted_with_sha256_and_size() {
let d = out_dir("list");
std::fs::write(d.join("b"), b"bb").unwrap();
std::fs::write(d.join("a"), b"a").unwrap();
let o = outputs::scan(&d, &Limits::default()).unwrap();
assert_eq!(o, vec![Output::of("a", b"a"), Output::of("b", b"bb")]);
}
#[test]
fn an_empty_output_dir_lists_nothing() {
assert!(outputs::scan(&out_dir("empty"), &Limits::default())
.unwrap()
.is_empty());
}
#[test]
fn a_directory_symlink_or_fifo_is_out_1() {
let d = out_dir("kinds");
std::fs::create_dir(d.join("sub")).unwrap();
assert_eq!(
outputs::scan(&d, &Limits::default()).unwrap_err().0,
codes::OUT_EXTRA
);
std::fs::remove_dir(d.join("sub")).unwrap();
std::os::unix::fs::symlink("/etc/passwd", d.join("l")).unwrap();
assert_eq!(
outputs::scan(&d, &Limits::default()).unwrap_err().0,
codes::OUT_EXTRA
);
std::fs::remove_file(d.join("l")).unwrap();
let st = std::process::Command::new("mkfifo")
.arg(d.join("p"))
.status();
if st.map(|s| s.success()).unwrap_or(false) {
assert_eq!(
outputs::scan(&d, &Limits::default()).unwrap_err().0,
codes::OUT_EXTRA
);
}
}
#[test]
fn a_symlink_named_like_an_output_is_never_followed() {
let d = out_dir("follow");
let secret = common::scratch("secret").join("s");
std::fs::write(&secret, b"host secret").unwrap();
std::os::unix::fs::symlink(&secret, d.join("report")).unwrap();
let e = outputs::scan(&d, &Limits::default()).unwrap_err();
assert_eq!(e.0, codes::OUT_EXTRA);
assert!(!e.1.contains("host secret"));
}
#[test]
fn file_count_and_size_limits_are_enforced() {
let d = out_dir("limits");
for i in 0..3 {
std::fs::write(d.join(format!("f{i}")), b"12345").unwrap();
}
let l = Limits {
max_output_files: 2,
..Limits::default()
};
assert_eq!(outputs::scan(&d, &l).unwrap_err().0, codes::OUT_EXTRA);
let l = Limits {
max_output_file_bytes: 4,
..Limits::default()
};
assert_eq!(outputs::scan(&d, &l).unwrap_err().0, codes::OUT_TOO_LARGE);
let l = Limits {
max_output_total_bytes: 14,
..Limits::default()
};
assert_eq!(outputs::scan(&d, &l).unwrap_err().0, codes::OUT_TOO_LARGE);
let l = Limits {
max_output_files: 3,
max_output_file_bytes: 5,
max_output_total_bytes: 15,
..Limits::default()
};
assert_eq!(outputs::scan(&d, &l).unwrap().len(), 3);
}
#[test]
fn a_name_with_a_control_character_is_out_1() {
let d = out_dir("ctl");
std::fs::write(d.join("a\tb"), b"").unwrap();
assert_eq!(
outputs::scan(&d, &Limits::default()).unwrap_err().0,
codes::OUT_EXTRA
);
}
#[test]
fn a_non_utf8_name_is_out_1() {
use std::os::unix::ffi::OsStrExt;
let d = out_dir("utf8");
std::fs::write(d.join(std::ffi::OsStr::from_bytes(b"\xff")), b"").unwrap();
assert_eq!(
outputs::scan(&d, &Limits::default()).unwrap_err().0,
codes::OUT_EXTRA
);
}
// ---- shim protocol ----
#[test]
fn cgroup_event_counts_parse() {
let t = "low 0\nhigh 0\nmax 3\noom 1\noom_kill 2\noom_group_kill 0\n";
assert_eq!(shim::events_count(t, "oom_kill"), 2);
assert_eq!(shim::events_count(t, "max"), 3);
assert_eq!(shim::events_count(t, "absent"), 0);
assert_eq!(shim::events_count("", "max"), 0);
}
#[test]
fn shim_request_and_response_round_trip() {
let q = shim::ShimRequest {
id: "heimdall-1".into(),
bundle: "/b".into(),
state_root: "/s".into(),
wall_ms: 5,
cgroup_dir: "/sys/fs/cgroup/heimdall/job-1".into(),
};
let back: shim::ShimRequest =
serde_json::from_str(&serde_json::to_string(&q).unwrap()).unwrap();
assert_eq!(back, q);
let r = shim::ShimResponse {
exit_code: Some(0),
..Default::default()
};
let back: shim::ShimResponse =
serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(back, r);
}