crates/nornir-build-thing/src/kernel_features.rs
//! **The SHARED kernel feature model** — a preset's kconfig symbol set as data:
//! names, states, the dependency forest, the cascade, and the committable fragment.
//!
//! # Why this lives HERE and not in a pane
//!
//! It used to live in dwarves-gui's `kernel_scene.rs`, fused to the 3D camera that
//! painted it — and that file admitted the shape in its own words: *"this is the join
//! edda's `KernelRun::unused_configs` performs, brought over"*. A brought-over join is
//! a TWIN (LAW 5), and it had the twin's usual consequence: any build thing that
//! wanted to answer "which features does this kernel offer, and what happens if I
//! drop one" had to parse the kconfig fragments itself, because the one existing
//! answer was welded to a renderer.
//!
//! The owner, 2026-08-26: *"kan vi få build things som drar en kernel att återvända
//! denna, så att inte varje build thing själv parsar kernel-config"*. This module is
//! that: the model with **no egui, no camera, no colour** in it. The pane renders
//! this; a CLI lists it; a build thing that composes a kernel asks it — one
//! derivation, three doors.
//!
//! # What the model IS
//!
//! Everything is derived from the COMMITTED fragments this crate already owns
//! ([`crate::kernel::BASE_FRAGMENT`] and friends) — no solver, no shell-out, no
//! container round-trip. The rules, unchanged from where they were proven:
//!
//! * **the parent relation is the Kconfig NAME hierarchy** — `P` parents `C` when `P`
//! is the longest symbol present such that `C` starts with `P` followed by `_`;
//! * **contract** symbols are those [`crate::kernel::BASE_FRAGMENT`] names — the
//! measured boot contract. Dropping one is refused BY NAME;
//! * **closure** symbols come from the tinyconfig dependency-closure fragment;
//! * the **cascade** runs both directions to a fixed point: a symbol whose parent is
//! dropped is dropped (forward), and a non-contract symbol whose every child is
//! dropped is dropped too (backward — "remove this part and then no need for this
//! dependency"). A symbol with no derivable child is never dropped backward: its
//! justification is simply not in this repository, and guessing would be a lie.
use std::collections::BTreeMap;
use anyhow::{anyhow, Result};
use crate::kernel::{
kconfig_symbol, merge_kconfig, HardeningProfile, Preset, BASE_FRAGMENT,
TINYCONFIG_DEPS_FRAGMENT,
};
/// One kconfig symbol in the picked set.
#[derive(Clone, Debug)]
pub struct KernelFeature {
/// `CONFIG_X`.
pub name: String,
/// The merged directive verbatim (`CONFIG_X=y` or `# CONFIG_X is not set`).
pub directive: String,
/// `true` for a positive directive, `false` for the `is not set` spelling. A vetoed
/// symbol is a feature the preset states as ABSENCE — that is the whole content of
/// the smallest preset, so it is a node, not a gap.
pub enabled: bool,
/// Named by the measured boot contract. Dropping one is refused by name.
pub contract: bool,
/// Named by the tinyconfig dependency-closure fragment — a PARENT that exists only
/// to enable something else.
pub closure: bool,
/// **What a RUN measured about this symbol**, when a report has been joined:
/// `Some(hits)` summed over every function in every object the symbol compiles,
/// `None` when the report does not mention it at all.
///
/// `Some(0)` and `None` are DIFFERENT and must stay so: zero hits is a measured
/// drop candidate, and absence is "no measurement covers this".
pub run_hits: Option<u64>,
/// Index of the derivable parent (the longest name-prefix symbol present), if any.
pub parent: Option<usize>,
/// Indices of the derivable children.
pub children: Vec<usize>,
}
impl KernelFeature {
/// Whether this symbol has no derivable edge at all — a consumer counts these and
/// says so, because a node floating alone is a claim about the DERIVATION, not
/// about the kernel.
#[must_use]
pub fn isolated(&self) -> bool {
self.parent.is_none() && self.children.is_empty()
}
}
/// What one symbol IS under a set of click-offs — the five states §10 names.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FeatureState {
/// In the preset, nothing derivable depends on it, nobody turned it off.
Kept,
/// Something you kept still needs it — it cannot go, and [`KernelFeatureModel::holders`]
/// names who.
Locked,
/// You turned this off, and the drop stands.
ClickedOff,
/// Nothing else needs it any more — it goes too. *The* answer to "remove this part
/// and then no need for this dependency."
CascadeDropped,
/// Dropping it would break the measured boot contract (`base.config`). Refused BY
/// NAME — never silently, and the drop does not take.
Refused,
}
impl FeatureState {
/// The stable token that rides in observable state and on the wire.
#[must_use]
pub fn key(self) -> &'static str {
match self {
FeatureState::Kept => "kept",
FeatureState::Locked => "locked",
FeatureState::ClickedOff => "clicked_off",
FeatureState::CascadeDropped => "cascade_dropped",
FeatureState::Refused => "refused",
}
}
}
/// The outcome of asking to toggle one symbol — the pure half of a click, so the CLI's
/// `--opt kernel.<symbol>=off` and a pane's pointer answer identically.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToggleOutcome {
/// The symbol was off and is back on; the new click-off set is inside the model's
/// caller (it removed the name).
On,
/// The drop stands.
Off,
/// The drop would break the measured boot contract — nothing changed, and here are
/// the contract symbols it would have broken, by name.
Refused(Vec<String>),
}
/// The symbol names a fragment names, in order.
fn symbols_of(fragment: &str) -> Vec<&str> {
fragment.lines().filter_map(kconfig_symbol).collect()
}
/// `true` when `parent` is a Kconfig name-hierarchy ancestor of `child`: the same name
/// plus `_` plus more. `CONFIG_NET` parents `CONFIG_NET_CORE` and does NOT parent
/// `CONFIG_NETDEVICES` — the underscore boundary is the whole rule, and without it the
/// relation would start inventing edges out of coincidental prefixes.
fn name_parents(parent: &str, child: &str) -> bool {
child.len() > parent.len() + 1
&& child.starts_with(parent)
&& child.as_bytes()[parent.len()] == b'_'
}
/// Every symbol any committed fragment of the kind names — the UNIVERSE a preset's pick
/// is a subset of. Deduped, order-stable.
#[must_use]
pub fn committed_universe() -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for frag in [
TINYCONFIG_DEPS_FRAGMENT,
BASE_FRAGMENT,
crate::kernel::HARDENED_FRAGMENT,
crate::kernel::K8S_HARDENED_FRAGMENT,
// The absence overlay only reaches the universe through the smallest preset.
Preset::Nuked.fragments()[2],
] {
for s in symbols_of(frag) {
if !out.iter().any(|k| k == s) {
out.push(s.to_string());
}
}
}
out
}
/// **The derived model for one pick** — `preset × profile`, nothing else.
///
/// Owns no selection, no click-off set and no refusal: those are a CONSUMER's state
/// (a pane's pointer, a CLI's flags). Every method that needs them takes them as
/// arguments, which is exactly what lets two consumers hold different picks over one
/// derivation.
#[derive(Clone, Debug)]
pub struct KernelFeatureModel {
/// The pick this model is true for.
pub preset: Preset,
/// …and its hardening half.
pub profile: HardeningProfile,
/// The symbols, in merged-fragment order.
pub symbols: Vec<KernelFeature>,
/// `(parent, child, parent_is_closure)` — the derivable forest, as edges.
pub edges: Vec<(usize, usize, bool)>,
}
impl KernelFeatureModel {
/// Derive the model for a pick. The ONLY constructor, so there is exactly one
/// spelling of the parent rule and the contract join in the constellation.
#[must_use]
pub fn derive(preset: Preset, profile: HardeningProfile) -> Self {
let mut frags = preset.fragments();
frags.extend(profile.fragments());
let merged = merge_kconfig(&frags);
let contract: Vec<&str> = symbols_of(BASE_FRAGMENT);
let closure: Vec<&str> = symbols_of(TINYCONFIG_DEPS_FRAGMENT);
let mut symbols: Vec<KernelFeature> = merged
.lines()
.filter_map(|line| {
let name = kconfig_symbol(line)?;
Some(KernelFeature {
name: name.to_string(),
directive: line.trim().to_string(),
enabled: !line.trim_start().starts_with('#'),
contract: contract.contains(&name),
closure: closure.contains(&name),
run_hits: None,
parent: None,
children: Vec::new(),
})
})
.collect();
// The parent of a symbol is the LONGEST name-hierarchy ancestor present in this
// pick — so `CONFIG_SECURITY_LOCKDOWN_LSM_EARLY` hangs off
// `CONFIG_SECURITY_LOCKDOWN_LSM` and not off `CONFIG_SECURITY`, and the
// relation is a forest rather than a transitive smear.
for i in 0..symbols.len() {
let mut best: Option<usize> = None;
for j in 0..symbols.len() {
if i == j || !name_parents(&symbols[j].name, &symbols[i].name) {
continue;
}
if best.is_none_or(|b| symbols[j].name.len() > symbols[b].name.len()) {
best = Some(j);
}
}
symbols[i].parent = best;
}
let mut edges: Vec<(usize, usize, bool)> = Vec::new();
for i in 0..symbols.len() {
if let Some(p) = symbols[i].parent {
symbols[p].children.push(i);
let is_closure = symbols[p].closure;
edges.push((p, i, is_closure));
}
}
Self { preset, profile, symbols, edges }
}
/// **Join a RUN's measurement onto the derived graph.** `Some(0)` (measured and
/// never entered) and `None` (no measurement covers this symbol) are kept apart
/// deliberately: one is a drop candidate, the other is ignorance.
pub fn join_run_hits(&mut self, hits: &BTreeMap<String, u64>) {
for sym in &mut self.symbols {
sym.run_hits = hits.get(&sym.name).copied();
}
}
/// Which symbols go if `off` goes, as a per-index mask.
///
/// Two directions, and both are the same fact read from opposite ends:
///
/// * **forward** — a symbol whose parent is dropped is dropped: it cannot exist
/// without what enables it;
/// * **backward** — a symbol that has children, is not itself in the boot
/// contract, and has NO kept child left, is dropped: nothing needs it any more.
///
/// A symbol with no derivable child is NEVER dropped backward — its justification
/// is simply not in this repository, and guessing would be the lie the module docs
/// refuse.
#[must_use]
pub fn cascade_for(&self, off: &[String]) -> Vec<bool> {
let mut dropped = vec![false; self.symbols.len()];
for name in off {
if let Some(i) = self.symbols.iter().position(|s| &s.name == name) {
dropped[i] = true;
}
}
// Iterate to a fixed point — a backward drop can trigger another forward one.
loop {
let mut changed = false;
for i in 0..self.symbols.len() {
if dropped[i] {
continue;
}
let s = &self.symbols[i];
let parent_gone = s.parent.map(|p| dropped[p]).unwrap_or(false);
let orphaned = !s.children.is_empty()
&& !s.contract
&& s.children.iter().all(|&c| dropped[c]);
if parent_gone || orphaned {
dropped[i] = true;
changed = true;
}
}
if !changed {
break;
}
}
dropped
}
/// The state of every symbol, in `symbols` order, under a click-off set and an
/// optional standing refusal `(clicked, broken)`.
#[must_use]
pub fn states(
&self,
clicked_off: &[String],
refusal: Option<(&str, &[String])>,
) -> Vec<FeatureState> {
let dropped = self.cascade_for(clicked_off);
let refused: Vec<&str> = match refusal {
Some((clicked, broken)) => {
let mut v: Vec<&str> = broken.iter().map(String::as_str).collect();
v.push(clicked);
v
}
None => Vec::new(),
};
(0..self.symbols.len())
.map(|i| {
let s = &self.symbols[i];
if refused.contains(&s.name.as_str()) {
FeatureState::Refused
} else if clicked_off.iter().any(|n| n == &s.name) {
FeatureState::ClickedOff
} else if dropped[i] {
FeatureState::CascadeDropped
} else if s.children.iter().any(|&c| !dropped[c]) {
FeatureState::Locked
} else {
FeatureState::Kept
}
})
.collect()
}
/// Who still holds `i` — the kept children that keep a `Locked` symbol alive.
#[must_use]
pub fn holders(&self, i: usize, dropped: &[bool]) -> Vec<String> {
self.symbols[i]
.children
.iter()
.filter(|&&c| !dropped[c])
.map(|&c| self.symbols[c].name.clone())
.collect()
}
/// **Ask to toggle one symbol** — the pure decision half of a click, shared by the
/// pane's pointer and the CLI's `--opt kernel.<symbol>=off`.
///
/// `clicked_off` is MUTATED on success (the name added or removed) and untouched on
/// a refusal. A drop that would reach the measured boot contract is REFUSED: the
/// returned list names the contract symbols it would have broken, which is the
/// difference between a door that says no and a door that says nothing.
pub fn try_toggle(&self, clicked_off: &mut Vec<String>, name: &str) -> Option<ToggleOutcome> {
self.symbols.iter().position(|s| s.name == name)?;
if let Some(pos) = clicked_off.iter().position(|n| n == name) {
clicked_off.remove(pos);
return Some(ToggleOutcome::On);
}
// Would this drop reach the contract? Ask BEFORE committing it.
let mut trial = clicked_off.clone();
trial.push(name.to_string());
let dropped = self.cascade_for(&trial);
let broken: Vec<String> = dropped
.iter()
.enumerate()
.filter(|&(j, &d)| d && self.symbols[j].contract)
.map(|(j, _)| self.symbols[j].name.clone())
.collect();
if !broken.is_empty() {
return Some(ToggleOutcome::Refused(broken));
}
*clicked_off = trial;
Some(ToggleOutcome::Off)
}
/// **THE DECISION, AS A KCONFIG FRAGMENT** — what a click (or a `--opt`) is FOR.
///
/// Renders the current pick as a fragment a person can commit beside
/// `nuked.config` and name in `kconfig_fragment`, so a decision made anywhere —
/// the pane's pointer or the CLI's flags — reaches a real build.
///
/// Every dropped symbol is written as `# CONFIG_X is not set` — the veto
/// spelling, NOT an omission — because the canary catches a veto creeping back in
/// and cannot catch a line that was never written. The cascade is included:
/// dropping a parent drops its children, and the fragment says all of them so the
/// file states the whole consequence rather than the click.
///
/// `evidence_source` names the RUN report whose hit counts were joined via
/// [`join_run_hits`](Self::join_run_hits), or `None` when nothing was measured.
/// The header records WHY each symbol was dropped, and the evidence is exactly as
/// strong as the model holds — it never claims a measurement it does not hold,
/// and a click AGAINST a measurement is written down as such.
#[must_use]
pub fn decision_fragment(&self, clicked_off: &[String], evidence_source: Option<&str>) -> String {
fn strip_config(name: &str) -> &str {
name.strip_prefix("CONFIG_").unwrap_or(name)
}
let dropped = self.cascade_for(clicked_off);
let mut out = String::new();
out.push_str("# nordisk KERNEL DECISION — rendered from the shared kernel feature model.\n#\n");
out.push_str(&format!(
"# preset = {} · profile = {}\n",
self.preset.as_str(),
self.profile.as_str()
));
match evidence_source {
Some(src) => out.push_str(&format!(
"# evidence: a RUN report from {src} — a symbol marked `never entered` was\n # MEASURED at zero hits by a profiled boot, not guessed.\n",
)),
None => out.push_str(
"# evidence: NONE. No RUN report was loaded, so every line below is the\n # operator's choice and nothing here was measured.\n",
),
}
out.push_str(
"#\n# Each drop is written as a VETO (`# CONFIG_X is not set`) rather than left\n # out: the build's canary catches a veto that creeps back in, and cannot catch\n # a line nobody wrote. Cascade drops are included, so this file states the whole\n # consequence of the clicks and not just the clicks.\n\n",
);
let mut any = false;
for (i, sym) in self.symbols.iter().enumerate() {
if !dropped[i] {
continue;
}
any = true;
let why = if clicked_off.iter().any(|n| n == &sym.name) {
match sym.run_hits {
Some(0) => "clicked off; the RUN measured ZERO hits",
Some(h) => {
out.push_str(&format!(
"# WARNING: {} was clicked off although the RUN measured {h} hit(s).\n",
sym.name
));
"clicked off AGAINST the measurement"
}
None => "clicked off; no measurement covers this symbol",
}
} else {
"dropped by cascade — nothing kept needs it"
};
out.push_str(&format!("# {why}\n# CONFIG_{} is not set\n", strip_config(&sym.name)));
}
if !any {
out.push_str("# (nothing is dropped — this fragment changes nothing)\n");
}
out
}
}
// ── the OPTION-ROW emission: the model as a declared vocabulary ──────────────
/// The `--opt kernel.<name>` handle for a kconfig symbol: `CONFIG_` stripped,
/// lowercased, `_` → `-`. `CONFIG_VIRTIO_NET` → `virtio-net`. One spelling, one
/// direction — the reverse lookup is [`KernelFeatureModel::symbol_for_option`],
/// which walks THIS function over the model rather than parsing the kebab form
/// back (a second grammar would be a second chance to disagree).
#[must_use]
pub fn option_name(symbol: &str) -> String {
symbol
.strip_prefix("CONFIG_")
.unwrap_or(symbol)
.to_ascii_lowercase()
.replace('_', "-")
}
/// **One declared option row** — a kconfig symbol of the pick, spoken in the
/// same vocabulary as an installer's [`OptionSpec`](crate::options::OptionSpec)
/// row: a name the CLI types, a default, and what refuses when you turn it off.
///
/// Presets are NOT a separate mechanism: a preset is the named DEFAULT-BUNDLE
/// these rows start from ([`crate::kernel::Preset::ALL`]), and `default_on` is
/// literally "enabled in the chosen preset".
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KernelOptionRow {
/// The `--opt kernel.<option>` handle ([`option_name`] of the symbol).
pub option: String,
/// The kconfig symbol, verbatim: `CONFIG_VIRTIO_NET`.
pub symbol: String,
/// Enabled in the preset — the state when nobody says otherwise. `false`
/// is a symbol the preset states as ABSENCE (a veto row is still a row).
pub default_on: bool,
/// The derivable parent this symbol requires, if the model holds one
/// (the Kconfig name hierarchy — dropping the parent drops this).
pub requires: Option<String>,
/// **The contract lock**: the boot-contract symbols that break if this row
/// is turned off, asked of [`KernelFeatureModel::try_toggle`] itself — the
/// same door that will refuse the actual `--opt`, so the listing and the
/// refusal cannot disagree. Empty ⇒ the row may be turned off.
pub locked_by: Vec<String>,
}
impl KernelOptionRow {
/// One row, one line — what `--list-options` prints and what a pane's
/// tooltip says. `kernel.virtio-net CONFIG_VIRTIO_NET default=on
/// requires=CONFIG_VIRTIO LOCKED(breaks CONFIG_VIRTIO_NET)`.
#[must_use]
pub fn describe(&self) -> String {
let mut s = format!(
"kernel.{} {} default={}",
self.option,
self.symbol,
if self.default_on { "on" } else { "off" }
);
if let Some(req) = &self.requires {
s.push_str(&format!(" requires={req}"));
}
if !self.locked_by.is_empty() {
s.push_str(&format!(" LOCKED(breaks {})", self.locked_by.join(", ")));
}
s
}
}
impl KernelFeatureModel {
/// **The declared rows, one per symbol of the pick** — the kernel half of
/// `--list-options`, in merged-fragment order (the same order the pane
/// draws). Every symbol is a row, including the vetoed ones: the smallest
/// preset consists ENTIRELY of stated absences, and a vocabulary that
/// omitted them would list nothing exactly where the preset says the most.
#[must_use]
pub fn option_rows(&self) -> Vec<KernelOptionRow> {
self.symbols
.iter()
.map(|s| {
// Ask the toggling door itself whether this row is locked, from
// a clean slate: a refusal here IS the refusal the CLI would
// get, so the listing can never advertise a drop the door
// refuses (or vice versa).
let mut probe: Vec<String> = Vec::new();
let locked_by = match self.try_toggle(&mut probe, &s.name) {
Some(ToggleOutcome::Refused(broken)) => broken,
_ => Vec::new(),
};
KernelOptionRow {
option: option_name(&s.name),
symbol: s.name.clone(),
default_on: s.enabled,
requires: s.parent.map(|p| self.symbols[p].name.clone()),
locked_by,
}
})
.collect()
}
/// The kconfig symbol behind an `--opt kernel.<option>` handle, or `None`
/// when the pick declares no such row (the caller's refusal then lists
/// [`option_rows`](Self::option_rows) — never applies the flag to nothing).
#[must_use]
pub fn symbol_for_option(&self, option: &str) -> Option<&str> {
self.symbols
.iter()
.find(|s| option_name(&s.name) == option)
.map(|s| s.name.as_str())
}
}
// ── the CLI resolver: `--opt kernel.*` through the SAME door as the pane ─────
/// **What the flags decided** — one preset pick plus the click-off set that
/// survived [`KernelFeatureModel::try_toggle`], and the fragment that states it.
///
/// The pane's pointer and the CLI's `--opt` both end here, and the fragment is
/// rendered by the one writer ([`KernelFeatureModel::decision_fragment`]), so
/// the same decision is the same bytes whichever door it came through.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KernelDecision {
/// The default-bundle the rows started from (declared, or overridden by
/// `kernel.preset=<name>`).
pub preset: Preset,
/// The hardening half of the pick — the declaration's, never a flag's.
pub profile: HardeningProfile,
/// The symbols the operator turned off, exactly as the model accepted them.
pub clicked_off: Vec<String>,
/// [`KernelFeatureModel::decision_fragment`] over `clicked_off` — the
/// committable veto fragment, cascade included.
pub fragment: String,
/// `kernel.coverage=<probe>`: the DEV probe kernel
/// ([`crate::kernel_trim::coverage`]), landed by
/// [`crate::kernel::KernelBuild::apply_decision`]. `None` = the row's kernel.
pub probe: Option<crate::kernel_trim::coverage::KernelProbe>,
}
impl KernelDecision {
/// Whether the fragment actually vetoes anything — the difference between
/// "merge this last" and "there is nothing to merge". A comment-only
/// fragment must not ride into a build's content id.
#[must_use]
pub fn changes_anything(&self) -> bool {
self.fragment.lines().any(|l| kconfig_symbol(l).is_some())
}
}
/// **A decision fragment, READ BACK** — what [`parse_decision_fragment`]
/// recovers from the committed text: the pick and the clicks, exactly the
/// inputs [`KernelFeatureModel::decision_fragment`] rendered from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParsedKernelDecision {
/// The preset the header names.
pub preset: Preset,
/// The profile the header names.
pub profile: HardeningProfile,
/// The symbols whose why-line attributes the veto to a CLICK
/// (`clicked off…`), in fragment order — the operator's own decisions.
/// Cascade drops are deliberately NOT here: they are consequences, and
/// re-deriving the model from the pick regenerates them.
pub clicked_off: Vec<String>,
/// Every vetoed symbol — clicks and cascade together, in fragment order.
pub vetoed: Vec<String>,
}
/// **Parse a decision fragment back into the decision** — the inverse of
/// [`KernelFeatureModel::decision_fragment`], which was designed to carry it:
/// the header states `preset` and `profile`, and every veto line sits under a
/// why-line saying whether it was a click or a cascade.
///
/// The round trip is a bijection law (see [`crate::laws`]):
/// `parse(render(preset, profile, clicked_off))` yields the same pick and the
/// same click set, and the parsed `vetoed` list equals the model's
/// [`cascade_for`](KernelFeatureModel::cascade_for) over those clicks.
///
/// # Errors
///
/// * no `# preset = … · profile = …` header, or two of them — a fragment that
/// does not carry its pick cannot be read back, and the render half always
/// writes exactly one;
/// * an unknown or empty preset / profile name in the header;
/// * a line that SETS a value (`CONFIG_X=y`) — a decision fragment can only
/// veto, so a value line means this text is not a decision fragment.
pub fn parse_decision_fragment(fragment: &str) -> Result<ParsedKernelDecision> {
let mut pick: Option<(Preset, HardeningProfile)> = None;
let mut clicked_off: Vec<String> = Vec::new();
let mut vetoed: Vec<String> = Vec::new();
let mut prev: Option<String> = None;
for raw in fragment.lines() {
let line = raw.trim().to_string();
if let Some(rest) = line.strip_prefix("# preset = ") {
if pick.is_some() {
return Err(anyhow!(
"two '# preset = …' header lines — which pick is this fragment for?"
));
}
let Some((p, pr)) = rest.split_once(" · profile = ") else {
return Err(anyhow!(
"the header line does not carry ' · profile = …': {line:?}"
));
};
if p.trim().is_empty() || pr.trim().is_empty() {
return Err(anyhow!("the header names an empty preset or profile: {line:?}"));
}
let preset = Preset::parse(p).ok_or_else(|| {
anyhow!(
"the header names unknown preset '{p}'; declared bundles: {}",
Preset::ALL.map(|x| x.as_str()).join(" · ")
)
})?;
let profile = HardeningProfile::parse(pr)
.ok_or_else(|| anyhow!("the header names unknown profile '{pr}'"))?;
pick = Some((preset, profile));
} else if let Some(sym) = kconfig_symbol(&line) {
if !line.starts_with('#') {
return Err(anyhow!(
"'{line}' SETS a value — a decision fragment can only veto \
(`# CONFIG_X is not set`), so this text is not one"
));
}
vetoed.push(sym.to_string());
let why = prev.as_deref().unwrap_or("");
if why.trim_start_matches('#').trim_start().starts_with("clicked off") {
clicked_off.push(sym.to_string());
}
}
prev = Some(line);
}
let Some((preset, profile)) = pick else {
return Err(anyhow!(
"no '# preset = … · profile = …' header — this text is not a decision \
fragment (or the render half stopped writing the pick, which breaks \
the decision ↔ fragment bijection)"
));
};
Ok(ParsedKernelDecision { preset, profile, clicked_off, vetoed })
}
/// Split a raw `--opt` list into the kernel pairs and the rest.
///
/// `kernel.preset=nuked` → `("preset", "nuked")`; `kernel.virtio-net=off` →
/// `("virtio-net", "off")`; everything not `kernel.`-prefixed passes through
/// verbatim for the installer-option path. The split happens BEFORE
/// [`crate::options::parse_cli_options`] because a kernel pair's value is not
/// `on|off` only (`preset=` carries a name), and because an installer resolver
/// handed `kernel.preset` would refuse it as an undeclared option — true words
/// about the wrong subject.
///
/// # Errors
///
/// A `kernel.`-prefixed entry with no `=`, or with an empty name or value, is
/// refused here — malformed is malformed whichever axis it meant.
pub fn split_kernel_opts(raw: &[String]) -> Result<(Vec<(String, String)>, Vec<String>)> {
let mut kernel: Vec<(String, String)> = Vec::new();
let mut rest: Vec<String> = Vec::new();
for o in raw {
let Some(k) = o.strip_prefix("kernel.") else {
rest.push(o.clone());
continue;
};
let Some((name, value)) = k.split_once('=') else {
return Err(anyhow!(
"--opt kernel.{k}: a kernel option is `kernel.<name>=<value>` \
(`kernel.preset=slim`, `kernel.virtio-net=off`)"
));
};
if name.trim().is_empty() || value.trim().is_empty() {
return Err(anyhow!("--opt {o}: empty name or value"));
}
kernel.push((name.trim().to_string(), value.trim().to_string()));
}
Ok((kernel, rest))
}
/// **Resolve `kernel.preset=<p>` + `kernel.<symbol>=on|off` pairs into a
/// [`KernelDecision`]** — the CLI's whole kernel grammar, in declaration order,
/// every step through [`KernelFeatureModel::try_toggle`].
///
/// `declared_preset` / `declared_profile` are the `[[build.kernel]]` row's own
/// pick — the state when nobody says otherwise, exactly as an installer
/// option's `default` is.
///
/// # Refusals — every one BY NAME, never a silent ignore
///
/// * a second `kernel.preset=` (which one was meant?);
/// * an unknown preset name, listing [`Preset::ALL`];
/// * an unknown `kernel.<name>`, listing the declared rows;
/// * a value that is neither `on` nor `off` for a symbol row;
/// * `on` for a symbol the preset vetoes — the decision fragment can only veto,
/// so "add this to the bundle" means picking a bundle that has it;
/// * **a contract-breaking `off`** — [`ToggleOutcome::Refused`] surfaces as an
/// error naming the boot-contract symbols the drop would have broken.
pub fn resolve_kernel_cli(
declared_preset: Preset,
declared_profile: HardeningProfile,
pairs: &[(String, String)],
) -> Result<KernelDecision> {
use crate::tree::{SettingsTree, ToggleRequest};
// The tree of the DECLARED pick judges; the preset choice rides `vals`
// (a val is chosen, not toggled) and re-derives the model inside the tree.
let declared = SettingsTree::of_kernel_pick("kernel", declared_preset, declared_profile);
let mut vals: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
for (name, value) in pairs {
if name != "preset" {
continue;
}
if vals.contains_key("preset") {
return Err(anyhow!("kernel.preset given twice — one bundle per build"));
}
Preset::parse(value).ok_or_else(|| {
anyhow!(
"kernel.preset={value}: unknown preset; declared bundles: {}",
Preset::ALL.map(|p| p.as_str()).join(" · ")
)
})?;
vals.insert("preset".to_string(), value.trim().to_ascii_lowercase());
}
// **`profile` is the kernel subtree's OTHER val, and it is chosen the same
// way.** `kernel_nodes` has rendered `preset` and `profile` side by side as
// vals since the tree landed, and ⚒ hands BYGG every flag the tree spells —
// so the pane offered `--opt kernel.profile=<p>` and this door refused it with
// *"a symbol row takes on|off"*, because the loop below judges anything that is
// not `preset` as a feature. A flag the pane renders and the prompt refuses is
// law C broken across the doors; found 2026-08-27 by the ⚒ lane comparing the
// two doors' plans on gunnar's real declaration, where it made
// `BYGG kärnan` unrunnable.
for (name, value) in pairs {
if name != "profile" {
continue;
}
if vals.contains_key("profile") {
return Err(anyhow!("kernel.profile given twice — one hardening profile per build"));
}
HardeningProfile::parse(value).ok_or_else(|| {
anyhow!("kernel.profile={value}: unknown profile; declared profiles: {}", HardeningProfile::names())
})?;
vals.insert("profile".to_string(), value.trim().to_ascii_lowercase());
}
// The tree the chosen preset spells — the rows the operator's `kernel.<name>`
// handles resolve against are the rows of THAT bundle. The profile picks which
// overlay layers on top, and so which fragment the decision renders.
let preset = vals.get("preset").and_then(|v| Preset::parse(v)).unwrap_or(declared_preset);
let profile = vals.get("profile").and_then(|v| HardeningProfile::parse(v)).unwrap_or(declared_profile);
let tree = if preset == declared_preset && profile == declared_profile {
declared
} else {
SettingsTree::of_kernel_pick("kernel", preset, profile)
};
let mut req = ToggleRequest { variant: "kernel".to_string(), path: String::new(), on: false, state: Default::default(), vals: vals.clone() };
for (name, value) in pairs {
// `coverage` is the DEV probe val (`kernel_trim::coverage`), landed by
// `chosen_kernel_for` beside the decision, never a symbol row.
if name == "preset" || name == "profile" || name == crate::kernel_trim::coverage::COVERAGE_OPT {
continue;
}
if tree.get(name).is_none() {
return Err(anyhow!(
"--opt kernel.{name}: no such symbol in the {} pick; declared rows: {}",
preset.as_str(),
tree.walk()
.into_iter()
.filter(|n| matches!(n.kind, crate::tree::NodeKind::Feature(_)))
.map(|n| n.path.clone())
.collect::<Vec<_>>()
.join(" · ")
));
}
req.path = name.clone();
req.on = match value.as_str() {
"off" => false,
"on" => true,
other => {
return Err(anyhow!(
"--opt kernel.{name}={other}: a symbol row takes on|off \
(kernel.preset takes a bundle name)"
));
}
};
let d = tree.try_toggle(&req);
if !d.verdict.ok {
let why = d.verdict.line();
return Err(if why.contains("vetoes") {
anyhow!("--opt kernel.{name}=on: the {} bundle VETOES it — {why}", preset.as_str())
} else {
anyhow!("--opt kernel.{name}={value}: {why}")
});
}
if req.on {
req.state.remove(&d.path);
} else {
req.state.insert(d.path, false);
}
}
let mut d = tree.kernel_decision("", &req.state, &vals)?;
let probe: Vec<(String, String)> = pairs.iter().filter(|(n, _)| n == crate::kernel_trim::coverage::COVERAGE_OPT).cloned().collect();
d.probe = crate::kernel_trim::coverage::probe_of_pairs(&probe)?;
Ok(d)
}
#[cfg(test)]
mod tests {
use super::*;
/// The name-hierarchy rule, watched failing on the coincidental prefix it exists to
/// reject. Before the underscore-boundary check `CONFIG_NET` "parented"
/// `CONFIG_NETDEVICES`, which is a different symbol and would have put a fabricated
/// edge in the cascade. (Moved here from dwarves-gui with the function itself.)
#[test]
fn the_parent_relation_stops_at_an_underscore() {
assert!(name_parents("CONFIG_NET", "CONFIG_NET_CORE"));
assert!(name_parents("CONFIG_VIRTIO", "CONFIG_VIRTIO_PCI"));
assert!(!name_parents("CONFIG_NET", "CONFIG_NETDEVICES"));
assert!(!name_parents("CONFIG_SECURITY", "CONFIG_SECURITYFS"));
assert!(!name_parents("CONFIG_NET", "CONFIG_NET"));
}
/// The model this file replaced lived in dwarves-gui and measured these numbers on
/// its own fixtures. They are re-asserted HERE so the lowering is provably the
/// same derivation and not a rewrite that drifted.
#[test]
fn the_lowered_model_matches_the_measured_pane_numbers() {
let m = KernelFeatureModel::derive(Preset::Normal, HardeningProfile::Vanilla);
let slim = KernelFeatureModel::derive(Preset::Slim, HardeningProfile::Vanilla);
let nuked = KernelFeatureModel::derive(Preset::Nuked, HardeningProfile::Vanilla);
let iso = |m: &KernelFeatureModel| m.symbols.iter().filter(|s| s.isolated()).count();
eprintln!("PANE-NUMBERS normal={}/{} slim={}/{} nuked={}/{} universe={}", m.symbols.len(), iso(&m), slim.symbols.len(), iso(&slim), nuked.symbols.len(), iso(&nuked), committed_universe().len());
// Re-measured 2026-08-28. The pane's original line was 16/8 · 32/16 · 48/29 ·
// universe 73. Two moves since, both in base.config (the contract, so
// EVERY preset grows by the same count): 2ae5bdc (2026-08-27) added the
// eleven the live nuked kernel died without (PROC_FS, MEMFD_CREATE, SHMEM,
// TMPFS, FILE_LOCKING, EPOLL, EVENTFD, TIMERFD, SIGNALFD, FUTEX,
// POSIX_TIMERS) and left this test red at 16; and CONFIG_SYSFS (the one
// the installer died without — `kernel::PID1_BOOT_CONTRACT`) is the
// twelfth. 16+12 = 28, and all twelve are isolated (no name-parent in
// the set), so the isolated count grows by the same twelve.
// Re-measured 2026-08-29 (evening): CONFIG_SMP joins base.config — the
// two-vCPU UpCloud appliance was serving on one core with tinyconfig's
// SMP off. One isolated node, so every preset and the universe grow by
// exactly one: normal 32→33/22→23, slim 48→49/30→31, nuked 68→69/45→46,
// universe 93→94. Lane K (same day): CONFIG_NR_CPUS=64 pinned beside
// SMP (olddefconfig only lifted it to 2 on the tinyconfig base —
// measured) — one more isolated node: normal 34/24, slim 50/32,
// nuked 70/47, universe 95.
assert_eq!(m.symbols.len(), 33, "normal is 33 nodes (lane AE 2026-08-30: CONFIG_TUN left base.config for gunnar's `wireguard` option)");
assert_eq!(iso(&m), 23, "normal: 23 isolated (TUN had no name-parent in the set)");
assert_eq!(slim.symbols.len(), 48, "slim is 48 (lane AE: −CONFIG_TUN from base.config, −CONFIG_MULTIUSER from the tinyconfig closure)");
assert_eq!(iso(&slim), 30, "slim: 30 isolated (both removals were isolated nodes)");
// Re-measured 2026-08-29: nuked.config vetoes IPv6 by name (IPV6 + six
// children: SIT, TUNNEL, SEG6_LWTUNNEL, IOAM6_LWTUNNEL, INET6_AH,
// INET6_ESP — the user's law, seen alive on the UpCloud VGA console).
// Seven rows, of which the two INET6_* have no name-parent in the set,
// so nuked grows 61→68 and its isolated count 43→45; the universe 86→93.
// Lane HOLGER-MEDIA (2026-10-24): the MULTIUSER veto left nuked.config for the
// appliance-boot contract (a consumer's default, lifted by name), so nuked is
// 68 and its isolated count 45 (the veto had no name-parent in the set).
assert_eq!(nuked.symbols.len(), 68, "nuked is 68 (lane AE: −CONFIG_TUN; lane HOLGER-MEDIA: −CONFIG_MULTIUSER, moved to the appliance-boot contract)");
assert_eq!(iso(&nuked), 45, "nuked: 45 isolated");
// Re-measured 2026-09-07 (F46): the hardened overlay grew by a NET
// SIXTEEN symbols and the universe with it, 94 → 110. Eighteen in —
// EXPERT (the prompt the vetoes need), RANDOMIZE_KSTACK_OFFSET (the
// parent the child was missing), ZERO_CALL_USED_REGS, PANIC_ON_OOPS,
// SECCOMP, SECCOMP_FILTER, STATIC_USERMODEHELPER(+_PATH),
// CPU_MITIGATIONS, MITIGATION_RETPOLINE,
// MITIGATION_PAGE_TABLE_ISOLATION and the seven attack-surface vetoes
// (KEXEC, DEVMEM, BLK_DEV_IO_TRACE, DEBUG_FS, BPF_SYSCALL,
// USERFAULTFD, PROC_KCORE) — and two OUT: STRICT_DEVMEM and
// IO_STRICT_DEVMEM, replaced by vetoing DEVMEM itself, which is
// strictly stronger than filtering it. The three per-preset counts
// above are unmoved because they are all `HardeningProfile::Vanilla`
// picks: the overlay is not in them, which is the point.
// 2026-10-29: the `k8s-hardened` overlay names BPF_SYSCALL (already in
// the universe, hardened vetoes it) and three new symbols — BPF_JIT,
// BPF_JIT_ALWAYS_ON, BPF_UNPRIV_DEFAULT_OFF — so 109 → 112.
assert_eq!(committed_universe().len(), 112, "the committed universe (F46 2026-09-07: the hardened overlay's KSPP closure; lane HOLGER-MEDIA 2026-10-24: −CONFIG_MULTIUSER, no longer a preset line; 2026-10-29: +3 from k8s-hardened)");
}
/// The option vocabulary: kebab handles, defaults from the preset, the parent
/// relation as `requires`, and the contract lock asked of `try_toggle` itself.
/// Watched red 2026-08-26 with `option_name` returning the symbol verbatim —
/// the kebab assertions and the `symbol_for_option` round-trip both failed.
#[test]
fn option_rows_speak_the_declared_vocabulary() {
let m = KernelFeatureModel::derive(Preset::Nuked, HardeningProfile::Vanilla);
let rows = m.option_rows();
assert_eq!(rows.len(), m.symbols.len(), "every symbol is a row");
let net = rows.iter().find(|r| r.symbol == "CONFIG_VIRTIO_NET").expect("virtio-net row");
assert_eq!(net.option, "virtio-net", "kebab-case of CONFIG_X without the prefix");
assert!(net.default_on, "in the nuked preset virtio-net is ENABLED");
assert_eq!(net.requires.as_deref(), Some("CONFIG_VIRTIO"), "the parent relation");
assert!(
net.locked_by.iter().any(|b| b == "CONFIG_VIRTIO_NET"),
"a contract symbol is locked and names itself among the broken: {:?}",
net.locked_by
);
// The holder is locked BY its contract children — the same refusal
// try_toggle gives, so listing and door agree by construction.
let virtio = rows.iter().find(|r| r.symbol == "CONFIG_VIRTIO").expect("virtio row");
assert!(
virtio.locked_by.iter().any(|b| b == "CONFIG_VIRTIO_NET"),
"dropping the parent breaks the contract children: {:?}",
virtio.locked_by
);
// A vetoed symbol is a ROW with default=off, not a gap — the nuked
// preset is made of stated absences.
assert!(
rows.iter().any(|r| !r.default_on),
"the nuked pick must list vetoed rows as default=off"
);
// The reverse lookup is the same spelling walked backwards.
assert_eq!(m.symbol_for_option("virtio-net"), Some("CONFIG_VIRTIO_NET"));
assert_eq!(m.symbol_for_option("no-such-thing"), None, "unknown handle is None, never a guess");
// The one-line rendering carries the lock and the requirement.
let line = net.describe();
assert!(line.starts_with("kernel.virtio-net CONFIG_VIRTIO_NET default=on"), "{line}");
assert!(line.contains("requires=CONFIG_VIRTIO"), "{line}");
assert!(line.contains("LOCKED("), "{line}");
}
/// Presets are named default-bundles, not a separate mechanism: every name in
/// `Preset::ALL` parses back to itself, so the refusal listing and the parser
/// cannot drift. Watched red 2026-08-26 with `ALL` misspelling a name.
#[test]
fn the_preset_bundles_are_the_parsers_own_names() {
for p in Preset::ALL {
assert_eq!(Preset::parse(p.as_str()), Some(p), "{}", p.as_str());
}
assert_eq!(Preset::ALL.len(), 4);
}
/// The split: kernel pairs out, installer opts through verbatim, malformed
/// refused. Watched red 2026-08-26 with the split keeping the `kernel.` prefix
/// on the pair names.
#[test]
fn the_split_carries_kernel_pairs_and_passes_the_rest_through() {
let raw: Vec<String> = [
"wireguard=off",
"kernel.preset=nuked",
"dhcp=on",
"kernel.virtio-net=on",
]
.map(String::from)
.to_vec();
let (kernel, rest) = split_kernel_opts(&raw).expect("well-formed");
assert_eq!(
kernel,
vec![
("preset".to_string(), "nuked".to_string()),
("virtio-net".to_string(), "on".to_string())
]
);
assert_eq!(rest, vec!["wireguard=off".to_string(), "dhcp=on".to_string()]);
// Malformed kernel pairs are refused HERE, not passed through to be
// refused as somebody else's unknown option.
assert!(split_kernel_opts(&["kernel.no-equals".to_string()]).is_err());
assert!(split_kernel_opts(&["kernel.=off".to_string()]).is_err());
}
/// The resolver's refusals, each BY NAME: a contract-breaking off names the
/// broken symbols and produces NO decision; an unknown row lists the declared
/// ones; `on` against a bundle veto refuses. Watched red 2026-08-26 with the
/// Refused arm sabotaged to commit the drop anyway — the refusal assertion
/// failed and the door built a brick.
#[test]
fn the_resolver_refuses_what_the_pane_refuses() {
// CONFIG_VIRTIO holds contract children — off must refuse and name them.
let err = resolve_kernel_cli(
Preset::Nuked,
HardeningProfile::Vanilla,
&[("virtio".to_string(), "off".to_string())],
)
.expect_err("a contract-breaking off must refuse");
let msg = format!("{err:#}");
assert!(msg.contains("REFUSED"), "{msg}");
assert!(msg.contains("CONFIG_VIRTIO_NET"), "the broken symbols are named: {msg}");
// An unknown row lists what IS declared — never applied to nothing.
let err = resolve_kernel_cli(
Preset::Nuked,
HardeningProfile::Vanilla,
&[("bluetooth-mesh".to_string(), "off".to_string())],
)
.expect_err("unknown row");
let msg = format!("{err:#}");
assert!(msg.contains("declared rows"), "{msg}");
assert!(msg.contains("virtio-net"), "the listing carries the real rows: {msg}");
// An unknown preset lists the bundles.
let err = resolve_kernel_cli(
Preset::Nuked,
HardeningProfile::Vanilla,
&[("preset".to_string(), "tiny".to_string())],
)
.expect_err("unknown preset");
assert!(format!("{err:#}").contains("nuked"), "{err:#}");
// `on` for a symbol the bundle VETOES: the fragment can only veto.
let m = KernelFeatureModel::derive(Preset::Nuked, HardeningProfile::Vanilla);
let vetoed = m.symbols.iter().find(|s| !s.enabled).expect("nuked vetoes plenty");
let err = resolve_kernel_cli(
Preset::Nuked,
HardeningProfile::Vanilla,
&[(option_name(&vetoed.name), "on".to_string())],
)
.expect_err("on against a veto");
assert!(format!("{err:#}").contains("VETOES"), "{err:#}");
}
/// The acceptance pair: `kernel.preset=<p>` picks the bundle, a legal off
/// stands with its cascade in the fragment, and the SAME decision through the
/// resolver and through the pane's own try_toggle door is BYTE-IDENTICAL.
/// Watched red 2026-08-26 with the resolver appending a trailing newline the
/// pane's path does not write.
#[test]
fn the_resolver_and_the_pane_render_the_same_bytes() {
// Find a legally droppable symbol in the slim pick (not contract-locked).
let model = KernelFeatureModel::derive(Preset::Slim, HardeningProfile::Vanilla);
let free = model
.option_rows()
.into_iter()
.find(|r| r.default_on && r.locked_by.is_empty())
.expect("slim has a legally droppable row");
let decision = resolve_kernel_cli(
Preset::Slim,
HardeningProfile::Vanilla,
&[
("preset".to_string(), "slim".to_string()),
(free.option.clone(), "off".to_string()),
],
)
.expect("legal drop");
assert_eq!(decision.preset, Preset::Slim);
assert_eq!(decision.clicked_off, vec![free.symbol.clone()]);
assert!(decision.changes_anything());
assert!(
decision.fragment.contains(&format!("# {} is not set", free.symbol)),
"the veto spelling for the clicked symbol:\n{}",
decision.fragment
);
// The pane's pointer: same pick, same click, the model's own doors.
let mut clicked: Vec<String> = Vec::new();
assert_eq!(model.try_toggle(&mut clicked, &free.symbol), Some(ToggleOutcome::Off));
let pane_fragment = model.decision_fragment(&clicked, None);
assert_eq!(
decision.fragment.as_bytes(),
pane_fragment.as_bytes(),
"one decision, one writer, identical bytes"
);
// A no-op decision states that it changes nothing.
let idle = resolve_kernel_cli(Preset::Slim, HardeningProfile::Vanilla, &[]).unwrap();
assert!(!idle.changes_anything());
// off then on round-trips to the idle fragment — the flags are a
// conversation, not a write-once ledger.
let back = resolve_kernel_cli(
Preset::Slim,
HardeningProfile::Vanilla,
&[
(free.option.clone(), "off".to_string()),
(free.option.clone(), "on".to_string()),
],
)
.unwrap();
assert_eq!(back.fragment.as_bytes(), idle.fragment.as_bytes());
}
/// A contract-breaking drop refuses and mutates nothing; a legal drop stands and a
/// second toggle brings it back.
#[test]
fn toggle_refuses_the_contract_and_round_trips_a_legal_drop() {
let m = KernelFeatureModel::derive(Preset::Normal, HardeningProfile::Vanilla);
let mut off: Vec<String> = Vec::new();
// CONFIG_VIRTIO holds VIRTIO_PCI/NET/BLK, all contract — dropping it must
// refuse and name them.
match m.try_toggle(&mut off, "CONFIG_VIRTIO") {
Some(ToggleOutcome::Refused(broken)) => {
assert!(broken.iter().any(|b| b == "CONFIG_VIRTIO_NET"), "{broken:?}");
assert!(off.is_empty(), "a refusal must not mutate the click-off set");
}
other => panic!("expected a refusal, got {other:?}"),
}
// An unknown symbol is None, not a silent no-op that looks like success.
assert_eq!(m.try_toggle(&mut off, "CONFIG_NOT_A_THING"), None);
}
/// **The decision ↔ fragment bijection** (LAW D of [`crate::laws`]): a fragment
/// rendered from a decision made through `try_toggle` parses back to the same
/// pick, the same click set, and the same total veto set the cascade computed.
#[test]
fn a_decision_fragment_parses_back_to_the_decision_that_wrote_it() {
let model = KernelFeatureModel::derive(Preset::Slim, HardeningProfile::Hardened);
let free = model
.option_rows()
.into_iter()
.find(|r| r.default_on && r.locked_by.is_empty())
.expect("slim has a legally droppable row");
let mut clicked: Vec<String> = Vec::new();
assert_eq!(model.try_toggle(&mut clicked, &free.symbol), Some(ToggleOutcome::Off));
let fragment = model.decision_fragment(&clicked, None);
let parsed = parse_decision_fragment(&fragment).expect("the fragment carries its decision");
assert_eq!(parsed.preset, Preset::Slim);
assert_eq!(parsed.profile, HardeningProfile::Hardened);
assert_eq!(parsed.clicked_off, clicked, "the clicks come back as clicks");
let dropped = model.cascade_for(&clicked);
let want: Vec<String> = model
.symbols
.iter()
.enumerate()
.filter(|&(i, _)| dropped[i])
.map(|(_, s)| s.name.clone())
.collect();
assert_eq!(parsed.vetoed, want, "clicks + cascade, in fragment order");
// The idle decision round-trips too: no clicks, but the pick still reads back.
let idle = parse_decision_fragment(&model.decision_fragment(&[], None)).unwrap();
assert!(idle.clicked_off.is_empty() && idle.vetoed.is_empty());
assert_eq!((idle.preset, idle.profile), (Preset::Slim, HardeningProfile::Hardened));
}
/// The parser's refusals, each watched refusing (LAW 2): a headerless text, a
/// value-setting line, an empty pick — and a cascade why-line must NOT read
/// back as a click, because regenerating the cascade is the model's job.
#[test]
fn the_fragment_parser_refuses_what_is_not_a_decision() {
let model = KernelFeatureModel::derive(Preset::Slim, HardeningProfile::Vanilla);
let fragment = model.decision_fragment(&[], None);
// Header removed → refused NAMING the header, not parsed to a guess.
let headerless: String = fragment
.lines()
.filter(|l| !l.trim().starts_with("# preset = "))
.collect::<Vec<_>>()
.join("\n");
let err = parse_decision_fragment(&headerless).unwrap_err().to_string();
assert!(err.contains("header"), "the refusal names the missing header: {err}");
// A value-setting line means this is not a veto fragment.
let doctored = "# preset = slim · profile = vanilla\nCONFIG_FOO=y\n";
let err = parse_decision_fragment(doctored).unwrap_err().to_string();
assert!(err.contains("SETS a value"), "{err}");
// An empty / unknown pick is refused by name — `Preset::parse(\"\")` reads
// as `normal` elsewhere, and a header must never lean on that default.
for bad in [
"# preset = · profile = vanilla\n",
"# preset = slin · profile = vanilla\n",
"# preset = slim · profile = hardned\n",
] {
assert!(parse_decision_fragment(bad).is_err(), "{bad:?} must refuse");
}
// A cascade drop stays a cascade drop: it is a consequence, not a click.
let cascade_only = "# preset = slim · profile = vanilla\n\
# dropped by cascade — nothing kept needs it\n\
# CONFIG_FOO is not set\n";
let parsed = parse_decision_fragment(cascade_only).unwrap();
assert!(parsed.clicked_off.is_empty(), "a cascade line is not a click");
assert_eq!(parsed.vetoed, vec!["CONFIG_FOO".to_string()]);
}
/// **`kernel.profile` is a VAL, and this door takes it** — law C across the
/// doors, wave 3.
///
/// The kernel subtree renders `preset` and `profile` side by side as vals, and
/// ⚒ hands BYGG every flag the tree spells. This door judged anything that was
/// not `preset` as a symbol row, so the flag the pane drew came back
/// *"a symbol row takes on|off"* and `BYGG kärnan` on gunnar's real declaration
/// could not run at all. RED before this landed: the first assert below was the
/// refusal.
///
/// And it is a real choice, not a word the parser swallows: hardened and vanilla
/// render DIFFERENT decision fragments off the same preset.
#[test]
fn the_profile_val_is_a_choice_this_door_takes_and_not_a_symbol() {
let pairs = |s: &[(&str, &str)]| -> Vec<(String, String)> {
s.iter().map(|(a, b)| ((*a).to_string(), (*b).to_string())).collect()
};
let vanilla = resolve_kernel_cli(Preset::Nuked, HardeningProfile::Vanilla, &pairs(&[("profile", "vanilla")]))
.expect("the pane's own flag `--opt kernel.profile=vanilla` must reach this door");
assert_eq!(vanilla.profile, HardeningProfile::Vanilla);
let hardened = resolve_kernel_cli(Preset::Nuked, HardeningProfile::Vanilla, &pairs(&[("profile", "hardened")]))
.expect("and so must the other one");
assert_eq!(hardened.profile, HardeningProfile::Hardened, "the CHOICE decides, not the declaration");
assert_ne!(
hardened.fragment, vanilla.fragment,
"a profile that changes nothing in the fragment is a control that does nothing"
);
// Unknown by name, twice by name — never a silent degrade to vanilla.
let bad = resolve_kernel_cli(Preset::Nuked, HardeningProfile::Vanilla, &pairs(&[("profile", "hardned")]));
assert!(bad.is_err_and(|e| format!("{e:#}").contains("unknown profile")), "a typo is refused BY NAME");
let twice = resolve_kernel_cli(
Preset::Nuked,
HardeningProfile::Vanilla,
&pairs(&[("profile", "vanilla"), ("profile", "hardened")]),
);
assert!(twice.is_err_and(|e| format!("{e:#}").contains("given twice")));
}
}