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/gate.rs

//! **The drift gate** (Heimdall tooling plan step 6, owner decision
//! 2026-10-31): `measure gate [--row R]… [--target D] [--json FILE]`.
//!
//! The forge runs it on every new head of a repository's main: it checks
//! EVERY row that has `.nornir/profiles/<row>/measure.toml` (or only the
//! named ones) with the same `heimdall profile check` the measure step's
//! `--check` runs, and fails when any row drifts — a call the checked-in
//! profile does not allow, named by row, program, call and test; a generated
//! file that no longer matches its `.json`; or a row that cannot be checked
//! at all (never measured, binary not built, tool missing or not the pinned
//! one). A refused row never hides the others: each row is checked and
//! reported on its own.
//!
//! It only CHECKS: it never probes into, generates or rewrites a profile.
//! A profile changes only by `measure syscalls <row>` and a reviewed commit.
//!
//! The JSON (`--json`, schema [`GATE_SCHEMA`]) is what the forge keeps and
//! the review UI reads. The licence boundary is the measure step's: the
//! `heimdall` binary is an external process, never linked.

use std::path::{Path, PathBuf};

use anyhow::{Result, anyhow, bail};
use serde::Serialize;

use super::{
    MANIFEST, MeasureVerb, PROFILES_DIR, ProfileTool, ToolIdentity, ToolPin, run_measure_verb,
};
use super::{Drift, MeasureManifest};

/// The gate report's schema name (bumped on an incompatible change).
pub const GATE_SCHEMA: &str = "nornir.measure.gate/v1";

/// The gate's usage line.
pub const GATE_USAGE: &str =
    "usage: measure gate [--row <row>]… [--build] [--target <dir>] [--json <file>]";

/// `measure gate …`, parsed.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GateVerb {
    /// Only these rows (empty: every row with a manifest).
    pub rows: Vec<String>,
    /// `{target}` (default `$CARGO_TARGET_DIR`, else `<repo>/target`).
    pub target: Option<PathBuf>,
    /// Also write the report as JSON here.
    pub json: Option<PathBuf>,
    /// Build each row's programs first (its `[[build]]` cargo lines).
    pub build: bool,
}

/// Parse the words after `measure` (the first is `gate`).
pub fn parse_gate_verb(args: &[String]) -> Result<GateVerb> {
    if args.first().map(String::as_str) != Some("gate") {
        bail!("{GATE_USAGE}");
    }
    let mut v = GateVerb::default();
    let mut i = 1;
    while i < args.len() {
        let val = |i: usize| {
            args.get(i + 1)
                .cloned()
                .ok_or_else(|| anyhow!("{} needs a value — {GATE_USAGE}", args[i]))
        };
        if args[i] == "--build" {
            v.build = true;
            i += 1;
            continue;
        }
        match args[i].as_str() {
            "--row" => v.rows.push(val(i)?),
            "--target" => v.target = Some(PathBuf::from(val(i)?)),
            "--json" => v.json = Some(PathBuf::from(val(i)?)),
            other => bail!("measure gate: unknown argument `{other}` — {GATE_USAGE}"),
        }
        i += 2;
    }
    Ok(v)
}

/// Every row with `.nornir/profiles/<row>/measure.toml`, sorted.
pub fn rows_with_profiles(repo_root: &Path) -> Result<Vec<String>> {
    let dir = repo_root.join(PROFILES_DIR);
    let mut rows = Vec::new();
    let entries = match std::fs::read_dir(&dir) {
        Ok(e) => e,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(rows),
        Err(e) => return Err(anyhow!("read {}: {e}", dir.display())),
    };
    for e in entries {
        let e = e.map_err(|e| anyhow!("read {}: {e}", dir.display()))?;
        if e.path().join(MANIFEST).is_file() {
            if let Some(n) = e.file_name().to_str() {
                rows.push(n.to_string());
            }
        }
    }
    rows.sort();
    Ok(rows)
}

/// One checked (program, libc).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateChecked {
    pub program: String,
    pub libc: String,
}

