crates/nornir-purgatory/src/kconfig/effective.rs
//! **The four view modes — Maven's `effective-pom`, for kconfig.**
//!
//! `REQUESTED · EFFECTIVE · DROPPED · ADDED`, as set arithmetic and nothing else.
//! Four different files, and two of them are invisible today
//! (`workspace_nordisk/mimir-v2.md` §3).
//!
//! | mode | what it is |
//! |---|---|
//! | `REQUESTED` | the union of every fragment — what we asked for |
//! | `EFFECTIVE` | the produced `.config` after `olddefconfig` — what ships |
//! | `DROPPED` | requested, not honoured in effective — the canary's refusal list |
//! | `ADDED` | enabled in effective, never requested — `olddefconfig`'s own `select` pulls |
//!
//! # Why the two invisible ones are the point
//!
//! Both caught real defects on 2026-09-07, by hand, which is the argument for
//! computing them:
//!
//! * **DROPPED** — the hardened overlay asked for 25 directives on `tinyconfig`
//! and **9 never survived `olddefconfig`** (tunnr F46). Invisible until the
//! canary ran.
//! * **ADDED** — `BLK_DEV_IO_TRACE` carries `select DEBUG_FS`, so a debugfs veto
//! lost to an undeclared dependency.
//!
//! **`ADDED` is the one with security value**: kernel functionality in the
//! shipped appliance that nobody asked for, arriving by `select` chain. That is
//! the surface the purgatorium exists to shrink, and today nobody looks at it.
//!
//! # The honesty rule this module is built around
//!
//! > *"The pane needs a real `.config` … It must not compute a predicted
//! > effective set and present it as measured."* — mimir-v2.md §3
//!
//! So [`Effective`] **cannot be constructed except from the bytes of a produced
//! `.config`**, and when there is no build yet the answer is the
//! [`Unavailable`] error, never a guess. A predicted effective set presented as
//! measured is precisely the class of defect this whole plan exists to catch, and
//! it would be a two-line convenience to add. It is not here on purpose.
use std::collections::{BTreeMap, BTreeSet};
use super::parse::{parse_config, Directive, Value};
/// **What we asked for**: the union of every committed fragment.
///
/// Conflicts between fragments are kept rather than silently resolved — two
/// fragments asking opposite things about one symbol is itself a finding, and
/// last-writer-wins would hide it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Requested {
by_symbol: BTreeMap<String, Value>,
conflicts: BTreeMap<String, Vec<Value>>,
/// Which fragment each symbol came from, for a message that names a file.
origin: BTreeMap<String, String>,
}
impl Requested {
/// Fold fragments in order. `(name, text)` so a finding can name its file.
#[must_use]
pub fn from_fragments<'a, I>(fragments: I) -> Requested
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
let mut r = Requested::default();
for (name, text) in fragments {
for d in parse_config(text) {
match r.by_symbol.get(&d.symbol) {
Some(prev) if *prev != d.value => {
r.conflicts
.entry(d.symbol.clone())
.or_insert_with(|| vec![prev.clone()])
.push(d.value.clone());
// Later fragment wins for the resolved view — that IS
// Kconfig's behaviour — but the disagreement is recorded.
r.by_symbol.insert(d.symbol.clone(), d.value.clone());
r.origin.insert(d.symbol.clone(), name.to_string());
}
Some(_) => {}
None => {
r.by_symbol.insert(d.symbol.clone(), d.value.clone());
r.origin.insert(d.symbol.clone(), name.to_string());
}
}
}
}
r
}
#[must_use]
pub fn get(&self, symbol: &str) -> Option<&Value> {
self.by_symbol.get(symbol)
}
#[must_use]
pub fn symbols(&self) -> BTreeSet<&str> {
self.by_symbol.keys().map(String::as_str).collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.by_symbol.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_symbol.is_empty()
}
/// Which fragment last set this symbol.
#[must_use]
pub fn origin(&self, symbol: &str) -> Option<&str> {
self.origin.get(symbol).map(String::as_str)
}
/// Symbols two fragments disagreed about, with every value seen.
#[must_use]
pub fn conflicts(&self) -> &BTreeMap<String, Vec<Value>> {
&self.conflicts
}
/// As directives, for rendering the REQUESTED view.
#[must_use]
pub fn directives(&self) -> Vec<Directive> {
self.by_symbol
.iter()
.map(|(s, v)| Directive {
symbol: s.clone(),
value: v.clone(),
})
.collect()
}
}
/// **What actually shipped**: the produced `.config`, after `olddefconfig`.
///
/// The only constructor takes the real file's bytes. There is deliberately no
/// `predict()`, no `from_requested()` and no `Default` — see the module docs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Effective {
by_symbol: BTreeMap<String, Value>,
/// From the `# Linux/x86 6.12.104 Kernel Configuration` banner, when present.
release: Option<String>,
}
/// Why there is no [`Effective`] to compare against.
///
/// A distinct type rather than an empty `Effective`, so a caller cannot mistake
/// "no build yet" for "the build produced nothing" — the pane must say
/// `EFFECTIVE unavailable — no build yet` rather than show an empty diff that
/// reads like a clean one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unavailable {
pub why: String,
}
impl std::fmt::Display for Unavailable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EFFECTIVE unavailable — {}", self.why)
}
}
impl std::error::Error for Unavailable {}
impl Effective {
/// Read a produced `.config`. The ONLY way to get one.
#[must_use]
pub fn from_produced_config(text: &str) -> Effective {
let mut by_symbol = BTreeMap::new();
for d in parse_config(text) {
by_symbol.insert(d.symbol, d.value);
}
let release = text.lines().take(10).find_map(|l| {
let l = l.trim().strip_prefix('#')?.trim();
let l = l.strip_prefix("Linux/")?;
let mut it = l.split_whitespace();
let _arch = it.next()?;
let rel = it.next()?;
(it.next()? == "Kernel" && it.next()? == "Configuration").then(|| rel.to_string())
});
Effective { by_symbol, release }
}
/// The kernel release that produced this `.config`, read off its banner —
/// so a Kconfig graph from another release can refuse to explain it.
#[must_use]
pub fn release(&self) -> Option<&str> {
self.release.as_deref()
}
/// Read it off disk; a missing file is [`Unavailable`], not an empty set.
pub fn from_path(path: &std::path::Path) -> Result<Effective, Unavailable> {
match std::fs::read_to_string(path) {
Ok(t) => Ok(Effective::from_produced_config(&t)),
Err(e) => Err(Unavailable {
why: format!("no produced .config at {} ({e})", path.display()),
}),
}
}
#[must_use]
pub fn get(&self, symbol: &str) -> Option<&Value> {
self.by_symbol.get(symbol)
}
/// A symbol absent from a `.config` is `n` — that is Kconfig's own rule.
#[must_use]
pub fn value_of(&self, symbol: &str) -> Value {
self.by_symbol.get(symbol).cloned().unwrap_or(Value::No)
}
#[must_use]
pub fn len(&self) -> usize {
self.by_symbol.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_symbol.is_empty()
}
/// Symbols that are actually ON (`y` or `m`).
#[must_use]
pub fn enabled(&self) -> BTreeSet<&str> {
self.by_symbol
.iter()
.filter(|(_, v)| v.is_enabled())
.map(|(s, _)| s.as_str())
.collect()
}
}
/// One symbol the build did not honour as asked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Drop_ {
pub symbol: String,
pub asked: Value,
pub got: Value,
/// The fragment that asked, when known.
pub fragment: Option<String>,
}
/// One symbol nobody asked for that is nonetheless in the kernel.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Added {
pub symbol: String,
pub got: Value,
}
/// The four modes, computed together because three of them are one traversal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveReport {
pub requested: Requested,
pub effective: Effective,
pub dropped: Vec<Drop_>,
pub added: Vec<Added>,
}
impl EffectiveReport {
/// Compute all four. Pure arithmetic over two sets — no kernel tree, no
/// solver, no container.
#[must_use]
pub fn compute(requested: Requested, effective: Effective) -> EffectiveReport {
let mut dropped = Vec::new();
for (symbol, asked) in requested.by_symbol.iter() {
let got = effective.value_of(symbol);
if got != *asked {
dropped.push(Drop_ {
symbol: symbol.clone(),
asked: asked.clone(),
got,
fragment: requested.origin(symbol).map(str::to_string),
});
}
}
// ADDED counts only symbols that are ON. A `.config` names thousands of
// symbols as `n`; "not requested and also not present" is not an
// addition, it is the whole rest of Kconfig.
let added = effective
.by_symbol
.iter()
.filter(|(s, v)| v.is_enabled() && requested.get(s).is_none())
.map(|(s, v)| Added {
symbol: s.clone(),
got: v.clone(),
})
.collect();
EffectiveReport {
requested,
effective,
dropped,
added,
}
}
/// One line per finding, in the workspace row form
/// (`nornir-workspace.toml:36`): a short id, a status, then the detail.
#[must_use]
pub fn rows(&self) -> Vec<String> {
let mut out = Vec::new();
for d in &self.dropped {
let from = d.fragment.as_deref().unwrap_or("?");
out.push(format!(
"{:<28} as dåligt DROPPED: {from} asked CONFIG_{}={} and the produced .config has {} — olddefconfig did not honour it. Either the symbol's `depends on` is unmet in this tree, or another fragment's veto won; a directive that silently does not survive is an opinion nobody enforced.",
format!("kconfig-{}", d.symbol.to_ascii_lowercase()),
d.symbol, d.asked, d.got
));
}
for a in &self.added {
out.push(format!(
"{:<28} as dåligt ADDED: CONFIG_{}={} is in the shipped kernel and NO fragment asked for it — it arrived through an undeclared `select` chain. This is the mode nobody looks at, and it is the one with security value: functionality in the appliance that no one requested.",
format!("kconfig-{}", a.symbol.to_ascii_lowercase()),
a.symbol, a.got
));
}
out
}
/// `(requested, effective, dropped, added)` — the headline counts.
#[must_use]
pub fn counts(&self) -> (usize, usize, usize, usize) {
(
self.requested.len(),
self.effective.len(),
self.dropped.len(),
self.added.len(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const FLOOR: &str = "\
CONFIG_SECCOMP=y
CONFIG_STACKPROTECTOR_STRONG=y
# CONFIG_DEBUG_FS is not set
";
const OVERLAY: &str = "\
CONFIG_RANDOMIZE_BASE=y
# CONFIG_ATA is not set
";
fn requested() -> Requested {
Requested::from_fragments([("hardened.config", FLOOR), ("nuked.config", OVERLAY)])
}
/// **DROPPED, the F46 shape**: a directive asked for that did not survive
/// `olddefconfig`. Nine of tunnr's twenty-five did exactly this.
#[test]
fn a_directive_that_did_not_survive_is_dropped_and_names_its_fragment() {
// The produced kernel honoured SECCOMP but not STACKPROTECTOR_STRONG.
let eff = Effective::from_produced_config(
"CONFIG_SECCOMP=y\nCONFIG_RANDOMIZE_BASE=y\n# CONFIG_ATA is not set\n",
);
let r = EffectiveReport::compute(requested(), eff);
let d: Vec<&str> = r.dropped.iter().map(|d| d.symbol.as_str()).collect();
assert_eq!(d, vec!["STACKPROTECTOR_STRONG"], "{:?}", r.dropped);
assert_eq!(r.dropped[0].asked, Value::Yes);
assert_eq!(r.dropped[0].got, Value::No, "absent from a .config IS n");
assert_eq!(r.dropped[0].fragment.as_deref(), Some("hardened.config"));
assert!(r.rows()[0].contains("DROPPED"));
}
/// **ADDED, the `BLK_DEV_IO_TRACE select DEBUG_FS` shape**: a veto lost to an
/// undeclared `select`, and the thing that arrived was never requested.
#[test]
fn a_select_chain_arrival_is_added_and_a_defeated_veto_is_dropped() {
let eff = Effective::from_produced_config(
"CONFIG_SECCOMP=y\nCONFIG_STACKPROTECTOR_STRONG=y\nCONFIG_RANDOMIZE_BASE=y\n\
# CONFIG_ATA is not set\nCONFIG_BLK_DEV_IO_TRACE=y\nCONFIG_DEBUG_FS=y\n",
);
let r = EffectiveReport::compute(requested(), eff);
// Nobody asked for BLK_DEV_IO_TRACE — it is an ADDITION.
let added: Vec<&str> = r.added.iter().map(|a| a.symbol.as_str()).collect();
assert_eq!(added, vec!["BLK_DEV_IO_TRACE"], "{:?}", r.added);
// And the debugfs veto WAS asked for and lost — that is a DROP, and the
// two together are the whole story of that defect.
let dropped: Vec<&str> = r.dropped.iter().map(|d| d.symbol.as_str()).collect();
assert_eq!(dropped, vec!["DEBUG_FS"]);
assert_eq!(r.dropped[0].asked, Value::No);
assert_eq!(r.dropped[0].got, Value::Yes);
assert!(r.rows().iter().any(|l| l.contains("ADDED")));
}
/// A `.config` names thousands of symbols as `n`. Those are not additions.
#[test]
fn unrequested_and_off_is_not_an_addition() {
let eff = Effective::from_produced_config(
"CONFIG_SECCOMP=y\nCONFIG_STACKPROTECTOR_STRONG=y\nCONFIG_RANDOMIZE_BASE=y\n\
# CONFIG_ATA is not set\n# CONFIG_XFS_FS is not set\n# CONFIG_BTRFS_FS is not set\n",
);
let r = EffectiveReport::compute(requested(), eff);
assert!(
r.added.is_empty(),
"off-and-unrequested is the rest of Kconfig: {:?}",
r.added
);
assert!(r.dropped.is_empty(), "{:?}", r.dropped);
}
/// **A predicted EFFECTIVE must be impossible, not merely discouraged.**
/// A missing `.config` is a typed refusal that says what to do.
#[test]
fn no_build_yet_is_unavailable_and_never_an_empty_diff() {
let e = Effective::from_path(std::path::Path::new(
"/nonexistent/.kernel-out/does-not-exist/.config",
))
.unwrap_err();
assert!(e.to_string().starts_with("EFFECTIVE unavailable"), "{e}");
assert!(e.to_string().contains("no produced .config"), "{e}");
// The contrast that makes the type worth having: an EMPTY .config is a
// real measurement and yields a full DROPPED list, not silence.
let empty = Effective::from_produced_config("");
let r = EffectiveReport::compute(requested(), empty);
assert_eq!(
r.dropped.len(),
3,
"3 of the 4 asked for something other than n"
);
assert!(r.added.is_empty());
}
/// Two fragments disagreeing is a finding, not a silent last-writer-wins.
#[test]
fn fragments_that_contradict_each_other_are_recorded() {
let r = Requested::from_fragments([
("a.config", "CONFIG_DEBUG_FS=y\n"),
("b.config", "# CONFIG_DEBUG_FS is not set\n"),
]);
assert_eq!(
r.get("DEBUG_FS"),
Some(&Value::No),
"later fragment wins, as Kconfig does"
);
assert_eq!(r.origin("DEBUG_FS"), Some("b.config"));
assert_eq!(
r.conflicts().get("DEBUG_FS").map(Vec::len),
Some(2),
"and the disagreement is KEPT: {:?}",
r.conflicts()
);
}
#[test]
fn counts_are_the_headline_and_a_clean_build_is_all_zero_diff() {
let eff = Effective::from_produced_config(
"CONFIG_SECCOMP=y\nCONFIG_STACKPROTECTOR_STRONG=y\nCONFIG_RANDOMIZE_BASE=y\n\
# CONFIG_DEBUG_FS is not set\n# CONFIG_ATA is not set\n",
);
let r = EffectiveReport::compute(requested(), eff);
let (req, effc, dropped, added) = r.counts();
// 5 requested: SECCOMP, STACKPROTECTOR_STRONG, DEBUG_FS from the floor and
// RANDOMIZE_BASE, ATA from the overlay — the two `n` vetoes count, because
// a veto is a claim (see `Value::No`). I first wrote 4 here by forgetting
// exactly that, and this test caught it.
assert_eq!(
(req, dropped, added),
(5, 0, 0),
"an honoured build has no diff"
);
assert_eq!(effc, 5);
assert!(r.rows().is_empty());
}
}