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;