/// A generated file that no longer matches its source.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateOutOfSync {
    pub program: String,
    pub file: String,
}

/// A program the gate cannot cover off a VM (listed, never a failure).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateVmLater {
    pub program: String,
    pub why: String,
}

/// One row's verdict.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateRow {
    pub row: String,
    pub ok: bool,
    /// The row could not be checked: the reason, in the step's words.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub refused: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool: Option<ToolIdentity>,
    pub checked: Vec<GateChecked>,
    pub drift: Vec<Drift>,
    pub out_of_sync: Vec<GateOutOfSync>,
    pub vm_later: Vec<GateVmLater>,
    /// The row has an approval file (`dwarves tune approve`): every reviewed file is held to it.
    pub reviewed: bool,
    /// Reviewed files that are not what was approved (one line each).
    pub not_approved: Vec<String>,
}

impl GateRow {
    fn refused(row: &str, why: String) -> GateRow {
        GateRow {
            row: row.into(),
            ok: false,
            refused: Some(why),
            tool: None,
            checked: Vec::new(),
            drift: Vec::new(),
            out_of_sync: Vec::new(),
            vm_later: Vec::new(),
            reviewed: false,
            not_approved: Vec::new(),
        }
    }
}

/// The gate's verdict over every row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GateReport {
    pub schema: &'static str,
    pub ok: bool,
    /// The gate as a whole could not run (no row has a manifest).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub refused: Option<String>,
    pub rows: Vec<GateRow>,
}

impl GateReport {
    /// The report as pretty JSON (schema [`GATE_SCHEMA`]), newline-terminated.
    pub fn to_json(&self) -> String {
        let mut s = serde_json::to_string_pretty(self).expect("the gate report serializes");
        s.push('\n');
        s
    }

    /// The operator's text: one line per finding, then the verdict.
    pub fn render(&self) -> String {
        let mut s = String::from("# measure gate — syscall profile drift\n");
        if let Some(why) = &self.refused {
            s.push_str(&format!("REFUSED: {why}\n"));
        }
        for r in &self.rows {
            if let Some(why) = &r.refused {
                s.push_str(&format!("{}: REFUSED — {why}\n", r.row));
                continue;
            }
            for c in &r.checked {
                let bad = r.drift.iter().any(|d| d.program == c.program && d.libc == c.libc)
                    || r.out_of_sync.iter().any(|o| o.program == c.program);
                s.push_str(&format!(
                    "{}/{} ({}): {}\n",
                    r.row,
                    c.program,
                    c.libc,
                    if bad { "DRIFT" } else { "OK" }
                ));
            }
            for d in &r.drift {
                s.push_str(&format!(
                    "DRIFT {}/{} ({}): new call {} ({}), made by test {}\n",
                    r.row,
                    d.program,
                    d.libc,
                    d.call,
                    d.action,
                    d.tests.join(", ")
                ));
            }
            for o in &r.out_of_sync {
                s.push_str(&format!(
                    "DRIFT {}/{}: {} does not match its source (stale or hand-edited)\n",
                    r.row, o.program, o.file
                ));
            }
            if !r.reviewed {
                s.push_str(&format!("{}: not reviewed yet (dwarves tune approve {})\n", r.row, r.row));
            }
            for n in &r.not_approved {
                s.push_str(&format!("{}: {n}\n", r.row));
            }
            for v in &r.vm_later {
                s.push_str(&format!("{}/{}: not covered off a VM: {}\n", r.row, v.program, v.why));
            }
        }
        if self.ok {
            s.push_str(&format!("GATE OK: {} row(s), no drift\n", self.rows.len()));
        } else {
            let bad: Vec<&str> = self.rows.iter().filter(|r| !r.ok).map(|r| r.row.as_str()).collect();
            s.push_str(&format!(
                "GATE FAILED: {}\nfix: measure syscalls <row> (probe + gen), review the profile diff, commit it; never widen a profile by hand\n",
                if bad.is_empty() { "no row to check".to_string() } else { bad.join(", ") }
            ));
        }
        s
    }
}

