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

//! **The join: measured system calls ↔ kernel options** (Heimdall tooling
//! plan step 3, owner decision 2026-10-31).
//!
//! The measure step ([`crate::measure`]) records which system calls every
//! program of a row makes during the row's runs; the static trim
//! ([`crate::kernel_trim`]) knows what leaves the `.config` with a dropped
//! symbol. This module joins the two through the composed kernel's OWN
//! source tree:
//!
//! * [`SyscallMap`] — for every x86_64 system call (the table
//!   `arch/x86/entry/syscalls/syscall_64.tbl`, `common` + `64` rows): the
//!   file that defines it (`SYSCALL_DEFINEn` / `COMPAT_SYSCALL_DEFINEn`), the
//!   `CONFIG_*` gates of that object (the kernel Makefiles' `obj-$(CONFIG_X)`
//!   lines read by [`crate::kernel_trim::makefile_gates`], ancestor
//!   directories and composite parents included, plus the `#ifdef CONFIG_X`
//!   blocks around the definition), and whether `kernel/sys_ni.c` carries a
//!   `COND_SYSCALL` stub (a gated call then answers `ENOSYS` when its option
//!   is off). Read off the tree, never hand-written; cached per kernel
//!   version under the trim's work dir.
//! * [`MeasuredRow`] — the row's measured calls, read as DATA from the
//!   profiles the external `heimdall` tool wrote
//!   (`.nornir/profiles/<row>/measure.json` + each program's
//!   `<arch>-<libc>.probe.json`). A call seen only in error-path runs is a
//!   near-miss. This crate never links heimdall.
//! * [`needs`] — `kernel needs <row>`: the options the row's programs need,
//!   by program and call, and the inverse hint: syscall-gating options the
//!   row enables that no measured call needs ("no measured caller"; a trim
//!   hint, never a veto).
//! * [`vetoes_for`] — the veto check `kernel what-if` / `candidates` run: a
//!   drop that takes away the kernel code of a measured call is REFUSED by
//!   name (`CONFIG_BPF_SYSCALL: tunnr-cni calls bpf (test …)`), unless the
//!   call is a near-miss — then it warns.
//!
//! # Honest limits
//!
//! Makefile `ifdef`/`ifeq` blocks are not read; an object listed under two
//! different gates counts only the gates common to both; `#if` expressions
//! with `||` or `#elif` branches give no gate; code paths INSIDE a call
//! (a `bpf` command that needs `CONFIG_CGROUP_BPF`) are not split out — only
//! "is the call there at all". Programs that only run inside the booted
//! appliance are not measured yet and are named (`vm_later`).

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

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

use crate::kernel_trim::{bare, full, makefile_gates};

/// The cached map's schema.
pub const SYSCALL_MAP_SCHEMA: &str = "nornir.syscall-map/1";
/// `kernel needs --json`.
pub const KERNEL_NEEDS_SCHEMA: &str = "nornir.kernel-needs/1";
/// `kernel what-if --json`.
pub const KERNEL_WHAT_IF_SCHEMA: &str = "nornir.kernel-what-if/1";
/// `kernel candidates --json`.
pub const KERNEL_CANDIDATES_SCHEMA: &str = "nornir.kernel-candidates/1";

// ── the map ─────────────────────────────────────────────────────────────────

/// One definition of a system call in the tree.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyscallDef {
    /// The `.c` file, relative to the source root.
    pub file: String,
    /// `COMPAT_SYSCALL_DEFINE` (the 32-bit/x32 entry; not an x86_64 call).
    pub compat: bool,
    /// `CONFIG_*` gates of the object (Makefiles), sorted.
    pub gates: Vec<String>,
    /// `CONFIG_*` gates of the `#ifdef` blocks around the definition, sorted.
    pub ifdef: Vec<String>,
}

impl SyscallDef {
    /// Every gate: Makefile + `#ifdef`.
    pub fn all_gates(&self) -> BTreeSet<String> {
        self.gates.iter().chain(&self.ifdef).cloned().collect()
    }
}

/// One x86_64 system call.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Syscall {
    /// The table's name (`stat`).
    pub name: String,
    /// The number.
    pub nr: u32,
    /// `common` or `64`.
    pub abi: String,
    /// The table's entry point (`sys_newstat`).
    pub entry: String,
    /// Every definition of the entry point found in the tree.
    pub defs: Vec<SyscallDef>,
    /// `COND_SYSCALL(<entry>)` in `kernel/sys_ni.c`: ENOSYS when compiled out.
    pub stub: bool,
}

