Gunnar

nordisk/heimdall

crates/heimdall-plain/src/lib.rs

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

//! heimdall-plain: [`PlainYoukiRunner`], the interim milestone 0 runner.
//!
//! **Heimdall is a defensive sandbox.** It runs untrusted parsing,
//! verification and scanning jobs so that a compromised job can reach only
//! its own input and output. This crate is the PLAIN implementation of
//! heimdall-core's [`JobRunner`]: an ordinary youki (`libcontainer`)
//! container per job, with NO Heimdall protections (no generated seccomp
//! profile, no landlock, no broker, no wasm). Its [`JobRunner::features`]
//! says so, and every signed result carries the same statement:
//! `runner: "youki-plain"`, `seccomp: Default`, `landlock: false`,
//! `broker: false`, `wasm: false`.
//!
//! One job: verify the pinned image and unpack it into a fresh root
//! ([`image`]), write the OCI config ([`spec`]), run the shim (a separate
//! process that drives libcontainer, [`shim`]), hash and list what the job
//! left in `output_dir` ([`outputs`]), and sign the result with the per-boot
//! key, which never enters the job or the shim.
//!
//! Inside the job: the image root is read-only, `/tmp` is a tmpfs,
//! `input_dir` is `/in` (read-only) and `output_dir` is `/out` (read-write).
#![forbid(unsafe_code)]

pub mod default_profile;
pub mod image;
pub mod outputs;
pub mod shim;
pub mod spec;

use std::io::{Read, Write};
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

use heimdall_core::{
    codes, BootKey, ExitInfo, Features, Job, JobKind, JobResult, JobRunner, KillReason, PinnedKey,
    Protection, RunError, SignedResult, Verdict, VerifyingKey,
};

use crate::image::OciLayout;
use crate::shim::{ShimRequest, ShimResponse};

/// The runner name in [`Features::runner`].
pub const RUNNER_NAME: &str = "youki-plain";

/// The pinned youki `libcontainer` version the shim is built with.
pub const LIBCONTAINER_VERSION: &str = "0.7.0";

/// Extra time the shim gets beyond the wall limit before the runner kills it.
pub const SHIM_GRACE: Duration = Duration::from_secs(30);

/// What the plain runner states about every job it runs. This is the truth
/// for this implementation; it never changes at run time.
pub fn plain_features() -> Features {
    Features {
        runner: RUNNER_NAME.into(),
        container: true,
        seccomp: Protection::Default,
        landlock: false,
        broker: false,
        wasm: false,
        image_verified_each_start: true,
    }
}

/// How to find and run the shim.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShimCommand {
    pub program: PathBuf,
    pub args: Vec<String>,
}

impl ShimCommand {
    /// `HEIMDALL_PLAIN_SHIM` if set, else `heimdall-plain-shim` next to the
    /// current executable (or its parent dir, for test binaries in `deps/`).
    pub fn locate() -> ShimCommand {
        if let Some(p) = std::env::var_os("HEIMDALL_PLAIN_SHIM") {
            return ShimCommand {
                program: p.into(),
                args: vec![],
            };
        }
        let exe = std::env::current_exe().unwrap_or_default();
        let dir = exe.parent().map(Path::to_path_buf).unwrap_or_default();
        let near = dir.join("heimdall-plain-shim");
        let program = if near.exists() {
            near
        } else {
            dir.parent()
                .map(|d| d.join("heimdall-plain-shim"))
                .filter(|p| p.exists())
                .unwrap_or(near)
        };
        ShimCommand {
            program,
            args: vec![],
        }
    }

    /// The current executable acting as the shim (it must call
    /// [`shim::run_if_shim`] first thing in `main`).
    pub fn current_exe() -> std::io::Result<ShimCommand> {
        Ok(ShimCommand {
            program: std::env::current_exe()?,
            args: vec![shim::SHIM_ARG.into()],
        })
    }
}

/// Configuration of a [`PlainYoukiRunner`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PlainConfig {
    /// A local OCI image layout holding the images jobs pin by digest.
    pub image_layout: PathBuf,
    /// Per-job bundles (`jobs/<id>/`) and libcontainer state (`state/`).
    pub work_dir: PathBuf,
    pub shim: ShimCommand,
    /// The cgroup v2 mount the shim sees.
    pub cgroup_root: PathBuf,
    /// Jobs' cgroups go below `<cgroup_root>/<cgroup_parent>/`.
    pub cgroup_parent: String,
}

impl PlainConfig {
    /// Defaults: located shim, `/sys/fs/cgroup`, parent `heimdall`.
    pub fn new(image_layout: impl Into<PathBuf>, work_dir: impl Into<PathBuf>) -> Self {
        Self {
            image_layout: image_layout.into(),
            work_dir: work_dir.into(),
            shim: ShimCommand::locate(),
            cgroup_root: "/sys/fs/cgroup".into(),
            cgroup_parent: "heimdall".into(),
        }
    }
}

/// An ordinary youki container per job. See the crate docs.
pub struct PlainYoukiRunner {
    cfg: PlainConfig,
    key: BootKey,
}