/// **Run the gate**: check every row (or the named ones). `tool_for` finds
/// the external tool for a row's pin (the real caller:
/// [`super::ExternalTool::locate`]); the pin itself is verified per row by
/// the measure step.
///
/// With `--build`, each row's `[[build]]` cargo lines run first through
/// `build(repo_root, argv, target)` (the real caller runs `cargo <argv>` with
/// `CARGO_TARGET_DIR = target`); a failed build refuses that row only.
pub fn run_gate<T, F, B>(v: &GateVerb, repo_root: &Path, tool_for: &F, build: &B) -> GateReport
where
    T: ProfileTool,
    F: Fn(&ToolPin) -> Result<T>,
    B: Fn(&Path, &[String], &Path) -> Result<()>,
{
    let rows = if v.rows.is_empty() {
        match rows_with_profiles(repo_root) {
            Ok(r) => r,
            Err(e) => return gate_refused(format!("{e:#}")),
        }
    } else {
        v.rows.clone()
    };
    if rows.is_empty() {
        return gate_refused(format!(
            "no row has {PROFILES_DIR}/<row>/{MANIFEST} under {} — measure a row first (measure syscalls <row>)",
            repo_root.display()
        ));
    }
    let mut out = Vec::new();
    for row in &rows {
        out.push(gate_row(row, v, repo_root, tool_for, build));
    }
    let ok = out.iter().all(|r| r.ok);
    GateReport {
        schema: GATE_SCHEMA,
        ok,
        refused: None,
        rows: out,
    }
}

fn gate_refused(why: String) -> GateReport {
    GateReport {
        schema: GATE_SCHEMA,
        ok: false,
        refused: Some(why),
        rows: Vec::new(),
    }
}

fn gate_row<T, F, B>(row: &str, v: &GateVerb, repo_root: &Path, tool_for: &F, build: &B) -> GateRow
where
    T: ProfileTool,
    F: Fn(&ToolPin) -> Result<T>,
    B: Fn(&Path, &[String], &Path) -> Result<()>,
{
    let m = match MeasureManifest::load(repo_root, row) {
        Ok(m) => m,
        Err(e) => return GateRow::refused(row, format!("{e:#}")),
    };
    if v.build {
        let target = v
            .target
            .clone()
            .or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
            .unwrap_or_else(|| repo_root.join("target"));
        for b in &m.build {
            if let Err(e) = build(repo_root, &b.cargo, &target) {
                return GateRow::refused(row, format!("cargo {}: {e:#}", b.cargo.join(" ")));
            }
        }
    }
    let tool = match tool_for(&m.tool) {
        Ok(t) => t,
        Err(e) => return GateRow::refused(row, format!("{e:#}")),
    };
    let verb = MeasureVerb {
        row: row.into(),
        check: true,
        target: v.target.clone(),
        ..Default::default()
    };
    let approval = match super::approval_status(repo_root, row) {
        Ok(a) => a,
        Err(e) => return GateRow::refused(row, format!("{e:#}")),
    };
    let not_approved = if approval.exists { approval.problems() } else { Vec::new() };
    match run_measure_verb(&verb, repo_root, &tool) {
        Err(e) => GateRow::refused(row, format!("{e:#}")),
        Ok(r) => GateRow {
            row: row.into(),
            ok: r.ok() && not_approved.is_empty(),
            reviewed: approval.exists,
            not_approved,
            refused: None,
            tool: r.tool.clone(),
            checked: r
                .checked
                .iter()
                .map(|(p, l)| GateChecked {
                    program: p.clone(),
                    libc: l.clone(),
                })
                .collect(),
            drift: r.drift.clone(),
            out_of_sync: r
                .out_of_sync
                .iter()
                .map(|(p, f)| GateOutOfSync {
                    program: p.clone(),
                    file: f.clone(),
                })
                .collect(),
            vm_later: r
                .vm_later
                .iter()
                .map(|(p, w)| GateVmLater {
                    program: p.clone(),
                    why: w.clone(),
                })
                .collect(),
        },
    }
}