impl Syscall {
    fn native(&self) -> impl Iterator<Item = &SyscallDef> {
        self.defs.iter().filter(|d| !d.compat)
    }

    /// The options the call needs: the gates common to every native
    /// definition (any one definition that builds gives the call). Empty =
    /// always built (or no definition found).
    pub fn needs(&self) -> Vec<String> {
        let mut it = self.native();
        let Some(first) = it.next() else { return Vec::new() };
        let mut common = first.all_gates();
        for d in it {
            let g = d.all_gates();
            common.retain(|x| g.contains(x));
        }
        common.into_iter().collect()
    }

    /// The options in `gone` that take the call away (every native
    /// definition loses a gate); `None` = the call stays (or is unknown).
    pub fn killers(&self, gone: &BTreeSet<String>) -> Option<Vec<String>> {
        let mut any = false;
        let mut out = BTreeSet::new();
        for d in self.native() {
            any = true;
            let k: Vec<String> = d.all_gates().into_iter().filter(|g| gone.contains(g)).collect();
            if k.is_empty() {
                return None;
            }
            out.extend(k);
        }
        if any { Some(out.into_iter().collect()) } else { None }
    }
}

/// **Every x86_64 system call of one kernel tree → its definitions and
/// gates.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SyscallMap {
    /// [`SYSCALL_MAP_SCHEMA`].
    pub schema: String,
    /// The kernel version the tree is.
    pub version: String,
    /// `x86_64`.
    pub arch: String,
    /// By table name.
    pub syscalls: BTreeMap<String, Syscall>,
}

/// The x86_64 table path.
pub const TABLE_X86_64: &str = "arch/x86/entry/syscalls/syscall_64.tbl";

impl SyscallMap {
    /// **Read a kernel source tree.** Only x86_64 is wired.
    pub fn read(src: &Path, arch: &str, version: &str) -> Result<Self> {
        if arch != "x86_64" {
            bail!("the syscall map is wired for x86_64 only, not {arch}");
        }
        let table_path = src.join(TABLE_X86_64);
        let table = std::fs::read_to_string(&table_path).with_context(|| format!("read {}", table_path.display()))?;
        let sys_ni = std::fs::read_to_string(src.join("kernel/sys_ni.c")).unwrap_or_default();
        let mut makefiles = Vec::new();
        let mut sources = Vec::new();
        walk(src, src, "x86", &mut makefiles, &mut sources)?;
        Ok(Self::from_texts(arch, version, &table, &sys_ni, &makefiles, &sources))
    }

    /// The testable core of [`Self::read`]: the table, `sys_ni.c`, the
    /// Makefiles (`(dir, text)`, dir `""` or `kernel/bpf/`) and the `.c`
    /// files that mention `SYSCALL_DEFINE` (`(path, text)`).
    pub fn from_texts(arch: &str, version: &str, table: &str, sys_ni: &str, makefiles: &[(String, String)], sources: &[(String, String)]) -> Self {
        let mk = MakeGates::of(makefiles);
        let stubs: BTreeSet<String> = cond_syscalls(sys_ni).into_iter().collect();
        // entry name (without `sys_`) → definitions.
        let mut defs: BTreeMap<String, Vec<SyscallDef>> = BTreeMap::new();
        for (path, text) in sources {
            let obj = format!("{}.o", path.strip_suffix(".c").unwrap_or(path));
            let gates: Vec<String> = mk.gates_of(&obj).into_iter().map(|g| full(&g)).collect();
            for (name, compat, ifdef) in syscall_defines(text) {
                defs.entry(name).or_default().push(SyscallDef { file: path.clone(), compat, gates: gates.clone(), ifdef });
            }
        }
        let mut syscalls = BTreeMap::new();
        for (nr, abi, name, entry) in table_entries(table) {
            let def_name = entry.strip_prefix("__x64_sys_").or_else(|| entry.strip_prefix("sys_")).unwrap_or(&entry).to_string();
            let d = defs.get(&def_name).cloned().unwrap_or_default();
            syscalls.insert(name.clone(), Syscall { name, nr, abi, stub: stubs.contains(&def_name), entry, defs: d });
        }
        SyscallMap { schema: SYSCALL_MAP_SCHEMA.into(), version: version.into(), arch: arch.into(), syscalls }
    }