impl std::fmt::Debug for PlainYoukiRunner {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PlainYoukiRunner")
            .field("cfg", &self.cfg)
            .field("key_id", &self.key.key_id())
            .finish()
    }
}

fn invalid(reason: impl Into<String>) -> RunError {
    RunError::InvalidJob {
        reason: reason.into(),
    }
}

fn io(e: impl std::fmt::Display) -> RunError {
    RunError::Io {
        reason: e.to_string(),
    }
}

fn now_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

fn existing_dir(what: &str, p: &Path) -> Result<PathBuf, RunError> {
    let c = p
        .canonicalize()
        .map_err(|e| invalid(format!("{what} {}: {e}", p.display())))?;
    if !c.is_dir() {
        return Err(invalid(format!(
            "{what} {} is not a directory",
            p.display()
        )));
    }
    Ok(c)
}

/// Check what can be checked before anything is created.
pub fn check_job(job: &Job) -> Result<(), RunError> {
    let JobKind::Native { image, argv } = &job.kind else {
        return Err(RunError::Unsupported {
            what: "wasm jobs: youki-plain runs only native OCI images".into(),
        });
    };
    if job.policy.net.is_some() {
        return Err(RunError::Unsupported {
            what: "a network peer: youki-plain has no broker to allow exactly one peer".into(),
        });
    }
    if image.sha256().is_none() {
        return Err(invalid(format!(
            "image digest `{}` is not sha256:<64 lower-case hex>",
            image.digest
        )));
    }
    if argv.is_empty() || argv[0].is_empty() {
        return Err(invalid("argv is empty"));
    }
    let l = &job.policy.limits;
    if l.wall.is_zero() {
        return Err(invalid("limits.wall is zero"));
    }
    if !(l.cpus.is_finite() && l.cpus > 0.0 && l.cpus <= 1024.0) {
        return Err(invalid(format!(
            "limits.cpus {} is not in (0, 1024]",
            l.cpus
        )));
    }
    if l.pids == 0 {
        return Err(invalid("limits.pids is zero"));
    }
    if l.memory_bytes < 4 * 1024 * 1024 {
        return Err(invalid("limits.memory_bytes is below 4 MiB"));
    }
    for p in job.policy.read.iter().chain(&job.policy.write) {
        if !p.is_absolute() || p.components().any(|c| c == std::path::Component::ParentDir) {
            return Err(invalid(format!(
                "policy path {} must be absolute without `..`",
                p.display()
            )));
        }
    }
    Ok(())
}

impl PlainYoukiRunner {
    /// A runner with a fresh per-boot key.
    pub fn new(cfg: PlainConfig) -> Self {
        Self::with_key(cfg, BootKey::generate())
    }

    /// A runner with a given key (tests and fixtures only).
    pub fn with_key(cfg: PlainConfig, key: BootKey) -> Self {
        Self { cfg, key }
    }

    /// The pinned-key record of this runner's per-boot key.
    pub fn pinned(&self) -> PinnedKey {
        self.key.pinned()
    }

    pub fn config(&self) -> &PlainConfig {
        &self.cfg
    }

