crates/heimdall-container/src/genprof.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! GENERATED seccomp profiles for [`crate::HeimdallRunner`] (spec §3, §5).
//!
//! Heimdall is a defensive sandbox. A job (its [`heimdall_core::Policy::seccomp`])
//! or the runner's config ([`crate::HeimdallConfig::seccomp`]) names a
//! profile that `heimdall profile gen` wrote, pinned by the SHA-256 of its OCI
//! seccomp JSON:
//!
//! - [`ProfileSource::Workload`]: a checked-in profile compiled into Heimdall
//! (crate heimdall-profiles), the one for this arch whose digest matches;
//! - [`ProfileSource::OciFile`]: an OCI seccomp JSON on the runner's host
//! (read by the runner, at most [`MAX_PROFILE_BYTES`], never by the job).
//!
//! [`resolve`] refuses a missing profile, a digest that differs, a profile
//! for another arch, one whose default is not `KILL_PROCESS` and one that
//! does not build, unless the reference says `fallback_to_default`: then the
//! job runs under the fixed profile ([`crate::profile`]) and its result says
//! [`heimdall_core::Protection::Default`].
//!
//! How it is applied: youki keeps installing the fixed profile (plus one
//! narrow `execveat` rule, [`crate::spec`]); the executor, inside the job
//! right before the job's program starts, installs the generated profile ON
//! TOP (heimdall-seccomp's BPF builder, `TSYNC`) and starts the program with
//! `execveat(EXEC_FD, "", .., AT_EMPTY_PATH)`, the same prelude the probe
//! used, so a generated profile fits unchanged. The kernel applies the
//! stricter of the two filters to every call. After the install the executor
//! reports the digest on its report fd ([`REPORT_FD`]; the only extra call the
//! generated filter allows), and the runner signs `Generated { profile_digest }`
//! only when that report came back.
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use heimdall_core::{ProfileSource, RunError, SeccompProfileRef, Sha256Digest};
use heimdall_seccomp::bpf::build_rules;
use heimdall_seccomp::launch::prelude;
use heimdall_seccomp::{Action, Arch, ArgCmp, Libc, Profile, Rule};
use serde::{Deserialize, Serialize};
/// The largest OCI seccomp JSON file a runner reads.
pub const MAX_PROFILE_BYTES: u64 = 1 << 20;
/// The fd the executor reports on after the generated filter is installed
/// (close-on-exec; the job never holds it).
pub const REPORT_FD: i32 = 1020;
/// A generated profile ready to apply: its digest and its rules.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeneratedPlan {
/// `sha256:<hex>` of the profile's OCI seccomp JSON.
pub digest: String,
pub profile: Profile,
}
impl GeneratedPlan {
/// The rule the executor adds in front of the profile: `write` on
/// [`REPORT_FD`] only.
pub fn report_rule() -> Rule {
Rule {
syscall: "write".into(),
action: Action::Allow,
when: vec![vec![ArgCmp::eq32(0, REPORT_FD as u32)]],
}
}
}
fn refuse(r: &SeccompProfileRef, why: impl std::fmt::Display) -> RunError {
let what = match &r.source {
ProfileSource::Workload(w) => format!("workload {w:?}"),
ProfileSource::OciFile(p) => format!("file {}", p.display()),
};
RunError::InvalidJob {
reason: format!("seccomp profile ({what}, {}): {why}", r.sha256),
}
}
/// The native arch, or a refusal.
fn native() -> Result<Arch, String> {
Arch::native().ok_or_else(|| "this arch has no seccomp profiles".to_string())
}
/// The checked-in profile `workload` for this arch whose OCI JSON hashes to
/// `digest`.
pub fn builtin(workload: &str, digest: &Sha256Digest) -> Result<Profile, String> {
let arch = native()?;
let mine: Vec<_> = heimdall_profiles::ALL
.iter()
.filter(|e| e.profile.workload == workload && e.profile.arch == arch)
.collect();
if mine.is_empty() {
return Err(format!(
"no checked-in profile for this workload on {arch} (missing)"
));
}
match mine
.iter()
.find(|e| Sha256Digest::of(e.oci_json.as_bytes()) == *digest)
{
Some(e) => Ok(Profile::from(e.profile)),
None => Err(format!(
"digest mismatch: the checked-in profiles for {arch} are {}",
mine.iter()
.map(|e| format!(
"{} {}",
e.profile.libc,
Sha256Digest::of(e.oci_json.as_bytes()).to_prefixed()
))
.collect::<Vec<_>>()
.join(", ")
)),
}
}
/// Read an OCI seccomp JSON file pinned by `digest`.
pub fn oci_file(path: &Path, digest: &Sha256Digest) -> Result<Profile, String> {
if !path.is_absolute() {
return Err("the path is not absolute".into());
}
let f = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(|e| format!("missing or unreadable: {e}"))?;
let mut bytes = Vec::new();
f.take(MAX_PROFILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| format!("read: {e}"))?;
if bytes.len() as u64 > MAX_PROFILE_BYTES {
return Err(format!("larger than {MAX_PROFILE_BYTES} bytes"));
}
let got = Sha256Digest::of(&bytes);
if got != *digest {
return Err(format!(
"digest mismatch: the file hashes to {}",
got.to_prefixed()
));
}
let text = std::str::from_utf8(&bytes).map_err(|_| "not UTF-8".to_string())?;
// The libc is not in the OCI form; the file name of `heimdall profile
// gen` (`<arch>-<libc>.oci.json`) says it, glibc otherwise (informational).
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let libc = if name.contains("musl") {
Libc::Musl
} else {
Libc::Glibc
};
let workload = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("oci-file");
heimdall_seccomp::oci::from_oci(text, workload, libc).map_err(|e| e.to_string())
}
/// Resolve a profile reference: `Ok(Some(plan))` = apply it; `Ok(None)` =
/// it is missing or differs and the reference allows the fixed profile
/// (Default); `Err` = refuse the job.
pub fn resolve(r: &SeccompProfileRef) -> Result<Option<GeneratedPlan>, RunError> {
let Some(digest) = r.digest() else {
return Err(refuse(r, "the pin is not sha256:<64 lower-case hex>"));
};
let found = match &r.source {
ProfileSource::Workload(w) => builtin(w, &digest),
ProfileSource::OciFile(p) => oci_file(p, &digest),
};
let profile = match found {
Ok(p) => p,
Err(_) if r.fallback_to_default => return Ok(None),
Err(e) => return Err(refuse(r, e)),
};
let arch = native().map_err(|e| refuse(r, e))?;
if profile.arch != arch {
return Err(refuse(
r,
format!("the profile is for {}, this runner is {arch}", profile.arch),
));
}
let mut all = prelude(false);
all.push(GeneratedPlan::report_rule());
all.extend(profile.rules.iter().cloned());
build_rules(arch, &all, Action::KillProcess)
.map_err(|e| refuse(r, format!("the profile does not build: {e}")))?;
Ok(Some(GeneratedPlan {
digest: digest.to_prefixed(),
profile,
}))
}