    /// [`Self::read`], cached as `<cache>/<version>-<arch>.json`.
    pub fn cached(src: &Path, cache: &Path, arch: &str, version: &str) -> Result<Self> {
        let file = cache.join(format!("{version}-{arch}.json"));
        if let Ok(t) = std::fs::read_to_string(&file) {
            if let Ok(m) = serde_json::from_str::<SyscallMap>(&t) {
                if m.schema == SYSCALL_MAP_SCHEMA && m.version == version && m.arch == arch {
                    return Ok(m);
                }
            }
        }
        let m = Self::read(src, arch, version)?;
        std::fs::create_dir_all(cache).with_context(|| format!("create {}", cache.display()))?;
        std::fs::write(&file, serde_json::to_string_pretty(&m)?).with_context(|| format!("write {}", file.display()))?;
        Ok(m)
    }

    /// Option → the x86_64 calls it ALONE takes away (it is a need of the
    /// call), sorted.
    pub fn gating_options(&self) -> BTreeMap<String, Vec<String>> {
        let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
        for (name, s) in &self.syscalls {
            for o in s.needs() {
                out.entry(o).or_default().push(name.clone());
            }
        }
        out
    }
}

fn walk(root: &Path, dir: &Path, arch_dir: &str, makefiles: &mut Vec<(String, String)>, sources: &mut Vec<(String, String)>) -> Result<()> {
    let Ok(rd) = std::fs::read_dir(dir) else { return Ok(()) };
    let rel_dir = dir.strip_prefix(root).unwrap_or(Path::new("")).to_string_lossy().to_string();
    let mut subdirs = Vec::new();
    for e in rd.flatten() {
        let Ok(ft) = e.file_type() else { continue };
        let name = e.file_name().to_string_lossy().to_string();
        if ft.is_dir() {
            let skip = name.starts_with('.')
                || (rel_dir.is_empty() && matches!(name.as_str(), "Documentation" | "tools" | "samples" | "scripts" | "usr" | "LICENSES"))
                || (rel_dir == "arch" && name != arch_dir);
            if !skip {
                subdirs.push(e.path());
            }
        } else if ft.is_file() {
            let rel = if rel_dir.is_empty() { name.clone() } else { format!("{rel_dir}/{name}") };
            if name == "Makefile" || name == "Kbuild" {
                if let Ok(t) = std::fs::read_to_string(e.path()) {
                    let d = if rel_dir.is_empty() { String::new() } else { format!("{rel_dir}/") };
                    makefiles.push((d, t));
                }
            } else if name.ends_with(".c") {
                if let Ok(b) = std::fs::read(e.path()) {
                    if b.windows(14).any(|w| w == b"SYSCALL_DEFINE") {
                        sources.push((rel, String::from_utf8_lossy(&b).into_owned()));
                    }
                }
            }
        }
    }
    subdirs.sort();
    for s in subdirs {
        walk(root, &s, arch_dir, makefiles, sources)?;
    }
    Ok(())
}

/// The Makefile side: what gates each object and directory (bare names).
#[derive(Debug, Default)]
struct MakeGates {
    /// Object or `dir/` → one entry per line listing it (`None` = `obj-y`).
    direct: BTreeMap<String, Vec<Option<String>>>,
    /// Composite part → `(parent object, gate of the part line)`.
    parts: BTreeMap<String, Vec<(String, Option<String>)>>,
}

impl MakeGates {
    fn of(makefiles: &[(String, String)]) -> Self {
        let mut m = MakeGates::default();
        for (dir, text) in makefiles {
            let gated = makefile_gates(text).into_iter().map(|(p, s, i)| (p, Some(s), i));
            let plain = makefile_plain(text).into_iter().map(|(p, i)| (p, None, i));
            for (prefix, sym, items) in gated.chain(plain) {
                for item in items {
                    let path = format!("{dir}{item}");
                    if prefix == "obj" {
                        m.direct.entry(path).or_default().push(sym.clone());
                    } else if item.ends_with(".o") {
                        m.parts.entry(path).or_default().push((format!("{dir}{prefix}.o"), sym.clone()));
                    }
                }
            }
        }
        m
    }

    /// The gates common to every line listing `key` (`None` = never listed).
    fn line_gates(lines: &[Option<String>]) -> BTreeSet<String> {
        if lines.iter().any(Option::is_none) {
            return BTreeSet::new();
        }
        let mut it = lines.iter().flatten();
        let Some(first) = it.next() else { return BTreeSet::new() };
        if it.all(|s| s == first) { [first.clone()].into() } else { BTreeSet::new() }
    }

