Gunnar

nordisk/heimdall

crates/heimdall-plain/src/shim.rs

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

//! The shim: one short-lived process per job that drives youki's
//! `libcontainer` (create, start, wait, kill on the wall clock, delete).
//!
//! Heimdall is a defensive sandbox. The runner never forks inside its own
//! (multi-threaded) process: it starts this shim with a JSON request on stdin
//! and reads a JSON response from stdout. The shim never receives the
//! per-boot signing key, and the job's stdio is `/dev/null` (or the shim's
//! stderr with `HEIMDALL_PLAIN_DEBUG=1`), so a job cannot write into the
//! runner's streams.

use std::path::PathBuf;

use serde::{Deserialize, Serialize};

/// The argument that makes a host binary act as the shim
/// (see [`run_if_shim`]).
pub const SHIM_ARG: &str = "--heimdall-plain-shim";

/// What the runner asks the shim to do.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShimRequest {
    /// Container id (unique per job).
    pub id: String,
    /// The bundle: `config.json` + `rootfs/`.
    pub bundle: PathBuf,
    /// libcontainer's state root.
    pub state_root: PathBuf,
    /// The wall-clock limit in milliseconds.
    pub wall_ms: u64,
    /// The job's cgroup v2 dir (to read `memory.events` / `pids.events`).
    pub cgroup_dir: PathBuf,
}

/// What happened.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShimResponse {
    /// The container never ran: why.
    pub error: Option<String>,
    pub exit_code: Option<i32>,
    pub signal: Option<i32>,
    /// The shim killed the job at the wall-clock limit.
    pub wall_killed: bool,
    /// `oom_kill` count from the job's `memory.events`.
    pub oom_kills: u64,
    /// `max` count from the job's `pids.events`.
    pub pids_max: u64,
    /// Milliseconds the job ran.
    pub ran_ms: u64,
}

/// Read `key N` from a cgroup `*.events` file.
pub fn events_count(text: &str, key: &str) -> u64 {
    text.lines()
        .filter_map(|l| l.split_once(' '))
        .find(|(k, _)| *k == key)
        .and_then(|(_, v)| v.trim().parse().ok())
        .unwrap_or(0)
}

/// Call first thing in `main()` of a binary that should also serve as the
/// shim (`PlainConfig::shim_args = vec![SHIM_ARG.into()]`): when the first
/// argument is [`SHIM_ARG`], it runs the shim and exits.
#[cfg(feature = "youki")]
pub fn run_if_shim() {
    if std::env::args().nth(1).as_deref() == Some(SHIM_ARG) {
        std::process::exit(main());
    }
}

/// The shim's main: request on stdin, response on stdout. Returns the exit code.
#[cfg(feature = "youki")]
pub fn main() -> i32 {
    use std::io::Read;
    let mut input = Vec::new();
    if let Err(e) = std::io::stdin().take(1 << 20).read_to_end(&mut input) {
        return respond(&ShimResponse {
            error: Some(format!("reading the request: {e}")),
            ..Default::default()
        });
    }
    let resp = match serde_json::from_slice::<ShimRequest>(&input) {
        Ok(req) => youki::run(&req),
        Err(e) => ShimResponse {
            error: Some(format!("bad request: {e}")),
            ..Default::default()
        },
    };
    respond(&resp)
}

#[cfg(feature = "youki")]
fn respond(r: &ShimResponse) -> i32 {
    println!("{}", serde_json::to_string(r).expect("response serializes"));
    0
}

#[cfg(feature = "youki")]
mod youki {
    use std::fs::File;
    use std::time::{Duration, Instant};

