Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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