    fn object_gates(&self, obj: &str, depth: usize) -> BTreeSet<String> {
        if let Some(lines) = self.direct.get(obj) {
            return Self::line_gates(lines);
        }
        let Some(parents) = self.parts.get(obj) else { return BTreeSet::new() };
        if depth > 4 {
            return BTreeSet::new();
        }
        // Each parent is another way in: only what every way needs counts.
        let mut common: Option<BTreeSet<String>> = None;
        for (parent, sym) in parents {
            let mut g = self.object_gates(parent, depth + 1);
            g.extend(sym.iter().cloned());
            common = Some(match common {
                None => g,
                Some(c) => c.intersection(&g).cloned().collect(),
            });
        }
        common.unwrap_or_default()
    }

    /// Every gate of an object: its own line(s) (or its composite parents')
    /// plus every ancestor directory's.
    fn gates_of(&self, obj: &str) -> BTreeSet<String> {
        let mut out = self.object_gates(obj, 0);
        let mut idx = 0;
        while let Some(p) = obj[idx..].find('/') {
            let dir = &obj[..idx + p + 1];
            if let Some(lines) = self.direct.get(dir) {
                out.extend(Self::line_gates(lines));
            }
            idx += p + 1;
        }
        out
    }
}

/// The ungated `<prefix>-y` / `<prefix>-objs` lines of one Makefile
/// (continuations joined): `(prefix, items)`; the gated ones are
/// [`makefile_gates`]'s.
pub fn makefile_plain(text: &str) -> Vec<(String, Vec<String>)> {
    let mut out = Vec::new();
    let mut cur = String::new();
    let mut logical = Vec::new();
    for line in text.lines() {
        let l = line.split('#').next().unwrap_or("");
        if let Some(st) = l.trim_end().strip_suffix('\\') {
            cur.push_str(st);
            cur.push(' ');
            continue;
        }
        cur.push_str(l);
        logical.push(std::mem::take(&mut cur));
    }
    logical.push(cur);
    for l in logical {
        let l = l.trim();
        let Some(op) = l.find(['+', ':', '=']) else { continue };
        let lhs = l[..op].trim();
        let prefix = if let Some(p) = lhs.strip_suffix("-y") {
            p
        } else if let Some(p) = lhs.strip_suffix("-objs") {
            p
        } else {
            continue;
        };
        if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
            continue;
        }
        let rhs = l[op..].trim_start_matches(['+', ':', '=']);
        let items: Vec<String> = rhs.split_whitespace().filter(|i| !i.contains('$') && (i.ends_with(".o") || i.ends_with('/'))).map(str::to_string).collect();
        if !items.is_empty() {
            out.push((prefix.to_string(), items));
        }
    }
    out
}

/// `COND_SYSCALL(name);` lines (native stubs; `COND_SYSCALL_COMPAT` is not
/// an x86_64 call).
pub fn cond_syscalls(text: &str) -> Vec<String> {
    text.lines()
        .filter_map(|l| l.trim().strip_prefix("COND_SYSCALL("))
        .filter_map(|r| r.split(')').next())
        .map(|n| n.trim().to_string())
        .filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'))
        .collect()
}

/// The x86_64 table: `(nr, abi, name, entry)` for every `common`/`64` row
/// with an entry point.
pub fn table_entries(text: &str) -> Vec<(u32, String, String, String)> {
    let mut out = Vec::new();
    for l in text.lines() {
        let l = l.trim();
        if l.is_empty() || l.starts_with('#') {
            continue;
        }
        let f: Vec<&str> = l.split_whitespace().collect();
        if f.len() < 4 || !(f[1] == "common" || f[1] == "64") {
            continue;
        }
        let Ok(nr) = f[0].parse() else { continue };
        out.push((nr, f[1].to_string(), f[2].to_string(), f[3].to_string()));
    }
    out
}

#[derive(Debug, Default)]
struct Frame {
    pos: Vec<String>,
    neg: Vec<String>,
    in_else: bool,
    elif: bool,
}

