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