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

nordisk/edda

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()])
        );
    }
}