/// `(positive CONFIG_ terms, a single negative term)` of an `#if` condition;
/// anything with `||` gives nothing.
fn condition(expr: &str) -> (Vec<String>, Vec<String>) {
    let expr = expr.split("/*").next().unwrap_or("").split("//").next().unwrap_or("").trim();
    if expr.contains("||") {
        return (Vec::new(), Vec::new());
    }
    let mut pos = Vec::new();
    let mut neg = Vec::new();
    let terms: Vec<&str> = expr.split("&&").collect();
    for t in &terms {
        let mut t = t.trim();
        while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
            t = inner.trim();
        }
        let (negated, t) = match t.strip_prefix('!') {
            Some(r) => (true, r.trim()),
            None => (false, t),
        };
        let sym = ["defined(", "IS_ENABLED(", "IS_BUILTIN(", "defined "]
            .iter()
            .find_map(|p| t.strip_prefix(p))
            .map(|r| r.trim_end_matches(')').trim())
            .unwrap_or(t);
        if sym.starts_with("CONFIG_") && sym.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
            if negated {
                neg.push(sym.to_string());
            } else {
                pos.push(sym.to_string());
            }
        }
    }
    // The else branch of `!A` is `A`; of anything else, unknown.
    let neg = if terms.len() == 1 { neg } else { Vec::new() };
    (pos, neg)
}

/// **The `SYSCALL_DEFINEn` / `COMPAT_SYSCALL_DEFINEn` of one `.c` file**:
/// `(name, compat, #ifdef CONFIG_ gates around it)`.
pub fn syscall_defines(text: &str) -> Vec<(String, bool, Vec<String>)> {
    let mut stack: Vec<Frame> = Vec::new();
    let mut out = Vec::new();
    for line in text.lines() {
        let t = line.trim_start();
        if let Some(d) = t.strip_prefix('#') {
            let d = d.trim_start();
            let (word, rest) = d.split_once(|c: char| c.is_whitespace() || c == '(').map(|(w, _)| (w, &d[w.len()..])).unwrap_or((d, ""));
            match word {
                "ifdef" => {
                    let s = rest.split_whitespace().next().unwrap_or("");
                    stack.push(Frame { pos: if s.starts_with("CONFIG_") { vec![s.to_string()] } else { vec![] }, ..Default::default() });
                }
                "ifndef" => {
                    let s = rest.split_whitespace().next().unwrap_or("");
                    stack.push(Frame { neg: if s.starts_with("CONFIG_") { vec![s.to_string()] } else { vec![] }, ..Default::default() });
                }
                "if" => {
                    let (pos, neg) = condition(rest);
                    stack.push(Frame { pos, neg, ..Default::default() });
                }
                "elif" => {
                    if let Some(f) = stack.last_mut() {
                        f.elif = true;
                    }
                }
                "else" => {
                    if let Some(f) = stack.last_mut() {
                        f.in_else = true;
                    }
                }
                "endif" => {
                    stack.pop();
                }
                _ => {}
            }
            continue;
        }
        let (compat, r) = match t.strip_prefix("COMPAT_SYSCALL_DEFINE") {
            Some(r) => (true, r),
            None => match t.strip_prefix("SYSCALL_DEFINE") {
                Some(r) => (false, r),
                None => continue,
            },
        };
        let digits = r.bytes().take_while(u8::is_ascii_digit).count();
        if digits == 0 || !r[digits..].starts_with('(') {
            continue;
        }
        let name: String = r[digits + 1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
        if name.is_empty() {
            continue;
        }
        let mut gates = BTreeSet::new();
        for f in &stack {
            let g = if f.elif {
                &[][..]
            } else if f.in_else {
                &f.neg[..]
            } else {
                &f.pos[..]
            };
            gates.extend(g.iter().cloned());
        }
        out.push((name, compat, gates.into_iter().collect()));
    }
    out
}

// ── the measured side ───────────────────────────────────────────────────────

/// One measured call of one program.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MeasuredCall {
    /// The syscall name (`bpf`).
    pub syscall: String,
    /// The runs (tests) that made it, sorted.
    pub runs: Vec<String>,
    /// Made only by error-path runs.
    pub near_miss: bool,
}

/// One program × libc of a row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MeasuredProgram {
    pub program: String,
    pub libc: String,
    pub arch: String,
    pub calls: Vec<MeasuredCall>,
}

/// **A row's measured calls**, read from the measure step's files.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MeasuredRow {
    pub row: String,
    pub programs: Vec<MeasuredProgram>,
    /// Programs that wait for the VM leg (not measured).
    pub vm_later: Vec<String>,
}

#[derive(Deserialize)]
struct EvidenceIn {
    #[serde(default)]
    programs: Vec<EvidenceProgram>,
    #[serde(default)]
    vm_later: Vec<VmLaterIn>,
}

