crates/nornir-build-thing/src/kernel_trim.rs
//! **The measured kernel trim — the STATIC half** (tunnr 1.1, lane ktrim,
//! owner decision 2026-10-30).
//!
//! The question an operator asks of a composed row kernel is *"what if this
//! row drops `CONFIG_X`?"*. [`crate::kconfig_graph`] already explains why each
//! symbol is in the `.config` (the chain UP to a head). This module answers the
//! other direction, which is the one a trim needs:
//!
//! * [`TrimModel::what_if`] — drop one or more symbols and settle the
//! `.config` the way `olddefconfig` would: which derived symbols leave with
//! them, which fragment lines would lose their gates (the build's canary
//! would refuse them, so they go too), which symbols would turn ON, which
//! drops cannot hold (a live `select` forces the symbol back, or it has no
//! prompt), and — refused BY NAME — every consumer feature that requires one
//! of the symbols that would leave.
//! * [`TrimModel::candidates`] — every symbol the row could drop without
//! breaking a requirement, ranked by the size of what leaves with it
//! (estimated from the built tree's objects, [`TreeSizes`]): the unclaimed
//! `preset-only` lines and the biggest `kconfig-derived` subtrees by head.
//! Nothing is applied; a candidate becomes a row veto only after the
//! coverage half and the owner.
//! * [`InverseCheck`] — the verification that matters
//! ([`crate::kconfig_graph`]'s own rule): recompose with the drops, build
//! the bzImage, and compare. Every symbol the what-if listed must really be
//! gone, and nothing else may have changed that the what-if did not name.
//!
//! # The model, and its honest limits
//!
//! A symbol stays ON when (Kconfig's own rules, `scripts/kconfig/symbol.c`):
//! a live symbol `select`s it (select ignores `depends on`), or its gates hold
//! and either the input `.config` gives a prompt symbol a value (the fragment
//! wins over the base), a live symbol `imply`s it, or the first `default`
//! whose condition holds is on. A `choice` member already on stays on while
//! its gates hold (the arbitration inside a choice is not re-derived). An
//! expression the parser cannot evaluate (`$(cc-option,…)`) is read as
//! "holds" when deciding whether something LEAVES and as "does not hold" when
//! deciding whether something APPEARS, so the model never invents a change.
//! Whatever the model gets wrong on the unchanged baseline (symbols it would
//! remove with no drop at all) is counted as `model_noise` and subtracted, so
//! a what-if reports only what the drop changes. The measured recompose
//! ([`InverseCheck`]) is the judge of all of it.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use serde::Serialize;
use crate::kconfig_graph::{ConfigView, Expr, KconfigGraph, Tri};
use crate::kernel_requires::{KconfigValue, KernelRequirements, Requirer, WhyClass, WhyIndex};
use crate::syscall_join::{self, MeasuredRow, SyscallMap, Veto};
/// `CONFIG_X` → `X`.
pub fn bare(s: &str) -> &str {
s.strip_prefix("CONFIG_").unwrap_or(s)
}
/// `X` / `CONFIG_X` → `CONFIG_X`.
pub fn full(s: &str) -> String {
format!("CONFIG_{}", bare(s))
}
// ── the answer types ─────────────────────────────────────────────────────────
/// A consumer feature that requires a symbol the what-if would remove (or
/// vetoes one it would turn on) — the drop is refused by this name.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Refused {
/// `CONFIG_X` — the symbol the requirement is on.
pub symbol: String,
/// What the requirer wants (`y`, `n`, `64`, …).
pub wanted: String,
/// `repo/variant` of the consumer.
pub consumer: String,
/// The feature on that consumer.
pub feature: String,
/// The declared why.
pub reason: String,
}
impl Refused {
/// `CONFIG_X=y ← tunnr/rusternetes-node:k8s (why)`.
pub fn render(&self) -> String {
format!("{}={} ← {}:{} ({})", self.symbol, self.wanted, self.consumer, self.feature, self.reason)
}
}
/// A drop that would not hold after `olddefconfig`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Held {
/// `CONFIG_X` — the dropped symbol.
pub symbol: String,
/// Why it comes back: `selected by CONFIG_Y`, `no prompt: …`.
pub reason: String,
}
/// **The answer to "what if this row drops X?"**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct WhatIf {
/// The dropped symbols, `CONFIG_`-prefixed, as asked.
pub drops: Vec<String>,
/// Derived (fragment-unnamed) symbols that leave with the drops.
pub leaving: Vec<String>,
/// Fragment lines (`=y`) whose gates the drops remove: the build's canary
/// would refuse them, so the recompose drops them too.
pub declared_leaving: Vec<String>,
/// Symbols that would turn ON (a `default y if !X`).
pub appearing: Vec<String>,
/// Drops that cannot hold.
pub held: Vec<Held>,
/// Requirements the drops would break — the drop is refused by these names.
pub refused: Vec<Refused>,
/// Symbols the model removes from the UNCHANGED baseline (its own error,
/// subtracted from every answer). Zero on a fully explained tree.
pub model_noise: usize,
/// Estimated size of what leaves, when the built tree was read.
pub estimate: Option<SizeEstimate>,
/// Measured calls (the row's syscall profiles) whose kernel code leaves
/// with the drops ([`crate::syscall_join`]): a non-near-miss refuses the
/// drop by name, a near-miss warns.
pub syscall_vetoes: Vec<Veto>,
/// The row's measured profiles were joined (otherwise "not checked").
pub syscalls_checked: bool,
}
/// Bytes an answer is estimated to remove.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
pub struct SizeEstimate {
/// Allocated (loaded) bytes of the linked objects that leave — text,
/// data, rodata, init; never `.bss` or debug sections.
pub object_bytes: u64,
/// `object_bytes` scaled by the baseline's measured bzImage/objects ratio.
pub bz_bytes: u64,
}
impl WhatIf {
/// Is the what-if clean: nothing refused (by a feature or by a measured
/// call), every drop holds?
pub fn is_clean(&self) -> bool {
self.refused.is_empty() && self.held.is_empty() && !self.syscall_refused()
}
/// A measured call (not a near-miss) loses its kernel code.
pub fn syscall_refused(&self) -> bool {
self.syscall_vetoes.iter().any(|v| !v.near_miss)
}
/// `CLEAN`, `REFUSED` or `DOES NOT HOLD`.
pub fn verdict(&self) -> &'static str {
if self.is_clean() {
"CLEAN"
} else if !self.refused.is_empty() || self.syscall_refused() {
"REFUSED"
} else {
"DOES NOT HOLD"
}
}
/// Join the row's measured calls: name every call the drops take away.
pub fn join_syscalls(&mut self, map: &SyscallMap, measured: &MeasuredRow) {
self.syscall_vetoes = syscall_join::vetoes_for(map, measured, &self.drops, &self.expected_gone());
self.syscalls_checked = true;
}
/// Every symbol the recompose is expected to lose: drops + leaving +
/// declared lines that lose their gates.
pub fn expected_gone(&self) -> BTreeSet<String> {
self.drops.iter().chain(&self.leaving).chain(&self.declared_leaving).cloned().collect()
}
/// The fragment lines the recompose merges LAST: one `is not set` per
/// drop and per declared line that loses its gates.
pub fn fragment_lines(&self) -> String {
let mut s = String::new();
for sym in self.drops.iter().chain(&self.declared_leaving) {
s.push_str(&format!("# {} is not set\n", full(sym)));
}
s
}
/// The human text.
pub fn render(&self) -> String {
let mut s = format!("# what-if: drop {}\n", self.drops.join(" "));
s.push_str(&format!("verdict: {}\n", self.verdict()));
if let Some(e) = self.estimate {
s.push_str(&format!("estimate: {} object bytes, ~{} bzImage bytes\n", e.object_bytes, e.bz_bytes));
}
for r in &self.refused {
s.push_str(&format!("REFUSED {}\n", r.render()));
}
for v in self.syscall_vetoes.iter().filter(|v| !v.near_miss) {
s.push_str(&format!("REFUSED by a measured call: {}\n", v.render()));
}
for v in self.syscall_vetoes.iter().filter(|v| v.near_miss) {
s.push_str(&format!("WARN near-miss: {}\n", v.render()));
}
if !self.syscalls_checked {
s.push_str("syscalls: not checked (no measured profiles for this row: measure syscalls <row>)\n");
} else if self.syscall_vetoes.is_empty() {
s.push_str("syscalls: no measured call loses its kernel code\n");
}
for h in &self.held {
s.push_str(&format!("HELD {} — {}\n", h.symbol, h.reason));
}
s.push_str(&format!("leaving ({}):", self.leaving.len()));
for l in &self.leaving {
s.push(' ');
s.push_str(l);
}
s.push('\n');
if !self.declared_leaving.is_empty() {
s.push_str(&format!("fragment lines that lose their gates ({}): {}\n", self.declared_leaving.len(), self.declared_leaving.join(" ")));
}
if !self.appearing.is_empty() {
s.push_str(&format!("appearing ({}): {}\n", self.appearing.len(), self.appearing.join(" ")));
}
if self.model_noise > 0 {
s.push_str(&format!("model noise on the unchanged baseline: {} symbol(s), subtracted\n", self.model_noise));
}
s
}
}
// ── the model ────────────────────────────────────────────────────────────────
/// A composed row kernel, ready to be asked: the graph, the built `.config`,
/// the base `.config`, the merged fragment and the consumers' requirements.
pub struct TrimModel<'a> {
graph: &'a KconfigGraph,
built: ConfigView,
/// The prompt symbols' input values: the base `.config`, then the merged
/// fragment over it (later wins) — what `olddefconfig` reads.
user: BTreeMap<String, String>,
declared: BTreeSet<String>,
reqs: KernelRequirements,
/// The baseline as the model settles it (built minus model noise).
settled: BTreeMap<String, String>,
noise: BTreeSet<String>,
/// What the model would turn on in the unchanged baseline (its own
/// error), subtracted from every `appearing`.
base_appear: BTreeSet<String>,
sizes: Option<&'a TreeSizes>,
/// bzImage bytes per object byte, measured on the baseline.
bz_ratio: Option<f64>,
}
impl<'a> TrimModel<'a> {
/// Build the model of one composed kernel.
pub fn new(graph: &'a KconfigGraph, fragment: &str, built: &str, base: &str, reqs: &KernelRequirements) -> Self {
let built = ConfigView::parse(built).with_env(graph.env.clone());
let frag = ConfigView::parse(fragment);
let mut user = ConfigView::parse(base).values;
for (k, v) in &frag.values {
user.insert(k.clone(), v.clone());
}
let declared: BTreeSet<String> = frag.values.keys().cloned().collect();
let mut m = TrimModel {
graph,
built: built.clone(),
user,
declared,
reqs: reqs.clone(),
settled: BTreeMap::new(),
noise: BTreeSet::new(),
base_appear: BTreeSet::new(),
sizes: None,
bz_ratio: None,
};
let start = live_values(&built);
let settled = m.settle(start.clone(), &BTreeMap::new());
m.noise = start.keys().filter(|k| !settled.contains_key(*k)).cloned().collect();
m.base_appear = m.appear(&settled, &BTreeMap::new());
m.settled = settled;
m
}
/// Attach the built tree's object sizes (and the baseline bzImage size, for
/// the bzImage estimate).
pub fn with_sizes(mut self, sizes: &'a TreeSizes, bz_image_bytes: Option<u64>) -> Self {
self.sizes = Some(sizes);
let total = sizes.total_bytes();
self.bz_ratio = bz_image_bytes.filter(|_| total > 0).map(|b| b as f64 / total as f64);
self
}
/// Symbols the model removes from the unchanged baseline.
pub fn model_noise(&self) -> &BTreeSet<String> {
&self.noise
}
fn holds(t: Tri, optimistic: bool) -> bool {
match t {
Tri::Yes | Tri::Mod => true,
Tri::No => false,
Tri::Unknown => optimistic,
}
}
fn cond_holds(cond: Option<&Expr>, cfg: &ConfigView, optimistic: bool) -> bool {
match cond {
None => true,
Some(c) => Self::holds(c.eval(cfg), optimistic),
}
}
/// Would `sym` be ON in `cfg` (the other symbols as they stand), with the
/// input values `user` (+ `overrides`, the drops)?
fn alive(&self, sym: &str, cfg: &ConfigView, overrides: &BTreeMap<String, String>, optimistic: bool, was_on: bool) -> bool {
let Some(def) = self.graph.symbols.get(sym) else {
// Not declared anywhere in the tree (a toolchain value the build
// writes): nothing the graph says can move it.
return was_on;
};
// 1. select forces, regardless of the target's gates.
for (from, cond) in self.graph.selected_by.get(sym).into_iter().flatten() {
if cfg.is_set(from) && Self::cond_holds(cond.as_ref(), cfg, optimistic) {
return true;
}
}
// 2. the gates.
for d in &def.depends {
if !Self::holds(d.eval(cfg), optimistic) {
return false;
}
}
// An int/hex/string option exists whenever it is visible.
if !def.kind.is_boolean() {
return was_on || optimistic;
}
// 3. an input value on a prompt symbol (the drop overrides first).
if def.prompt {
if let Some(v) = overrides.get(sym).or_else(|| self.user.get(sym)) {
return v != "n";
}
}
// 4. a choice member already chosen stays chosen while its gates hold.
if def.in_choice && was_on {
return true;
}
// 5. imply.
for (from, cond) in self.graph.implied_by.get(sym).into_iter().flatten() {
if cfg.is_set(from) && Self::cond_holds(cond.as_ref(), cfg, optimistic) {
return true;
}
}
// 6. the first default whose condition holds decides.
for (val, cond) in &def.defaults {
let c = match cond {
None => Tri::Yes,
Some(c) => c.eval(cfg),
};
match c {
Tri::No => continue,
Tri::Unknown => return optimistic,
Tri::Yes | Tri::Mod => {
return Self::holds(val.eval(cfg), optimistic);
}
}
}
false
}
/// Settle a `.config` the way `olddefconfig` would, starting from `start`
/// and only REMOVING (a monotone fixpoint), under `overrides`.
fn settle(&self, start: BTreeMap<String, String>, overrides: &BTreeMap<String, String>) -> BTreeMap<String, String> {
// The drops need no special pass: `alive` reads them as the prompt
// symbol's input value (`n`), so a dropped symbol leaves on the first
// round unless a live select forces it or it has no prompt.
let mut cfg = ConfigView { values: start, env: self.built.env.clone(), open: false };
for _ in 0..256 {
let live: Vec<String> = cfg.values.keys().cloned().collect();
let mut changed = false;
for s in &live {
if !self.alive(s, &cfg, overrides, true, true) {
cfg.values.remove(s);
changed = true;
}
}
if !changed {
break;
}
}
cfg.values
}
/// The live symbol whose `select` forces `sym` on, if any.
fn selected_live(&self, sym: &str, cfg: &ConfigView) -> Option<String> {
self.graph
.selected_by
.get(sym)
.into_iter()
.flatten()
.find(|(from, cond)| cfg.is_set(from) && Self::cond_holds(cond.as_ref(), cfg, true))
.map(|(from, _)| from.clone())
}
/// The boolean symbols that would turn ON in `values` (pessimistic: an
/// opaque condition turns nothing on), as a fixpoint.
fn appear(&self, values: &BTreeMap<String, String>, overrides: &BTreeMap<String, String>) -> BTreeSet<String> {
let mut cfg = ConfigView { values: values.clone(), env: self.built.env.clone(), open: false };
let mut out = BTreeSet::new();
for _ in 0..16 {
let mut changed = false;
for (name, def) in &self.graph.symbols {
if cfg.values.contains_key(name) || !def.kind.is_boolean() || def.in_choice {
continue;
}
if overrides.get(name).map(|v| v == "n").unwrap_or(false) {
continue;
}
if self.alive(name, &cfg, overrides, false, false) {
cfg.values.insert(name.clone(), "y".into());
out.insert(name.clone());
changed = true;
}
}
if !changed {
break;
}
}
out
}
/// **What if the row drops `drops`?** `with_appearing` also computes the
/// symbols that would turn on (one pass over the whole tree; the candidate
/// ranking skips it).
pub fn what_if(&self, drops: &[String], with_appearing: bool) -> WhatIf {
let drops_bare: Vec<String> = drops.iter().map(|d| bare(d).to_string()).collect();
let overrides: BTreeMap<String, String> = drops_bare.iter().map(|d| (d.clone(), "n".to_string())).collect();
let after = self.settle(self.settled.clone(), &overrides);
let after_cfg = ConfigView { values: after.clone(), env: self.built.env.clone(), open: false };
let mut w = WhatIf { drops: drops_bare.iter().map(|d| full(d)).collect(), model_noise: self.noise.len(), ..Default::default() };
// Which drops hold?
for d in &drops_bare {
if !self.settled.contains_key(d) {
w.held.push(Held { symbol: full(d), reason: "not on in this row's kernel".to_string() });
} else if after.contains_key(d) {
let prompt = self.graph.symbols.get(d).map(|def| def.prompt).unwrap_or(false);
let reason = match self.selected_live(d, &after_cfg) {
Some(from) => format!("selected by CONFIG_{from}, which stays — drop it too"),
None if !prompt => "no prompt: an `is not set` line is ignored by olddefconfig; it leaves only with its causes".to_string(),
None => "the model keeps it on".to_string(),
};
w.held.push(Held { symbol: full(d), reason });
}
}
for (s, _) in &self.settled {
if after.contains_key(s) || overrides.contains_key(s) {
continue;
}
if self.declared.contains(s) && self.user.get(s).map(|v| v != "n").unwrap_or(false) {
w.declared_leaving.push(full(s));
} else {
w.leaving.push(full(s));
}
}
if with_appearing {
let now = self.appear(&after, &overrides);
w.appearing = now.difference(&self.base_appear).map(|s| full(s)).collect();
}
// Refusals: every requirement on a symbol that leaves (or a veto on
// one that appears).
let gone: BTreeSet<String> = w.expected_gone();
for sym in &gone {
for (v, who) in self.reqs.rows.get(sym).into_iter().flatten() {
if *v != KconfigValue::N {
w.refused.push(refused(sym, v, who));
}
}
}
for sym in &w.appearing {
for (v, who) in self.reqs.rows.get(sym).into_iter().flatten() {
if *v == KconfigValue::N {
w.refused.push(refused(sym, v, who));
}
}
}
w.refused.sort();
w.refused.dedup();
if let Some(sizes) = self.sizes {
let gone_bare: BTreeSet<String> = gone.iter().map(|s| bare(s).to_string()).collect();
let object_bytes = sizes.bytes_leaving(&gone_bare);
let bz_bytes = self.bz_ratio.map(|r| (object_bytes as f64 * r).round() as u64).unwrap_or(0);
w.estimate = Some(SizeEstimate { object_bytes, bz_bytes });
}
w
}
/// **The trim candidates** of this row: every ON boolean symbol with a
/// prompt that no requirement keeps (directly or through what leaves with
/// it) and that no live `select` forces back — each with its what-if —
/// ranked by estimated size (or, without sizes, by how many symbols leave).
pub fn candidates(&self, why: &WhyIndex) -> Candidates {
let class_of: BTreeMap<&str, String> = why
.rows
.iter()
.map(|r| {
let c = match &r.class {
WhyClass::Feature { .. } => "feature".to_string(),
WhyClass::PresetOnly => "preset-only".to_string(),
WhyClass::KconfigDerived { head_class, .. } if !head_class.is_empty() => format!("derived:{head_class}"),
WhyClass::KconfigDerived { .. } => "derived".to_string(),
};
(r.symbol.as_str(), c)
})
.collect();
let mut out = Candidates::default();
for (sym, val) in &self.settled {
let Some(def) = self.graph.symbols.get(sym) else { continue };
if !def.prompt || !def.kind.is_boolean() || val == "n" {
continue;
}
let class = class_of.get(full(sym).as_str()).cloned().unwrap_or_else(|| "derived".to_string());
if class == "feature" {
out.required += 1;
continue;
}
let w = self.what_if(&[full(sym)], true);
if !w.refused.is_empty() {
out.refused += 1;
continue;
}
if !w.held.is_empty() {
out.held += 1;
continue;
}
out.rows.push(Candidate {
symbol: full(sym),
class,
leaving: w.leaving.len() + w.declared_leaving.len(),
estimate: w.estimate,
near_miss: Vec::new(),
no_measured_caller: false,
});
}
out.rows.sort_by(|a, b| {
let ka = (a.estimate.map(|e| e.object_bytes).unwrap_or(0), a.leaving);
let kb = (b.estimate.map(|e| e.object_bytes).unwrap_or(0), b.leaving);
kb.cmp(&ka).then_with(|| a.symbol.cmp(&b.symbol))
});
out
}
}
fn refused(sym: &str, v: &KconfigValue, who: &Requirer) -> Refused {
Refused {
symbol: full(sym),
wanted: v.as_str(),
consumer: who.consumer.clone(),
feature: who.feature.clone(),
reason: who.reason.clone(),
}
}
fn live_values(c: &ConfigView) -> BTreeMap<String, String> {
c.values.iter().filter(|(_, v)| v.as_str() != "n").map(|(k, v)| (k.clone(), v.clone())).collect()
}
/// One trim candidate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Candidate {
/// `CONFIG_X`.
pub symbol: String,
/// `preset-only`, `derived:<head class>` (the why-index's class).
pub class: String,
/// How many symbols leave with it (itself not counted).
pub leaving: usize,
/// Estimated bytes, when the tree was read.
pub estimate: Option<SizeEstimate>,
/// Near-miss calls the drop would take away (a warning, not a veto).
pub near_miss: Vec<String>,
/// A syscall-gating option no measured call needs (a hint).
pub no_measured_caller: bool,
}
/// The ranked candidates, with what was left out and why.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Candidates {
/// Ranked, biggest first.
pub rows: Vec<Candidate>,
/// Symbols a feature requires directly (never candidates).
pub required: usize,
/// Symbols whose drop would take a required symbol along.
pub refused: usize,
/// Symbols a live `select` would force back.
pub held: usize,
/// Candidates refused because their drop takes a measured call away
/// (one entry per call), by name.
pub syscall_vetoed: Vec<Veto>,
/// The row's measured profiles were joined.
pub syscalls_checked: bool,
}
impl Candidates {
/// The human table, top `n`.
pub fn render(&self, n: usize) -> String {
let mut s = format!(
"# trim candidates: {} (left out: {} required by a feature, {} would take a required symbol along, {} forced back by a select)\n",
self.rows.len(),
self.required,
self.refused,
self.held
);
if !self.syscalls_checked {
s.push_str("# syscalls: not checked (no measured profiles for this row: measure syscalls <row>)\n");
} else {
let names: Vec<String> = self.syscall_vetoed.iter().map(Veto::render).collect();
if names.is_empty() {
s.push_str("# vetoed by a measured call (0)\n");
} else {
s.push_str(&format!("# vetoed by a measured call ({}): {}\n", names.len(), names.join("; ")));
}
}
s.push_str(&format!("{:<4} {:<40} {:<26} {:>8} {:>12} {:>12} {}\n", "#", "symbol", "class", "leaving", "obj bytes", "~bz bytes", "syscalls"));
for (i, c) in self.rows.iter().take(n).enumerate() {
let (o, b) = c.estimate.map(|e| (e.object_bytes.to_string(), e.bz_bytes.to_string())).unwrap_or(("-".into(), "-".into()));
let note = if !c.near_miss.is_empty() {
format!("near-miss: {}", c.near_miss.join("; "))
} else if c.no_measured_caller {
"no measured caller".to_string()
} else {
String::new()
};
s.push_str(&format!("{:<4} {:<40} {:<26} {:>8} {:>12} {:>12} {}\n", i + 1, c.symbol, c.class, c.leaving, o, b, note).trim_end());
s.push('\n');
}
s
}
/// The `preset-only` candidates (unclaimed fragment lines), ranked.
pub fn preset_only(&self) -> Vec<&Candidate> {
self.rows.iter().filter(|c| c.class == "preset-only").collect()
}
}
// ── sizes off the built tree ────────────────────────────────────────────────
/// **What each Kconfig symbol costs in the built tree** — read off the
/// kernel's own Makefiles (`obj-$(CONFIG_X) += foo.o bar/`, and composite
/// parts `foo-$(CONFIG_X) += part.o`) and the objects actually linked into
/// `vmlinux` (the members of the thin archive `vmlinux.a`), each sized by its
/// allocated ELF sections.
///
/// Limits, stated: code under `#ifdef CONFIG_X` inside a file that stays is
/// not attributed (a floor, not the full delta); `lib-y` objects (`lib.a`) and
/// `ifdef` blocks in Makefiles are not read. The measured bzImage delta of a
/// what-if is the judge.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TreeSizes {
/// Linked object (path relative to the source root) → allocated bytes.
pub objects: BTreeMap<String, u64>,
/// Object path or directory path (`dir/`) → the symbols that gate it.
pub gates: BTreeMap<String, BTreeSet<String>>,
/// Composite parts: `(part path, parent object path, symbol, bytes)`.
pub parts: Vec<(String, String, String, u64)>,
/// Every listed part of a composite object (`foo-y += a.o`, `foo-objs :=
/// a.o`, `foo-$(CONFIG_X) += a.o`) → its parent (`foo.o`): a built-in
/// composite is linked as its parts, so the parent's gates are the part's.
pub parents: BTreeMap<String, String>,
}
impl TreeSizes {
/// The sum over every linked object.
pub fn total_bytes(&self) -> u64 {
self.objects.values().sum()
}
/// Build from Makefile texts (`(dir, text)`, dir `""` or `drivers/net/`),
/// linked objects and a part sizer — the testable core of [`Self::read`].
pub fn from_parts(makefiles: &[(String, String)], objects: BTreeMap<String, u64>, part_size: &dyn Fn(&str) -> Option<u64>) -> Self {
let mut t = TreeSizes { objects, ..Default::default() };
for (dir, text) in makefiles {
for (prefix, items) in makefile_composites(text) {
for item in items {
t.parents.insert(format!("{dir}{item}"), format!("{dir}{prefix}.o"));
}
}
for (prefix, sym, items) in makefile_gates(text) {
for item in items {
let path = format!("{dir}{item}");
if prefix == "obj" {
t.gates.entry(path).or_default().insert(sym.clone());
} else if item.ends_with(".o") {
let parent = format!("{dir}{prefix}.o");
if let Some(b) = part_size(&path) {
t.parts.push((path, parent, sym.clone(), b));
}
}
}
}
}
t
}
/// Every symbol gating an object: its own line plus every ancestor
/// directory's line.
pub fn gates_of(&self, obj: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
if let Some(g) = self.gates.get(obj) {
out.extend(g.iter().cloned());
}
let mut idx = 0;
while let Some(p) = obj[idx..].find('/') {
let dir = &obj[..idx + p + 1];
if let Some(g) = self.gates.get(dir) {
out.extend(g.iter().cloned());
}
idx += p + 1;
}
out
}
/// Bytes that leave when every symbol in `leaving` (bare names) is gone.
pub fn bytes_leaving(&self, leaving: &BTreeSet<String>) -> u64 {
let mut gone_objects = BTreeSet::new();
let mut total = 0;
for (obj, bytes) in &self.objects {
if self.gates_of(obj).iter().any(|g| leaving.contains(g)) {
gone_objects.insert(obj.as_str());
total += bytes;
}
}
for (_, parent, sym, bytes) in &self.parts {
if leaving.contains(sym) && !gone_objects.contains(parent.as_str()) && self.objects.contains_key(parent) {
total += bytes;
}
}
total
}
/// **Read a built kernel tree**: every `Makefile`/`Kbuild` under `src`,
/// the members of `vmlinux.a`, each sized off its ELF sections.
pub fn read(src: &Path) -> Result<Self> {
let archive = src.join("vmlinux.a");
let members = thin_archive_members(&archive)?;
let mut objects = BTreeMap::new();
for m in members {
if let Some(b) = elf_alloc_bytes(&src.join(&m)) {
objects.insert(m, b);
}
}
let mut makefiles = Vec::new();
collect_makefiles(src, src, &mut makefiles)?;
let root = src.to_path_buf();
Ok(Self::from_parts(&makefiles, objects, &|p: &str| elf_alloc_bytes(&root.join(p))))
}
}
fn collect_makefiles(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) -> Result<()> {
let rd = match std::fs::read_dir(dir) {
Ok(r) => r,
Err(_) => return Ok(()),
};
let mut subdirs = Vec::new();
for e in rd.flatten() {
let p = e.path();
let Ok(ft) = e.file_type() else { continue };
if ft.is_dir() {
let name = e.file_name();
let n = name.to_string_lossy();
if !n.starts_with('.') && n != "Documentation" && n != "tools" {
subdirs.push(p);
}
} else if ft.is_file() {
let name = e.file_name();
if name == "Makefile" || name == "Kbuild" {
if let Ok(text) = std::fs::read_to_string(&p) {
let rel = dir.strip_prefix(root).unwrap_or(Path::new(""));
let mut d = rel.to_string_lossy().to_string();
if !d.is_empty() {
d.push('/');
}
out.push((d, text));
}
}
}
}
for s in subdirs {
collect_makefiles(root, &s, out)?;
}
Ok(())
}
/// The `<prefix>-$(CONFIG_X) <op> items…` lines of one Makefile (continuations
/// joined): `(prefix, X, items)`; items with a `$` are skipped.
pub fn makefile_gates(text: &str) -> Vec<(String, String, Vec<String>)> {
let mut logical = Vec::new();
let mut cur = String::new();
for line in text.lines() {
let l = line.split('#').next().unwrap_or("");
if let Some(stripped) = l.trim_end().strip_suffix('\\') {
cur.push_str(stripped);
cur.push(' ');
continue;
}
cur.push_str(l);
logical.push(std::mem::take(&mut cur));
}
if !cur.is_empty() {
logical.push(cur);
}
let mut out = Vec::new();
for l in logical {
let l = l.trim();
let Some(pos) = l.find("-$(CONFIG_") else { continue };
let prefix = &l[..pos];
if prefix.is_empty() || !prefix.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
continue;
}
let rest = &l[pos + "-$(CONFIG_".len()..];
let Some(close) = rest.find(')') else { continue };
let sym = &rest[..close];
if sym.is_empty() || !sym.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
continue;
}
let after = rest[close + 1..].trim_start();
let items = if let Some(i) = after.strip_prefix("+=") {
i
} else if let Some(i) = after.strip_prefix(":=") {
i
} else if let Some(i) = after.strip_prefix('=') {
i
} else {
continue;
};
let items: Vec<String> = items
.split_whitespace()
.filter(|i| !i.contains('$') && (i.ends_with(".o") || i.ends_with('/')))
.map(str::to_string)
.collect();
if !items.is_empty() {
out.push((prefix.to_string(), sym.to_string(), items));
}
}
out
}
/// The kbuild list names that are not composite objects.
const KBUILD_LISTS: &[&str] = &[
"obj", "lib", "extra", "always", "targets", "hostprogs", "userprogs", "subdir", "head", "core", "init", "libs", "drivers", "real-obj",
"ccflags", "asflags", "ldflags", "subdir-ccflags", "clean-files", "no-clean-files",
];
/// The composite-object lists of one Makefile — `foo-y += a.o b.o`, `foo-objs
/// := a.o`, `foo-$(CONFIG_X) += a.o` — as `(foo, [a.o, …])`; kbuild's own
/// lists (`obj-y`, `lib-y`, …) are not composites.
pub fn makefile_composites(text: &str) -> Vec<(String, Vec<String>)> {
let mut out = Vec::new();
let mut cur = String::new();
let mut logical = Vec::new();
for line in text.lines() {
let l = line.split('#').next().unwrap_or("");
if let Some(stripped) = l.trim_end().strip_suffix('\\') {
cur.push_str(stripped);
cur.push(' ');
continue;
}
cur.push_str(l);
logical.push(std::mem::take(&mut cur));
}
for l in logical {
let l = l.trim();
let Some(op) = l.find(|c| c == '+' || c == ':' || c == '=') else { continue };
let lhs = l[..op].trim();
let rhs = l[op..].trim_start_matches(['+', ':', '=']);
let prefix = if let Some(p) = lhs.strip_suffix("-y").or_else(|| lhs.strip_suffix("-objs")) {
p
} else if let Some(pos) = lhs.find("-$(CONFIG_") {
if !lhs.ends_with(')') {
continue;
}
&lhs[..pos]
} else {
continue;
};
if prefix.is_empty() || KBUILD_LISTS.contains(&prefix) || !prefix.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
continue;
}
let items: Vec<String> = rhs.split_whitespace().filter(|i| !i.contains('$') && i.ends_with(".o")).map(str::to_string).collect();
if !items.is_empty() {
out.push((prefix.to_string(), items));
}
}
out
}
/// The member paths of a (thin, possibly nested) `ar` archive.
pub fn thin_archive_members(path: &Path) -> Result<Vec<String>> {
let data = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
let base = path.parent().unwrap_or(Path::new(""));
let a = object::read::archive::ArchiveFile::parse(&*data).map_err(|e| anyhow!("{}: not an ar archive: {e}", path.display()))?;
let mut out = Vec::new();
for m in a.members() {
let m = m.map_err(|e| anyhow!("{}: {e}", path.display()))?;
let name = String::from_utf8_lossy(m.name()).to_string();
if name.ends_with(".a") {
let nested = base.join(&name);
if let Ok(inner) = thin_archive_members(&nested) {
let pre = Path::new(&name).parent().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
for i in inner {
// Nested thin archive members are relative to the nested archive.
if pre.is_empty() || i.starts_with(&format!("{pre}/")) {
out.push(i);
} else {
out.push(format!("{pre}/{i}"));
}
}
}
continue;
}
out.push(name);
}
if out.is_empty() {
bail!("{}: no members", path.display());
}
Ok(out)
}
/// The allocated (loaded, file-backed) bytes of one ELF object.
pub fn elf_alloc_bytes(path: &Path) -> Option<u64> {
use object::{Object, ObjectSection, SectionFlags, SectionKind};
let data = std::fs::read(path).ok()?;
let f = object::File::parse(&*data).ok()?;
let mut total = 0;
for s in f.sections() {
let alloc = match s.flags() {
SectionFlags::Elf { sh_flags, .. } => sh_flags.0 & 2 != 0,
_ => false,
};
if !alloc || matches!(s.kind(), SectionKind::UninitializedData | SectionKind::UninitializedTls) {
continue;
}
total += s.size();
}
Some(total)
}
// ── the inverse check ───────────────────────────────────────────────────────
/// **The measured answer** of a what-if: the recomposed kernel against the
/// baseline.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct InverseCheck {
/// bzImage bytes, baseline.
pub base_bz: u64,
/// bzImage bytes, recomposed.
pub whatif_bz: u64,
/// Listed by the what-if and really gone.
pub gone_as_listed: Vec<String>,
/// Listed by the what-if but still ON in the recompose — the model was wrong.
pub listed_but_stayed: Vec<String>,
/// Gone in the recompose but never listed — unexpected.
pub gone_unlisted: Vec<String>,
/// ON in the recompose, not in the baseline, and not listed as appearing.
pub appeared_unlisted: Vec<String>,
/// Listed as appearing and really appeared.
pub appeared_as_listed: Vec<String>,
/// Same symbol, another value (an int/string option moved).
pub changed_values: Vec<String>,
}
impl InverseCheck {
/// Compare two built `.config`s against a what-if.
pub fn of(w: &WhatIf, base_config: &str, whatif_config: &str, base_bz: u64, whatif_bz: u64) -> Self {
let b = live_values(&ConfigView::parse(base_config));
let a = live_values(&ConfigView::parse(whatif_config));
let expected = w.expected_gone();
let appearing: BTreeSet<String> = w.appearing.iter().cloned().collect();
let mut c = InverseCheck { base_bz, whatif_bz, ..Default::default() };
for sym in &expected {
if a.contains_key(bare(sym)) {
c.listed_but_stayed.push(sym.clone());
} else {
c.gone_as_listed.push(sym.clone());
}
}
for (k, v) in &b {
match a.get(k) {
None if !expected.contains(&full(k)) => c.gone_unlisted.push(full(k)),
Some(v2) if v2 != v => c.changed_values.push(format!("CONFIG_{k}: {v} -> {v2}")),
_ => {}
}
}
for k in a.keys() {
if !b.contains_key(k) {
if appearing.contains(&full(k)) {
c.appeared_as_listed.push(full(k));
} else {
c.appeared_unlisted.push(full(k));
}
}
}
c
}
/// The inverse holds: everything listed is gone, nothing unlisted changed.
pub fn holds(&self) -> bool {
self.listed_but_stayed.is_empty() && self.gone_unlisted.is_empty() && self.appeared_unlisted.is_empty() && self.changed_values.is_empty()
}
/// bzImage delta (negative = smaller).
pub fn delta(&self) -> i64 {
self.whatif_bz as i64 - self.base_bz as i64
}
/// The human text.
pub fn render(&self) -> String {
let mut s = format!(
"# measured: bzImage {} -> {} bytes ({:+})\ninverse: {}\ngone as listed: {}\n",
self.base_bz,
self.whatif_bz,
self.delta(),
if self.holds() { "HOLDS" } else { "BROKEN" },
self.gone_as_listed.len()
);
let mut list = |name: &str, v: &Vec<String>| {
if !v.is_empty() {
s.push_str(&format!("{name} ({}): {}\n", v.len(), v.join(" ")));
}
};
list("listed but stayed", &self.listed_but_stayed);
list("gone but not listed", &self.gone_unlisted);
list("appeared, listed", &self.appeared_as_listed);
list("appeared, not listed", &self.appeared_unlisted);
list("changed values", &self.changed_values);
s
}
}
// ── the composer seam + the CLI verbs ───────────────────────────────────────
/// A composed kernel on disk: everything the static half reads.
#[derive(Debug, Clone)]
pub struct Composed {
/// `bzImage`.
pub bz_image: PathBuf,
/// The built `.config`.
pub config: PathBuf,
/// The base `.config` (before the fragment).
pub base_config: PathBuf,
/// The extracted, built source tree (`linux-<version>`).
pub src: PathBuf,
}
/// **Compose one kernel** (the seam: the live leg builds it in the kernel
/// toolchain container; a test hands back fixtures).
pub trait Composer {
/// Build `k` with `work_root` as its repo root (so `.kernel-out/` lands there).
fn compose(&self, k: &crate::kernel::KernelBuild, work_root: &Path) -> Result<Composed>;
}
/// The paths a compose in `work_root` leaves (whether or not it exists yet).
pub fn composed_paths(k: &crate::kernel::KernelBuild, work_root: &Path) -> Composed {
let out = crate::kernel::kernel_output_dir(work_root, k);
Composed {
bz_image: out.join("bzImage"),
config: out.join("kernel.config"),
base_config: out.join(crate::kernel::KERNEL_BASE_CONFIG),
src: out.join("work").join("src").join(format!("linux-{}", k.version)),
}
}
impl Composed {
/// Is everything the static half reads present?
pub fn complete(&self) -> bool {
self.bz_image.is_file() && self.config.is_file() && self.base_config.is_file() && self.src.join("Kconfig").is_file()
}
}
/// The LIVE composer: the same `build_kernel_with` every kernel row uses
/// (pinned tarball, the nordisk kernel toolchain image, airgapped).
#[cfg(feature = "kernel-build")]
pub struct LiveComposer;
#[cfg(feature = "kernel-build")]
impl Composer for LiveComposer {
fn compose(&self, k: &crate::kernel::KernelBuild, work_root: &Path) -> Result<Composed> {
let runner = crate::kernel::DraupnirKernelRunner::new();
crate::kernel::build_kernel_with(&crate::kernel::UreqFetcher, &runner, k, work_root)?;
let c = composed_paths(k, work_root);
if !c.complete() {
bail!("kernel '{}' composed in {} but the tree or a config is missing", k.name, work_root.display());
}
Ok(c)
}
}
/// One row's kernel, as the recipe resolves it.
#[derive(Debug, Clone)]
pub struct RowKernel {
/// The row (`rusternetes-node`).
pub row: String,
/// The `--build … --opt …` line it was resolved under.
pub line: String,
/// The chosen kernel declaration (decision + requirements landed).
pub chosen: crate::kernel::KernelBuild,
/// The repo's committed veto file + decision text (merged last).
pub user_fragment: Option<String>,
}
impl RowKernel {
/// The declaration with every file input frozen into text and `extra`
/// lines merged last — buildable in any work root.
pub fn frozen(&self, extra: &str) -> crate::kernel::KernelBuild {
let mut k = self.chosen.clone();
k.kconfig_fragment = None;
let mut u = self.user_fragment.clone().unwrap_or_default();
if !extra.is_empty() {
if !u.is_empty() && !u.ends_with('\n') {
u.push('\n');
}
u.push_str(extra);
}
k.decision_fragment = if u.is_empty() { None } else { Some(u) };
k
}
/// The merged fragment of [`Self::frozen`].
pub fn fragment(&self, extra: &str) -> Result<String> {
let k = self.frozen(extra);
crate::kernel::merged_fragment(&k, k.decision_fragment.as_deref())
}
fn requirements(&self) -> KernelRequirements {
self.chosen.requirements.clone().unwrap_or_default()
}
/// The work root of one compose: `<work>/<row>-<sha12 of the fragment>`.
pub fn work_root(&self, work: &Path, extra: &str) -> Result<PathBuf> {
let frag = self.fragment(extra)?;
let sha = nornir_hash::sha256_hex(frag.as_bytes());
Ok(work.join(format!("{}-{}", self.row, &sha[..12])))
}
/// Compose (or reuse) one kernel.
pub fn compose(&self, composer: &dyn Composer, work: &Path, extra: &str) -> Result<Composed> {
let root = self.work_root(work, extra)?;
let k = self.frozen(extra);
let c = composed_paths(&k, &root);
if c.complete() {
return Ok(c);
}
std::fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
composer.compose(&k, &root)
}
}
/// What the static half loads off one composed baseline.
pub struct Loaded {
/// The graph of the composed tree.
pub graph: KconfigGraph,
/// The merged fragment.
pub fragment: String,
/// The built `.config` text.
pub config: String,
/// The base `.config` text.
pub base: String,
/// The tree's sizes, when `vmlinux.a` is there.
pub sizes: Option<TreeSizes>,
/// bzImage bytes.
pub bz_bytes: u64,
/// The composed paths.
pub composed: Composed,
}
/// Load a composed baseline for the static half.
pub fn load(row: &RowKernel, composed: Composed) -> Result<Loaded> {
let k = &row.chosen;
let graph = KconfigGraph::read(&composed.src, crate::kconfig_graph::kconfig_env(&k.arch, &k.version))?;
let read = |p: &Path| std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()));
Ok(Loaded {
graph,
fragment: row.fragment("")?,
config: read(&composed.config)?,
base: read(&composed.base_config)?,
sizes: TreeSizes::read(&composed.src).ok(),
bz_bytes: std::fs::metadata(&composed.bz_image).map(|m| m.len()).unwrap_or(0),
composed,
})
}
/// The parsed `kernel …` verb.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct KernelVerb {
/// `what-if`, `candidates` or `needs`.
pub verb: String,
/// The row.
pub row: String,
/// `--drop CONFIG_X` (what-if).
pub drops: Vec<String>,
/// `--measure` (what-if).
pub measure: bool,
/// `--top N` (candidates, default 20).
pub top: usize,
/// `--work DIR`.
pub work: Option<PathBuf>,
/// `--json`: the machine form (schemas `nornir.kernel-what-if/1`,
/// `nornir.kernel-candidates/1`, `nornir.kernel-needs/1`).
pub json: bool,
/// The repo root whose `.nornir/profiles/<row>/` holds the row's
/// measured syscalls (set by the door, not a flag). `None` = the join is
/// not checked (what-if / candidates say so; `needs` refuses).
pub repo_root: Option<PathBuf>,
/// The rest: the `--build … --opt …` line.
pub line: String,
/// `--run <suite>` (coverage, veto; default `bridge`).
pub suite: String,
/// `veto <row> CONFIG_X` — the ONE symbol.
pub symbol: Option<String>,
}
/// The verbs of the `kernel` door.
pub const KERNEL_VERBS: &[&str] = &["what-if", "candidates", "needs", "coverage", "veto", "vetoes"];
/// Parse `what-if <row> --drop CONFIG_X … [--measure] [--work D] [--json] [line…]` /
/// `candidates <row> [--top N] [--work D] [--json] [line…]` /
/// `needs <row> [--work D] [--json] [line…]` / `coverage <row> [--run
/// <suite>]` / `veto <row> CONFIG_X [--run <suite>]` / `vetoes <row>`.
pub fn parse_kernel_verb(args: &[String]) -> Result<KernelVerb> {
let usage = "usage: kernel what-if <row> --drop CONFIG_X [--drop …] [--measure] [--work <dir>] [--json] [--build kernel --opt …] | kernel candidates <row> [--top N] [--work <dir>] [--json] [--build kernel --opt …] | kernel needs <row> [--work <dir>] [--json] [--build kernel --opt …] | kernel coverage <row> [--run <suite>] | kernel veto <row> CONFIG_X [--run <suite>] | kernel vetoes <row>";
let verb = args.first().ok_or_else(|| anyhow!("{usage}"))?.clone();
if !KERNEL_VERBS.contains(&verb.as_str()) {
bail!("unknown kernel verb `{verb}` — {usage}");
}
let row = args.get(1).filter(|r| !r.starts_with("--")).ok_or_else(|| anyhow!("{usage}"))?.clone();
let mut v = KernelVerb { verb, row, top: 20, suite: "bridge".to_string(), ..Default::default() };
let mut rest = Vec::new();
let mut i = 2;
while i < args.len() {
match args[i].as_str() {
sym if sym.starts_with("CONFIG_") && v.verb == "veto" => {
if let Some(first) = &v.symbol {
bail!("kernel veto takes ONE symbol per run ({first} and {sym} given) — each veto gets its own full rerun and receipt");
}
v.symbol = Some(sym.to_string());
i += 1;
}
"--run" => {
v.suite = args.get(i + 1).filter(|s| !s.starts_with("--")).ok_or_else(|| anyhow!("--run needs a suite name"))?.clone();
i += 2;
}
"--drop" => {
let s = args.get(i + 1).ok_or_else(|| anyhow!("--drop needs a CONFIG_ symbol"))?;
if !s.starts_with("CONFIG_") || s.len() <= 7 {
bail!("--drop {s:?}: not a CONFIG_* symbol");
}
v.drops.push(s.clone());
i += 2;
}
"--measure" => {
v.measure = true;
i += 1;
}
"--json" => {
v.json = true;
i += 1;
}
"--top" => {
v.top = args.get(i + 1).and_then(|n| n.parse().ok()).ok_or_else(|| anyhow!("--top needs a number"))?;
i += 2;
}
"--work" => {
v.work = Some(PathBuf::from(args.get(i + 1).ok_or_else(|| anyhow!("--work needs a directory"))?));
i += 2;
}
other => {
rest.push(other.to_string());
i += 1;
}
}
}
if v.verb == "what-if" && v.drops.is_empty() {
bail!("what-if needs at least one --drop CONFIG_X — {usage}");
}
if v.verb != "what-if" && (v.measure || !v.drops.is_empty()) {
bail!("{} takes no --drop/--measure — {usage}", v.verb);
}
if v.verb == "veto" && v.symbol.is_none() {
bail!("kernel veto needs the ONE CONFIG_X to veto — {usage}");
}
let mut line = rest.join(" ");
if !line.contains("--build") {
line = if line.is_empty() { "--build kernel".to_string() } else { format!("--build kernel {line}") };
}
v.line = line;
Ok(v)
}
/// What the coverage and veto verbs need besides the composer.
#[derive(Default)]
pub struct VerbContext<'a> {
/// The row repo's root (`.nornir/kernel-coverage`, `.nornir/kernel-vetoes`).
pub repo_root: Option<&'a Path>,
/// The row's check suite (KVM).
pub suite: Option<&'a dyn veto::SuiteRunner>,
/// The row's veto list.
pub store: Option<&'a dyn veto::VetoStore>,
/// Tool lines for reports and receipts.
pub tools: Vec<String>,
}
#[derive(Serialize)]
struct WhatIfJson<'a> {
schema: &'static str,
row: &'a str,
kernel: &'a str,
version: &'a str,
baseline_bz: u64,
verdict: &'static str,
what_if: &'a WhatIf,
measured: Option<&'a InverseCheck>,
}
#[derive(Serialize)]
struct CandidatesJson<'a> {
schema: &'static str,
row: &'a str,
kernel: &'a str,
version: &'a str,
baseline_bz: u64,
model_noise: usize,
candidates: &'a Candidates,
}
/// **Run the `kernel` verb** for a row the recipe already resolved.
pub fn run_kernel_verb(v: &KernelVerb, row: &RowKernel, composer: &dyn Composer, default_work: &Path) -> Result<String> {
run_kernel_verb_in(v, row, composer, default_work, &VerbContext::default())
}
/// [`run_kernel_verb`] with the coverage/veto context.
pub fn run_kernel_verb_in(v: &KernelVerb, row: &RowKernel, composer: &dyn Composer, default_work: &Path, cx: &VerbContext<'_>) -> Result<String> {
let work = v.work.clone().unwrap_or_else(|| default_work.to_path_buf());
let need_root = || cx.repo_root.ok_or_else(|| anyhow!("kernel {}: no repo root given to this door", v.verb));
let tool = || {
let mut t = vec![format!("nornir-build-thing {}", env!("CARGO_PKG_VERSION"))];
t.extend(cx.tools.iter().cloned());
t
};
match v.verb.as_str() {
"coverage" => {
let root = need_root()?;
let suite = cx.suite.ok_or_else(|| anyhow!("kernel coverage: this door has no suite runner"))?;
let out = work.join(format!("{}-coverage", row.row));
let (r, p) = veto::run_coverage(row, &v.suite, suite, root, &out, &tool().join("; "))?;
return Ok(format!("{}report: {}\n", r.render(v.top), p.display()));
}
"veto" => {
let root = need_root()?;
let suite = cx.suite.ok_or_else(|| anyhow!("kernel veto: this door has no suite runner"))?;
let store = cx.store.ok_or_else(|| anyhow!("kernel veto: this door has no veto list"))?;
let sym = v.symbol.as_deref().ok_or_else(|| anyhow!("kernel veto needs a CONFIG_X"))?;
let out = work.join(format!("{}-veto", row.row));
let vcx = veto::VetoContext { repo_root: root, store, composer, suite, work: &work, out: &out, tools: tool() };
let (r, p) = veto::run_veto(row, sym, &v.suite, &vcx)?;
return Ok(format!("{}receipt: {}\n", r.render(), p.display()));
}
"vetoes" => {
let root = need_root()?;
let list = match cx.store {
Some(s) => veto::parse_vetoes(&s.read(&row.row)?)?,
None => Vec::new(),
};
return Ok(veto::render_receipts(&row.row, &veto::list_receipts(root, &row.row)?, &list));
}
_ => {}
}
let base = row.compose(composer, &work, "")?;
let l = load(row, base)?;
let reqs = row.requirements();
let mut model = TrimModel::new(&l.graph, &l.fragment, &l.config, &l.base, &reqs);
if let Some(s) = &l.sizes {
model = model.with_sizes(s, Some(l.bz_bytes));
}
// The join: the row's measured calls and the tree's syscall map (cached
// per kernel version under the work dir).
let measured = match &v.repo_root {
Some(r) => MeasuredRow::load(r, &row.row)?,
None => None,
};
let map = match &measured {
Some(_) => Some(SyscallMap::cached(&l.composed.src, &work.join("syscall-map"), &row.chosen.arch, &row.chosen.version)?),
None => None,
};
let joined = measured.as_ref().zip(map.as_ref());
let mut out = format!(
"# row {} ({}) — kernel {} {} — baseline bzImage {} bytes, {}\n",
row.row,
row.line,
row.chosen.name,
row.chosen.version,
l.bz_bytes,
l.composed.bz_image.display()
);
match v.verb.as_str() {
"needs" => {
let Some((m, map)) = joined else {
bail!(
"no measured profiles for row {} ({}/{}/{}): run `measure syscalls {}` first",
row.row,
crate::measure::PROFILES_DIR,
row.row,
crate::measure::EVIDENCE,
row.row
);
};
let mut n = syscall_join::needs(map, m, &l.config);
n.kernel = row.chosen.name.clone();
if v.json {
return Ok(serde_json::to_string_pretty(&n)?);
}
out.push_str(&n.render());
}
"candidates" => {
let why = WhyIndex::of(&l.fragment, &reqs).with_built_config(&l.config, &reqs);
let why = why.with_kconfig_chains(&l.composed.src, &l.fragment, &l.config, &l.base, &row.chosen.arch, &row.chosen.version).unwrap_or_else(|_| WhyIndex::of(&l.fragment, &reqs).with_built_config(&l.config, &reqs));
let mut c = model.candidates(&why);
if let Some((m, map)) = joined {
syscall_join::join_candidates(&mut c, &model, map, m, &l.config);
}
if v.json {
let j = CandidatesJson {
schema: syscall_join::KERNEL_CANDIDATES_SCHEMA,
row: &row.row,
kernel: &row.chosen.name,
version: &row.chosen.version,
baseline_bz: l.bz_bytes,
model_noise: model.model_noise().len(),
candidates: &c,
};
return Ok(serde_json::to_string_pretty(&j)?);
}
out.push_str(&format!("# model noise on the unchanged baseline: {} symbol(s), subtracted from every answer\n", model.model_noise().len()));
out.push_str(&c.render(v.top));
if let Some(root) = cx.repo_root {
match coverage::load_for(root, &row.row, &l.fragment)? {
Some(cov) => {
let protect = coverage::ProtectList::load(root, &row.row)?;
let rows = veto::annotate(&model, &c, &cov, &protect);
out.push_str(&format!("# coverage: suite {} measured {} ({})\n", cov.suite, cov.measured_on, cov.snapshots.join(" ")));
out.push_str(&veto::render_annotated(&rows, v.top));
}
None => out.push_str(&format!("# coverage: none measured on this fragment — `kernel coverage {} --run <suite>`\n", row.row)),
}
}
}
_ => {
let mut w = model.what_if(&v.drops, true);
if let Some((m, map)) = joined {
w.join_syscalls(map, m);
}
out.push_str(&w.render());
let mut measured_check = None;
if v.measure {
if !w.is_clean() {
bail!("{out}--measure refused: the what-if is not clean (see REFUSED/HELD above)");
}
let extra = w.fragment_lines();
let after = row.compose(composer, &work, &extra)?;
let read = |p: &Path| std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()));
let wbz = std::fs::metadata(&after.bz_image).map(|m| m.len()).unwrap_or(0);
let ic = InverseCheck::of(&w, &l.config, &read(&after.config)?, l.bz_bytes, wbz);
out.push_str(&format!("recomposed: {}\n", after.bz_image.display()));
out.push_str(&ic.render());
measured_check = Some(ic);
}
if v.json {
let j = WhatIfJson {
schema: syscall_join::KERNEL_WHAT_IF_SCHEMA,
row: &row.row,
kernel: &row.chosen.name,
version: &row.chosen.version,
baseline_bz: l.bz_bytes,
verdict: w.verdict(),
what_if: &w,
measured: measured_check.as_ref(),
};
return Ok(serde_json::to_string_pretty(&j)?);
}
}
}
Ok(out)
}
/// **The coverage half** — the DEV probe kernel (`kernel.coverage=gcov`), its
/// gcov counters read out of a booted VM's memory, and per `CONFIG_*` symbol
/// executed / never executed / unmeasured under the row's check suite.
pub mod coverage;
/// **Row vetoes, one at a time, each with a receipt** — `kernel veto <row>
/// CONFIG_X` applies one veto, recomposes, reruns the suite, keeps it only
/// when the suite stays green.
pub mod veto;
#[cfg(test)]
mod tests;