    use libcontainer::container::builder::ContainerBuilder;
    use libcontainer::syscall::syscall::SyscallType;
    use libcontainer::workload::default::DefaultExecutor;
    use nix::sys::signal::Signal;
    use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};

    use super::{events_count, ShimRequest, ShimResponse};

    fn stdio(debug: bool) -> std::io::Result<(File, File, File)> {
        use std::os::fd::AsFd;
        let null = || File::options().read(true).write(true).open("/dev/null");
        let err = if debug {
            File::from(std::io::stderr().as_fd().try_clone_to_owned()?)
        } else {
            null()?
        };
        Ok((null()?, null()?, err))
    }

    fn err(e: impl std::fmt::Display) -> ShimResponse {
        ShimResponse {
            error: Some(e.to_string()),
            ..Default::default()
        }
    }

    pub(super) fn run(req: &ShimRequest) -> ShimResponse {
        let debug = std::env::var_os("HEIMDALL_PLAIN_DEBUG").is_some();
        let (i, o, e) = match stdio(debug) {
            Ok(x) => x,
            Err(e) => return err(format!("stdio: {e}")),
        };
        if let Err(e) = std::fs::create_dir_all(&req.state_root) {
            return err(format!("state root: {e}"));
        }
        let builder = match ContainerBuilder::new(req.id.clone(), SyscallType::default())
            .with_executor(DefaultExecutor {})
            .with_root_path(req.state_root.clone())
        {
            Ok(b) => b,
            Err(e) => return err(format!("container root: {e}")),
        };
        let mut container = match builder
            .with_stdin(i)
            .with_stdout(o)
            .with_stderr(e)
            .as_init(&req.bundle)
            .with_systemd(false)
            .with_detach(true)
            .build()
        {
            Ok(c) => c,
            Err(e) => return err(format!("create: {e}")),
        };
        let Some(pid) = container.pid() else {
            let _ = container.delete(true);
            return err("create: no init pid");
        };
        // No swap beyond the memory limit. libcgroups 0.7.0 skips writing a
        // computed `memory.swap.max` of 0 (OCI swap == limit), which leaves
        // swap unlimited; write it here, before the job starts.
        let swap = req.cgroup_dir.join("memory.swap.max");
        if swap.exists() {
            if let Err(e) = std::fs::write(&swap, "0") {
                let _ = container.kill(Signal::SIGKILL, true);
                let _ = waitpid(pid, None);
                let _ = container.delete(true);
                return err(format!("memory.swap.max: {e}"));
            }
        }
        let t0 = Instant::now();
        if let Err(e) = container.start() {
            let _ = container.kill(Signal::SIGKILL, true);
            let _ = waitpid(pid, None);
            let _ = container.delete(true);
            return err(format!("start: {e}"));
        }
        let deadline = t0 + Duration::from_millis(req.wall_ms);
        let mut resp = ShimResponse::default();
        let status = loop {
            match waitpid(pid, Some(WaitPidFlag::WNOHANG)) {
                Ok(WaitStatus::StillAlive) => {
                    if Instant::now() >= deadline {
                        resp.wall_killed = true;
                        let _ = container.kill(Signal::SIGKILL, true);
                        break waitpid(pid, None);
                    }
                    std::thread::sleep(Duration::from_millis(5));
                }
                other => break other,
            }
        };
        resp.ran_ms = t0.elapsed().as_millis() as u64;
        match status {
            Ok(WaitStatus::Exited(_, c)) => resp.exit_code = Some(c),
            Ok(WaitStatus::Signaled(_, s, _)) => resp.signal = Some(s as i32),
            Ok(other) => resp.error = Some(format!("unexpected wait status {other:?}")),
            Err(e) => resp.error = Some(format!("wait: {e}")),
        }
        let read = |f: &str| std::fs::read_to_string(req.cgroup_dir.join(f)).unwrap_or_default();
        resp.oom_kills = events_count(&read("memory.events"), "oom_kill");
        resp.pids_max = events_count(&read("pids.events"), "max");
        if let Err(e) = container.delete(true) {
            if resp.error.is_none() && debug {
                eprintln!("heimdall-plain-shim: delete: {e}");
            }
        }
        resp
    }
}