// 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()
}
}