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

nordisk/heimdall

crates/heimdall-plain/tests/idmap.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! The opt-in host id range for container root (heimdall-plain idmap).
//! Heimdall is a defensive sandbox; these tests check only its own id
//! mapping logic, and the runners' refusals, without namespaces.

mod common;

use std::os::unix::fs::MetadataExt;
use std::path::Path;

use common::*;
use heimdall_core::*;
use heimdall_plain::idmap::{self, HostIds};
use heimdall_plain::*;

fn whoami() -> (u32, u32) {
    let m = std::fs::metadata("/proc/self").unwrap();
    (m.uid(), m.gid())
}

/// Can this process give files to `id` (root with `id` mapped in its
/// namespace)? The ownership tests need it; elsewhere they say so and skip.
fn can_chown_to(id: u32) -> bool {
    let p = scratch("idmap-probe").join("f");
    std::fs::write(&p, b"").unwrap();
    std::os::unix::fs::lchown(&p, Some(id), Some(id)).is_ok()
}

#[test]
fn a_range_needs_a_count_a_nonzero_base_and_room_below_the_no_id_value() {
    assert!(HostIds::new(100_000, 65_536).is_ok());
    assert!(HostIds::new(1, 1).is_ok());
    for (b, c) in [
        (100_000, 0),
        (0, 65_536),
        (0, 1),
        (u32::MAX, 1),
        (u32::MAX - 10, 11),
    ] {
        let e = HostIds::new(b, c).unwrap_err();
        assert!(e.contains("host_ids"), "{b} {c}: {e}");
    }
    assert!(HostIds::new(u32::MAX - 10, 10).is_ok());
}

#[test]
fn contains_and_shift_cover_exactly_base_to_base_plus_count() {
    let r = HostIds::new(100_000, 65_536).unwrap();
    assert!(!r.contains(99_999));
    assert!(r.contains(100_000));
    assert!(r.contains(165_535));
    assert!(!r.contains(165_536));
    assert!(!r.contains(0));
    assert_eq!(r.shift(0), 100_000);
    assert_eq!(r.shift(1000), 101_000);
    // An id the range cannot hold stays as it is (unmapped: nobody inside).
    assert_eq!(r.shift(65_536), 65_536);
}

#[test]
fn a_runner_that_is_not_root_or_whose_own_ids_are_in_the_range_is_refused_by_name() {
    // Not root: only root writes an id map other than its own id.
    let e = HostIds::for_runner(100_000, 65_536, 1000, 1000).unwrap_err();
    assert!(
        matches!(&e, RunError::Unsupported { what } if what.contains("host_ids") && what.contains("not root")),
        "{e}"
    );
    // Root whose gid is in the range.
    let e = HostIds::for_runner(100_000, 65_536, 0, 100_005).unwrap_err();
    assert!(
        matches!(&e, RunError::Unsupported { what } if what.contains("host_ids") && what.contains("overlaps")),
        "{e}"
    );
    // An invalid range is refused the same way.
    let e = HostIds::for_runner(0, 65_536, 0, 0).unwrap_err();
    assert!(
        matches!(&e, RunError::Unsupported { what } if what.contains("host_ids")),
        "{e}"
    );
    assert_eq!(
        HostIds::for_runner(100_000, 65_536, 0, 0).unwrap(),
        HostIds::new(100_000, 65_536).unwrap()
    );
}

#[test]
fn apply_replaces_both_maps_with_the_one_line_0_base_count() {
    let mut c = serde_json::json!({"linux": {
        "uidMappings": [{"containerID": 0, "hostID": 0, "size": 1}],
        "gidMappings": [{"containerID": 0, "hostID": 0, "size": 1}],
    }});
    idmap::apply(&mut c, HostIds::new(100_000, 65_536).unwrap());
    let want = serde_json::json!([{"containerID": 0, "hostID": 100_000, "size": 65_536}]);
    assert_eq!(c["linux"]["uidMappings"], want);
    assert_eq!(c["linux"]["gidMappings"], want);
}

#[test]
fn shift_tree_and_give_tree_change_owners_without_following_symlinks() {
    let ids = HostIds::new(10_000, 1000).unwrap();
    if whoami().0 != 0 || !can_chown_to(10_999) {
        eprintln!("skipped: needs root with ids 10000..11000 mapped");
        return;
    }
    let d = scratch("idmap-tree");
    let outside = scratch("idmap-outside").join("target");
    std::fs::write(&outside, b"x").unwrap();
    std::fs::create_dir_all(d.join("sub")).unwrap();
    std::fs::write(d.join("sub/f"), b"x").unwrap();
    std::os::unix::fs::lchown(d.join("sub/f"), Some(5), Some(7)).unwrap();
    std::os::unix::fs::symlink(&outside, d.join("l")).unwrap();
    idmap::shift_tree(&d, ids).unwrap();
    let own = |p: &Path| {
        let m = std::fs::symlink_metadata(p).unwrap();
        (m.uid(), m.gid())
    };
    assert_eq!(own(&d), (10_000, 10_000));
    assert_eq!(own(&d.join("sub")), (10_000, 10_000));
    assert_eq!(own(&d.join("sub/f")), (10_005, 10_007));
    assert_eq!(own(&d.join("l")), (10_000, 10_000));
    assert_eq!(own(&outside), (0, 0), "the symlink's target is untouched");
    idmap::give_tree(&d, ids).unwrap();
    assert_eq!(own(&d.join("sub/f")), (10_000, 10_000));
    assert_eq!(own(&outside), (0, 0));
}