#[derive(Deserialize)]
struct EvidenceProgram {
    program: String,
    #[serde(default)]
    libc: String,
    #[serde(default)]
    arch: String,
    #[serde(default)]
    syscalls: Vec<String>,
    #[serde(default)]
    profile: String,
}

#[derive(Deserialize)]
struct VmLaterIn {
    program: String,
}

#[derive(Deserialize)]
struct ProbeIn {
    #[serde(default)]
    runs: Vec<ProbeRun>,
    #[serde(default)]
    calls: Vec<ProbeCallIn>,
}

#[derive(Deserialize)]
struct ProbeRun {
    name: String,
    #[serde(default)]
    error_path: bool,
}

#[derive(Deserialize)]
struct ProbeCallIn {
    syscall: String,
    #[serde(default)]
    runs: Vec<String>,
}

impl MeasuredRow {
    /// Load `<repo>/.nornir/profiles/<row>/measure.json` and each program's
    /// `.probe.json`; `None` when the row has no evidence file.
    pub fn load(repo_root: &Path, row: &str) -> Result<Option<Self>> {
        let ev = crate::measure::row_dir(repo_root, row).join(crate::measure::EVIDENCE);
        let Ok(text) = std::fs::read_to_string(&ev) else { return Ok(None) };
        let e: EvidenceIn = serde_json::from_str(&text).with_context(|| format!("{}: not the measure step's evidence", ev.display()))?;
        let mut programs = Vec::new();
        for p in e.programs {
            let probe_path = PathBuf::from(format!("{}.probe.json", repo_root.join(&p.profile).display()));
            let calls = match std::fs::read_to_string(&probe_path) {
                Ok(t) => {
                    let probe: ProbeIn = serde_json::from_str(&t).with_context(|| format!("{}: not the tool's probe JSON", probe_path.display()))?;
                    let error: BTreeSet<&str> = probe.runs.iter().filter(|r| r.error_path).map(|r| r.name.as_str()).collect();
                    let mut by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
                    for c in &probe.calls {
                        by.entry(c.syscall.clone()).or_default().extend(c.runs.iter().cloned());
                    }
                    by.into_iter()
                        .map(|(syscall, runs)| {
                            let near_miss = !runs.is_empty() && runs.iter().all(|r| error.contains(r.as_str()));
                            MeasuredCall { syscall, runs: runs.into_iter().collect(), near_miss }
                        })
                        .collect()
                }
                Err(_) => p.syscalls.iter().map(|s| MeasuredCall { syscall: s.clone(), runs: Vec::new(), near_miss: false }).collect(),
            };
            programs.push(MeasuredProgram { program: p.program, libc: p.libc, arch: p.arch, calls });
        }
        Ok(Some(MeasuredRow { row: row.to_string(), programs, vm_later: e.vm_later.into_iter().map(|v| v.program).collect() }))
    }
}

// ── the join ────────────────────────────────────────────────────────────────

fn runs_note(runs: &[String], near_miss: bool) -> String {
    if runs.is_empty() {
        String::new()
    } else if near_miss {
        format!(" (near-miss: {})", runs.join(", "))
    } else {
        format!(" (test {})", runs.join(", "))
    }
}

/// One program's call of an option.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Caller {
    pub program: String,
    pub libc: String,
    pub syscall: String,
    pub runs: Vec<String>,
    pub near_miss: bool,
}

/// One measured call → the options it needs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CallNeed {
    pub program: String,
    pub libc: String,
    pub syscall: String,
    /// `CONFIG_*`, sorted.
    pub options: Vec<String>,
    pub runs: Vec<String>,
    pub near_miss: bool,
}

/// One option the row's measured calls need.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OptionNeed {
    /// `CONFIG_X`.
    pub option: String,
    /// On (`y`/`m`) in the row's built `.config`.
    pub enabled: bool,
    pub callers: Vec<Caller>,
}

/// A syscall-gating option the row enables that no measured call needs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NoCaller {
    /// `CONFIG_X`.
    pub option: String,
    /// The calls it alone gates.
    pub syscalls: Vec<String>,
}

/// **`kernel needs <row>`.**
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelNeeds {
    /// [`KERNEL_NEEDS_SCHEMA`].
    pub schema: String,
    pub row: String,
    /// The kernel declaration's name (filled by the verb).
    pub kernel: String,
    pub version: String,
    pub arch: String,
    /// By program and call (gated calls only).
    pub calls: Vec<CallNeed>,
    /// By option.
    pub options: Vec<OptionNeed>,
    /// Measured calls with no option gate (always built), sorted.
    pub ungated: Vec<String>,
    /// `program call` the tree has no x86_64 definition for.
    pub unknown: Vec<String>,
    /// The inverse hint (never a veto).
    pub no_measured_caller: Vec<NoCaller>,
    /// Programs with runs (or all their runs) waiting for the VM leg.
    pub vm_later: Vec<String>,
    /// The programs the probe measured.
    pub measured_programs: Vec<String>,
}

