crates/nornir-purgatory/src/syscall.rs
//! **The syscall → CONFIG join** — the one genuine data gap `mimir-v2.md` §2
//! names, and the first real [`crate::EvidenceSource`].
//!
//! A syscall probe (`strace` on the appliance, tunnr `961d534`) yields syscall
//! NAMES. The purgatorium needs Kconfig SYMBOLS. The edge between them is not in
//! any table the kernel ships, so §2 gives three roads, in order of preference:
//!
//! 1. **derive** — read it off the tree: the syscall table names the call, a
//! `SYSCALL_DEFINEn(name, …)` in some `.c` defines it, that file's Makefile
//! chain (`obj-$(CONFIG_X) += file.o`, composed down the directory path and
//! through composite objects) says when it is built, and any `#ifdef CONFIG_Y`
//! around the definition says the rest. Fully data-driven. [`DefinitionIndex`],
//! [`object_guards`].
//! 2. **probe** — a measured guard: *"on a kernel with `X=n` this syscall returned
//! `ENOSYS`"*. [`ProbedGuard`].
//! 3. **table** — a carried mapping, **acceptable only if a test checks each entry
//! against the tree, so a stale entry goes red instead of lying**.
//! [`CHECKED_TABLE`] + [`check_table`]: an entry the tree contradicts is
//! rejected and never joins; one the tree cannot judge is set aside and
//! never joins either.
//!
//! [`SyscallJoin::resolve`] consults them in that order and tags every answer
//! with its [`Road`]. A syscall no road places is [`Resolution::Unknown`], and a
//! probe that observed one is **unsound** — the probe or the table is wrong and
//! we do not know which, so invariant 3 subtracts nothing.
//!
//! # Scope — the new thing a real evidence source forced
//!
//! A syscall probe knows nothing about a NIC driver. Its demand set says which
//! syscall-gated symbols were reached; about `E1000` it has no opinion at all.
//! Before [`crate::Scope`] existed, fresh probe evidence would have subtracted
//! every enabled symbol it had not seen — which is every driver in the kernel.
//! So a [`crate::Demand`] now carries the set of symbols its evidence is
//! competent to judge — here, every CONFIG any road can name — and the
//! subtractor spares everything outside it, saying so.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Path, PathBuf};
use crate::kconfig::tree::KconfigTree;
use crate::{Demand, Freshness, Scope};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyscallError {
pub why: String,
}
impl fmt::Display for SyscallError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.why)
}
}
impl std::error::Error for SyscallError {}
// ── the syscall table ────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyscallEntry {
pub nr: u32,
pub abi: String,
pub name: String,
pub entry: Option<String>,
}
/// `arch/<arch>/entry/syscalls/syscall_64.tbl`, or the generic
/// `scripts/syscall.tbl` when the arch table is absent (a distro headers tree).
#[derive(Debug, Clone, Default)]
pub struct SyscallTable {
source: String,
entries: Vec<SyscallEntry>,
by_name: BTreeMap<String, usize>,
}
impl SyscallTable {
#[must_use]
pub fn parse(source: &str, text: &str) -> SyscallTable {
let mut t = SyscallTable {
source: source.to_string(),
..SyscallTable::default()
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut it = line.split_whitespace();
let (Some(nr), Some(abi), Some(name)) = (it.next(), it.next(), it.next()) else {
continue;
};
let Ok(nr) = nr.parse::<u32>() else { continue };
let idx = t.entries.len();
t.entries.push(SyscallEntry {
nr,
abi: abi.to_string(),
name: name.to_string(),
entry: it.next().map(str::to_string),
});
t.by_name.entry(name.to_string()).or_insert(idx);
}
t
}
/// The arch table first; the generic one as the fallback, and
/// [`SyscallTable::is_generic`] remembers which.
pub fn read(root: &Path, arch: &str) -> Result<SyscallTable, SyscallError> {
let candidates = [
format!("arch/{arch}/entry/syscalls/syscall_64.tbl"),
"scripts/syscall.tbl".to_string(),
];
for rel in &candidates {
if let Ok(text) = std::fs::read_to_string(root.join(rel)) {
return Ok(SyscallTable::parse(rel, &text));
}
}
Err(SyscallError {
why: format!(
"no syscall table under {} (tried {})",
root.display(),
candidates.join(", ")
),
})
}
/// Tree-relative path of the file this table came from.
#[must_use]
pub fn source(&self) -> &str {
&self.source
}
/// The generic `scripts/syscall.tbl` is not any one arch's: it lacks the
/// legacy calls (`poll`, `pause`, `arch_prctl`) x86 still has.
#[must_use]
pub fn is_generic(&self) -> bool {
self.source == "scripts/syscall.tbl"
}
#[must_use]
pub fn contains(&self, name: &str) -> bool {
self.by_name.contains_key(name)
}
#[must_use]
pub fn number(&self, name: &str) -> Option<u32> {
self.by_name.get(name).map(|&i| self.entries[i].nr)
}
#[must_use]
pub fn len(&self) -> usize {
self.by_name.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_name.is_empty()
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.by_name.keys().map(String::as_str)
}
}
// ── Makefile guards ──────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuardError {
pub why: String,
}
impl fmt::Display for GuardError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.why)
}
}
impl std::error::Error for GuardError {}
/// One `name-<guard> op items` line of a Kbuild file, continuations joined.
#[derive(Debug, Clone)]
struct Rule {
/// `obj` for `obj-…`; otherwise a composite object's name (`landlock`, `mmu`).
name: String,
/// `Some(X)` for `$(CONFIG_X)`; `None` for `y`/`m`/`objs` (unconditional).
guard: Option<String>,
items: Vec<String>,
}
struct Rules {
file: String,
rules: Vec<Rule>,
}
fn read_rules(root: &Path, rel_dir: &str) -> Result<Rules, GuardError> {
let dir = if rel_dir.is_empty() {
root.to_path_buf()
} else {
root.join(rel_dir)
};
let (file, text) = ["Kbuild", "Makefile"]
.iter()
.find_map(|n| std::fs::read_to_string(dir.join(n)).ok().map(|t| (n, t)))
.ok_or_else(|| GuardError {
why: format!(
"no Kbuild or Makefile in {}/",
if rel_dir.is_empty() { "." } else { rel_dir }
),
})?;
let file = if rel_dir.is_empty() {
(*file).to_string()
} else {
format!("{rel_dir}/{file}")
};
// Join `\` continuations.
let mut joined: Vec<String> = Vec::new();
let mut cur = String::new();
for line in text.lines() {
let line = match line.find('#') {
Some(i) => &line[..i],
None => line,
};
if let Some(stripped) = line.trim_end().strip_suffix('\\') {
cur.push_str(stripped);
cur.push(' ');
} else {
cur.push_str(line);
joined.push(std::mem::take(&mut cur));
}
}
if !cur.is_empty() {
joined.push(cur);
}
let mut rules = Vec::new();
for line in joined {
let Some(op_at) = line
.find(":=")
.or_else(|| line.find("+="))
.or_else(|| line.find('='))
else {
continue;
};
let lhs = line[..op_at].trim();
let rhs_start = op_at + if line[op_at..].starts_with('=') { 1 } else { 2 };
let rhs = &line[rhs_start..];
let Some((name, suffix)) = lhs.rsplit_once('-') else {
continue;
};
if name.is_empty() || name.contains(char::is_whitespace) {
continue;
}
let guard = match suffix {
"y" | "m" | "objs" => None,
s if s.starts_with("$(CONFIG_") && s.ends_with(')') => {
Some(s["$(CONFIG_".len()..s.len() - 1].to_string())
}
_ => continue,
};
rules.push(Rule {
name: name.to_string(),
guard,
items: rhs.split_whitespace().map(str::to_string).collect(),
});
}
Ok(Rules { file, rules })
}
/// **Every `CONFIG_` a kernel object is built under**, composed down its
/// directory path (root `Kbuild` → each directory's `Kbuild`/`Makefile`) and
/// through composite objects (`landlock-y := … syscalls.o` inherits
/// `landlock.o`'s guard; `$(mmu-y)` inherits the rule that expands it).
///
/// Empty set = built unconditionally (`obj-y` all the way down). An object no
/// rule adds is an error naming the file it was looked for in — a table entry
/// with a wrong path must be REJECTED, not guessed unconditional.
pub fn object_guards(root: &Path, object: &str) -> Result<BTreeSet<String>, GuardError> {
let parts: Vec<&str> = object.split('/').filter(|p| !p.is_empty()).collect();
let Some((file, dirs)) = parts.split_last() else {
return Err(GuardError {
why: format!("`{object}` is not an object path"),
});
};
// `arch/$(SRCARCH)/` in the root Kbuild: the arch is whatever the path says.
let arch = if dirs.first() == Some(&"arch") {
dirs.get(1).copied()
} else {
None
};
let mut guards = BTreeSet::new();
let mut rel_dir = String::new();
let mut i = 0;
while i < dirs.len() {
let rules = read_rules(root, &rel_dir)?;
// A rule may descend more than one level at once (`arch/$(SRCARCH)/`).
let mut matched = None;
for span in (1..=dirs.len() - i).rev() {
let target = format!("{}/", dirs[i..i + span].join("/"));
if let Some(rule) = rules.rules.iter().find(|r| {
r.name == "obj" && r.items.iter().any(|it| item_matches_dir(it, &target, arch))
}) {
matched = Some((rule.guard.clone(), span));
break;
}
}
let Some((guard, span)) = matched else {
return Err(GuardError {
why: format!("no rule in {} descends into `{}/`", rules.file, dirs[i]),
});
};
guards.extend(guard);
for d in &dirs[i..i + span] {
if !rel_dir.is_empty() {
rel_dir.push('/');
}
rel_dir.push_str(d);
}
i += span;
}
let rules = read_rules(root, &rel_dir)?;
let mut seen = BTreeSet::new();
guards.extend(object_guard_in(&rules, file, &mut seen)?);
Ok(guards)
}
fn item_matches_dir(item: &str, target: &str, arch: Option<&str>) -> bool {
if item == target {
return true;
}
match arch {
Some(a) => item.replace("$(SRCARCH)", a) == target,
None => false,
}
}
/// The guards for `file.o` within one Kbuild file, following composites.
fn object_guard_in(
rules: &Rules,
file: &str,
seen: &mut BTreeSet<String>,
) -> Result<BTreeSet<String>, GuardError> {
if !seen.insert(file.to_string()) {
return Ok(BTreeSet::new());
}
let mut out = BTreeSet::new();
let mut found = false;
for r in rules
.rules
.iter()
.filter(|r| r.items.iter().any(|it| it == file))
{
found = true;
out.extend(r.guard.clone());
if r.name != "obj" {
// A composite: `name.o` or `$(name-y)` is what an obj rule adds.
let as_object = format!("{}.o", r.name);
let as_var = format!("$({}-y)", r.name);
let parent: Vec<&Rule> = rules
.rules
.iter()
.filter(|p| p.items.iter().any(|it| *it == as_object || *it == as_var))
.collect();
if parent.is_empty() {
return Err(GuardError { why: format!("`{file}` is part of composite `{}` in {}, but nothing adds `{as_object}` or `{as_var}`", r.name, rules.file) });
}
for p in parent {
out.extend(p.guard.clone());
if p.name != "obj" {
out.extend(object_guard_in(rules, &format!("{}.o", p.name), seen)?);
}
}
}
}
if !found {
return Err(GuardError { why: format!("nothing adds `{file}` in {} (obj-y builds it unconditionally only if a rule says so)", rules.file) });
}
Ok(out)
}
// ── SYSCALL_DEFINE sites ─────────────────────────────────────────────────────
/// Where a syscall is defined, and the `#ifdef CONFIG_…` nesting there.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefSite {
pub file: String,
pub line: usize,
/// Positive `CONFIG_` guards open at the definition (`#ifdef`, `#if
/// IS_ENABLED(…)`, `#if defined(…)`) — the guard a Makefile cannot see.
pub cpp_guards: BTreeSet<String>,
}
/// `SYSCALL_DEFINEn(name, …)` across every `.c` in the tree.
#[derive(Debug, Clone, Default)]
pub struct DefinitionIndex {
sites: BTreeMap<String, Vec<DefSite>>,
files_scanned: usize,
}
impl DefinitionIndex {
/// Scan the tree, skipping `arch/` entirely (every architecture defines
/// `mmap`; only one is ours) and the non-kernel directories.
pub fn scan(root: &Path) -> Result<DefinitionIndex, SyscallError> {
Self::scan_for_arch(root, None)
}
/// Scan the tree plus `arch/<arch>/`.
pub fn scan_for_arch(root: &Path, arch: Option<&str>) -> Result<DefinitionIndex, SyscallError> {
let mut idx = DefinitionIndex::default();
let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
let skip_top = [
"Documentation",
"tools",
"scripts",
"samples",
"usr",
"include",
"rust",
"certs",
".git",
];
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).map_err(|e| SyscallError {
why: format!("read {}: {e}", dir.display()),
})?;
for entry in rd.flatten() {
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
if rel.starts_with("arch/") {
match arch {
Some(a)
if rel == format!("arch/{a}")
|| rel.starts_with(&format!("arch/{a}/")) => {}
_ => continue,
}
}
if rel == "arch" && arch.is_none() {
continue;
}
if skip_top.contains(&rel.as_str()) {
continue;
}
stack.push(path);
} else if rel.ends_with(".c") {
if let Ok(text) = std::fs::read_to_string(&path) {
idx.files_scanned += 1;
idx.index_text(&rel, &text);
}
}
}
}
Ok(idx)
}
fn index_text(&mut self, rel: &str, text: &str) {
// (guards, negated) per open #if.
let mut stack: Vec<(BTreeSet<String>, bool)> = Vec::new();
for (i, line) in text.lines().enumerate() {
let t = line.trim_start();
if let Some(d) = t.strip_prefix('#') {
let d = d.trim_start();
if let Some(rest) = d.strip_prefix("ifdef") {
stack.push((config_tokens(rest), false));
} else if let Some(rest) = d.strip_prefix("ifndef") {
stack.push((config_tokens(rest), true));
} else if let Some(rest) = d.strip_prefix("if") {
stack.push((config_tokens(rest), rest.trim_start().starts_with('!')));
} else if let Some(rest) = d.strip_prefix("elif") {
if let Some(top) = stack.last_mut() {
*top = (config_tokens(rest), false);
}
} else if d.starts_with("else") {
if let Some(top) = stack.last_mut() {
top.1 = !top.1;
}
} else if d.starts_with("endif") {
stack.pop();
}
continue;
}
let Some(pos) = t.find("SYSCALL_DEFINE") else {
continue;
};
if pos > 0
&& (t.as_bytes()[pos - 1].is_ascii_alphanumeric() || t.as_bytes()[pos - 1] == b'_')
{
continue; // COMPAT_SYSCALL_DEFINE, or a macro definition
}
let after = &t[pos + "SYSCALL_DEFINE".len()..];
let digits = after.chars().take_while(char::is_ascii_digit).count();
let Some(paren) = after[digits..].strip_prefix('(') else {
continue;
};
let name: String = paren
.chars()
.take_while(|c| *c != ',' && *c != ')')
.collect();
let name = name.trim().to_string();
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
continue;
}
let cpp_guards = stack
.iter()
.filter(|(_, neg)| !neg)
.flat_map(|(g, _)| g.iter().cloned())
.collect();
self.sites.entry(name).or_default().push(DefSite {
file: rel.to_string(),
line: i + 1,
cpp_guards,
});
}
}
#[must_use]
pub fn sites(&self, syscall: &str) -> &[DefSite] {
self.sites.get(syscall).map_or(&[], Vec::as_slice)
}
#[must_use]
pub fn len(&self) -> usize {
self.sites.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.sites.is_empty()
}
#[must_use]
pub fn files_scanned(&self) -> usize {
self.files_scanned
}
pub fn syscalls(&self) -> impl Iterator<Item = &str> {
self.sites.keys().map(String::as_str)
}
}
/// `CONFIG_X` tokens in a preprocessor condition, bare symbols.
fn config_tokens(expr: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let mut rest = expr;
while let Some(i) = rest.find("CONFIG_") {
let after = &rest[i + "CONFIG_".len()..];
let sym: String = after
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect();
let n = sym.len();
if !sym.is_empty() {
out.insert(sym);
}
rest = &after[n..];
}
out
}
// ── the three roads ──────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Road {
Derived,
Probed,
Table,
}
impl fmt::Display for Road {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Road::Derived => "derived from the tree",
Road::Probed => "measured by a probe",
Road::Table => "checked table",
})
}
}
/// What a syscall is gated by, and how we know.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Guard {
pub syscall: String,
/// Empty = built unconditionally.
pub configs: BTreeSet<String>,
pub road: Road,
pub evidence: String,
}
/// Road 2: a measured guard. *"On a kernel with `config=n`, `syscall` returned
/// `ENOSYS`"* — the seccomp instrument tunnr `961d534` built can produce these.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbedGuard {
pub syscall: String,
pub config: String,
pub evidence: String,
}
/// Road 3: one carried entry, verifiable against a tree.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TableEntry {
pub syscall: &'static str,
/// The `.c` that defines it, tree-relative — the Makefile chain is read from here.
pub file: &'static str,
/// `None` = unconditional. `Some(X)` must be among the Makefile guards.
pub config: Option<&'static str>,
}
/// **The carried mapping.** Every entry is checked against the tree by
/// [`check_table`] before it may join, and the real-tree test demands zero
/// rejections — a stale line here goes red instead of lying in a gate.
///
/// Kept to what a Makefile chain can verify: a syscall whose only guard is a
/// `#ifdef` inside the `.c` (e.g. `setuid` under `CONFIG_MULTIUSER`,
/// `delete_module` under `CONFIG_MODULE_UNLOAD`) is the derive road's job, and
/// is not listed. `arch_prctl` is not listed: its object is `process_$(BITS).o`,
/// a rule this reader does not expand.
pub const CHECKED_TABLE: &[TableEntry] = &[
// Unconditional — the bulk of tunnr's measured list.
TableEntry {
syscall: "read",
file: "fs/read_write.c",
config: None,
},
TableEntry {
syscall: "write",
file: "fs/read_write.c",
config: None,
},
TableEntry {
syscall: "close",
file: "fs/open.c",
config: None,
},
TableEntry {
syscall: "fcntl",
file: "fs/fcntl.c",
config: None,
},
TableEntry {
syscall: "poll",
file: "fs/select.c",
config: None,
},
TableEntry {
syscall: "pause",
file: "kernel/signal.c",
config: None,
},
TableEntry {
syscall: "rt_sigaction",
file: "kernel/signal.c",
config: None,
},
TableEntry {
syscall: "rt_sigprocmask",
file: "kernel/signal.c",
config: None,
},
TableEntry {
syscall: "sigaltstack",
file: "kernel/signal.c",
config: None,
},
TableEntry {
syscall: "prctl",
file: "kernel/sys.c",
config: None,
},
TableEntry {
syscall: "getpid",
file: "kernel/sys.c",
config: None,
},
TableEntry {
syscall: "gettid",
file: "kernel/sys.c",
config: None,
},
TableEntry {
syscall: "getrandom",
file: "drivers/char/random.c",
config: None,
},
// Gated.
TableEntry {
syscall: "recvfrom",
file: "net/socket.c",
config: Some("NET"),
},
TableEntry {
syscall: "sendto",
file: "net/socket.c",
config: Some("NET"),
},
TableEntry {
syscall: "mmap",
file: "arch/x86/kernel/sys_x86_64.c",
config: Some("X86_64"),
},
TableEntry {
syscall: "munmap",
file: "mm/mmap.c",
config: Some("MMU"),
},
TableEntry {
syscall: "brk",
file: "mm/mmap.c",
config: Some("MMU"),
},
TableEntry {
syscall: "mprotect",
file: "mm/mprotect.c",
config: Some("MMU"),
},
TableEntry {
syscall: "futex",
file: "kernel/futex/syscalls.c",
config: Some("FUTEX"),
},
TableEntry {
syscall: "seccomp",
file: "kernel/seccomp.c",
config: Some("SECCOMP"),
},
TableEntry {
syscall: "bpf",
file: "kernel/bpf/syscall.c",
config: Some("BPF_SYSCALL"),
},
TableEntry {
syscall: "io_uring_setup",
file: "io_uring/io_uring.c",
config: Some("IO_URING"),
},
TableEntry {
syscall: "io_uring_enter",
file: "io_uring/io_uring.c",
config: Some("IO_URING"),
},
TableEntry {
syscall: "io_uring_register",
file: "io_uring/register.c",
config: Some("IO_URING"),
},
TableEntry {
syscall: "userfaultfd",
file: "fs/userfaultfd.c",
config: Some("USERFAULTFD"),
},
TableEntry {
syscall: "kexec_load",
file: "kernel/kexec.c",
config: Some("KEXEC"),
},
TableEntry {
syscall: "kexec_file_load",
file: "kernel/kexec_file.c",
config: Some("KEXEC_FILE"),
},
TableEntry {
syscall: "memfd_create",
file: "mm/memfd.c",
config: Some("MEMFD_CREATE"),
},
TableEntry {
syscall: "landlock_create_ruleset",
file: "security/landlock/syscalls.c",
config: Some("SECURITY_LANDLOCK"),
},
TableEntry {
syscall: "epoll_create1",
file: "fs/eventpoll.c",
config: Some("EPOLL"),
},
TableEntry {
syscall: "signalfd4",
file: "fs/signalfd.c",
config: Some("SIGNALFD"),
},
TableEntry {
syscall: "timerfd_create",
file: "fs/timerfd.c",
config: Some("TIMERFD"),
},
TableEntry {
syscall: "eventfd2",
file: "fs/eventfd.c",
config: Some("EVENTFD"),
},
TableEntry {
syscall: "init_module",
file: "kernel/module/main.c",
config: Some("MODULES"),
},
TableEntry {
syscall: "finit_module",
file: "kernel/module/main.c",
config: Some("MODULES"),
},
];
/// The outcome of checking a table against a tree.
#[derive(Debug, Clone, Default)]
pub struct TableCheck {
/// Verified: the syscall is in the table, the guard is what the Makefile
/// chain says, the symbol is in Kconfig, and the definition is where the
/// entry says when the `.c` is there to look at.
pub accepted: Vec<Guard>,
/// CONTRADICTED by the tree — `(entry, why)`. Stale; go red.
pub rejected: Vec<(String, String)>,
/// The tree could not judge it — `(entry, why)`. Never joins either.
pub unverifiable: Vec<(String, String)>,
/// Entries whose `.c` is absent (a headers tree), so the definition itself
/// was not checked. Counted, not hidden.
pub definitions_unverified: usize,
}
/// **Verify every entry against the tree.** See [`TableCheck`].
#[must_use]
pub fn check_table(
entries: &[TableEntry],
root: &Path,
table: &SyscallTable,
kconfig: &KconfigTree,
defs: Option<&DefinitionIndex>,
) -> TableCheck {
let mut out = TableCheck::default();
for e in entries {
let label = format!(
"{} → {} → {}",
e.syscall,
e.file,
e.config
.map_or("unconditional".to_string(), |c| format!("CONFIG_{c}"))
);
// (a) the syscall exists.
if !table.contains(e.syscall) {
let why = format!("`{}` is not in {}", e.syscall, table.source());
if table.is_generic() {
out.unverifiable.push((label, format!("{why} — the generic table lacks arch-specific legacy calls, so this is not a contradiction")));
} else {
out.rejected.push((label, why));
}
continue;
}
// (c) the Makefile chain agrees on the guard.
let object = match e.file.strip_suffix(".c") {
Some(stem) => format!("{stem}.o"),
None => {
out.rejected
.push((label, format!("`{}` is not a .c file", e.file)));
continue;
}
};
let guards = match object_guards(root, &object) {
Ok(g) => g,
Err(err) => {
out.unverifiable.push((label, err.why));
continue;
}
};
match e.config {
Some(c) if !guards.contains(c) => {
let chain = if guards.is_empty() {
"obj-y builds it unconditionally".to_string()
} else {
format!("the Makefile chain guards it with {guards:?}")
};
out.rejected.push((
label,
format!(
"{chain}; CONFIG_{c} is not among the guards ({}/Makefile chain)",
e.file.rsplit_once('/').map_or("", |(d, _)| d)
),
));
continue;
}
None if !guards.is_empty() => {
out.rejected.push((label, format!("the table says unconditional but the Makefile chain guards it with {guards:?}")));
continue;
}
_ => {}
}
// (b) the symbol is a Kconfig symbol.
let mut unknown: Vec<&String> = guards
.iter()
.filter(|g| kconfig.symbol(g).is_none())
.collect();
unknown.sort();
if !unknown.is_empty() {
out.unverifiable.push((
label,
format!("the Makefile chain names {unknown:?} but no Kconfig read declares it"),
));
continue;
}
// (d) the definition is where the entry says, when the .c is there.
let mut evidence = format!(
"{} via the Makefile chain under {}/",
e.file,
e.file.rsplit_once('/').map_or("", |(d, _)| d)
);
if root.join(e.file).is_file() {
match defs {
Some(d) => {
if !d.sites(e.syscall).iter().any(|s| s.file == e.file) {
out.rejected.push((
label,
format!(
"{} exists and has no SYSCALL_DEFINE for `{}`",
e.file, e.syscall
),
));
continue;
}
evidence.push_str(", definition confirmed");
}
None => out.definitions_unverified += 1,
}
} else {
out.definitions_unverified += 1;
}
out.accepted.push(Guard {
syscall: e.syscall.to_string(),
configs: guards,
road: Road::Table,
evidence,
});
}
out
}
/// What the join says about one syscall.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolution {
Gated(Guard),
Unconditional { road: Road },
Unknown { why: String },
}
/// **The join**: derive > probe > table, built once per tree.
#[derive(Debug, Clone)]
pub struct SyscallJoin {
table: SyscallTable,
derived: BTreeMap<String, Guard>,
probed: BTreeMap<String, Guard>,
checked: BTreeMap<String, Guard>,
check: TableCheck,
derive_skipped: Vec<(String, String)>,
}
impl SyscallJoin {
pub fn build(
root: &Path,
arch: &str,
kconfig: &KconfigTree,
probes: &[ProbedGuard],
) -> Result<SyscallJoin, SyscallError> {
let table = SyscallTable::read(root, arch)?;
let defs = DefinitionIndex::scan_for_arch(root, Some(arch))?;
let mut derived: BTreeMap<String, Guard> = BTreeMap::new();
let mut derive_skipped = Vec::new();
for name in defs.syscalls() {
for site in defs.sites(name) {
let object = site
.file
.strip_suffix(".c")
.map(|s| format!("{s}.o"))
.unwrap_or_default();
match object_guards(root, &object) {
Ok(mk) => {
let g = derived.entry(name.to_string()).or_insert_with(|| Guard {
syscall: name.to_string(),
configs: BTreeSet::new(),
road: Road::Derived,
evidence: String::new(),
});
// Two sites → the union: more demand is the fail-closed direction.
g.configs.extend(mk.iter().cloned());
g.configs.extend(site.cpp_guards.iter().cloned());
if !g.evidence.is_empty() {
g.evidence.push_str("; ");
}
g.evidence.push_str(&format!(
"SYSCALL_DEFINE at {}:{} under {}/Makefile chain {:?}{}",
site.file,
site.line,
site.file.rsplit_once('/').map_or("", |(d, _)| d),
mk,
if site.cpp_guards.is_empty() {
String::new()
} else {
format!(" and #if {:?}", site.cpp_guards)
}
));
}
Err(e) => derive_skipped.push((format!("{name} @ {}", site.file), e.why)),
}
}
}
let mut probed: BTreeMap<String, Guard> = BTreeMap::new();
for p in probes {
let g = probed.entry(p.syscall.clone()).or_insert_with(|| Guard {
syscall: p.syscall.clone(),
configs: BTreeSet::new(),
road: Road::Probed,
evidence: String::new(),
});
g.configs.insert(p.config.clone());
if !g.evidence.is_empty() {
g.evidence.push_str("; ");
}
g.evidence.push_str(&p.evidence);
}
let check = check_table(CHECKED_TABLE, root, &table, kconfig, Some(&defs));
let checked = check
.accepted
.iter()
.map(|g| (g.syscall.clone(), g.clone()))
.collect();
Ok(SyscallJoin {
table,
derived,
probed,
checked,
check,
derive_skipped,
})
}
/// derive > probe > table; Unknown when no road places a known syscall.
#[must_use]
pub fn resolve(&self, syscall: &str) -> Resolution {
if !self.table.contains(syscall) {
return Resolution::Unknown {
why: format!("`{syscall}` is not in {}", self.table.source()),
};
}
for map in [&self.derived, &self.probed, &self.checked] {
if let Some(g) = map.get(syscall) {
return if g.configs.is_empty() {
Resolution::Unconditional { road: g.road }
} else {
Resolution::Gated(g.clone())
};
}
}
Resolution::Unknown { why: format!("`{syscall}` is in {} but no road places it: not derived (no SYSCALL_DEFINE found), not probed, not in the checked table", self.table.source()) }
}
/// Every CONFIG any road can name — what a syscall probe is competent to judge.
#[must_use]
pub fn scope(&self) -> BTreeSet<String> {
self.derived
.values()
.chain(self.probed.values())
.chain(self.checked.values())
.flat_map(|g| g.configs.iter().cloned())
.collect()
}
#[must_use]
pub fn table(&self) -> &SyscallTable {
&self.table
}
/// The table check that fed road 3.
#[must_use]
pub fn table_check(&self) -> &TableCheck {
&self.check
}
/// Definition sites whose Makefile chain could not be read.
#[must_use]
pub fn derive_skipped(&self) -> &[(String, String)] {
&self.derive_skipped
}
#[must_use]
pub fn derived_count(&self) -> usize {
self.derived.len()
}
}
// ── the first real EvidenceSource ────────────────────────────────────────────
/// What a probe saw: syscall names, at a revision of the appliance's code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Probe {
pub observed: BTreeSet<String>,
/// The appliance revision the probe ran against.
pub revision: String,
/// How it was measured, for the record (`strace -f …`).
pub how: String,
}
/// A [`Probe`] joined through a [`SyscallJoin`]: an [`crate::EvidenceSource`].
///
/// Freshness, in order: no observations → `Absent`; a revision other than the
/// one asked for → `Stale`; an observed syscall no road places → `Unsound`
/// (the probe or the join is wrong, and we do not know which); else `Fresh`.
#[derive(Debug, Clone)]
pub struct ProbeEvidence {
demand: Demand,
freshness: Freshness,
unplaced: Vec<(String, String)>,
}
impl ProbeEvidence {
#[must_use]
pub fn new(probe: Probe, join: &SyscallJoin, expected_revision: &str) -> ProbeEvidence {
let mut symbols = BTreeSet::new();
let mut unplaced = Vec::new();
let mut roads: BTreeMap<Road, usize> = BTreeMap::new();
for s in &probe.observed {
match join.resolve(s) {
Resolution::Gated(g) => {
symbols.extend(g.configs.iter().cloned());
*roads.entry(g.road).or_default() += 1;
}
Resolution::Unconditional { road } => *roads.entry(road).or_default() += 1,
Resolution::Unknown { why } => unplaced.push((s.clone(), why)),
}
}
let freshness = if probe.observed.is_empty() {
Freshness::Absent
} else if probe.revision != expected_revision {
Freshness::Stale {
why: format!(
"the probe ran at revision {} but the build is {expected_revision}",
probe.revision
),
}
} else if !unplaced.is_empty() {
Freshness::Unsound {
why: format!(
"{} observed syscall(s) cannot be placed on any road: {}",
unplaced.len(),
unplaced
.iter()
.map(|(_, w)| w.as_str())
.collect::<Vec<_>>()
.join("; ")
),
}
} else {
Freshness::Fresh {
revision: probe.revision.clone(),
}
};
let roads_note: Vec<String> = roads.iter().map(|(r, n)| format!("{n} {r}")).collect();
let demand = Demand {
symbols,
scope: Scope::Only(join.scope()),
freshness_note: format!(
"syscall probe ({}) at revision {}: {} syscalls observed, placed by {}",
probe.how,
probe.revision,
probe.observed.len(),
roads_note.join(", ")
),
};
ProbeEvidence {
demand,
freshness,
unplaced,
}
}
/// Observed syscalls no road could place — the reason for `Unsound`.
#[must_use]
pub fn unplaced(&self) -> Vec<String> {
self.unplaced.iter().map(|(n, _)| n.clone()).collect()
}
}
impl crate::EvidenceSource for ProbeEvidence {
fn demand(&self, _repo: &str) -> Demand {
self.demand.clone()
}
fn freshness(&self) -> Freshness {
self.freshness.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_tokens_are_bare_symbols() {
assert_eq!(
config_tokens(" CONFIG_MULTIUSER"),
BTreeSet::from(["MULTIUSER".to_string()])
);
assert_eq!(
config_tokens(" IS_ENABLED(CONFIG_BPF) && defined(CONFIG_NET)"),
BTreeSet::from(["BPF".to_string(), "NET".to_string()])
);
assert!(config_tokens(" 0").is_empty());
}
#[test]
fn the_table_parser_takes_number_abi_name_entry() {
let t = SyscallTable::parse(
"x.tbl",
"# c\n0\tcommon\tread\tsys_read\n1 common write sys_write\nbad line\n",
);
assert_eq!(t.len(), 2);
assert_eq!(t.number("write"), Some(1));
assert!(!t.is_generic());
assert!(SyscallTable::parse("scripts/syscall.tbl", "").is_generic());
}
#[test]
fn a_definition_under_an_else_is_not_guarded_by_the_if() {
let mut idx = DefinitionIndex::default();
idx.index_text("k.c", "#ifdef CONFIG_A\nint x;\n#else\nSYSCALL_DEFINE0(foo)\n{}\n#endif\n#if IS_ENABLED(CONFIG_B)\nSYSCALL_DEFINE1(bar, int, a)\n#endif\nCOMPAT_SYSCALL_DEFINE1(bar, int, a)\n");
assert!(idx.sites("foo")[0].cpp_guards.is_empty());
assert_eq!(idx.sites("bar").len(), 1, "COMPAT_ is not a definition");
assert_eq!(
idx.sites("bar")[0].cpp_guards,
BTreeSet::from(["B".to_string()])
);
}
}