crates/nornir-build-thing/src/measure.rs
//! **The measure step — system calls** (Heimdall tooling plan phase 1, tunnr
//! 1.2; owner decision 2026-10-31).
//!
//! `measure syscalls <row> [--check]` runs every program of a row under
//! Heimdall's syscall probe while the row's runs execute, and writes the
//! generated seccomp profiles BESIDE THE ROW:
//!
//! ```text
//! .nornir/profiles/<row>/measure.toml the row's programs and runs (hand-written)
//! .nornir/profiles/<row>/measure.json the evidence: tool pin + counts + what waits for a VM
//! .nornir/profiles/<row>/<program>/workload.toml GENERATED from measure.toml (heimdall's format)
//! .nornir/profiles/<row>/<program>/<arch>-<libc>.probe.json the probe's observations (per call: the runs)
//! .nornir/profiles/<row>/<program>/<arch>-<libc>.json the reviewable profile (rules + review)
//! .nornir/profiles/<row>/<program>/<arch>-<libc>.oci.json the same as OCI seccomp JSON (seccompProfile: Localhost)
//! .nornir/profiles/<row>/<program>/<arch>-<libc>.rs the same as a Rust table (for Heimdall-linked code only)
//! ```
//!
//! `--gen` regenerates the profiles from the probe evidence already beside the
//! row (no probe). `--workload <driver>` adds the runs of a named
//! `[[driver]]` of `measure.toml` (an app driven by a script or command) to
//! its program; the drivers used are remembered in `measure.json`, so `--gen`
//! and `--check` reuse them. A reviewer's approval of the profiles lives in
//! `approved.toml` beside the row ([`approval_status`], [`approve`]).
//!
//! `--check` runs `heimdall profile check` per program and fails on drift,
//! naming the call and the test (run) that made it, or on a generated file
//! that no longer matches its `.json`.
//!
//! # The licence boundary (owner decision 2026-10-31, option A)
//!
//! This crate is MIT OR Apache-2.0; the heimdall crates are AGPL-3.0-only.
//! **Build Thing never links a heimdall crate.** It runs the `heimdall`
//! binary (crate `nordisk-heimdall-cli`) as an EXTERNAL TOOL — a separate
//! process — and reads the JSON files that tool writes. Nothing here is
//! derived from heimdall's code: the files are read as data, by field name.
//! The tool is pinned in the row's `measure.toml` by version AND by the
//! sha256 of the binary; another version or another build is refused by
//! name, and a missing tool is refused with the one install line. The
//! generated `.rs` names `heimdall_seccomp` types and so belongs only to code
//! that already links Heimdall; a row that is not AGPL reads the `.json` /
//! `.oci.json` instead.
//!
//! # What the probe needs
//!
//! seccomp `USER_NOTIF` with `FLAG_CONTINUE` (kernel ≥ 5.5) and no outer
//! seccomp profile in the way: on a build host that is a privileged rootless
//! podman container (heimdall's own `scripts/seccomp-tests.sh` runs the same
//! way). A program that only runs inside the booted appliance carries
//! `needs_vm = "why"` and is listed, never probed, until the VM leg exists.
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};
/// The tool's binary name.
pub const TOOL: &str = "heimdall";
/// The crate that ships it.
pub const TOOL_CRATE: &str = "nordisk-heimdall-cli";
/// An explicit path to the tool (otherwise `PATH` is searched).
pub const TOOL_ENV: &str = "HEIMDALL_TOOL";
/// Where the profiles of every row live, under the repo root.
pub const PROFILES_DIR: &str = ".nornir/profiles";
/// The row's hand-written manifest.
pub const MANIFEST: &str = "measure.toml";
/// The row's evidence file.
pub const EVIDENCE: &str = "measure.json";
/// The libcs heimdall knows.
pub const LIBCS: &[&str] = &["glibc", "musl"];
/// heimdall's exit code for "the check found something" (`EXIT_REJECTED`).
const TOOL_REJECTED: i32 = 1;
/// The pinned tool: `[tool]` in `measure.toml`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ToolPin {
/// `nordisk-heimdall-cli` version, as `heimdall --version` says it.
pub version: String,
/// sha256 of the binary file the row was measured with.
pub sha256: String,
}
/// One `[[program.run]]`: one invocation the row's tests make, named after
/// the test (the name a drift reports). Copied verbatim into heimdall's
/// `[[run]]` (placeholders `{dir}`, `{corpus}`, `{tmp}`, `{target}` are the
/// tool's).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RunSpec {
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub error_path: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect_exit: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdin: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
/// One `[[program]]` of the row.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Program {
/// The workload name (a dir under the row).
pub name: String,
/// One line for the reader.
#[serde(default)]
pub about: String,
/// libc → binary path (`{target}` = `$CARGO_TARGET_DIR` or `<repo>/target`).
#[serde(default)]
pub binary: BTreeMap<String, String>,
/// Run a copy of the binary under this file name (a multi-call binary
/// that dispatches on argv0, e.g. `tunnr-cni`).
#[serde(default)]
pub install_as: Option<String>,
/// This program runs only in the booted appliance: listed, not probed.
#[serde(default)]
pub needs_vm: Option<String>,
/// Paths of THIS program its runs here cannot reach (they need the
/// booted appliance): listed beside the profile, so a reader knows what
/// the profile does not cover yet.
#[serde(default)]
pub later: Vec<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub repeat: Option<u32>,
#[serde(default)]
pub exact_limit: Option<usize>,
#[serde(default)]
pub allow_wx: bool,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(rename = "run", default)]
pub runs: Vec<RunSpec>,
}
/// `.nornir/profiles/<row>/measure.toml`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MeasureManifest {
pub row: String,
pub tool: ToolPin,
/// Keep the generated `.rs` table (default). `false` for a row whose repo
/// does not link Heimdall: the tool's `.rs` carries Heimdall's AGPL SPDX
/// header and names `heimdall_seccomp` types, so such a row keeps only the
/// `.json` / `.oci.json` (measure removes the `.rs`; check does not ask
/// for it).
#[serde(default = "yes")]
pub rust_table: bool,
#[serde(rename = "program", default)]
pub programs: Vec<Program>,
/// How the row's programs are built: cargo lines run from the repo root
/// with `CARGO_TARGET_DIR = {target}` (`measure gate --build` runs them
/// first, so the gate checks what this commit builds).
#[serde(default)]
pub build: Vec<BuildSpec>,
/// Named workload drivers (`--workload <name>`): extra runs of one program.
#[serde(rename = "driver", default)]
pub drivers: Vec<Driver>,
}
/// One `[[driver]]`: a named workload driver (contract agreed with lane
/// HEIM-APP, 2026-11-01). `--workload <name>` selects it.
///
/// * Without `command`: its `[[driver.run]]` runs are added to `program`'s
/// corpus runs in `workload.toml`, named `<name>/<run>` (the name a drift
/// reports).
/// * With `command`: a SERVED app. The target (`serve`, default the
/// program's binary) runs under the tool's `probe-exec` (or
/// `probe-container` when `image` is set), `command` drives it from outside
/// the probed tree, the evidence is merged into the program's probe
/// evidence, then `--gen` regenerates. That orchestration is the caller's
/// (dwarves `tune`); this crate only carries the fields, as data.
///
/// Placeholders: `{channel}` (the host channel dir) and the tool's own
/// (`{dir}`, `{target}`, `{tmp}`, `{corpus}`).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Driver {
pub name: String,
/// The `[[program]]` whose profile the runs feed.
pub program: String,
/// One line for the reader.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub about: String,
/// The app driver (argv), run NOT under the tool. Set = a served driver.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
/// The long-running target to probe (argv; default the program's binary).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub serve: Vec<String>,
/// When the target is ready to be driven.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready: Option<Ready>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
/// The signal that stops the target (default `TERM`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stop_signal: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
/// Probe inside this OCI image (`probe-container`) instead of `probe-exec`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<DriverImage>,
/// Give the target a channel dir (`{channel}`) for unix sockets.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub channel: bool,
/// The corpus runs it contributes (the `[[program.run]]` fields).
#[serde(rename = "run", default, skip_serializing_if = "Vec::is_empty")]
pub runs: Vec<RunSpec>,
}
/// `ready = { socket = "…" } | { file = "…" } | { delay_ms = N }`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Ready {
Socket(String),
File(String),
DelayMs(u64),
}
/// `image = { layout = "…", digest = "sha256:…" }`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct DriverImage {
pub layout: String,
pub digest: String,
}
impl Driver {
/// A served driver: `command` drives a running target (not corpus runs).
pub fn served(&self) -> bool {
!self.command.is_empty()
}
/// The runs as the probe sees them: `<driver>/<run>`.
pub fn named_runs(&self) -> Vec<RunSpec> {
self.runs
.iter()
.map(|r| RunSpec {
name: format!("{}/{}", self.name, r.name),
..r.clone()
})
.collect()
}
}
/// One `[[build]]`: a `cargo build …` line (the argv after `cargo`).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct BuildSpec {
pub cargo: Vec<String>,
}
fn yes() -> bool {
true
}
fn plain_name(s: &str) -> bool {
!s.is_empty()
&& !s.starts_with('.')
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
/// The row's profiles dir.
pub fn row_dir(repo_root: &Path, row: &str) -> PathBuf {
repo_root.join(PROFILES_DIR).join(row)
}
impl MeasureManifest {
/// Load and validate the row's manifest.
pub fn load(repo_root: &Path, row: &str) -> Result<MeasureManifest> {
if !plain_name(row) {
bail!("{row:?} is not a row name");
}
let path = row_dir(repo_root, row).join(MANIFEST);
let shown = format!("{PROFILES_DIR}/{row}/{MANIFEST}");
let text = std::fs::read_to_string(&path)
.with_context(|| format!("row `{row}` has no {shown} — the measure step needs the row's programs and runs there"))?;
let m: MeasureManifest = toml::from_str(&text).with_context(|| shown.clone())?;
if m.row != row {
bail!("{shown}: row = {:?}, but it lives under row `{row}`", m.row);
}
for b in &m.build {
if b.cargo.first().map(String::as_str) != Some("build") {
bail!(
"{shown}: [[build]] cargo must start with `build` (the gate builds, it runs nothing else): {:?}",
b.cargo
);
}
}
let mut seen = std::collections::BTreeSet::new();
for p in &m.programs {
if !plain_name(&p.name) {
bail!("{shown}: program {:?} is not a plain name", p.name);
}
if !seen.insert(p.name.as_str()) {
bail!("{shown}: program `{}` is declared twice", p.name);
}
if let Some(a) = &p.install_as {
if !plain_name(a) {
bail!(
"{shown}: program `{}`: install_as {a:?} is not a plain file name",
p.name
);
}
}
for l in p.binary.keys() {
if !LIBCS.contains(&l.as_str()) {
bail!(
"{shown}: program `{}`: binary for libc {l:?} — heimdall knows {}",
p.name,
LIBCS.join(", ")
);
}
}
match (&p.needs_vm, p.runs.is_empty(), p.binary.is_empty()) {
(Some(_), true, _) => {}
(Some(why), false, _) => {
bail!(
"{shown}: program `{}` has runs AND needs_vm = {why:?} — it is either probed here or waits for the VM",
p.name
)
}
(None, true, _) => bail!(
"{shown}: program `{}` has no [[program.run]] — give it the runs its tests make, or needs_vm = \"why\"",
p.name
),
(None, false, true) => {
bail!("{shown}: program `{}` has runs but no binary", p.name)
}
(None, false, false) => {}
}
let mut runs = std::collections::BTreeSet::new();
for r in &p.runs {
if r.name.is_empty() || !runs.insert(r.name.as_str()) {
bail!(
"{shown}: program `{}`: run {:?} is empty or declared twice",
p.name,
r.name
);
}
}
}
let mut drivers = std::collections::BTreeSet::new();
for dr in &m.drivers {
if !plain_name(&dr.name) {
bail!("{shown}: driver {:?} is not a plain name", dr.name);
}
if !drivers.insert(dr.name.as_str()) {
bail!("{shown}: driver `{}` is declared twice", dr.name);
}
let Some(p) = m.programs.iter().find(|p| p.name == dr.program) else {
bail!(
"{shown}: driver `{}` names program `{}`, which the row does not declare",
dr.name,
dr.program
);
};
if p.needs_vm.is_some() {
bail!(
"{shown}: driver `{}` feeds program `{}`, which has needs_vm — it is not probed here",
dr.name,
p.name
);
}
if dr.runs.is_empty() && !dr.served() {
bail!(
"{shown}: driver `{}` has no [[driver.run]] and no command — give it the runs it contributes, or the command that drives the app",
dr.name
);
}
let mut runs: std::collections::BTreeSet<&str> =
p.runs.iter().map(|r| r.name.as_str()).collect();
let named = dr.named_runs();
for r in &named {
if r.name.len() == dr.name.len() + 1 || !runs.insert(r.name.as_str()) {
bail!(
"{shown}: driver `{}`: run {:?} is empty or declared twice",
dr.name,
r.name
);
}
}
}
Ok(m)
}
/// The drivers named by `--workload`, refused by name when unknown.
pub fn select_drivers(&self, names: &[String]) -> Result<Vec<&Driver>> {
names
.iter()
.map(|n| {
self.drivers.iter().find(|d| &d.name == n).ok_or_else(|| {
if self.drivers.is_empty() {
anyhow!(
"row `{}` has no workload driver `{n}` — it declares none; add a [[driver]] to {PROFILES_DIR}/{}/{MANIFEST}",
self.row,
self.row
)
} else {
let have: Vec<&str> = self.drivers.iter().map(|d| d.name.as_str()).collect();
anyhow!(
"row `{}` has no workload driver `{n}` — it has {}",
self.row,
have.join(", ")
)
}
})
})
.collect()
}
}
/// Heimdall's `workload.toml` (its `deny_unknown_fields` format, field for
/// field) — written by Build Thing, read by the tool.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkloadToml {
pub workload: String,
pub binary: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repeat: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exact_limit: Option<usize>,
#[serde(default)]
pub allow_wx: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub run: Vec<RunSpec>,
}
/// The staged copy of an `install_as` binary, relative to `{target}`.
fn staged(row: &str, libc: &str, name: &str) -> String {
format!("heimdall-measure/{row}/{libc}/{name}")
}
/// Project one program to heimdall's `workload.toml` (deterministic bytes).
pub fn workload_toml(m: &MeasureManifest, p: &Program) -> Result<String> {
workload_toml_with(m, p, &[])
}
/// [`workload_toml`] with the runs of these drivers added (those of `p`).
pub fn workload_toml_with(m: &MeasureManifest, p: &Program, drivers: &[&Driver]) -> Result<String> {
let mut run = p.runs.clone();
for d in drivers.iter().filter(|d| d.program == p.name && !d.served()) {
run.extend(d.named_runs());
}
let binary = p
.binary
.iter()
.map(|(l, b)| {
let b = match &p.install_as {
Some(a) => format!("{{target}}/{}", staged(&m.row, l, a)),
None => b.clone(),
};
(l.clone(), b)
})
.collect();
let w = WorkloadToml {
workload: p.name.clone(),
binary,
timeout_secs: p.timeout_secs,
repeat: p.repeat,
exact_limit: p.exact_limit,
allow_wx: p.allow_wx,
env: p.env.clone(),
run,
};
let body = toml::to_string(&w).context("serialize workload.toml")?;
let mut s = format!(
"# GENERATED by Build Thing (`measure syscalls {}`) from ../{MANIFEST}; edit that file, not this one.\n",
m.row
);
if !p.about.is_empty() {
s.push_str(&format!("# {}\n", p.about));
}
s.push('\n');
s.push_str(&body);
Ok(s)
}
// ── the external tool ───────────────────────────────────────────────────────
/// What one tool call returned.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolOutput {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
/// The external `heimdall` binary, as Build Thing sees it: a path and a
/// process call. (A trait so the step is tested against a scripted fake.)
pub trait ProfileTool {
/// The binary file (hashed for the pin).
fn path(&self) -> &Path;
/// Run it with these args and extra environment.
fn call(&self, args: &[String], env: &[(String, String)]) -> Result<ToolOutput>;
}
impl<T: ProfileTool + ?Sized> ProfileTool for &T {
fn path(&self) -> &Path {
(**self).path()
}
fn call(&self, args: &[String], env: &[(String, String)]) -> Result<ToolOutput> {
(**self).call(args, env)
}
}
/// The real tool: a separate process. Never a linked library.
#[derive(Debug, Clone)]
pub struct ExternalTool {
path: PathBuf,
}
impl ExternalTool {
/// The tool at `$HEIMDALL_TOOL`, else `heimdall` on `PATH`.
pub fn locate(pin: &ToolPin) -> Result<ExternalTool> {
locate_in(std::env::var_os(TOOL_ENV), std::env::var_os("PATH"), pin)
}
}
impl ProfileTool for ExternalTool {
fn path(&self) -> &Path {
&self.path
}
fn call(&self, args: &[String], env: &[(String, String)]) -> Result<ToolOutput> {
let out = std::process::Command::new(&self.path)
.args(args)
.envs(env.iter().map(|(k, v)| (k, v)))
.stdin(std::process::Stdio::null())
.output()
.with_context(|| format!("run {} {}", self.path.display(), args.join(" ")))?;
Ok(ToolOutput {
code: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
fn install_line(pin: &ToolPin) -> String {
format!(
"cargo install {TOOL_CRATE} --version {} --locked",
pin.version
)
}
/// Find the tool: `heimdall_tool` (the value of `$HEIMDALL_TOOL`) wins, else
/// the first `heimdall` file on `path_var`. Missing = refused by name.
pub fn locate_in(
heimdall_tool: Option<OsString>,
path_var: Option<OsString>,
pin: &ToolPin,
) -> Result<ExternalTool> {
if let Some(p) = heimdall_tool.filter(|p| !p.is_empty()) {
let p = PathBuf::from(p);
if !p.is_file() {
bail!(
"{TOOL_ENV}={} is not a file — the external tool `{TOOL}` ({TOOL_CRATE} {}): {}",
p.display(),
pin.version,
install_line(pin)
);
}
return Ok(ExternalTool { path: p });
}
if let Some(pv) = path_var {
for d in std::env::split_paths(&pv) {
let p = d.join(TOOL);
if p.is_file() {
return Ok(ExternalTool { path: p });
}
}
}
bail!(
"the external tool `{TOOL}` ({TOOL_CRATE} {}) is not on PATH and {TOOL_ENV} is not set — Build Thing runs it as a \
separate process and never links it; install it with `{}` (or point {TOOL_ENV} at the binary)",
pin.version,
install_line(pin)
)
}
/// The tool as measured: crate, version, sha256 (written into the evidence).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ToolIdentity {
#[serde(rename = "crate")]
pub krate: String,
pub version: String,
pub sha256: String,
}
/// Hold the tool to the row's pin: the version `--version` says and the
/// sha256 of the file. Either one off = refused, naming both values.
pub fn verify_pin(tool: &dyn ProfileTool, pin: &ToolPin) -> Result<ToolIdentity> {
let path = tool.path();
let bytes = std::fs::read(path).with_context(|| format!("read the tool {}", path.display()))?;
let sha256 = nornir_hash::sha256_hex(&bytes);
let out = tool.call(&["--version".to_string()], &[])?;
let line = out.stdout.lines().next().unwrap_or("").trim().to_string();
let version = line.split_whitespace().last().unwrap_or("").to_string();
if out.code != 0 || version.is_empty() {
bail!(
"`{} --version` did not answer (exit {}): {}{}",
path.display(),
out.code,
out.stdout,
out.stderr
);
}
if version != pin.version {
bail!(
"{} is {TOOL_CRATE} {version}, the row pins {} — install the pinned one: {}",
path.display(),
pin.version,
install_line(pin)
);
}
if sha256 != pin.sha256 {
bail!(
"{} has sha256 {sha256}, the row pins {} — another build of {TOOL_CRATE} {version}; measure with the pinned \
binary, or re-pin after review",
path.display(),
pin.sha256
);
}
Ok(ToolIdentity {
krate: TOOL_CRATE.into(),
version,
sha256,
})
}
// ── the verb ────────────────────────────────────────────────────────────────
/// `measure syscalls <row> [--check] [--program P]… [--libc L]… [--target D]`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MeasureVerb {
pub row: String,
/// Drift check instead of measure.
pub check: bool,
/// Regenerate from the probe evidence beside the row (no probe).
pub gen_only: bool,
/// Workload drivers whose runs are added (`--workload`; empty: the ones
/// `measure.json` remembers).
pub workloads: Vec<String>,
/// Only these programs (empty: all).
pub programs: Vec<String>,
/// Only these libcs (empty: every libc the program has a binary for).
pub libcs: Vec<String>,
/// `{target}` (default `$CARGO_TARGET_DIR`, else `<repo>/target`).
pub target: Option<PathBuf>,
}
/// The usage line.
pub const USAGE: &str = "usage: measure syscalls <row> [--check | --gen] [--workload <driver>]… [--program <name>]… [--libc glibc|musl]… [--target <dir>]";
/// Parse the words after `measure`.
pub fn parse_measure_verb(args: &[String]) -> Result<MeasureVerb> {
let what = args.first().ok_or_else(|| anyhow!("{USAGE}"))?;
if what != "syscalls" {
bail!(
"measure `{what}`: only `syscalls` and `measure gate` are built here (the kernel coverage half is `kernel coverage`) — {USAGE}"
);
}
let row = args
.get(1)
.filter(|r| !r.starts_with("--"))
.ok_or_else(|| anyhow!("{USAGE}"))?
.clone();
let mut v = MeasureVerb {
row,
..Default::default()
};
let mut i = 2;
while i < args.len() {
let val = |i: usize| {
args.get(i + 1)
.cloned()
.ok_or_else(|| anyhow!("{} needs a value — {USAGE}", args[i]))
};
match args[i].as_str() {
"--check" => {
v.check = true;
i += 1;
}
"--gen" => {
v.gen_only = true;
i += 1;
}
"--workload" => {
v.workloads.push(val(i)?);
i += 2;
}
"--program" => {
v.programs.push(val(i)?);
i += 2;
}
"--libc" => {
let l = val(i)?;
if !LIBCS.contains(&l.as_str()) {
bail!("--libc {l:?}: heimdall knows {}", LIBCS.join(", "));
}
v.libcs.push(l);
i += 2;
}
"--target" => {
v.target = Some(PathBuf::from(val(i)?));
i += 2;
}
other => bail!("measure syscalls: unknown argument `{other}` — {USAGE}"),
}
}
if v.check && v.gen_only {
bail!("measure syscalls: --gen writes the profiles and --check only reads them: give one — {USAGE}");
}
Ok(v)
}
// ── the report ──────────────────────────────────────────────────────────────
/// One program × libc, measured: counts read back from the tool's JSON.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Measured {
pub program: String,
pub libc: String,
pub arch: String,
/// Runs the probe made (`.probe.json` runs).
pub runs: usize,
/// Distinct calls observed (`.probe.json` calls).
pub calls: usize,
/// Unknown syscall numbers observed.
pub unknown: usize,
/// Rules in the generated profile (`.json` rules).
pub rules: usize,
/// Calls seen only on error paths (kept, listed for review).
pub near_miss: usize,
/// Calls allowed bare because their argument tuples were too many.
pub bare: usize,
/// W|X mappings left out.
pub wx: usize,
/// The distinct syscall names observed, sorted.
pub syscalls: Vec<String>,
/// The runs (tests) the probe made, by name.
pub run_names: Vec<String>,
/// `call (by run, run)` for every near-miss call.
pub near_miss_calls: Vec<String>,
/// The calls allowed bare.
pub bare_calls: Vec<String>,
/// The profile files' stem, relative to the repo root
/// (`.nornir/profiles/<row>/<program>/<arch>-<libc>`).
pub profile: String,
}
/// A call the checked-in profile does not allow — the drift gate's finding.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Drift {
pub program: String,
pub libc: String,
/// `sysinfo`, `openat arg2=0x241`, `nr 451`.
pub call: String,
/// What the enforcing filter would do (`kill_process`).
pub action: String,
/// The runs (tests) that made it.
pub tests: Vec<String>,
}
/// What the step found.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MeasureReport {
pub row: String,
pub check: bool,
/// `--gen`: regenerated from the probe evidence, nothing probed.
pub gen_only: bool,
/// The workload drivers whose runs were used.
pub workloads: Vec<String>,
pub tool: Option<ToolIdentity>,
/// measure: every program × libc probed.
pub programs: Vec<Measured>,
/// check: every (program, libc) checked.
pub checked: Vec<(String, String)>,
/// check: the calls outside the profile.
pub drift: Vec<Drift>,
/// check: (program, file) generated files that no longer match `.json`.
pub out_of_sync: Vec<(String, String)>,
/// (program, why) not probed: they need the booted appliance.
pub vm_later: Vec<(String, String)>,
/// The row's profiles dir, relative to the repo root.
pub dir: String,
/// The `.rs` table is kept.
pub rust_table: bool,
}
impl MeasureReport {
/// No drift and nothing out of sync.
pub fn ok(&self) -> bool {
self.drift.is_empty() && self.out_of_sync.is_empty()
}
/// The operator's text: what was probed, which calls, the near-miss,
/// where the profile was written, how to check it.
pub fn render(&self) -> String {
let mut s = String::new();
let mut verb = if self.check {
" --check".to_string()
} else if self.gen_only {
" --gen".to_string()
} else {
String::new()
};
for w in &self.workloads {
verb.push_str(&format!(" --workload {w}"));
}
s.push_str(&format!("# measure syscalls {}{verb}", self.row));
if let Some(t) = &self.tool {
s.push_str(&format!(
" — {} {} (sha256 {})",
t.krate, t.version, t.sha256
));
}
s.push('\n');
for m in &self.programs {
s.push_str(&format!(
"\n{} ({}-{}): {} run(s): {}\n",
m.program,
m.arch,
m.libc,
m.runs,
m.run_names.join(", ")
));
s.push_str(&format!(
" calls ({}): {}\n",
m.calls,
m.syscalls.join(" ")
));
s.push_str(&format!(
" profile: {} rule(s) -> {}.json (review), .oci.json (pods){}; probe evidence {}.probe.json\n",
m.rules,
m.profile,
if self.rust_table { ", .rs" } else { "" },
m.profile
));
if m.near_miss_calls.is_empty() {
s.push_str(" near-miss (seen only on error paths): none\n");
} else {
s.push_str(&format!(
" near-miss (seen only on error paths, kept): {}\n",
m.near_miss_calls.join("; ")
));
}
if !m.bare_calls.is_empty() {
s.push_str(&format!(
" bare allows (too many argument tuples): {}\n",
m.bare_calls.join(" ")
));
}
if m.wx > 0 || m.unknown > 0 {
s.push_str(&format!(
" W|X left out: {}; unknown syscall numbers: {}\n",
m.wx, m.unknown
));
}
}
for (p, l) in &self.checked {
let bad = self.drift.iter().any(|d| &d.program == p && &d.libc == l)
|| self.out_of_sync.iter().any(|(q, _)| q == p);
if bad {
s.push_str(&format!("{p} ({l}): DRIFT\n"));
} else {
s.push_str(&format!(
"{p} ({l}): OK — every call is in {}/{p}/{}-{l}.json\n",
self.dir,
std::env::consts::ARCH
));
}
}
for d in &self.drift {
s.push_str(&format!(
"DRIFT {} ({}): new call {} ({}), made by test {}\n",
d.program,
d.libc,
d.call,
d.action,
d.tests.join(", ")
));
}
for (p, f) in &self.out_of_sync {
if f.ends_with("workload.toml") {
s.push_str(&format!("DRIFT {p}: {f} no longer matches {MANIFEST} (the runs changed: measure again)\n"));
} else {
s.push_str(&format!(
"DRIFT {p}: {f} does not match its .json (hand-edited or stale)\n"
));
}
}
if !self.vm_later.is_empty() {
s.push('\n');
}
for (p, why) in &self.vm_later {
if why.starts_with("not covered yet: ") {
s.push_str(&format!("{p}: {why} (needs a VM, later)\n"));
} else {
s.push_str(&format!("{p}: not probed, needs a VM (later): {why}\n"));
}
}
if !self.check {
s.push_str(&format!(
"\nevidence: {}/{EVIDENCE}\nnext: review the diff (git diff {}), commit it, then check for drift: measure syscalls {} --check\n",
self.dir, self.dir, self.row
));
} else if !self.ok() {
s.push_str(&format!(
"fix: measure syscalls {} (probe + gen), then review the profile diff; never widen a profile by hand\n",
self.row
));
}
s
}
}
/// Read `heimdall profile check`'s stderr: `heimdall profile check: new call
/// <call> (<action>), made by test <a>, <b>` and `… <file> does not match
/// <json> (hand-edited or stale)`.
pub fn parse_check_stderr(
program: &str,
libc: &str,
stderr: &str,
) -> (Vec<Drift>, Vec<(String, String)>) {
const PREFIX: &str = "heimdall profile check: ";
let mut drift = Vec::new();
let mut oos = Vec::new();
for line in stderr.lines() {
let Some(rest) = line.strip_prefix(PREFIX) else {
continue;
};
if let Some(rest) = rest.strip_prefix("new call ") {
let Some((left, tests)) = rest.rsplit_once(", made by test ") else {
continue;
};
let (call, action) = match left.rsplit_once(" (") {
Some((c, a)) => (c.to_string(), a.trim_end_matches(')').to_string()),
None => (left.to_string(), String::new()),
};
drift.push(Drift {
program: program.into(),
libc: libc.into(),
call,
action,
tests: tests
.split(", ")
.filter(|t| !t.is_empty())
.map(String::from)
.collect(),
});
} else if let Some((file, _)) = rest.split_once(" does not match ") {
oos.push((program.to_string(), file.to_string()));
}
}
(drift, oos)
}
// ── reading the tool's JSON (as data, by field name) ────────────────────────
#[derive(Deserialize)]
struct ProbeJson {
arch: String,
libc: String,
#[serde(default)]
runs: Vec<serde_json::Value>,
#[serde(default)]
calls: Vec<ProbeCall>,
#[serde(default)]
unknown: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct ProbeCall {
syscall: String,
}
#[derive(Deserialize)]
struct ProfileJson {
#[serde(default)]
rules: Vec<serde_json::Value>,
#[serde(default)]
review: Review,
}
#[derive(Deserialize, Default)]
struct Review {
#[serde(default)]
near_miss: Vec<serde_json::Value>,
#[serde(default)]
wx: Vec<serde_json::Value>,
#[serde(default)]
bare: Vec<serde_json::Value>,
}
fn read_json<T: for<'de> Deserialize<'de>>(p: &Path) -> Result<T> {
let t =
std::fs::read_to_string(p).with_context(|| format!("the tool wrote no {}", p.display()))?;
serde_json::from_str(&t).with_context(|| format!("{}: not the tool's JSON", p.display()))
}
/// A review entry: a bare string, or `{call, runs}`.
fn review_entry(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Object(o) => {
let call = o
.get("call")
.and_then(|c| c.as_str())
.unwrap_or("?")
.to_string();
let runs: Vec<&str> = o
.get("runs")
.and_then(|r| r.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str()).collect())
.unwrap_or_default();
if runs.is_empty() {
call
} else {
format!("{call} (by {})", runs.join(", "))
}
}
other => other.to_string(),
}
}
fn run_name(v: &serde_json::Value) -> String {
v.get("name")
.and_then(|n| n.as_str())
.unwrap_or("?")
.to_string()
}
fn count_unknown(v: &Option<serde_json::Value>) -> usize {
match v {
Some(serde_json::Value::Array(a)) => a.len(),
Some(serde_json::Value::Object(o)) => o.len(),
_ => 0,
}
}
#[derive(Serialize)]
struct Evidence<'a> {
row: &'a str,
tool: &'a ToolIdentity,
#[serde(skip_serializing_if = "<[String]>::is_empty")]
workloads: &'a [String],
programs: &'a [Measured],
vm_later: Vec<VmLater<'a>>,
}
#[derive(Serialize)]
struct VmLater<'a> {
program: &'a str,
why: &'a str,
}
// ── the step ────────────────────────────────────────────────────────────────
/// The drivers the last measure used (`measure.json` `workloads`).
fn remembered_workloads(dir: &Path) -> Vec<String> {
#[derive(Deserialize)]
struct W {
#[serde(default)]
workloads: Vec<String>,
}
std::fs::read_to_string(dir.join(EVIDENCE))
.ok()
.and_then(|t| serde_json::from_str::<W>(&t).ok())
.map(|w| w.workloads)
.unwrap_or_default()
}
fn expand_target(s: &str, target: &Path) -> PathBuf {
PathBuf::from(s.replace("{target}", &target.display().to_string()))
}
/// **Run `measure syscalls`** on a row: pin the tool, then per program and
/// libc either probe + gen (measure; writes the evidence) or check.
pub fn run_measure_verb(
v: &MeasureVerb,
repo_root: &Path,
tool: &dyn ProfileTool,
) -> Result<MeasureReport> {
let m = MeasureManifest::load(repo_root, &v.row)?;
for want in &v.programs {
if !m.programs.iter().any(|p| &p.name == want) {
let have: Vec<&str> = m.programs.iter().map(|p| p.name.as_str()).collect();
bail!(
"row `{}` has no program `{want}` — it has {}",
v.row,
have.join(", ")
);
}
}
let dir = row_dir(repo_root, &v.row);
let workloads = if v.workloads.is_empty() && (v.check || v.gen_only) {
remembered_workloads(&dir)
} else {
v.workloads.clone()
};
let drivers = m.select_drivers(&workloads)?;
let id = verify_pin(tool, &m.tool)?;
let target = v
.target
.clone()
.or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
.unwrap_or_else(|| repo_root.join("target"));
let env = vec![("CARGO_TARGET_DIR".to_string(), target.display().to_string())];
let arch = std::env::consts::ARCH;
let shown_dir = format!("{PROFILES_DIR}/{}", v.row);
let mut r = MeasureReport {
row: v.row.clone(),
check: v.check,
gen_only: v.gen_only,
workloads: workloads.clone(),
tool: Some(id.clone()),
dir: shown_dir.clone(),
rust_table: m.rust_table,
..Default::default()
};
for p in &m.programs {
if !v.programs.is_empty() && !v.programs.contains(&p.name) {
continue;
}
if let Some(why) = &p.needs_vm {
r.vm_later.push((p.name.clone(), why.clone()));
continue;
}
for l in &p.later {
r.vm_later
.push((p.name.clone(), format!("not covered yet: {l}")));
}
let pdir = dir.join(&p.name);
let w = pdir.join("workload.toml");
let want = workload_toml_with(&m, p, &drivers)?;
if v.check {
// The check probes what workload.toml says; it must still be what
// measure.toml says, or the check would hold the wrong runs.
match std::fs::read_to_string(&w) {
Err(_) => bail!(
"program `{}` has no profile yet ({shown_dir}/{}/workload.toml is missing) — run `measure syscalls {}` first",
p.name,
p.name,
v.row
),
Ok(have) if have != want => {
r.out_of_sync.push((
p.name.clone(),
format!("{shown_dir}/{}/workload.toml", p.name),
));
}
Ok(_) => {}
}
} else {
std::fs::create_dir_all(&pdir).with_context(|| format!("create {}", pdir.display()))?;
std::fs::write(&w, want).with_context(|| format!("write {}", w.display()))?;
}
for (libc, bin) in &p.binary {
if !v.libcs.is_empty() && !v.libcs.contains(libc) {
continue;
}
let base = pdir.join(format!("{arch}-{libc}"));
let built = expand_target(bin, &target);
if v.gen_only {
let pj = base.with_extension("probe.json");
if !pj.is_file() {
bail!(
"program `{}` ({libc}): no probe evidence ({shown_dir}/{}/{arch}-{libc}.probe.json) — probe it first: measure syscalls {}",
p.name,
p.name,
v.row
);
}
} else if !built.is_file() {
bail!(
"program `{}` ({libc}): {} is not built — build the row first{}",
p.name,
built.display(),
if m.build.is_empty() {
String::new()
} else {
let lines: Vec<String> =
m.build.iter().map(|b| format!("cargo {}", b.cargo.join(" "))).collect();
format!(": {}", lines.join("; "))
}
);
}
if let Some(a) = &p.install_as
&& !v.gen_only
{
let to = target.join(staged(&m.row, libc, a));
std::fs::create_dir_all(to.parent().expect("staged path has a parent"))
.with_context(|| format!("create {}", to.display()))?;
std::fs::copy(&built, &to)
.with_context(|| format!("stage {} as {}", built.display(), to.display()))?;
}
let args = |verb: &str| -> Vec<String> {
vec![
"profile".into(),
verb.into(),
p.name.clone(),
"--profiles".into(),
dir.display().to_string(),
"--libc".into(),
libc.clone(),
]
};
let refuse = |verb: &str, o: &ToolOutput| -> anyhow::Error {
anyhow!(
"program `{}` ({libc}): `{TOOL} profile {verb}` exited {}:\n{}{}",
p.name,
o.code,
o.stdout,
o.stderr
)
};
if v.check {
let o = tool.call(&args("check"), &env)?;
r.checked.push((p.name.clone(), libc.clone()));
match o.code {
0 => {}
TOOL_REJECTED => {
let (d, oos) = parse_check_stderr(&p.name, libc, &o.stderr);
if d.is_empty() && oos.is_empty() {
return Err(refuse("check", &o));
}
r.drift.extend(d);
// A row without the `.rs` table: its absence is not drift.
let rs_absent =
|f: &str| !m.rust_table && f.ends_with(".rs") && !Path::new(f).exists();
r.out_of_sync
.extend(oos.into_iter().filter(|(_, f)| !rs_absent(f)));
}
_ => return Err(refuse("check", &o)),
}
continue;
}
let verbs: &[&str] = if v.gen_only { &["gen"] } else { &["probe", "gen"] };
for &verb in verbs {
let o = tool.call(&args(verb), &env)?;
if o.code != 0 {
return Err(refuse(verb, &o));
}
}
if !m.rust_table {
let rs = base.with_extension("rs");
if rs.exists() {
std::fs::remove_file(&rs)
.with_context(|| format!("remove {}", rs.display()))?;
}
}
let probe: ProbeJson = read_json(&base.with_extension("probe.json"))?;
let prof: ProfileJson = read_json(&base.with_extension("json"))?;
if probe.libc != *libc {
bail!(
"program `{}`: the probe says libc {}, the row says {libc}",
p.name,
probe.libc
);
}
let mut syscalls: Vec<String> = probe.calls.iter().map(|c| c.syscall.clone()).collect();
syscalls.sort();
syscalls.dedup();
r.programs.push(Measured {
program: p.name.clone(),
libc: libc.clone(),
arch: probe.arch,
runs: probe.runs.len(),
calls: syscalls.len(),
unknown: count_unknown(&probe.unknown),
rules: prof.rules.len(),
near_miss: prof.review.near_miss.len(),
bare: prof.review.bare.len(),
wx: prof.review.wx.len(),
syscalls,
run_names: probe.runs.iter().map(run_name).collect(),
near_miss_calls: prof.review.near_miss.iter().map(review_entry).collect(),
bare_calls: prof.review.bare.iter().map(review_entry).collect(),
profile: format!("{shown_dir}/{}/{arch}-{libc}", p.name),
});
}
}
if !v.check {
let ev = Evidence {
row: &m.row,
tool: &id,
workloads: &workloads,
programs: &r.programs,
vm_later: r
.vm_later
.iter()
.map(|(p, w)| VmLater { program: p, why: w })
.collect(),
};
let path = dir.join(EVIDENCE);
let mut text = serde_json::to_string_pretty(&ev).context("serialize the evidence")?;
text.push('\n');
std::fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
}
Ok(r)
}
mod approval;
pub use approval::{
APPROVAL, ApprovalEntry, ApprovalFile, ApprovalState, ApprovalStatus, FileApproval, approval_status,
approve,
};
mod gate;
pub use gate::{
GATE_SCHEMA, GATE_USAGE, GateChecked, GateOutOfSync, GateReport, GateRow, GateVerb,
GateVmLater, parse_gate_verb, rows_with_profiles, run_gate,
};
#[cfg(test)]
mod tests;