crates/heimdall-probe/src/workload.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! A workload: `profiles/<workload>/workload.toml` (the binary per libc and
//! the probe corpus), and the probe / gen / check steps over it that
//! `heimdall profile` and a Build Thing step call. Heimdall is a defensive
//! sandbox; the corpus runs only the workload's own tests.
//!
//! ```toml
//! workload = "heimdall-wasm-host"
//! timeout_secs = 60 # per run
//! repeat = 2 # every run this many times (union)
//! exact_limit = 16
//! allow_wx = false
//! [binary]
//! glibc = "{target}/release/heimdall"
//! musl = "{target}/x86_64-unknown-linux-musl/release/heimdall"
//! [env]
//! RUST_BACKTRACE = "0"
//! [[run]]
//! name = "good_guest_is_accepted"
//! args = ["run", "--job", "wasm", "--module", "{corpus}/good.wasm", "--out", "{tmp}/out"]
//! expect_exit = 0
//! [[run]]
//! name = "trapping_guest_is_refused"
//! error_path = true
//! args = ["..."]
//! expect_exit = 4
//! ```
//!
//! Placeholders: `{dir}` the workload dir, `{corpus}` its `corpus/`,
//! `{tmp}` a fresh empty dir per run (also the run's cwd), `{target}`
//! `$CARGO_TARGET_DIR` or `<profiles>/../target`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use heimdall_seccomp::{Arch, Libc, Profile};
use serde::Deserialize;
use crate::check::{check, Violation};
use crate::codegen::files;
use crate::learn::{learn, ProbeRun};
use crate::minimise::{generate, GenOptions, Generated};
use crate::observe::Observations;
/// Why a probe step failed.
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
#[error("{path}: {err}")]
Io { path: PathBuf, err: std::io::Error },
#[error("{path}: {msg}")]
Manifest { path: PathBuf, msg: String },
#[error("workload {workload}: no binary for {libc} in workload.toml [binary]")]
NoBinary { workload: String, libc: Libc },
#[error("{binary}: the binary is {found}, not {wanted}")]
WrongLibc {
binary: PathBuf,
found: String,
wanted: Libc,
},
#[error("probe run {run}: {msg}")]
Run { run: String, msg: String },
#[error("this arch is not supported by heimdall-seccomp")]
Arch,
#[error("{path}: {msg}")]
Profile { path: PathBuf, msg: String },
}
fn io(path: &Path) -> impl FnOnce(std::io::Error) -> ProbeError + '_ {
move |err| ProbeError::Io {
path: path.to_path_buf(),
err,
}
}
/// `workload.toml`.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub workload: String,
#[serde(default)]
pub binary: BTreeMap<String, String>,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_repeat")]
pub repeat: u32,
#[serde(default = "default_exact")]
pub exact_limit: usize,
#[serde(default)]
pub allow_wx: bool,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(rename = "run", default)]
pub runs: Vec<RunSpec>,
}
fn default_timeout() -> u64 {
60
}
fn default_repeat() -> u32 {
1
}
fn default_exact() -> usize {
16
}
/// One `[[run]]`.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunSpec {
pub name: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub error_path: bool,
/// The exit code the run must end with (a corpus that stops working
/// fails the probe instead of shrinking the profile).
#[serde(default)]
pub expect_exit: Option<i32>,
#[serde(default)]
pub stdin: Option<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
/// A loaded workload.
#[derive(Clone, Debug)]
pub struct Workload {
pub dir: PathBuf,
pub manifest: Manifest,
target: PathBuf,
}
/// The files of one workload/arch/libc.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Paths {
pub probe: PathBuf,
pub rs: PathBuf,
pub json: PathBuf,
pub oci: PathBuf,
}
/// Options of one probe.
#[derive(Clone, Debug, Default)]
pub struct ProbeOptions {
pub libc: Option<Libc>,
/// Use this binary instead of the manifest's.
pub binary: Option<PathBuf>,
/// Only runs whose name contains one of these.
pub only: Vec<String>,
}
static NEXT_TMP: AtomicUsize = AtomicUsize::new(0);
impl Workload {
/// Load `<profiles>/<name>/workload.toml`.
pub fn load(profiles: &Path, name: &str) -> Result<Workload, ProbeError> {
if name.is_empty() || name.contains('/') || name.starts_with('.') {
return Err(ProbeError::Manifest {
path: profiles.to_path_buf(),
msg: format!("{name:?} is not a workload name"),
});
}
// Absolute: every run has its own cwd.
let manifest_path = profiles.join(name).join("workload.toml");
let dir = std::fs::canonicalize(profiles.join(name)).map_err(io(&manifest_path))?;
let path = dir.join("workload.toml");
let text = std::fs::read_to_string(&path).map_err(io(&path))?;
let manifest: Manifest = toml::from_str(&text).map_err(|e| ProbeError::Manifest {
path: path.clone(),
msg: e.to_string(),
})?;
if manifest.workload != name {
return Err(ProbeError::Manifest {
path,
msg: format!(
"workload = {:?}, but the dir is {name:?}",
manifest.workload
),
});
}
let target = std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| {
dir.parent()
.and_then(Path::parent)
.unwrap_or(Path::new("."))
.join("target")
});
Ok(Workload {
dir,
manifest,
target,
})
}
/// The generated files for an arch and libc.
pub fn paths(&self, arch: Arch, libc: Libc) -> Paths {
let base = format!("{arch}-{libc}");
Paths {
probe: self.dir.join(format!("{base}.probe.json")),
rs: self.dir.join(format!("{base}.rs")),
json: self.dir.join(format!("{base}.json")),
oci: self.dir.join(format!("{base}.oci.json")),
}
}
fn expand(&self, s: &str, tmp: &Path) -> String {
s.replace("{dir}", &self.dir.display().to_string())
.replace("{corpus}", &self.dir.join("corpus").display().to_string())
.replace("{tmp}", &tmp.display().to_string())
.replace("{target}", &self.target.display().to_string())
}
/// The binary and its libc.
pub fn binary(&self, opts: &ProbeOptions) -> Result<(PathBuf, Libc), ProbeError> {
let bin =
match (&opts.binary, opts.libc) {
(Some(b), _) => b.clone(),
(None, Some(l)) => {
PathBuf::from(self.expand(
self.manifest.binary.get(l.as_str()).ok_or_else(|| {
ProbeError::NoBinary {
workload: self.manifest.workload.clone(),
libc: l,
}
})?,
Path::new("/nonexistent"),
))
}
(None, None) => {
let first = self.manifest.binary.values().next().ok_or_else(|| {
ProbeError::NoBinary {
workload: self.manifest.workload.clone(),
libc: Libc::Glibc,
}
})?;
PathBuf::from(self.expand(first, Path::new("/nonexistent")))
}
};
let elf = std::fs::read(&bin).map_err(io(&bin))?;
let found = Libc::detect(&elf);
let libc = match (opts.libc, found) {
(Some(w), Some(f)) if w != f => {
return Err(ProbeError::WrongLibc {
binary: bin,
found: f.to_string(),
wanted: w,
})
}
(Some(w), _) => w,
(None, Some(f)) => f,
(None, None) => {
return Err(ProbeError::WrongLibc {
binary: bin,
found: "of unknown libc".into(),
wanted: Libc::Glibc,
})
}
};
Ok((bin, libc))
}
/// Probe: run the corpus (every run `repeat` times) in learning mode.
pub fn probe(&self, opts: &ProbeOptions) -> Result<Observations, ProbeError> {
let arch = Arch::native().ok_or(ProbeError::Arch)?;
let (bin, libc) = self.binary(opts)?;
let mut obs = Observations::new(&self.manifest.workload, arch, libc);
let runs: Vec<&RunSpec> = self
.manifest
.runs
.iter()
.filter(|r| opts.only.is_empty() || opts.only.iter().any(|o| r.name.contains(o)))
.collect();
if runs.is_empty() {
return Err(ProbeError::Manifest {
path: self.dir.join("workload.toml"),
msg: "no [[run]] to probe".into(),
});
}
for spec in runs {
for _ in 0..self.manifest.repeat.max(1) {
self.probe_one(spec, &bin, &mut obs)?;
}
}
Ok(obs)
}
fn probe_one(
&self,
spec: &RunSpec,
bin: &Path,
obs: &mut Observations,
) -> Result<(), ProbeError> {
let n = NEXT_TMP.fetch_add(1, Ordering::SeqCst);
let tmp = std::env::temp_dir().join(format!(
"heimdall-probe-{}-{}-{n}",
std::process::id(),
self.manifest.workload
));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).map_err(io(&tmp))?;
let out = tmp.join(".probe-output");
let mut run = ProbeRun::new(&spec.name, bin);
run.args = spec
.args
.iter()
.map(|a| self.expand(a, &tmp).into())
.collect();
let mut env: BTreeMap<String, String> = BTreeMap::new();
env.insert("PATH".into(), "/usr/bin:/bin".into());
env.extend(self.manifest.env.clone());
env.extend(spec.env.clone());
run.env = env
.into_iter()
.map(|(k, v)| (k.into(), self.expand(&v, &tmp).into()))
.collect();
run.cwd = Some(tmp.clone());
run.stdin = spec
.stdin
.as_ref()
.map(|s| PathBuf::from(self.expand(s, &tmp)));
run.output = Some(out.clone());
run.error_path = spec.error_path;
run.timeout = Duration::from_secs(self.manifest.timeout_secs);
let r = learn(&run, obs).map_err(|e| ProbeError::Run {
run: spec.name.clone(),
msg: e.to_string(),
});
let tail = || {
let s = std::fs::read_to_string(&out).unwrap_or_default();
let lines: Vec<&str> = s.lines().collect();
lines[lines.len().saturating_sub(8)..].join("\n")
};
let result = match r {
Err(e) => Err(e),
Ok(o) if o.timed_out => Err(ProbeError::Run {
run: spec.name.clone(),
msg: format!(
"timed out after {} s\n{}",
self.manifest.timeout_secs,
tail()
),
}),
Ok(o) => match spec.expect_exit {
Some(want) if o.exit != heimdall_seccomp::launch::Exit::Code(want) => {
Err(ProbeError::Run {
run: spec.name.clone(),
msg: format!("expected exit {want}, got {}\n{}", o.exit, tail()),
})
}
_ => Ok(()),
},
};
let _ = std::fs::remove_dir_all(&tmp);
result
}
/// The generator options from the manifest.
pub fn gen_options(&self) -> GenOptions {
GenOptions {
exact_limit: self.manifest.exact_limit,
allow_wx: self.manifest.allow_wx,
}
}
}
/// `heimdall profile probe`: probe and write `<arch>-<libc>.probe.json`.
pub fn probe_and_write(
w: &Workload,
opts: &ProbeOptions,
) -> Result<(Observations, PathBuf), ProbeError> {
let obs = w.probe(opts)?;
let p = w.paths(obs.arch, obs.libc).probe;
std::fs::write(&p, obs.to_json()).map_err(io(&p))?;
Ok((obs, p))
}
/// `heimdall profile gen`: read the probe file, write `.rs`, `.json`, `.oci.json`.
pub fn gen_and_write(
w: &Workload,
arch: Arch,
libc: Libc,
) -> Result<(Generated, Paths), ProbeError> {
let paths = w.paths(arch, libc);
let text = std::fs::read_to_string(&paths.probe).map_err(io(&paths.probe))?;
let obs = Observations::from_json(&text).map_err(|msg| ProbeError::Profile {
path: paths.probe.clone(),
msg,
})?;
let g = generate(&obs, w.gen_options());
let (rs, json, oci) = files(&g);
for (p, t) in [(&paths.rs, rs), (&paths.json, json), (&paths.oci, oci)] {
std::fs::write(p, t).map_err(io(p))?;
}
Ok((g, paths))
}
/// The result of `heimdall profile check`.
#[derive(Debug)]
pub struct CheckReport {
pub paths: Paths,
/// Calls the checked-in profile does not allow.
pub violations: Vec<Violation>,
/// Generated files that do not match the `.json` (hand-edited or stale).
pub out_of_sync: Vec<PathBuf>,
pub near_miss: usize,
}
impl CheckReport {
pub fn ok(&self) -> bool {
self.violations.is_empty() && self.out_of_sync.is_empty()
}
}
/// Load the checked-in generated profile of an arch/libc.
pub fn load_generated(w: &Workload, arch: Arch, libc: Libc) -> Result<Generated, ProbeError> {
let paths = w.paths(arch, libc);
let text = std::fs::read_to_string(&paths.json).map_err(io(&paths.json))?;
Generated::from_json(&text).map_err(|e| ProbeError::Profile {
path: paths.json.clone(),
msg: e.to_string(),
})
}
/// `heimdall profile check`: re-probe and hold the result to the checked-in
/// `.json`; also check that `.rs` and `.oci.json` are what `.json` generates.
pub fn check_workload(w: &Workload, opts: &ProbeOptions) -> Result<CheckReport, ProbeError> {
let obs = w.probe(opts)?;
let g = load_generated(w, obs.arch, obs.libc)?;
let paths = w.paths(obs.arch, obs.libc);
let (rs, _, oci) = files(&g);
let mut out_of_sync = Vec::new();
for (p, want) in [(&paths.rs, rs), (&paths.oci, oci)] {
if std::fs::read_to_string(p).ok().as_deref() != Some(want.as_str()) {
out_of_sync.push(p.clone());
}
}
let profile: &Profile = &g.profile;
Ok(CheckReport {
violations: check(&obs, profile),
near_miss: g.review.near_miss.len(),
out_of_sync,
paths,
})
}