crates/nornir-purgatory/tests/against_a_real_kernel_tree.rs
//! **The same questions, asked of a REAL kernel tree and a REAL produced
//! `.config`** — and of the real committed fragments.
//!
//! The fixture tests prove the reader understands the shapes; these prove it
//! understands the kernel. A distro's `linux-headers-*` package is a real tree
//! for this purpose: every `Kconfig` (1 900 files), every `Makefile` (3 190) and
//! `scripts/syscall.tbl`, plus the `.config` that produced the running kernel —
//! only the `.c` files are absent, so the definition road is unverifiable here
//! and the test says so rather than pretending.
//!
//! Discovery: `NORNIR_PURGATORY_KERNEL_TREE=/path` wins; otherwise the newest
//! `/usr/src/linux-headers-*` that carries `arch/x86/Kconfig`. With neither, each
//! test prints ONE loud `as dåligt` row and passes — a box without kernel headers
//! is not a defect in this crate, and a red that teaches people to delete tests
//! is worse than a stated skip. The fixture tests never skip.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use nornir_purgatory::kconfig::tree::KconfigTree;
use nornir_purgatory::kconfig::{Effective, EffectiveReport, Requested};
use nornir_purgatory::syscall::{
check_table, object_guards, Probe, ProbeEvidence, SyscallJoin, SyscallTable, CHECKED_TABLE,
};
use nornir_purgatory::{
immune_from_fragments, EvidenceSource, Freshness, KconfigAwareSubtractor, KconfigSource, Scope,
Subtractor,
};
/// The tree, or a loud skip.
fn real_tree() -> Option<PathBuf> {
if let Ok(p) = std::env::var("NORNIR_PURGATORY_KERNEL_TREE") {
let p = PathBuf::from(p);
assert!(
p.join("arch/x86/Kconfig").is_file(),
"NORNIR_PURGATORY_KERNEL_TREE={} has no arch/x86/Kconfig",
p.display()
);
return Some(p);
}
let mut found: Vec<PathBuf> = std::fs::read_dir("/usr/src")
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("linux-headers-"))
&& p.join("arch/x86/Kconfig").is_file()
&& p.join("scripts/syscall.tbl").is_file()
})
.collect();
found.sort();
let tree = found.pop();
if tree.is_none() {
eprintln!(
"{:<28} as dåligt SKIPPED: no kernel tree on this box (no /usr/src/linux-headers-* with arch/x86/Kconfig, and NORNIR_PURGATORY_KERNEL_TREE unset). The fixture tests still ran; the real-tree ones did not.",
"purgatory-real-tree"
);
}
tree
}
/// The `.config` that produced the running kernel, next to the headers tree
/// (`linux-headers-<ver>-generic/.config`), when the distro ships it.
fn real_produced_config(tree: &Path) -> Option<PathBuf> {
if tree.join(".config").is_file() {
return Some(tree.join(".config"));
}
let name = tree.file_name()?.to_str()?;
let parent = tree.parent()?;
let mut candidates: Vec<PathBuf> = std::fs::read_dir(parent)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(name) && n != name)
&& p.join(".config").is_file()
})
.collect();
candidates.sort();
candidates.pop().map(|p| p.join(".config"))
}
fn kconfig_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates/")
.join("nornir-build-thing/src/kconfig")
}
fn read(name: &str) -> String {
let p = kconfig_dir().join(name);
std::fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
}
fn policy_fragments() -> Vec<(String, String)> {
[
"base.config",
"deps-tinyconfig.config",
"hardened.config",
"nuked.config",
]
.iter()
.map(|n| ((*n).to_string(), read(n)))
.collect()
}
fn requested() -> Requested {
let owned = policy_fragments();
Requested::from_fragments(owned.iter().map(|(n, t)| (n.as_str(), t.as_str())))
}
fn immune_seeds() -> BTreeSet<String> {
let owned = policy_fragments();
immune_from_fragments(owned.iter().map(|(n, t)| (n.as_str(), t.as_str())))
}
/// The reader on 1 900 real Kconfig files: the relations `hardened.config`
/// cites in prose are read off the tree exactly.
#[test]
fn the_real_tree_reads_and_the_relations_hardened_config_cites_are_there() {
let Some(root) = real_tree() else { return };
let t = KconfigTree::read(&root, "x86").expect("the real tree must read");
eprintln!(
"{:<28} as bra {} symbols from {} Kconfig files, release {:?}, {} unresolved source(s): {:?}",
"purgatory-real-tree", t.len(), t.files_read(), t.release(), t.missing_sources().len(), t.missing_sources()
);
assert!(
t.len() > 10_000,
"a real tree has tens of thousands of symbols; got {}",
t.len()
);
assert!(t.files_read() > 1_000, "{}", t.files_read());
assert!(t.release().is_some());
// hardened.config:187-188 — `BLK_DEV_IO_TRACE` carries `select DEBUG_FS`.
assert!(
t.selects("BLK_DEV_IO_TRACE")
.contains(&"DEBUG_FS".to_string()),
"{:?}",
t.selects("BLK_DEV_IO_TRACE")
);
assert_eq!(
t.symbol("BLK_DEV_IO_TRACE").unwrap().file,
"kernel/trace/Kconfig"
);
// hardened.config:138 — `SECCOMP_FILTER` depends on NET.
let d = t.depends("SECCOMP_FILTER");
assert!(
d.contains(&"SECCOMP".to_string()) && d.contains(&"NET".to_string()),
"{d:?}"
);
// hardened.config:140-141 — `SECURITY_LANDLOCK depends on SECURITY`, `SECURITY depends on MULTIUSER`.
assert!(t
.depends("SECURITY_LANDLOCK")
.contains(&"SECURITY".to_string()));
assert!(t.depends("SECURITY").contains(&"MULTIUSER".to_string()));
// hardened.config:28-30 — the `bool "…" if EXPERT` vetoes. IO_URING still is
// one in 7.0; USERFAULTFD lost its `if EXPERT` after 6.12, which is exactly
// the kind of drift reading the tree (rather than trusting prose) catches.
let io = t.symbol("IO_URING").expect("IO_URING");
assert_eq!(io.prompt_cond.as_deref(), Some("EXPERT"), "{io:?}");
let uffd = t.symbol("USERFAULTFD").expect("USERFAULTFD");
assert!(uffd.prompt.is_some(), "{uffd:?}");
if uffd.prompt_cond.is_none() {
eprintln!(
"{:<28} as dåligt hardened.config:28-30 says USERFAULTFD is `bool \"…\" if EXPERT`; this {} tree prompts it unconditionally — prose drifted from the tree",
"purgatory-real-tree",
t.release().unwrap_or("?")
);
}
// The transitive answer to §2: what does BLK_DEV_IO_TRACE pull in?
let pulled = t.select_closure("BLK_DEV_IO_TRACE");
for s in [
"DEBUG_FS",
"RELAY",
"TRACEPOINTS",
"GENERIC_TRACER",
"TRACING",
"RING_BUFFER",
] {
assert!(pulled.contains(s), "{s} not in {pulled:?}");
}
}
/// **Invariant 2 on the real policy through the real graph**: the committed
/// fragments name ~N symbols; what those imply through `depends on` and
/// `select` is immune too, and it is a strictly larger set that contains what
/// the prose in hardened.config says it must.
#[test]
fn the_real_hardening_symbols_protect_what_they_depend_on() {
let Some(root) = real_tree() else { return };
let t = KconfigTree::read(&root, "x86").unwrap();
let seeds = immune_seeds();
let sub = KconfigAwareSubtractor::new(&t, seeds.clone(), false);
let im = sub.immune();
eprintln!("{:<28} as bra {} fragment symbols widen to {} immune symbols through the real Kconfig graph", "purgatory-immune", seeds.len(), im.len());
assert!(im.len() > seeds.len(), "the closure must widen the set");
assert!(im.is_superset(&seeds));
for s in [
"SECCOMP",
"SECCOMP_FILTER",
"NET",
"STACKPROTECTOR",
"HAVE_ARCH_SECCOMP_FILTER",
"HAVE_ARCH_SECCOMP",
] {
assert!(im.contains(s), "{s} must be immune (seed or implied)");
}
}
/// **The four view modes on the real produced `.config` of the running kernel
/// against the real policy** — and ADDED explained through the real graph.
#[test]
fn the_running_kernels_config_against_our_policy_dropped_and_added_explained() {
let Some(root) = real_tree() else { return };
let Some(cfg) = real_produced_config(&root) else {
eprintln!("{:<28} as dåligt SKIPPED: the headers tree has no sibling *-generic/.config to read as EFFECTIVE", "purgatory-real-config");
return;
};
let t = KconfigTree::read(&root, "x86").unwrap();
let eff = Effective::from_path(&cfg).expect("a real produced .config");
assert!(eff.len() > 5_000, "{}", eff.len());
assert_eq!(
eff.release().map(str::to_string),
t.release().map(str::to_string),
"the headers and their .config are one kernel"
);
let report = EffectiveReport::compute(requested(), eff);
let (req, effc, dropped, added) = report.counts();
eprintln!("{:<28} as bra REQUESTED {req} · EFFECTIVE {effc} · DROPPED {dropped} · ADDED {added} against {}", "purgatory-real-config", cfg.display());
// A distro kernel defeats our vetoes wholesale: the two the plan is built on
// are among the DROPPED, as `n` asked and `y` got.
for s in ["DEBUG_FS", "BLK_DEV_IO_TRACE", "BPF_SYSCALL"] {
let d = report
.dropped
.iter()
.find(|d| d.symbol == s)
.unwrap_or_else(|| {
panic!(
"{s} must be DROPPED; dropped={:?}",
report.dropped.iter().map(|d| &d.symbol).collect::<Vec<_>>()
)
});
assert_eq!(d.fragment.as_deref(), Some("hardened.config"), "{d:?}");
}
// ADDED is large on a distro kernel, and every one of them is explicable or
// honestly not: RELAY nobody asked for is explained by BLK_DEV_IO_TRACE.
assert!(added > 1_000, "{added}");
let enabled = report.effective.enabled();
let relay = t.explain_arrival("RELAY", &enabled);
assert!(
relay
.iter()
.any(|s| s.by == "BLK_DEV_IO_TRACE" && s.file == "kernel/trace/Kconfig"),
"{relay:?}"
);
let explained = report
.added
.iter()
.filter(|a| !t.explain_arrival(&a.symbol, &enabled).is_empty())
.count();
eprintln!("{:<28} as bra {explained} of {added} ADDED symbols are explained by an enabled `select`er; the rest are defaults or prompts", "purgatory-added");
assert!(explained > 500, "{explained}/{added}");
}
/// **Road 3's condition, on the real tree**: every entry of the checked table is
/// verified — syscall in the table, symbol in Kconfig, guard in the Makefile
/// chain — with ZERO rejections and ZERO unverifiable. A stale entry goes red
/// here instead of lying in a gate.
#[test]
fn every_checked_table_entry_is_verified_against_the_real_tree() {
let Some(root) = real_tree() else { return };
let t = KconfigTree::read(&root, "x86").unwrap();
let table = SyscallTable::read(&root, "x86").unwrap();
eprintln!(
"{:<28} as bra {} syscalls from {}",
"purgatory-syscall-table",
table.len(),
table.source()
);
assert!(table.len() > 300, "{}", table.len());
for s in [
"read",
"seccomp",
"bpf",
"io_uring_enter",
"landlock_create_ruleset",
"kexec_load",
] {
assert!(table.contains(s), "{s}");
}
// The composition the fixture proved, on the real Makefiles.
assert_eq!(
object_guards(&root, "kernel/bpf/syscall.o").unwrap(),
BTreeSet::from(["BPF".to_string(), "BPF_SYSCALL".to_string()])
);
assert_eq!(
object_guards(&root, "security/landlock/syscalls.o").unwrap(),
BTreeSet::from(["SECURITY_LANDLOCK".to_string()])
);
assert_eq!(
object_guards(&root, "mm/mprotect.o").unwrap(),
BTreeSet::from(["MMU".to_string()]),
"through $(mmu-y)"
);
assert!(object_guards(&root, "fs/read_write.o").unwrap().is_empty());
let c = check_table(CHECKED_TABLE, &root, &table, &t, None);
for (e, why) in &c.rejected {
eprintln!(
"{:<28} as dåligt STALE TABLE ENTRY {e}: {why}",
"purgatory-table"
);
}
for (e, why) in &c.unverifiable {
eprintln!(
"{:<28} as dåligt UNVERIFIABLE TABLE ENTRY {e}: {why}",
"purgatory-table"
);
}
assert!(c.rejected.is_empty(), "{:?}", c.rejected);
// On the GENERIC table, and only there, an x86 legacy call (`poll`, `pause`)
// cannot be looked up; that is the one unverifiable reason allowed.
for (e, why) in &c.unverifiable {
assert!(
table.is_generic() && why.contains("generic table"),
"{e}: {why}"
);
}
assert_eq!(c.accepted.len() + c.unverifiable.len(), CHECKED_TABLE.len());
eprintln!("{:<28} as bra all {} checked-table entries verified against {} (definitions unverifiable: no .c in a headers tree — {} noted)", "purgatory-table", c.accepted.len(), root.display(), c.definitions_unverified);
}
/// **The whole chain on real data**: tunnr's measured syscall list as the probe,
/// the real tree as the join, the running kernel's `.config` as EFFECTIVE, the
/// committed fragments as policy. First it FAILS CLOSED, because the generic
/// `syscall.tbl` has no `arch_prctl` and the probe saw one; then, with that one
/// removed, it proposes — and every proposal is in scope, none is policy, and
/// `SECCOMP` survives.
#[test]
fn tunnrs_measured_probe_through_the_real_join_on_the_running_kernel() {
let Some(root) = real_tree() else { return };
let Some(cfg) = real_produced_config(&root) else {
return;
};
let t = KconfigTree::read(&root, "x86").unwrap();
let j = SyscallJoin::build(&root, "x86", &t, &[]).unwrap();
let eff = Effective::from_path(&cfg).unwrap();
let sub = KconfigAwareSubtractor::new(&t, immune_seeds(), false);
// tunnr 961d534 `sandbox.rs` ALLOWED, How::Measured, plus `seccomp`.
let mut observed: BTreeSet<String> = [
"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",
]
.into_iter()
.map(str::to_string)
.collect();
let ev = ProbeEvidence::new(
Probe {
observed: observed.clone(),
revision: "961d534".into(),
how: "strace -f, tunnr 961d534".into(),
},
&j,
"961d534",
);
if j.table().is_generic() {
// The generic table is not x86's: arch_prctl (and the legacy poll/pause)
// cannot be placed → UNSOUND, and the gate subtracts nothing.
match ev.freshness() {
Freshness::Unsound { why } => assert!(why.contains("arch_prctl"), "{why}"),
f => panic!("a probe with arch_prctl on the generic table must be unsound, got {f:?}"),
}
let d = sub.propose(&eff, &ev.demand("tunnr"), &ev.freshness());
assert!(d.subtractions.is_empty() && d.refused.is_some());
eprintln!(
"{:<28} as bra FAIL CLOSED on real data: {}",
"purgatory-probe",
d.refused.unwrap()
);
let unplaced = ev.unplaced();
for n in &unplaced {
assert!(
["arch_prctl", "poll", "pause"].contains(&n.as_str()),
"only x86 legacy calls may be missing from the generic table, not `{n}`"
);
observed.remove(n);
}
}
let ev = ProbeEvidence::new(
Probe {
observed,
revision: "961d534".into(),
how: "strace -f, tunnr 961d534".into(),
},
&j,
"961d534",
);
assert_eq!(
ev.freshness(),
Freshness::Fresh {
revision: "961d534".into()
},
"{:?}",
ev.unplaced()
);
let demand = ev.demand("tunnr");
assert!(demand.symbols.contains("SECCOMP"), "{:?}", demand.symbols);
assert!(
demand.symbols.contains("FUTEX")
&& demand.symbols.contains("NET")
&& demand.symbols.contains("MMU"),
"{:?}",
demand.symbols
);
let Scope::Only(scope) = &demand.scope else {
panic!("a probe is never whole-scope")
};
let d = sub.propose(&eff, &demand, &ev.freshness());
assert!(d.refused.is_none(), "{:?}", d.refused);
eprintln!(
"{:<28} as bra {} subtractions proposed, {} spared, from a {}-symbol scope on {}",
"purgatory-probe",
d.subtractions.len(),
d.spared.len(),
scope.len(),
cfg.display()
);
for s in &d.subtractions {
assert!(
scope.contains(&s.symbol),
"{} is outside the probe's competence",
s.symbol
);
assert!(!sub.immune().contains(&s.symbol), "{} is policy", s.symbol);
assert!(
!demand.symbols.contains(&s.symbol),
"{} was reached",
s.symbol
);
eprintln!(" {}", s.symbol);
}
assert!(!d.subtractions.iter().any(|s| s.symbol == "SECCOMP"));
assert!(
d.spared
.iter()
.any(|s| s.symbol == "SECCOMP" && s.why.contains("IMMUNE")),
"policy is stated before evidence"
);
// Everything a syscall probe cannot see is spared as such, never cut.
let out_of_scope = d
.spared
.iter()
.filter(|s| s.why.contains("OUT OF SCOPE"))
.count();
assert!(
out_of_scope > 1_000,
"a distro kernel has thousands of symbols no syscall gates: {out_of_scope}"
);
}