fn enabled_set(config: &str) -> BTreeSet<String> {
    config
        .lines()
        .filter_map(|l| l.split_once('='))
        .filter(|(k, v)| k.starts_with("CONFIG_") && (*v == "y" || *v == "m"))
        .map(|(k, _)| k.to_string())
        .collect()
}

/// **The join, needs direction.**
pub fn needs(map: &SyscallMap, measured: &MeasuredRow, built_config: &str) -> KernelNeeds {
    let on = enabled_set(built_config);
    let mut calls = Vec::new();
    let mut by_opt: BTreeMap<String, Vec<Caller>> = BTreeMap::new();
    let mut ungated = BTreeSet::new();
    let mut unknown = Vec::new();
    for p in &measured.programs {
        for c in &p.calls {
            let Some(s) = map.syscalls.get(&c.syscall).filter(|s| s.defs.iter().any(|d| !d.compat)) else {
                unknown.push(format!("{} {}", p.program, c.syscall));
                continue;
            };
            let opts = s.needs();
            if opts.is_empty() {
                ungated.insert(c.syscall.clone());
                continue;
            }
            for o in &opts {
                by_opt.entry(o.clone()).or_default().push(Caller {
                    program: p.program.clone(),
                    libc: p.libc.clone(),
                    syscall: c.syscall.clone(),
                    runs: c.runs.clone(),
                    near_miss: c.near_miss,
                });
            }
            calls.push(CallNeed { program: p.program.clone(), libc: p.libc.clone(), syscall: c.syscall.clone(), options: opts, runs: c.runs.clone(), near_miss: c.near_miss });
        }
    }
    let no_measured_caller = map
        .gating_options()
        .into_iter()
        .filter(|(o, _)| on.contains(o) && !by_opt.contains_key(o))
        .map(|(option, syscalls)| NoCaller { option, syscalls })
        .collect();
    let options = by_opt.into_iter().map(|(option, callers)| OptionNeed { enabled: on.contains(&option), option, callers }).collect();
    KernelNeeds {
        schema: KERNEL_NEEDS_SCHEMA.into(),
        row: measured.row.clone(),
        kernel: String::new(),
        version: map.version.clone(),
        arch: map.arch.clone(),
        calls,
        options,
        ungated: ungated.into_iter().collect(),
        unknown,
        no_measured_caller,
        vm_later: measured.vm_later.clone(),
        measured_programs: measured.programs.iter().map(|p| p.program.clone()).collect(),
    }
}

impl KernelNeeds {
    /// The human text.
    pub fn render(&self) -> String {
        let programs: BTreeSet<&str> = self.calls.iter().map(|c| c.program.as_str()).collect();
        let mut s = format!(
            "# kernel needs {} (kernel {} {} {}): {} gated call(s) of {} program(s) need {} option(s)\n",
            self.row,
            self.kernel,
            self.version,
            self.arch,
            self.calls.len(),
            programs.len(),
            self.options.len()
        );
        s.push_str("## by program and call\n");
        for c in &self.calls {
            s.push_str(&format!("  {} {} -> {}{}\n", c.program, c.syscall, c.options.join(", "), runs_note(&c.runs, c.near_miss)));
        }
        s.push_str("## by option\n");
        for o in &self.options {
            let who: Vec<String> = o.callers.iter().map(|c| format!("{} {}{}", c.program, c.syscall, if c.near_miss { " (near-miss)" } else { "" })).collect();
            let state = if o.enabled { "on" } else { "OFF — the call gets ENOSYS in this row today" };
            s.push_str(&format!("  {} {}: {}\n", o.option, state, who.join(", ")));
        }
        s.push_str(&format!("always built (no option gate) ({}): {}\n", self.ungated.len(), self.ungated.join(" ")));
        if !self.unknown.is_empty() {
            s.push_str(&format!("no x86_64 definition in the tree ({}): {}\n", self.unknown.len(), self.unknown.join(", ")));
        }
        s.push_str(&format!(
            "no measured caller ({}) — syscall-gating options this row enables that no measured call needs (a trim hint, never a veto):\n",
            self.no_measured_caller.len()
        ));
        for n in &self.no_measured_caller {
            s.push_str(&format!("  {} gates {}\n", n.option, n.syscalls.join(", ")));
        }
        let measured: BTreeSet<&str> = self.measured_programs.iter().map(String::as_str).collect();
        let (partly, not_yet): (Vec<&String>, Vec<&String>) = self.vm_later.iter().partition(|p| measured.contains(p.as_str()));
        if !not_yet.is_empty() {
            s.push_str(&format!("not measured yet (VM): {}\n", not_yet.iter().map(|p| p.as_str()).collect::<Vec<_>>().join(", ")));
        }
        if !partly.is_empty() {
            s.push_str(&format!("partly measured (runs wait for the VM): {}\n", partly.iter().map(|p| p.as_str()).collect::<Vec<_>>().join(", ")));
        }
        s
    }
}

