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