crates/nornir-purgatory/tests/kconfig_graph_and_syscall_join.rs
//! **Step 2 of `mimir-v2.md`, on a miniature kernel tree** — the Kconfig graph
//! reader, the syscall → CONFIG join, and the first real `EvidenceSource`,
//! `KconfigSource` and `Subtractor` implementations.
//!
//! `tests/fixtures/kernel-mini` is a few dozen files in the SHAPE of a real tree:
//! `Kconfig` sourcing `arch/$(SRCARCH)/Kconfig`, `if`/`menu`/`choice` scopes,
//! help blocks, `select … if`, Makefiles with `obj-$(CONFIG_X)` and composite
//! objects, `.c` files with `SYSCALL_DEFINEn` under `#ifdef CONFIG_X`, and a
//! `syscall_64.tbl`. Every relation in it is copied from the real kernel; only
//! the help text is shortened. The same assertions run against a REAL tree in
//! `against_a_real_kernel_tree.rs`.
//!
//! Each invariant below was written RED first — the assertion existed before the
//! code that satisfies it.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use nornir_purgatory::kconfig::tree::{symbols_in_expr, KconfigTree, Polarity};
use nornir_purgatory::kconfig::{Effective, EffectiveReport, Requested};
use nornir_purgatory::syscall::{
check_table, object_guards, DefinitionIndex, Probe, ProbeEvidence, ProbedGuard, Resolution,
Road, SyscallJoin, SyscallTable, TableEntry, CHECKED_TABLE,
};
use nornir_purgatory::{
immune_from_fragments, propose, Demand, EvidenceSource, Freshness, KconfigAwareSubtractor,
KconfigSource, Policy, Scope, Subtractor,
};
fn mini() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/kernel-mini")
}
fn tree() -> KconfigTree {
KconfigTree::read(&mini(), "x86").expect("the fixture tree must read")
}
fn set(syms: &[&str]) -> BTreeSet<String> {
syms.iter().map(|s| (*s).to_string()).collect()
}
// ── the Kconfig reader ───────────────────────────────────────────────────────
/// `source "arch/$(SRCARCH)/Kconfig"` is followed, nested sources are followed,
/// and a source that does not exist is RECORDED rather than ignored or fatal.
#[test]
fn the_reader_follows_source_directives_and_records_the_missing_one() {
let t = tree();
assert_eq!(
t.release(),
Some("6.12.104"),
"VERSION.PATCHLEVEL.SUBLEVEL from the Makefile"
);
// Reached through the root, through arch/$(SRCARCH), through security/.
for sym in [
"EXPERT",
"SECCOMP_FILTER",
"SECURITY_LANDLOCK",
"BLK_DEV_IO_TRACE",
"IO_URING",
] {
assert!(
t.symbol(sym).is_some(),
"{sym} must be read; got {} symbols",
t.len()
);
}
assert_eq!(
t.missing_sources(),
&["this/one/does/not/exist/Kconfig".to_string()],
"a missing source is a finding, not silence"
);
assert!(t.files_read() >= 13, "files read: {}", t.files_read());
// Kconfig.include declares macros, never symbols.
assert!(t.symbol("y").is_none() && t.symbol("success").is_none());
}
/// The relations the plan is built on, read off the fixture exactly as the
/// kernel spells them.
#[test]
fn select_and_depends_are_read_with_their_conditions() {
let t = tree();
let blk = t.symbol("BLK_DEV_IO_TRACE").unwrap();
let selects: Vec<&str> = blk.selects.iter().map(|s| s.target.as_str()).collect();
assert_eq!(
selects,
[
"RELAY",
"DEBUG_FS",
"TRACEPOINTS",
"GENERIC_TRACER",
"STACKTRACE"
]
);
assert_eq!(blk.file, "kernel/trace/Kconfig");
assert!(blk.line > 0);
// `depends on` lines AND the enclosing `if FTRACE` block.
assert_eq!(t.depends("BLK_DEV_IO_TRACE"), ["SYSFS", "BLOCK", "FTRACE"]);
let sf = t.symbol("SECCOMP_FILTER").unwrap();
assert_eq!(sf.depends, ["HAVE_ARCH_SECCOMP_FILTER && SECCOMP && NET"]);
assert_eq!(
t.depends("SECCOMP_FILTER"),
["HAVE_ARCH_SECCOMP_FILTER", "SECCOMP", "NET"]
);
// `select X if COND` keeps its condition.
let x86 = t.symbol("X86").unwrap();
let huge = x86
.selects
.iter()
.find(|s| s.target == "HAVE_ARCH_HUGE_VMAP")
.unwrap();
assert_eq!(huge.cond.as_deref(), Some("X86_64 || X86_PAE"));
// `bool "…" if EXPERT` — the shape hardened.config:28-36 explains.
let io = t.symbol("IO_URING").unwrap();
assert_eq!(io.prompt.as_deref(), Some("Enable IO uring support"));
assert_eq!(io.prompt_cond.as_deref(), Some("EXPERT"));
assert!(
t.symbol("IO_WQ").unwrap().prompt.is_none(),
"a promptless symbol"
);
// `prompt` as its own line, `def_bool` as type + default.
let sec = t.symbol("SECCOMP").unwrap();
assert_eq!(
sec.prompt.as_deref(),
Some("Enable seccomp to safely execute untrusted bytecode")
);
assert_eq!(sec.defaults.len(), 1);
// menuconfig is a config.
assert!(t.symbol("NET").is_some() && t.symbol("MODULES").is_some());
}
/// A help block is prose. The fixture's `CC_VERSION_TEXT` help contains a line
/// beginning `select DEBUG_FS`; it must not become a select.
#[test]
fn help_text_is_never_parsed_as_attributes() {
let t = tree();
assert!(t.symbol("CC_VERSION_TEXT").unwrap().selects.is_empty());
assert!(t
.selected_by("DEBUG_FS")
.iter()
.all(|s| s.by != "CC_VERSION_TEXT"));
// and the block after the help resumed parsing.
assert!(t.symbol("IRQ_WORK").is_some());
}
/// Scoping: `if X … endif`, `menu` and `choice` deps reach the entries inside
/// them and stop at the closer.
#[test]
fn if_menu_and_choice_scopes_apply_to_what_they_enclose_and_nothing_else() {
let t = tree();
assert_eq!(
t.depends("MODULE_UNLOAD"),
["MODULES"],
"inside `if MODULES`"
);
assert!(
!t.depends("SYSFS").contains(&"MODULES".to_string()),
"after `endif`"
);
assert_eq!(
t.depends("PARAVIRT"),
["HAVE_STATIC_CALL", "HYPERVISOR_GUEST"]
);
assert!(
t.depends("HYPERVISOR_GUEST").is_empty(),
"after `endif` and `endmenu`"
);
assert_eq!(
t.depends("DEBUG_FS_ALLOW_ALL"),
["DEBUG_FS"],
"a choice's `depends on`"
);
assert!(
t.depends("STACKTRACE").iter().all(|d| d != "DEBUG_FS"),
"after `endchoice`"
);
assert_eq!(t.depends("INET"), ["NET"], "inside `if NET`");
assert!(t.depends("HAVE_CBPF_JIT").is_empty());
}
/// Expression polarity: `!X` and `X=n` are NEGATIVE dependencies. Subtracting a
/// negatively-depended symbol cannot hurt the dependant, so it must not be made
/// immune by it — `HARDENED_USERCOPY depends on !SLUB_TINY` is the real case
/// (`hardened.config:53`).
#[test]
fn polarity_is_read_so_a_negative_dependency_does_not_protect_its_symbol() {
let pos = |e: &str| -> Vec<String> {
symbols_in_expr(e)
.into_iter()
.filter(|(_, p)| *p == Polarity::Positive)
.map(|(s, _)| s)
.collect()
};
let neg = |e: &str| -> Vec<String> {
symbols_in_expr(e)
.into_iter()
.filter(|(_, p)| *p == Polarity::Negative)
.map(|(s, _)| s)
.collect()
};
assert_eq!(
pos("HAVE_ARCH_SECCOMP_FILTER && SECCOMP && NET"),
["HAVE_ARCH_SECCOMP_FILTER", "SECCOMP", "NET"]
);
assert_eq!(neg("!SLUB_TINY"), ["SLUB_TINY"]);
assert!(pos("!SLUB_TINY").is_empty());
assert_eq!(neg("CFG80211=n"), ["CFG80211"]);
assert_eq!(
pos("IPV6 || IPV6=n"),
["IPV6"],
"one positive mention is enough to protect"
);
assert_eq!(pos("KUNIT=y"), ["KUNIT"]);
assert_eq!(
neg("BATMAN_ADV && !(CFG80211=m && BATMAN_ADV=y)"),
["CFG80211", "BATMAN_ADV"]
);
assert_eq!(
pos("$(cc-option,-fstack-protector-strong)"),
Vec::<String>::new(),
"a macro is not a symbol"
);
assert_eq!(
pos("INIT_STACK_NONE || !CC_IS_CLANG || CLANG_VERSION >= 140000"),
["INIT_STACK_NONE", "CLANG_VERSION"]
);
assert!(
pos("y").is_empty() && pos("m").is_empty() && pos("n").is_empty(),
"constants"
);
assert_eq!(
pos("\"$(ARCH)\" = \"x86\""),
Vec::<String>::new(),
"quoted strings are not symbols"
);
let t = tree();
assert!(
t.depends("HARDENED_USERCOPY").is_empty(),
"the only dep is negative"
);
}
/// **§2's question answered from Kconfig alone: what does `CONFIG_X` pull in?**
/// The transitive `select` closure, no C scan.
#[test]
fn the_select_closure_is_transitive_and_is_the_debugfs_story() {
let t = tree();
let pulled = t.select_closure("BLK_DEV_IO_TRACE");
for s in [
"RELAY",
"DEBUG_FS",
"TRACEPOINTS",
"GENERIC_TRACER",
"STACKTRACE",
"IRQ_WORK",
"TRACING",
"RING_BUFFER",
] {
assert!(pulled.contains(s), "{s} missing from {pulled:?}");
}
assert!(
!pulled.contains("BLK_DEV_IO_TRACE"),
"a symbol does not pull itself"
);
assert!(!pulled.contains("SYSFS"), "depends on is not select");
// The KconfigSource view is one hop.
assert_eq!(t.selects("RELAY"), ["IRQ_WORK"]);
assert_eq!(t.selects("GENERIC_TRACER"), ["TRACING"]);
}
/// **ADDED, explained.** An enabled symbol nobody asked for is explained by the
/// enabled symbol that `select`ed it, with the file it happened in. This is the
/// 2026-09-07 defect found by hand, made routine.
#[test]
fn an_arrival_is_explained_by_the_enabled_symbol_that_selected_it() {
let t = tree();
let requested = Requested::from_fragments([("nuked.config", "# CONFIG_DEBUG_FS is not set\n")]);
let eff = Effective::from_produced_config(
"# Linux/x86 6.12.104 Kernel Configuration\nCONFIG_BLK_DEV_IO_TRACE=y\nCONFIG_DEBUG_FS=y\nCONFIG_RELAY=y\nCONFIG_IRQ_WORK=y\n",
);
let report = EffectiveReport::compute(requested, eff);
// DEBUG_FS was asked `n` and came back `y`: DROPPED. RELAY nobody named: ADDED.
assert!(report.dropped.iter().any(|d| d.symbol == "DEBUG_FS"));
assert!(report.added.iter().any(|a| a.symbol == "RELAY"));
let enabled = report.effective.enabled();
let why = t.explain_arrival("RELAY", &enabled);
assert_eq!(why.len(), 1, "{why:?}");
assert_eq!(why[0].by, "BLK_DEV_IO_TRACE");
assert_eq!(why[0].file, "kernel/trace/Kconfig");
// And the defeated veto is explained the same way.
let why = t.explain_arrival("DEBUG_FS", &enabled);
assert_eq!(why[0].by, "BLK_DEV_IO_TRACE");
// A selector that is itself OFF does not explain anything.
let off: BTreeSet<&str> = BTreeSet::from(["DEBUG_FS"]);
assert!(t.explain_arrival("DEBUG_FS", &off).is_empty());
// IRQ_WORK arrived two hops away: explained by RELAY, which is on.
assert_eq!(t.explain_arrival("IRQ_WORK", &enabled)[0].by, "RELAY");
}
// ── the Kconfig-aware subtractor: invariant 2, transitively ─────────────────
/// **INVARIANT 2, TRANSITIVE — the new red.** The fragment names only
/// `STACKPROTECTOR_STRONG`. It `depends on STACKPROTECTOR`. A subtractor that
/// drops `STACKPROTECTOR` because "nothing reaches it" silently loses the
/// hardening symbol at `olddefconfig` — policy weakened by evidence, through one
/// hop of Kconfig. The bare `propose` does exactly that; the Kconfig-aware one
/// must not.
#[test]
fn a_dependency_of_a_hardening_symbol_is_immune_too() {
let t = tree();
let seeds = immune_from_fragments([("hardened.config", "CONFIG_STACKPROTECTOR_STRONG=y\n")]);
let eff = Effective::from_produced_config(
"# Linux/x86 6.12.104 Kernel Configuration\nCONFIG_HAVE_STACKPROTECTOR=y\nCONFIG_STACKPROTECTOR=y\n\
CONFIG_STACKPROTECTOR_STRONG=y\nCONFIG_SLUB_TINY=y\nCONFIG_EXPERT=y\n",
);
let demand = Demand {
symbols: BTreeSet::new(),
scope: Scope::Whole,
freshness_note: "test".into(),
};
let fresh = Freshness::Fresh {
revision: "t".into(),
};
// The bare subtractor — the pre-existing behaviour — cuts the dependency.
let bare = propose(
&eff,
&demand,
&Policy {
immune: seeds.clone(),
report_only: false,
},
&fresh,
);
assert!(
bare.subtractions
.iter()
.any(|s| s.symbol == "STACKPROTECTOR"),
"this is the defect"
);
let sub = KconfigAwareSubtractor::new(&t, seeds, false);
assert!(sub.immune().contains("STACKPROTECTOR"), "one hop");
assert!(sub.immune().contains("HAVE_STACKPROTECTOR"), "two hops");
assert!(!sub.immune().contains("SLUB_TINY"), "not related");
let d = sub.propose(&eff, &demand, &fresh);
let cut: Vec<&str> = d.subtractions.iter().map(|s| s.symbol.as_str()).collect();
assert!(!cut.contains(&"STACKPROTECTOR"), "{cut:?}");
assert!(!cut.contains(&"HAVE_STACKPROTECTOR"), "{cut:?}");
assert!(
cut.contains(&"SLUB_TINY"),
"and it still subtracts what it may: {cut:?}"
);
assert!(d
.spared
.iter()
.any(|s| s.symbol == "STACKPROTECTOR" && s.why.contains("IMMUNE")));
}
/// What policy `select`s is policy: SECCOMP_FILTER=y in the fragment implies
/// SECCOMP and NET (depends) — and a fragment's `select` targets likewise.
#[test]
fn what_policy_implies_is_policy() {
let t = tree();
let im = t.policy_closure(&set(&["SECCOMP_FILTER", "BLK_DEV_IO_TRACE"]));
for s in [
"SECCOMP_FILTER",
"SECCOMP",
"NET",
"HAVE_ARCH_SECCOMP_FILTER",
"HAVE_ARCH_SECCOMP",
"NLATTR",
"DEBUG_FS",
"RELAY",
"IRQ_WORK",
"SYSFS",
"BLOCK",
"FTRACE",
] {
assert!(im.contains(s), "{s} missing from {im:?}");
}
assert!(!im.contains("IO_URING"));
}
/// **Futility is reported, not hidden.** Subtracting `DEBUG_FS` while an enabled
/// survivor `select`s it is a directive `olddefconfig` will put straight back —
/// the exact 2026-09-07 defect. The subtractor says so and spares it; when the
/// selector is itself subtracted, the subtraction stands.
#[test]
fn a_subtraction_that_a_surviving_selector_would_undo_is_spared_and_says_who() {
let t = tree();
let eff = Effective::from_produced_config(
"# Linux/x86 6.12.104 Kernel Configuration\nCONFIG_BLK_DEV_IO_TRACE=y\nCONFIG_DEBUG_FS=y\nCONFIG_RELAY=y\n",
);
let fresh = Freshness::Fresh {
revision: "t".into(),
};
let sub = KconfigAwareSubtractor::new(&t, BTreeSet::new(), false);
// The evidence says the code reaches BLK_DEV_IO_TRACE, so it survives.
let demand = Demand {
symbols: set(&["BLK_DEV_IO_TRACE"]),
scope: Scope::Whole,
freshness_note: "test".into(),
};
let d = sub.propose(&eff, &demand, &fresh);
assert!(
d.subtractions
.iter()
.all(|s| s.symbol != "DEBUG_FS" && s.symbol != "RELAY"),
"{:?}",
d.subtractions
);
let sp = d
.spared
.iter()
.find(|s| s.symbol == "DEBUG_FS")
.expect("spared");
assert!(
sp.why.contains("SELECTED BY")
&& sp.why.contains("BLK_DEV_IO_TRACE")
&& sp.why.contains("kernel/trace/Kconfig"),
"{}",
sp.why
);
// Nothing is demanded: the selector goes, and so may what it selected.
let none = Demand {
symbols: BTreeSet::new(),
scope: Scope::Whole,
freshness_note: "test".into(),
};
let d = sub.propose(&eff, &none, &fresh);
let cut: Vec<&str> = d.subtractions.iter().map(|s| s.symbol.as_str()).collect();
assert_eq!(cut, ["BLK_DEV_IO_TRACE", "DEBUG_FS", "RELAY"], "{cut:?}");
}
/// **INVARIANT 3 on the Kconfig side.** A graph read from one kernel says
/// nothing about a `.config` produced by another: release mismatch is STALE
/// and subtracts nothing.
#[test]
fn a_tree_from_another_kernel_release_is_stale_evidence() {
let t = tree();
let eff = Effective::from_produced_config(
"# Linux/x86 6.6.1 Kernel Configuration\nCONFIG_SLUB_TINY=y\n",
);
assert_eq!(eff.release(), Some("6.6.1"));
let sub = KconfigAwareSubtractor::new(&t, BTreeSet::new(), false);
let demand = Demand {
symbols: BTreeSet::new(),
scope: Scope::Whole,
freshness_note: "test".into(),
};
let d = sub.propose(
&eff,
&demand,
&Freshness::Fresh {
revision: "t".into(),
},
);
assert!(d.subtractions.is_empty());
let r = d.refused.expect("must say why");
assert!(r.contains("6.6.1") && r.contains("6.12.104"), "{r}");
// Same release: it works. No banner at all: the .config cannot be dated, and
// that too is doubt.
let same = Effective::from_produced_config(
"# Linux/x86 6.12.104 Kernel Configuration\nCONFIG_SLUB_TINY=y\n",
);
assert!(!sub
.propose(
&same,
&demand,
&Freshness::Fresh {
revision: "t".into()
}
)
.subtractions
.is_empty());
let undated = Effective::from_produced_config("CONFIG_SLUB_TINY=y\n");
assert!(sub
.propose(
&undated,
&demand,
&Freshness::Fresh {
revision: "t".into()
}
)
.refused
.is_some());
}
// ── the syscall → CONFIG join ────────────────────────────────────────────────
#[test]
fn the_syscall_table_parses_and_names_its_file() {
let t = SyscallTable::read(&mini(), "x86").unwrap();
assert_eq!(t.source(), "arch/x86/entry/syscalls/syscall_64.tbl");
assert!(t.contains("seccomp") && t.contains("bpf") && t.contains("io_uring_enter"));
assert!(!t.contains("frobnicate"));
assert_eq!(t.number("seccomp"), Some(317));
assert!(t.len() >= 16, "{}", t.len());
// Comment lines are not entries.
assert!(!t.contains("The"));
}
/// The Makefile chain is a CONJUNCTION of guards down the directory path, and a
/// composite object (`landlock-y := … syscalls.o`, `$(mmu-y)`) resolves through
/// its parent.
#[test]
fn makefile_guards_compose_down_the_path_and_through_composites() {
let root = mini();
assert_eq!(
object_guards(&root, "kernel/seccomp.o").unwrap(),
set(&["SECCOMP"])
);
assert_eq!(
object_guards(&root, "kernel/bpf/syscall.o").unwrap(),
set(&["BPF", "BPF_SYSCALL"])
);
assert_eq!(
object_guards(&root, "kernel/bpf/log.o").unwrap(),
set(&["BPF", "BPF_SYSCALL"]),
"a continuation line"
);
assert_eq!(
object_guards(&root, "io_uring/io_uring.o").unwrap(),
set(&["IO_URING"])
);
assert_eq!(
object_guards(&root, "io_uring/register.o").unwrap(),
set(&["IO_URING"]),
"deep continuation"
);
assert_eq!(
object_guards(&root, "fs/read_write.o").unwrap(),
BTreeSet::new(),
"obj-y is unconditional"
);
assert_eq!(
object_guards(&root, "kernel/sys.o").unwrap(),
BTreeSet::new()
);
assert_eq!(
object_guards(&root, "security/landlock/syscalls.o").unwrap(),
set(&["SECURITY_LANDLOCK"]),
"composite"
);
assert_eq!(
object_guards(&root, "fs/userfaultfd.o").unwrap(),
set(&["USERFAULTFD"])
);
let e = object_guards(&root, "kernel/nowhere.o").unwrap_err();
assert!(
e.to_string().contains("nowhere.o") && e.to_string().contains("kernel/Makefile"),
"{e}"
);
let e = object_guards(&root, "drivers/gpu/thing.o").unwrap_err();
assert!(e.to_string().contains("Makefile"), "{e}");
}
/// `SYSCALL_DEFINEn(name, …)` is found across the tree, with the `#ifdef
/// CONFIG_X` nesting at the definition — the guard a Makefile cannot see.
#[test]
fn definitions_are_indexed_with_their_preprocessor_guards() {
let idx = DefinitionIndex::scan(&mini()).unwrap();
let s = idx.sites("seccomp");
assert_eq!(s.len(), 1);
assert_eq!(s[0].file, "kernel/seccomp.c");
assert!(s[0].cpp_guards.is_empty());
let s = idx.sites("setuid");
assert_eq!(s[0].file, "kernel/sys.c");
assert_eq!(
s[0].cpp_guards,
set(&["MULTIUSER"]),
"the guard the Makefile cannot see"
);
assert!(
idx.sites("getpid")[0].cpp_guards.is_empty(),
"after the #endif"
);
assert_eq!(idx.sites("io_uring_enter")[0].file, "io_uring/io_uring.c");
assert_eq!(idx.sites("read")[0].file, "fs/read_write.c");
assert!(
idx.sites("kexec_load").is_empty(),
"no .c in the fixture defines it"
);
// COND_SYSCALL is a stub, not a definition.
assert!(idx.sites("landlock_create_ruleset").is_empty());
assert!(idx.len() >= 9, "{}", idx.len());
}
/// **derive > probe > table**, each answer tagged with the road it came by.
#[test]
fn the_join_prefers_derive_then_probe_then_the_checked_table() {
let k = tree();
let probes = vec![
// A probe CONTRADICTING the source for seccomp: derive must still win.
ProbedGuard {
syscall: "seccomp".into(),
config: "SOMETHING_ELSE".into(),
evidence: "a bad probe".into(),
},
// kexec_load has no .c in the fixture; the probe should answer before the table.
ProbedGuard {
syscall: "kexec_load".into(),
config: "KEXEC".into(),
evidence: "ENOSYS on a KEXEC=n boot".into(),
},
];
let j = SyscallJoin::build(&mini(), "x86", &k, &probes).unwrap();
match j.resolve("seccomp") {
Resolution::Gated(g) => {
assert_eq!(g.road, Road::Derived);
assert_eq!(g.configs, set(&["SECCOMP"]));
assert!(
g.evidence.contains("kernel/seccomp.c") && g.evidence.contains("kernel/Makefile"),
"{}",
g.evidence
);
}
other => panic!("{other:?}"),
}
match j.resolve("setuid") {
Resolution::Gated(g) => {
assert_eq!((g.road, g.configs), (Road::Derived, set(&["MULTIUSER"])))
}
other => panic!("{other:?}"),
}
match j.resolve("bpf") {
Resolution::Gated(g) => assert_eq!(
(g.road, g.configs),
(Road::Derived, set(&["BPF", "BPF_SYSCALL"]))
),
other => panic!("{other:?}"),
}
assert!(
matches!(
j.resolve("read"),
Resolution::Unconditional {
road: Road::Derived
}
),
"{:?}",
j.resolve("read")
);
match j.resolve("kexec_load") {
Resolution::Gated(g) => assert_eq!((g.road, g.configs), (Road::Probed, set(&["KEXEC"]))),
other => panic!("{other:?}"),
}
match j.resolve("memfd_create") {
Resolution::Gated(g) => {
assert_eq!((g.road, g.configs), (Road::Table, set(&["MEMFD_CREATE"])))
}
other => panic!("{other:?}"),
}
// In the table, no road answers: Unknown, and it says so.
assert!(matches!(
j.resolve("epoll_pwait"),
Resolution::Unknown { .. }
));
// Not even in the syscall table.
match j.resolve("frobnicate") {
Resolution::Unknown { why } => assert!(
why.contains("frobnicate") && why.contains("syscall_64.tbl"),
"{why}"
),
other => panic!("{other:?}"),
}
// The scope is every CONFIG any road can name.
let scope = j.scope();
for s in [
"SECCOMP",
"BPF_SYSCALL",
"MULTIUSER",
"KEXEC",
"MEMFD_CREATE",
"IO_URING",
] {
assert!(scope.contains(s), "{s} missing from {scope:?}");
}
assert!(
!scope.contains("SLUB_TINY"),
"a syscall probe cannot see the allocator"
);
}
/// **The table is checked against the tree, or it is not used.** An entry the
/// tree contradicts is REJECTED loudly; one the tree cannot judge is set aside;
/// only verified entries join. This is plan §2's condition on road 3.
#[test]
fn a_checked_table_entry_the_tree_contradicts_goes_red_instead_of_lying() {
let k = tree();
let t = SyscallTable::read(&mini(), "x86").unwrap();
let idx = DefinitionIndex::scan(&mini()).unwrap();
let entries = [
TableEntry {
syscall: "seccomp",
file: "kernel/seccomp.c",
config: Some("SECCOMP"),
},
TableEntry {
syscall: "seccomp",
file: "kernel/seccomp.c",
config: Some("SECCOMP_FILTER"),
}, // wrong guard
TableEntry {
syscall: "bpf",
file: "kernel/bpf/syscall.c",
config: Some("BPF_SYSCALL"),
},
TableEntry {
syscall: "bpf",
file: "kernel/bpf/syscall.c",
config: Some("BPF_SYSCALL_OLD_NAME"),
}, // no such symbol
TableEntry {
syscall: "frobnicate",
file: "kernel/frob.c",
config: None,
}, // no such syscall
TableEntry {
syscall: "read",
file: "fs/read_write.c",
config: None,
},
TableEntry {
syscall: "read",
file: "fs/read_write.c",
config: Some("BLOCK"),
}, // claims a guard obj-y has not
TableEntry {
syscall: "seccomp",
file: "kernel/bpf/syscall.c",
config: Some("BPF_SYSCALL"),
}, // wrong file: it exists, guard matches, no such definition
TableEntry {
syscall: "epoll_create1",
file: "fs/eventpoll.c",
config: Some("EPOLL"),
},
TableEntry {
syscall: "acct",
file: "kernel/acct.c",
config: Some("BSD_PROCESS_ACCT"),
}, // fixture Kconfig lacks it
];
let c = check_table(&entries, &mini(), &t, &k, Some(&idx));
let ok: Vec<&str> = c.accepted.iter().map(|g| g.syscall.as_str()).collect();
assert_eq!(
ok,
["seccomp", "bpf", "read", "epoll_create1"],
"{:?}",
c.accepted
);
let bad: Vec<&str> = c.rejected.iter().map(|(e, _)| e.as_str()).collect();
assert_eq!(bad.len(), 5, "{:?}", c.rejected);
assert!(
c.rejected
.iter()
.any(|(e, why)| e.contains("SECCOMP_FILTER") && why.contains("kernel/Makefile")),
"{:?}",
c.rejected
);
assert!(
c.rejected
.iter()
.any(|(e, why)| e.contains("BPF_SYSCALL_OLD_NAME")
&& why.contains("not among the guards")),
"{:?}",
c.rejected
);
assert!(
c.rejected
.iter()
.any(|(e, why)| e.contains("frobnicate") && why.contains("syscall_64.tbl")),
"{:?}",
c.rejected
);
assert!(
c.rejected
.iter()
.any(|(e, why)| e.contains("read") && why.contains("obj-y")),
"{:?}",
c.rejected
);
assert!(
c.rejected
.iter()
.any(|(e, why)| e.starts_with("seccomp → kernel/bpf/syscall.c")
&& why.contains("SYSCALL_DEFINE")),
"{:?}",
c.rejected
);
// acct: the fixture has no kernel/acct.o rule and no BSD_PROCESS_ACCT — the
// tree cannot judge it, which is different from contradicting it.
assert_eq!(c.unverifiable.len(), 1, "{:?}", c.unverifiable);
assert!(c.unverifiable[0].0.contains("acct"));
// The real table on the fixture: nothing contradicted. (Complete verification
// is the real-tree test's job; the fixture models only part of the kernel.)
let real = check_table(CHECKED_TABLE, &mini(), &t, &k, Some(&idx));
assert!(
real.rejected.is_empty(),
"the shipped table contradicts the fixture: {:?}",
real.rejected
);
assert!(real.accepted.iter().any(|g| g.syscall == "memfd_create"));
}
// ── the first real EvidenceSource: a probe ───────────────────────────────────
fn tunnr_measured() -> BTreeSet<String> {
// tunnr 961d534, `sandbox.rs` ALLOWED — the 21 syscalls measured under
// strace on the real wg-init, plus `seccomp` from the fatal tier.
set(&[
"poll",
"read",
"write",
"recvfrom",
"sendto",
"getrandom",
"close",
"fcntl",
"futex",
"pause",
"mmap",
"munmap",
"mprotect",
"brk",
"rt_sigaction",
"rt_sigprocmask",
"sigaltstack",
"prctl",
"arch_prctl",
"gettid",
"getpid",
"seccomp",
])
}
/// A probe's demand is the CONFIGs its observed syscalls are gated by, and its
/// scope is the CONFIGs a syscall can be gated by at all.
#[test]
fn a_probe_demands_what_its_syscalls_are_gated_by_and_nothing_else() {
let k = tree();
let j = SyscallJoin::build(&mini(), "x86", &k, &[]).unwrap();
let probe = Probe {
observed: set(&["read", "write", "seccomp", "setuid", "getpid", "prctl"]),
revision: "abc123".into(),
how: "strace -f".into(),
};
let ev = ProbeEvidence::new(probe, &j, "abc123");
assert_eq!(
ev.freshness(),
Freshness::Fresh {
revision: "abc123".into()
}
);
let d = ev.demand("tunnr");
assert_eq!(d.symbols, set(&["SECCOMP", "MULTIUSER"]), "{d:?}");
match &d.scope {
Scope::Only(s) => assert!(s.contains("BPF_SYSCALL") && !s.contains("SLUB_TINY")),
Scope::Whole => panic!("a syscall probe is never whole-scope"),
}
assert!(d.freshness_note.contains("strace -f") && d.freshness_note.contains("abc123"));
}
/// **INVARIANT 3, three ways, on a real evidence source.** An observed syscall
/// the join cannot place is UNSOUND — the probe or the table is wrong and we do
/// not know which. A probe from another revision is STALE. No observations is
/// ABSENT. Each yields zero subtractions.
#[test]
fn a_probe_that_cannot_be_placed_or_dated_fails_closed() {
let k = tree();
let j = SyscallJoin::build(&mini(), "x86", &k, &[]).unwrap();
let eff = Effective::from_produced_config(
"# Linux/x86 6.12.104 Kernel Configuration\nCONFIG_BPF_SYSCALL=y\nCONFIG_SECCOMP=y\n",
);
let sub = KconfigAwareSubtractor::new(&k, BTreeSet::new(), false);
let unsound = ProbeEvidence::new(
Probe {
observed: set(&["read", "frobnicate"]),
revision: "r".into(),
how: "t".into(),
},
&j,
"r",
);
match unsound.freshness() {
Freshness::Unsound { why } => assert!(why.contains("frobnicate"), "{why}"),
f => panic!("{f:?}"),
}
let stale = ProbeEvidence::new(
Probe {
observed: set(&["read"]),
revision: "old".into(),
how: "t".into(),
},
&j,
"new",
);
assert!(matches!(stale.freshness(), Freshness::Stale { .. }));
let absent = ProbeEvidence::new(
Probe {
observed: BTreeSet::new(),
revision: "r".into(),
how: "t".into(),
},
&j,
"r",
);
assert_eq!(absent.freshness(), Freshness::Absent);
for ev in [&unsound, &stale, &absent] {
let d = sub.propose(&eff, &ev.demand("x"), &ev.freshness());
assert!(d.subtractions.is_empty() && d.refused.is_some(), "{d:?}");
}
// In the table but on no road (epoll_pwait): the honest answer is still "we
// cannot place it", and that is unsound too.
let unplaced = ProbeEvidence::new(
Probe {
observed: set(&["epoll_pwait"]),
revision: "r".into(),
how: "t".into(),
},
&j,
"r",
);
assert!(matches!(unplaced.freshness(), Freshness::Unsound { .. }));
}
/// **The new red that made `Scope` exist.** A syscall probe knows nothing about
/// a NIC driver. Before `Scope`, fresh probe evidence subtracted every enabled
/// symbol it had not seen — which was everything. Out of scope is SPARED and
/// says so; in scope and unobserved is subtracted; observed is spared.
#[test]
fn a_syscall_probe_must_not_subtract_a_nic_driver_it_cannot_see() {
let k = tree();
let j = SyscallJoin::build(&mini(), "x86", &k, &[]).unwrap();
let eff = Effective::from_produced_config(
"# Linux/x86 6.12.104 Kernel Configuration\nCONFIG_SECCOMP=y\nCONFIG_BPF_SYSCALL=y\nCONFIG_BPF=y\n\
CONFIG_MEMFD_CREATE=y\nCONFIG_E1000=y\nCONFIG_SLUB_TINY=y\nCONFIG_NET=y\nCONFIG_SECCOMP_FILTER=y\n",
);
let ev = ProbeEvidence::new(
Probe {
observed: tunnr_measured(),
revision: "r".into(),
how: "strace".into(),
},
&j,
"r",
);
// arch_prctl IS in the fixture x86 table but no road places it: unsound.
assert!(
matches!(ev.freshness(), Freshness::Unsound { .. }),
"{:?}",
ev.freshness()
);
let mut observed = tunnr_measured();
observed.remove("arch_prctl");
let ev = ProbeEvidence::new(
Probe {
observed,
revision: "r".into(),
how: "strace".into(),
},
&j,
"r",
);
assert_eq!(
ev.freshness(),
Freshness::Fresh {
revision: "r".into()
}
);
let seeds = immune_from_fragments([("hardened.config", "CONFIG_SECCOMP_FILTER=y\n")]);
let sub = KconfigAwareSubtractor::new(&k, seeds, false);
let d = sub.propose(&eff, &ev.demand("tunnr"), &ev.freshness());
let cut: Vec<&str> = d.subtractions.iter().map(|s| s.symbol.as_str()).collect();
assert_eq!(
cut,
["BPF", "BPF_SYSCALL", "MEMFD_CREATE"],
"in scope, unobserved, not policy: {cut:?}"
);
let spared = |sym: &str| {
d.spared
.iter()
.find(|s| s.symbol == sym)
.map(|s| s.why.clone())
.unwrap_or_default()
};
assert!(
spared("E1000").contains("OUT OF SCOPE"),
"{}",
spared("E1000")
);
assert!(spared("SLUB_TINY").contains("OUT OF SCOPE"));
assert!(
spared("SECCOMP").contains("IMMUNE"),
"policy first: {}",
spared("SECCOMP")
);
assert!(
spared("NET").contains("IMMUNE"),
"SECCOMP_FILTER depends on NET"
);
// BPF is `select`ed by BPF_SYSCALL — but BPF_SYSCALL is being cut too, so no
// SURVIVOR selects BPF and its own subtraction stands.
assert!(d.rows().iter().all(|r| !r.contains("E1000")));
}
/// Invariant 1 survives the new machinery: a `Demand` naming a symbol that is
/// OFF changes nothing.
#[test]
fn demand_for_a_vetoed_symbol_still_cannot_turn_it_on() {
let k = tree();
let eff = Effective::from_produced_config("# Linux/x86 6.12.104 Kernel Configuration\n# CONFIG_BPF_SYSCALL is not set\nCONFIG_SECCOMP=y\n");
let sub = KconfigAwareSubtractor::new(&k, BTreeSet::new(), false);
let demand = Demand {
symbols: set(&["BPF_SYSCALL"]),
scope: Scope::Whole,
freshness_note: "t".into(),
};
let d = sub.propose(
&eff,
&demand,
&Freshness::Fresh {
revision: "t".into(),
},
);
assert!(d
.subtractions
.iter()
.chain(d.spared.iter())
.all(|s| s.symbol != "BPF_SYSCALL"));
}