Gunnar

nordisk/heimdall

crates/heimdall-plain/src/spec.rs

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

//! The OCI runtime config (`config.json`) of one plain job.
//!
//! Heimdall is a defensive sandbox. This is an ORDINARY container, as the
//! milestone 0 demand says: user, mount, pid, ipc, uts, cgroup and network
//! namespaces (the network namespace is empty: only a down loopback), a
//! read-only root from the pinned image plus a tmpfs `/tmp`, `input_dir`
//! bound read-only at `/in`, `output_dir` bound read-write at `/out`, every
//! capability dropped, no-new-privs, the fixed default seccomp profile, and
//! cgroup v2 limits from [`heimdall_core::Limits`]. The wall clock is
//! enforced by the shim, not here.

use std::path::Path;

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

use crate::default_profile as p;

/// Where `input_dir` appears inside the container.
pub const IN_MOUNT: &str = "/in";
/// Where `output_dir` appears inside the container.
pub const OUT_MOUNT: &str = "/out";
/// Size of the `/tmp` tmpfs (also counted against the memory limit).
pub const TMP_SIZE: &str = "64m";
/// The cgroup v2 CPU period the `cpus` quota is expressed in (microseconds).
pub const CPU_PERIOD_US: u64 = 100_000;

/// What one job's config needs from the runner.
pub struct SpecInput<'a> {
    pub argv: &'a [String],
    /// `KEY=value` pairs (the image's `Env`).
    pub env: &'a [String],
    pub input_dir: &'a Path,
    pub output_dir: &'a Path,
    /// Extra read-only paths (bound at the same path inside).
    pub read: &'a [std::path::PathBuf],
    /// Extra writable paths (bound at the same path inside).
    pub write: &'a [std::path::PathBuf],
    pub limits: &'a Limits,
    /// The host uid / gid that container root maps to (one id each).
    pub uid: u32,
    pub gid: u32,
    /// The cgroup path below the cgroup v2 root, e.g. `/heimdall/job-<id>`.
    pub cgroup_path: &'a str,
}

/// The default seccomp profile as an OCI `linux.seccomp` object.
pub fn seccomp_json() -> Value {
    let mut rules = vec![json!({
        "names": p::ALLOW,
        "action": "SCMP_ACT_ALLOW",
    })];
    rules.push(json!({
        "names": ["socket"],
        "action": "SCMP_ACT_ALLOW",
        "args": [{"index": 0, "value": p::SOCKET_DOMAIN_BELOW, "op": "SCMP_CMP_LT"}],
    }));
    for d in p::SOCKET_DOMAINS {
        rules.push(json!({
            "names": ["socket"],
            "action": "SCMP_ACT_ALLOW",
            "args": [{"index": 0, "value": d, "op": "SCMP_CMP_EQ"}],
        }));
    }
    for v in p::PERSONALITIES {
        rules.push(json!({
            "names": ["personality"],
            "action": "SCMP_ACT_ALLOW",
            "args": [{"index": 0, "value": v, "op": "SCMP_CMP_EQ"}],
        }));
    }
    rules.push(json!({
        "names": ["clone"],
        "action": "SCMP_ACT_ALLOW",
        "args": [{"index": 0, "value": p::CLONE_NS_MASK, "valueTwo": 0, "op": "SCMP_CMP_MASKED_EQ"}],
    }));
    rules.push(json!({
        "names": ["clone3"],
        "action": "SCMP_ACT_ERRNO",
        "errnoRet": p::CLONE3_ERRNO,
    }));
    json!({
        "defaultAction": "SCMP_ACT_ERRNO",
        "defaultErrnoRet": p::DEFAULT_ERRNO,
        "architectures": ["SCMP_ARCH_X86_64"],
        "syscalls": rules,
    })
}

fn bind(src: &Path, dst: &str, rw: bool) -> Value {
    json!({
        "destination": dst,
        "type": "bind",
        "source": src,
        "options": ["rbind", if rw { "rw" } else { "ro" }, "nosuid", "nodev"],
    })
}

/// The cgroup v2 CPU quota for `cpus` CPUs (at least 1 ms per period).
pub fn cpu_quota(cpus: f32) -> i64 {
    let q = (f64::from(cpus) * CPU_PERIOD_US as f64).round();
    (q as i64).max(1000)
}

/// The full `config.json` for one job. `rootfs` is relative to the bundle.
pub fn build(s: &SpecInput<'_>) -> Value {
    let mut mounts = vec![
        json!({"destination": "/proc", "type": "proc", "source": "proc", "options": ["nosuid", "noexec", "nodev"]}),
        json!({"destination": "/dev", "type": "tmpfs", "source": "tmpfs", "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]}),
        json!({"destination": "/dev/pts", "type": "devpts", "source": "devpts", "options": ["nosuid", "noexec", "newinstance", "ptmxmode=0666", "mode=0620"]}),
        json!({"destination": "/dev/shm", "type": "tmpfs", "source": "shm", "options": ["nosuid", "noexec", "nodev", "mode=1777", "size=65536k"]}),
        json!({"destination": "/dev/mqueue", "type": "mqueue", "source": "mqueue", "options": ["nosuid", "noexec", "nodev"]}),
        json!({"destination": "/tmp", "type": "tmpfs", "source": "tmpfs", "options": ["nosuid", "nodev", "mode=1777", format!("size={TMP_SIZE}")]}),
        bind(s.input_dir, IN_MOUNT, false),
        bind(s.output_dir, OUT_MOUNT, true),
    ];
    for r in s.read {
        mounts.push(bind(r, &r.to_string_lossy(), false));
    }
    for w in s.write {
        mounts.push(bind(w, &w.to_string_lossy(), true));
    }
    let l = s.limits;
    let mem = i64::try_from(l.memory_bytes).unwrap_or(i64::MAX);
    json!({
        "ociVersion": "1.0.2",
        "root": {"path": "rootfs", "readonly": true},
        "hostname": "heimdall-job",
        "process": {
            "terminal": false,
            "user": {"uid": 0, "gid": 0},
            "args": s.argv,
            "env": s.env,
            "cwd": "/",
            "noNewPrivileges": true,
            "capabilities": {
                "bounding": [], "effective": [], "inheritable": [], "permitted": [], "ambient": []
            },
            "rlimits": [
                {"type": "RLIMIT_NOFILE", "hard": 1024u64, "soft": 1024u64},
                {"type": "RLIMIT_CORE", "hard": 0u64, "soft": 0u64}
            ]
        },
        "mounts": mounts,
        "linux": {
            "uidMappings": [{"containerID": 0, "hostID": s.uid, "size": 1}],
            "gidMappings": [{"containerID": 0, "hostID": s.gid, "size": 1}],
            "namespaces": [
                {"type": "user"}, {"type": "mount"}, {"type": "pid"}, {"type": "ipc"},
                {"type": "uts"}, {"type": "network"}, {"type": "cgroup"}
            ],
            "cgroupsPath": s.cgroup_path,
            "resources": {
                "memory": {"limit": mem, "swap": mem},
                "pids": {"limit": i64::from(l.pids)},
                "cpu": {"quota": cpu_quota(l.cpus), "period": CPU_PERIOD_US}
            },
            "seccomp": seccomp_json(),
            "maskedPaths": [
                "/proc/acpi", "/proc/asound", "/proc/kcore", "/proc/keys", "/proc/latency_stats",
                "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/proc/scsi"
            ],
            "readonlyPaths": [
                "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger"
            ]
        }
    })
}