/// A measured call a drop would take away.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Veto {
    /// `CONFIG_X` — the option named (a drop when one of the drops takes it).
    pub option: String,
    /// Other leaving options that take it too.
    pub also: Vec<String>,
    pub program: String,
    pub libc: String,
    pub syscall: String,
    pub runs: Vec<String>,
    /// Only on error paths: warn, do not refuse.
    pub near_miss: bool,
}

impl Veto {
    /// `CONFIG_BPF_SYSCALL: tunnr-cni calls bpf (test add_attaches_bpf)`.
    pub fn render(&self) -> String {
        format!("{}: {} calls {}{}", self.option, self.program, self.syscall, runs_note(&self.runs, self.near_miss))
    }
}

/// [`vetoes_for`] with no drop preferred in the naming.
pub fn vetoes(map: &SyscallMap, measured: &MeasuredRow, gone: &BTreeSet<String>) -> Vec<Veto> {
    vetoes_for(map, measured, &[], gone)
}

/// **The veto check**: every measured call whose kernel code leaves with
/// `gone` (`CONFIG_*`: the drops plus what leaves with them), named by the
/// drop that causes it when one does.
pub fn vetoes_for(map: &SyscallMap, measured: &MeasuredRow, drops: &[String], gone: &BTreeSet<String>) -> Vec<Veto> {
    let mut out = Vec::new();
    for p in &measured.programs {
        for c in &p.calls {
            let Some(k) = map.syscalls.get(&c.syscall).and_then(|s| s.killers(gone)) else { continue };
            let option = drops.iter().map(|d| full(bare(d))).find(|d| k.contains(d)).unwrap_or_else(|| k[0].clone());
            let also = k.iter().filter(|x| **x != option).cloned().collect();
            out.push(Veto { option, also, program: p.program.clone(), libc: p.libc.clone(), syscall: c.syscall.clone(), runs: c.runs.clone(), near_miss: c.near_miss });
        }
    }
    out.sort_by(|a, b| (a.near_miss, &a.option, &a.program, &a.syscall).cmp(&(b.near_miss, &b.option, &b.program, &b.syscall)));
    out
}

/// **The candidates, joined**: a candidate whose drop takes a measured call
/// away (not a near-miss) leaves the ranking and is listed as vetoed by
/// name; a near-miss is noted on its row; a syscall-gating candidate no
/// measured call needs is marked "no measured caller".
pub fn join_candidates(c: &mut crate::kernel_trim::Candidates, model: &crate::kernel_trim::TrimModel, map: &SyscallMap, measured: &MeasuredRow, built_config: &str) {
    let n = needs(map, measured, built_config);
    let no_caller: BTreeSet<&str> = n.no_measured_caller.iter().map(|x| x.option.as_str()).collect();
    let mut kept = Vec::new();
    for mut row in std::mem::take(&mut c.rows) {
        let w = model.what_if(std::slice::from_ref(&row.symbol), false);
        let v = vetoes_for(map, measured, std::slice::from_ref(&row.symbol), &w.expected_gone());
        let (hard, near): (Vec<Veto>, Vec<Veto>) = v.into_iter().partition(|x| !x.near_miss);
        if !hard.is_empty() {
            c.syscall_vetoed.extend(hard);
            continue;
        }
        row.near_miss = near.iter().map(Veto::render).collect();
        row.no_measured_caller = no_caller.contains(row.symbol.as_str());
        kept.push(row);
    }
    c.rows = kept;
    c.syscalls_checked = true;
}

#[cfg(test)]
mod tests;