Gunnar

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