    fn run_shim(&self, req: &ShimRequest, wall: Duration) -> Result<ShimResponse, RunError> {
        let setup = |e: String| RunError::Setup { reason: e };
        let mut child = Command::new(&self.cfg.shim.program)
            .args(&self.cfg.shim.args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(if std::env::var_os("HEIMDALL_PLAIN_DEBUG").is_some() {
                Stdio::inherit()
            } else {
                Stdio::null()
            })
            .spawn()
            .map_err(|e| {
                setup(format!(
                    "cannot start the shim {}: {e}",
                    self.cfg.shim.program.display()
                ))
            })?;
        {
            let mut stdin = child.stdin.take().expect("piped");
            let body = serde_json::to_vec(req).expect("request serializes");
            stdin
                .write_all(&body)
                .map_err(|e| setup(format!("shim stdin: {e}")))?;
        }
        let deadline = Instant::now() + wall + SHIM_GRACE;
        loop {
            match child.try_wait() {
                Ok(Some(_)) => break,
                Ok(None) if Instant::now() < deadline => {
                    std::thread::sleep(Duration::from_millis(10))
                }
                Ok(None) => {
                    let _ = child.kill();
                    let _ = child.wait();
                    // The job's processes outlive a killed shim: end them by cgroup.
                    let _ = std::fs::write(req.cgroup_dir.join("cgroup.kill"), "1");
                    return Err(setup("the shim did not finish in time".into()));
                }
                Err(e) => return Err(setup(format!("waiting for the shim: {e}"))),
            }
        }
        let mut out = String::new();
        child
            .stdout
            .take()
            .expect("piped")
            .take(1 << 20)
            .read_to_string(&mut out)
            .map_err(|e| setup(format!("shim stdout: {e}")))?;
        serde_json::from_str(out.trim())
            .map_err(|e| setup(format!("bad shim response ({e}): {}", out.trim())))
    }

    fn run_inner(&self, job: &Job, bundle: &Path) -> Result<SignedResult, RunError> {
        let JobKind::Native { image, argv } = &job.kind else {
            unreachable!("checked")
        };
        let input_dir = existing_dir("input_dir", &job.input_dir)?;
        let output_dir = existing_dir("output_dir", &job.output_dir)?;
        if std::fs::read_dir(&output_dir).map_err(io)?.next().is_some() {
            return Err(invalid(format!(
                "output_dir {} is not empty",
                job.output_dir.display()
            )));
        }
        for p in job.policy.read.iter().chain(&job.policy.write) {
            if !p.exists() {
                return Err(invalid(format!(
                    "policy path {} does not exist",
                    p.display()
                )));
            }
        }
        let rootfs = bundle.join("rootfs");
        std::fs::create_dir_all(&rootfs).map_err(io)?;
        let unpacked = OciLayout::new(&self.cfg.image_layout)
            .unpack(image, &rootfs)
            .map_err(|e| RunError::Image {
                reason: e.to_string(),
            })?;
        let me = std::fs::metadata("/proc/self").map_err(io)?;
        let cgroup_path = format!("/{}/job-{}", self.cfg.cgroup_parent, job.id);
        let limits = job.policy.limits;
        let cfg = spec::build(&spec::SpecInput {
            argv,
            env: &unpacked.env,
            input_dir: &input_dir,
            output_dir: &output_dir,
            read: &job.policy.read,
            write: &job.policy.write,
            limits: &limits,
            uid: me.uid(),
            gid: me.gid(),
            cgroup_path: &cgroup_path,
        });
        std::fs::write(
            bundle.join("config.json"),
            serde_json::to_vec_pretty(&cfg).expect("config serializes"),
        )
        .map_err(io)?;

        let req = ShimRequest {
            id: format!("heimdall-{}", job.id),
            bundle: bundle.to_path_buf(),
            state_root: self.cfg.work_dir.join("state"),
            wall_ms: limits.wall_ms(),
            cgroup_dir: self
                .cfg
                .cgroup_root
                .join(cgroup_path.trim_start_matches('/')),
        };
        let started_unix = now_unix();
        let resp = self.run_shim(&req, limits.wall)?;
        let finished_unix = now_unix().max(started_unix);
        if let Some(e) = resp.error {
            if resp.exit_code.is_none() && resp.signal.is_none() {
                return Err(RunError::Setup { reason: e });
            }
        }

        let killed = if resp.wall_killed {
            Some(KillReason::Wall)
        } else if resp.oom_kills > 0 {
            Some(KillReason::Memory)
        } else {
            None
        };
        let exit = ExitInfo {
            code: resp.exit_code,
            signal: resp.signal,
            killed,
        };
        let scanned = outputs::scan(&output_dir, &limits);
        let verdict = match (&killed, &scanned) {
            (Some(KillReason::Wall), _) => Verdict::refused(
                codes::LIMIT_WALL,
                format!("killed at the wall-clock limit of {} ms", limits.wall_ms()),
            ),
            (Some(KillReason::Memory), _) => Verdict::refused(
                codes::LIMIT_MEMORY,
                format!(
                    "killed at the memory limit of {} bytes ({} OOM kills)",
                    limits.memory_bytes, resp.oom_kills
                ),
            ),
            (_, Err((code, reason))) => Verdict::refused(*code, reason.clone()),
            _ => match (resp.exit_code, resp.signal) {
                (Some(0), _) => Verdict::Accepted,
                (Some(c), _) => {
                    let mut why = format!("the job exited with code {c}");
                    if resp.pids_max > 0 {
                        why.push_str(&format!(
                            " after hitting the process limit of {}",
                            limits.pids
                        ));
                    }
                    Verdict::refused(codes::EXIT_NONZERO, why)
                }
                (None, Some(s)) => {
                    Verdict::refused(codes::EXIT_SIGNAL, format!("the job died from signal {s}"))
                }
                (None, None) => Verdict::refused(codes::EXIT_SIGNAL, "the job left no exit status"),
            },
        };
        let result = JobResult {
            job_id: job.id,
            nonce: job.nonce,
            verdict,
            outputs: scanned.unwrap_or_default(),
            exit,
            started_unix,
            finished_unix,
            runner: plain_features(),
            events: vec![],
        };
        Ok(self.key.sign(result)?)
    }
}

impl JobRunner for PlainYoukiRunner {
    fn run(&self, job: Job) -> Result<SignedResult, RunError> {
        check_job(&job)?;
        let bundle = self.cfg.work_dir.join("jobs").join(job.id.to_hex());
        std::fs::create_dir_all(bundle.parent().expect("has parent")).map_err(io)?;
        if let Err(e) = std::fs::create_dir(&bundle) {
            return Err(if e.kind() == std::io::ErrorKind::AlreadyExists {
                invalid(format!("job id {} is already running or ran", job.id))
            } else {
                io(e)
            });
        }
        let r = self.run_inner(&job, &bundle);
        let _ = std::fs::remove_dir_all(&bundle);
        r
    }

    fn verifying_key(&self) -> VerifyingKey {
        self.key.verifying_key()
    }

    fn features(&self) -> Features {
        plain_features()
    }
}