// ---- PlainYoukiRunner ----

fn plain_fx(name: &str) -> (std::path::PathBuf, Layout, ImageRef) {
    let base = scratch(name);
    let layout = Layout::new(&base.join("layout"));
    let image = layout.image(
        &[(
            Layout::tar(&[
                Entry::Dir("bin"),
                Entry::File("bin/tool".into(), b"#!/bin/sh\n".to_vec(), 0o755),
            ]),
            true,
        )],
        &["PATH=/bin"],
    );
    (base, layout, image)
}

const OK: &str = r#"{"error":null,"exit_code":0,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":3}"#;

/// A runner whose shim copies the bundle's config.json to `<base>/config.json`
/// and writes the owners (uid:gid) of the rootfs, a file in it, `d`'s input
/// dir, its file `a` and the output dir to `<base>/owners`.
fn dumping_runner(base: &Path, layout: &Layout, d: &JobDirs) -> PlainYoukiRunner {
    let script = base.join("dump.sh");
    std::fs::write(
        &script,
        format!(
            "req=$(cat)\nb=$(printf '%s' \"$req\" | sed 's/.*\"bundle\":\"\\([^\"]*\\)\".*/\\1/')\n\
             cp \"$b/config.json\" {base}/config.json\n\
             stat -c %u:%g \"$b/rootfs\" \"$b/rootfs/bin/tool\" {i} {i}/a {o} > {base}/owners\n\
             echo '{OK}'\n",
            base = base.display(),
            i = d.input.display(),
            o = d.output.display(),
        ),
    )
    .unwrap();
    let mut cfg = PlainConfig::new(&layout.dir, base.join("work"));
    cfg.shim = ShimCommand {
        program: "/bin/sh".into(),
        args: vec![script.display().to_string()],
    };
    PlainYoukiRunner::new(cfg)
}

fn dirs(base: &Path) -> JobDirs {
    let d = job_dirs(base);
    std::fs::write(d.input.join("a"), b"in").unwrap();
    d
}

fn run_dump(base: &Path, d: &JobDirs, image: &ImageRef, r: &PlainYoukiRunner) -> serde_json::Value {
    let s = r.run(native_job(image, &["/bin/tool"], d)).unwrap();
    assert!(s.result.verdict.is_accepted(), "{:?}", s.result.verdict);
    serde_json::from_slice(&std::fs::read(base.join("config.json")).unwrap()).unwrap()
}

#[test]
fn without_host_ids_container_root_is_the_runners_own_uid_and_gid_one_id_each() {
    let (base, layout, image) = plain_fx("idmap-off");
    let d = dirs(&base);
    let r = dumping_runner(&base, &layout, &d);
    assert_eq!(r.host_ids(), None);
    let c = run_dump(&base, &d, &image, &r);
    let (uid, gid) = whoami();
    assert_eq!(
        c["linux"]["uidMappings"],
        serde_json::json!([{"containerID": 0, "hostID": uid, "size": 1}])
    );
    assert_eq!(
        c["linux"]["gidMappings"],
        serde_json::json!([{"containerID": 0, "hostID": gid, "size": 1}])
    );
    // Nothing was given away: everything is still the runner's.
    let owners = std::fs::read_to_string(base.join("owners")).unwrap();
    for l in owners.lines() {
        assert_eq!(l, format!("{uid}:{gid}"), "{owners}");
    }
}

#[test]
fn with_host_ids_the_shim_gets_the_range_and_the_root_input_and_output_belong_to_it() {
    let (base, layout, image) = plain_fx("idmap-on");
    let d = dirs(&base);
    let r = dumping_runner(&base, &layout, &d);
    let (uid, _) = whoami();
    if uid != 0 {
        let e = r.with_host_ids(10_000, 1000).unwrap_err();
        assert!(
            matches!(&e, RunError::Unsupported { what } if what.contains("host_ids")),
            "{e}"
        );
        eprintln!("not root: refused as expected; the mapping part needs root");
        return;
    }
    if !can_chown_to(10_999) {
        eprintln!("skipped: ids 10000..11000 are not mapped here");
        return;
    }
    let r = r.with_host_ids(10_000, 1000).unwrap();
    assert_eq!(r.host_ids(), Some(HostIds::new(10_000, 1000).unwrap()));
    let c = run_dump(&base, &d, &image, &r);
    let want = serde_json::json!([{"containerID": 0, "hostID": 10_000, "size": 1000}]);
    assert_eq!(c["linux"]["uidMappings"], want);
    assert_eq!(c["linux"]["gidMappings"], want);
    let owners = std::fs::read_to_string(base.join("owners")).unwrap();
    let lines: Vec<&str> = owners.lines().collect();
    assert_eq!(lines, ["10000:10000"; 5], "{owners}");
}

#[test]
fn plain_with_host_ids_refuses_an_invalid_or_overlapping_range_by_name() {
    let (base, layout, _image) = plain_fx("idmap-refuse");
    let d = dirs(&base);
    let mut cases = vec![(0, 10), (10_000, 0), (u32::MAX, 1)];
    let (uid, gid) = whoami();
    if uid != 0 {
        // Not root: every range is refused, also one around its own ids.
        cases.extend([(uid, 1), (gid, 1), (100_000, 65_536)]);
    }
    for (b, c) in cases {
        let e = dumping_runner(&base, &layout, &d)
            .with_host_ids(b, c)
            .unwrap_err();
        assert!(
            matches!(&e, RunError::Unsupported { what } if what.contains("host_ids")),
            "{b} {c}: {e}"
        );
    }
}