crates/heimdall-container/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 protected job: heimdall-plain's
//! ordinary container config, tightened.
//!
//! Heimdall is a defensive sandbox. On top of heimdall-plain's config
//! (namespaces incl. an empty netns, read-only root, tmpfs `/tmp`, `/in`
//! read-only, `/out`, every capability dropped, no-new-privs, cgroup v2
//! memory/pids/cpu): the stricter [`crate::profile`], rlimits nofile, nproc
//! (= `pids` + 1, a backstop above the cgroup), fsize (= `max_output_file_bytes`) and core = 0, no
//! `/dev/mqueue`. `io.weight` is NOT here: libcgroups 0.7.0 overflows u16
//! converting an OCI blockIO.weight, so the shim writes `io.weight` itself;
//! io.max throttles are added by [`crate::iolimit::apply`].
//! Landlock is not in the config: the shim's executor applies it.
use heimdall_plain::spec::SpecInput;
use serde_json::{json, Value};
use crate::{profile, UnlistedMount};
/// `RLIMIT_NOFILE` of every job, unless the runner
/// ([`crate::HeimdallRunner::with_nofile`]) or the job
/// ([`heimdall_core::Limits::nofile`]) says otherwise.
pub const NOFILE: u64 = heimdall_core::NOFILE_DEFAULT;
/// The smallest `RLIMIT_NOFILE` a runner or a job may set (stdio plus a few files).
pub const NOFILE_MIN: u64 = heimdall_core::NOFILE_MIN;
/// The largest `RLIMIT_NOFILE` a runner or a job may set (also capped by the
/// runner's own hard limit, which a job can never exceed).
pub const NOFILE_MAX: u64 = heimdall_core::NOFILE_MAX;
/// Set the job's `RLIMIT_NOFILE` (soft = hard = `n`) in a built config.
pub fn set_nofile(c: &mut Value, n: u64) {
if let Some(rl) = c["process"]["rlimits"].as_array_mut() {
for r in rl.iter_mut().filter(|r| r["type"] == "RLIMIT_NOFILE") {
r["soft"] = json!(n);
r["hard"] = json!(n);
}
}
}
/// The one `execveat` the fixed profile allows when a generated profile is
/// applied on top (crate::genprof): on heimdall-seccomp's `EXEC_FD` with
/// `AT_EMPTY_PATH` only, the executor's start of the job's program. Without
/// a generated profile the fixed profile refuses every `execveat`.
pub fn exec_fd_rule() -> Value {
json!({
"names": ["execveat"],
"action": "SCMP_ACT_ALLOW",
"args": [
{"index": 0, "value": heimdall_seccomp::launch::EXEC_FD, "op": "SCMP_CMP_EQ"},
{"index": 4, "value": libc::AT_EMPTY_PATH, "op": "SCMP_CMP_EQ"}
],
})
}
/// The full `config.json` for one job. `generated`: a generated profile is
/// applied on top of the fixed one (adds [`exec_fd_rule`]).
pub fn build(s: &SpecInput<'_>, unlisted: &[UnlistedMount], generated: bool) -> Value {
let mut c = heimdall_plain::spec::build(s);
let l = s.limits;
c["process"]["rlimits"] = json!([
{"type": "RLIMIT_NOFILE", "hard": NOFILE, "soft": NOFILE},
// One above the cgroup's pids.max: the cgroup refuses a fork first and
// counts it in pids.events (heimdall-limit-pids); the rlimit is the backstop.
{"type": "RLIMIT_NPROC", "hard": u64::from(l.pids) + 1, "soft": u64::from(l.pids) + 1},
{"type": "RLIMIT_FSIZE", "hard": l.max_output_file_bytes, "soft": l.max_output_file_bytes},
{"type": "RLIMIT_CORE", "hard": 0u64, "soft": 0u64}
]);
c["linux"]["seccomp"] = profile::seccomp_json();
if generated {
if let Some(r) = c["linux"]["seccomp"]["syscalls"].as_array_mut() {
r.push(exec_fd_rule());
}
}
if let Some(m) = c["mounts"].as_array_mut() {
m.retain(|m| m["destination"] != "/dev/mqueue");
for u in unlisted {
m.push(json!({
"destination": u.destination,
"type": "bind",
"source": u.source,
"options": ["rbind", if u.writable { "rw" } else { "ro" }, "nosuid", "nodev"],
}));
}
}
c
}