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/src/idmap.rs

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

//! A host id range for container root (opt-in, since 0.2.1).
//!
//! Heimdall is a defensive sandbox. By default a job's root (uid/gid 0 in
//! its user namespace) maps to the runner's OWN uid/gid, one id each. When
//! the runner itself runs as host root (an appliance where the runner is PID
//! 1), that makes every job's root host root: capabilities are dropped and
//! the other layers still apply, but the job is not rootless.
//!
//! [`HostIds`] names a range of host ids instead (like crun's userns base,
//! e.g. 100000 + 65536): container ids `0..count` map to host ids
//! `base..base + count`, uid and gid alike. The mapping is ONE line per map,
//! which libcontainer writes straight into `/proc/<pid>/uid_map` and
//! `gid_map` (no newuidmap/newgidmap); only a root runner may write a range
//! other than its own id, so a runner that is not root is refused. The range
//! must not contain the runner's own uid or gid, nor host id 0.
//!
//! Before the job starts the runner gives the job its files: the unpacked
//! root is shifted into the range ([`shift_tree`]: owner `u` becomes
//! `base + u` when `u < count`), and `input_dir` and `output_dir` with what
//! they hold are given to the job's root ([`give_tree`], `base`:`base`).
//! Extra policy read/write paths are NOT changed: they are reachable only as
//! their modes allow for the mapped ids.

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

use heimdall_core::RunError;
use serde_json::{json, Value};

/// A host id range container root maps to: container ids `0..count` are host
/// ids `base..base + count` (uid and gid alike).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HostIds {
    base: u32,
    count: u32,
}

impl HostIds {
    /// A valid range: `count` > 0, `base` > 0 (host root is never in it) and
    /// the last id `base + count - 1` below `u32::MAX` (the kernel's
    /// "no id"). The reason names `host_ids`.
    pub fn new(base: u32, count: u32) -> Result<Self, String> {
        if count == 0 {
            return Err("host_ids: count is 0".into());
        }
        if base == 0 {
            return Err("host_ids: base 0 maps container root to host root".into());
        }
        match base.checked_add(count - 1) {
            Some(last) if last < u32::MAX => Ok(Self { base, count }),
            _ => Err(format!(
                "host_ids: {base} + {count} runs past the last host id"
            )),
        }
    }

    pub fn base(&self) -> u32 {
        self.base
    }

    pub fn count(&self) -> u32 {
        self.count
    }

    /// Whether host id `id` is in the range.
    pub fn contains(&self, id: u32) -> bool {
        id >= self.base && id - self.base < self.count
    }

    /// The range for a runner whose process has `uid`/`gid` (euid/egid):
    /// [`HostIds::new`], and the runner must be root (only root writes an id
    /// map other than its own id without newuidmap) and neither its uid nor
    /// its gid may be in the range. Refused as `RunError::Unsupported`,
    /// whose text names `host_ids`.
    pub fn for_runner(base: u32, count: u32, uid: u32, gid: u32) -> Result<Self, RunError> {
        let unsupported = |what: String| RunError::Unsupported { what };
        let ids = Self::new(base, count).map_err(unsupported)?;
        if uid != 0 {
            return Err(unsupported(format!(
                "host_ids: the runner is not root (uid {uid}); only a root runner maps container root to a host id range"
            )));
        }
        if ids.contains(uid) || ids.contains(gid) {
            return Err(unsupported(format!(
                "host_ids: {base}..{} overlaps the runner's own uid {uid} / gid {gid}",
                u64::from(base) + u64::from(count)
            )));
        }
        Ok(ids)
    }

    /// [`HostIds::for_runner`] for this process (the owner of `/proc/self`).
    pub fn for_this_runner(base: u32, count: u32) -> Result<Self, RunError> {
        let me = std::fs::metadata("/proc/self").map_err(|e| RunError::Io {
            reason: format!("/proc/self: {e}"),
        })?;
        Self::for_runner(base, count, me.uid(), me.gid())
    }

    /// The owner `u` shifted into the range: `base + u` when `u < count`,
    /// else unchanged (it stays unmapped: "nobody" inside).
    pub fn shift(&self, u: u32) -> u32 {
        if u < self.count {
            self.base + u
        } else {
            u
        }
    }
}

/// Replace the config's `linux.uidMappings` / `gidMappings` with the one
/// line `0 base count` each.
pub fn apply(config: &mut Value, ids: HostIds) {
    let m = json!([{"containerID": 0, "hostID": ids.base, "size": ids.count}]);
    config["linux"]["uidMappings"] = m.clone();
    config["linux"]["gidMappings"] = m;
}

/// Shift the owner and group of `root` and everything below it into the
/// range ([`HostIds::shift`]), never following a symlink.
pub fn shift_tree(root: &Path, ids: HostIds) -> std::io::Result<()> {
    walk(root, &|p, m| {
        std::os::unix::fs::lchown(p, Some(ids.shift(m.uid())), Some(ids.shift(m.gid())))
    })
}

/// Give `root` and everything below it to the job's root (`base`:`base`),
/// never following a symlink.
pub fn give_tree(root: &Path, ids: HostIds) -> std::io::Result<()> {
    walk(root, &|p, _| {
        std::os::unix::fs::lchown(p, Some(ids.base), Some(ids.base))
    })
}

fn walk(
    p: &Path,
    f: &dyn Fn(&Path, &std::fs::Metadata) -> std::io::Result<()>,
) -> std::io::Result<()> {
    let m = std::fs::symlink_metadata(p)?;
    if m.is_dir() {
        for e in std::fs::read_dir(p)? {
            walk(&e?.path(), f)?;
        }
    }
    f(p, &m)
}