Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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;