nordisk/edda
crates/nornir-build-thing/src/kernel.rs
//! **Built-Linux-kernel build target** — the typed descriptor a ⚒ Build Thing
//! variant renders to compile a REAL vanilla Linux kernel inside a toolchain
//! container, producing a `bzImage` + its `.config` + a pin file of sha256s.
//!
//! Where an [`IsoBuild`](crate::iso::IsoBuild) masters a medium around a kernel
//! the constellation already has, and a [`VmBuild`](crate::vm::VmBuild) boots
//! one, a [`KernelBuild`] is where that kernel COMES FROM: fetch the pinned
//! kernel.org tarball (host-side, pure Rust), merge a **preset** (what is IN the
//! kernel) plus an optional **hardening profile** over a declared **base**
//! (`defconfig` or `tinyconfig`), optionally run a **SAT minify pass** over the
//! Kconfig DAG, and drive `make bzImage` in a container — the container may
//! shell and may run python (that is its purpose); the host stays pure-Rust
//! orchestration (the zero-shell law holds where it applies).
//!
//! This sentence said "merge a named hardening profile over `defconfig`" for a
//! day after the preset axis landed, which is the same defect
//! [`KernelBuild::command_line`] carried: a second description of what gets
//! built, naming a base the declaration may never use.
//!
//! This descriptor is *data* (pure serde, dependency-light). It materializes:
//! * the merged kconfig fragment ([`merged_fragment`] over [`merge_kconfig`],
//! a pure function over kconfig text);
//! * the in-container build script ([`build_script`] — `merge_config.sh` +
//! `olddefconfig` + a canary check that every requested option SURVIVED, then
//! `make -j bzImage`);
//! * the container run request ([`KernelRunRequest`]: pinned image ref, one
//! workdir bind mount, a bounded timeout);
//! * the artifact set + pin record ([`KernelPin`]) provenance seals over.
//!
//! > **Honest scope.** The descriptor, validation, merge, script, orchestration
//! > ([`build_kernel_with`] over the [`TarballFetcher`] / [`KernelContainerRunner`]
//! > seams) and the pin/receipt shape are REAL and fully tested with fakes — no
//! > network, no daemon. The LIVE legs ([`UreqFetcher`], [`DraupnirKernelRunner`]
//! > over draupnir's `backend-oci` bollard engine) compile under the
//! > `kernel-build` feature and are exercised only by the `#[ignore]`d heavy
//! > integration test (`tests/kernel_build_live.rs`) — a real kernel compile is
//! > never smuggled into a default `cargo test`. That test's `#[ignore]` string
//! > still says "30+ min"; MEASURED on this constellation it is **235 s** for
//! > `normal` and **78–103 s** for the small presets. The estimate was written
//! > from fear, before anything had been built.
//!
//! # The hardening profiles
//!
//! First-class, committed IN THIS CRATE (one authority, [`HardeningProfile`]):
//!
//! | profile | fragment(s) | what it is |
//! |---|---|---|
//! | `vanilla` | `src/kconfig/base.config` | defconfig + the constellation's MEASURED boot contract: virtio blk/pci/net builtin, TUN builtin, vfat + cp437 NLS builtin (the install record's `IO charset iso8859-1 not found` lesson), EFI stub + 8250 serial, ISO-9660 builtin |
//! | `hardened` | base + `src/kconfig/hardened.config` | a KSPP-style overlay ON TOP of vanilla: HARDENED_USERCOPY, INIT_ON_ALLOC, KASLR, lockdown LSM, freelist hardening, `MODULES=n` (feasible here — the appliances are measured to load zero modules; the fragment documents each choice's cost and the MODULE_SIG_FORCE fallback) |
//! | `k8s-hardened` | base + `hardened.config` + `src/kconfig/k8s-hardened.config` | `hardened` with ONE decision reversed: `BPF_SYSCALL=y` + `BPF_JIT`, `BPF_JIT_ALWAYS_ON`, `BPF_UNPRIV_DEFAULT_OFF` — a Kubernetes node's cgroup v2 device controller is a BPF program, so a node cannot have the veto (the fragment prices it) |
//!
//! # Declaration
//!
//! ```toml
//! [[build.kernel]]
//! name = "korp-kernel-hardened"
//! version = "6.12.4"
//! # REQUIRED — a kernel fetched unpinned is refused by name:
//! tarball_sha256 = "8b8bb37e4ddbe4bc9b62931d29e0a58fda706a58bbebeba549e2274db34bb122"
//! profile = "hardened" # or "vanilla" (default)
//! # source_url defaults to the kernel.org CDN for `version`:
//! # https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.4.tar.xz
//! # extra repo-relative kconfig fragment, merged LAST (optional):
//! kconfig_fragment = "kernel/extra.config"
//! # toolchain container (debian:13/trixie family per the glibc-law notes);
//! # pin it by digest so the build environment is content-addressed too:
//! toolchain_image = "docker.io/library/debian:trixie"
//! toolchain_image_sha256 = "0d8caf1a2b2312b1b76a7c26527b545ea9b5479bb95e2e97a6b3b0dcc4bd2f4c"
//! timeout_secs = 7200 # the bound; overrun is a loud refusal
//! # the kconfig STARTING POINT the fragments merge over (default "defconfig"):
//! base = "tinyconfig" # start at ~nothing; the fragment is
//! # then the WHOLE positive set and every
//! # missing dependency refuses BY NAME
//! # the bounds — a build container is a GUEST, never a host process:
//! mem_limit_mb = 8192 # podman --memory; 0 is REFUSED
//! cpus = 8 # podman --cpus, AND the `make -j` width
//! # MINIFY — the SAT pass over the Kconfig DAG (off by default):
//! solver = "klocalizer" # needs an image carrying kmax
//! solver_timeout_secs = 1800 # its OWN bound; overrun is a refusal
//! ```
//!
//! # MINIFY — the SAT pass over the Kconfig DAG
//!
//! `solver = "klocalizer"` runs kmax's solver in the SAME bounded container,
//! between the fragment merge and `olddefconfig`, and hands `olddefconfig` a
//! `.config` whose `depends on` / `select` chains are already closed. The
//! expensive half — the `kextract`+`kclause` formula, ~20 MB, ~46 s — is
//! content-addressed in the same shared cache the tarball uses
//! ([`kclause_cache_entry`]), and the solved dependency closure is written back
//! as `deps-<version>.config`, a fragment an operator commits. See
//! [`solver`](KernelBuild::solver) for what it measured, and for the one thing
//! it CANNOT do.
//!
//! # The bounded-guest law
//!
//! 2026-08-20, measured and expensive: an unbounded build on this constellation
//! reached 33 GB, the OOM killer took the service, and the box went down. So a
//! kernel build declares [`mem_limit_mb`](KernelBuild::mem_limit_mb) and
//! [`cpus`](KernelBuild::cpus), `0` is a NAMED refusal rather than "unlimited",
//! `-j` is derived from `cpus` and never from the host's `nproc`, and the two
//! ride draupnir's ordinary `--cpus`/`--memory` builders — no second spelling.
//!
//! `kind = "kernel"` on a `[[build.variant]]` row is the equally-legal field
//! spelling (the [vocabulary](crate#the-vocabulary)); `[[build.kernel]]` is
//! permanent sugar for it, exactly like `iso` / `installer`.
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::provenance::Artifact;
/// The committed `vanilla` base fragment — the constellation's measured boot
/// contract, merged over `defconfig` for EVERY profile.
pub const BASE_FRAGMENT: &str = include_str!("kconfig/base.config");
/// The committed `hardened` (KSPP-style) overlay, merged ON TOP of
/// [`BASE_FRAGMENT`] for the `hardened` profile.
pub const HARDENED_FRAGMENT: &str = include_str!("kconfig/hardened.config");
/// The committed `k8s-hardened` overlay, merged ON TOP of [`HARDENED_FRAGMENT`]
/// for the `k8s-hardened` profile: the KSPP-style overlay with exactly one
/// decision reversed — `bpf(2)` is compiled in (JIT-only, unprivileged loads
/// off by default), because cgroup v2's device controller is a BPF program.
pub const K8S_HARDENED_FRAGMENT: &str = include_str!("kconfig/k8s-hardened.config");
/// The committed `profile` overlay — ftrace's function profiler, merged ONLY
/// when `profile_run = true`. A kernel carrying this is a MEASURING kernel: it
/// instruments every function entry, which costs text and time, and nothing in
/// the tree ships it.
pub const PROFILE_FRAGMENT: &str = include_str!("kconfig/profile.config");
/// The committed `minimal` overlay — what is LEFT of the linker's knife after
/// the arch was measured. Dead-code elimination is gated on
/// `HAVE_LD_DEAD_CODE_DATA_ELIMINATION`, which only arm, mips, riscv and powerpc
/// select, so on the one arch this kind wires (`x86_64`) `minimal = true` is a
/// NAMED REFUSAL rather than a no-op. The fragment carries only `EXPERT`, the
/// dependency the real option needs wherever it exists, and is kept for the day
/// this kind wires an arch that has it.
pub const MINIMAL_FRAGMENT: &str = include_str!("kconfig/minimal.config");
/// The kconfig symbol full clang LTO rides on. NOT in [`MINIMAL_FRAGMENT`]: it
/// exists only under `LLVM=1`, so it is set by the toolchain axis, never by a
/// fragment that a gcc build would then silently drop.
pub const LTO_SYMBOL: &str = "CONFIG_LTO_CLANG_FULL=y";
/// The wired kernel toolchains. `gcc` is the kernel's default; `clang` renders
/// `LLVM=1` on every make line AND is the only one that can carry LTO.
pub const WIRED_TOOLCHAINS: &[&str] = &["gcc", "clang"];
/// The committed dependency closure for the `tinyconfig` starting point —
/// merged FIRST (before the boot contract) by every preset that starts there.
/// Every line was produced by a canary refusal, not by reading Kconfig.
pub const TINYCONFIG_DEPS_FRAGMENT: &str = include_str!("kconfig/deps-tinyconfig.config");
/// The committed `nuked` overlay — what a tunnr-class appliance is, stated as
/// absence (no gfx, no sound, no input, no USB, no wireless, no legacy storage).
/// Merged LAST over the `slim` set by [`Preset::Nuked`].
pub const NUKED_FRAGMENT: &str = include_str!("kconfig/nuked.config");
/// **The nordisk kernel toolchain image name** — the ONE image every step of the
/// kernel kind runs in (compile, minify, LTO, probe-boot). Built through the
/// image kind from [`KERNEL_TOOLCHAIN_CONTAINERFILE`], then PINNED BY DIGEST in
/// the declaration; a build on this image declares no packages and therefore
/// runs `--network none`.
pub const KERNEL_TOOLCHAIN_IMAGE: &str = "nordisk-kernel-toolchain";
/// The committed Containerfile for [`KERNEL_TOOLCHAIN_IMAGE`]. ONE authority,
/// beside the kconfig fragments, for the same reason: a build environment that
/// is `apt-get`ed at build time is not pinned, it is hoped for.
pub const KERNEL_TOOLCHAIN_CONTAINERFILE: &str =
include_str!("containerfiles/kernel-toolchain.Containerfile");
/// **The instant [`KERNEL_TOOLCHAIN_CONTAINERFILE`] is minted at**, read out of
/// its own `ENV SOURCE_DATE_EPOCH=` line.
///
/// # Why this is a parse and not a second constant
///
/// The number has to be in TWO places to do its job: inside the image (so
/// CPython emits hash-based `.pyc` files instead of mtime-stamped ones, and so
/// anything else that honours `SOURCE_DATE_EPOCH` stops reading the wall clock)
/// and on the BUILDER (`podman build --timestamp`, or podman's `timestamp`
/// build parameter — the image config's `created` field and the layer file
/// timestamps are the builder's to write, and no Dockerfile instruction can
/// reach them). Two places, one fact: so exactly one of them may be the writer,
/// and it is the Containerfile, because that is the file a reader of the recipe
/// is looking at. LAW 5 — a parse, not a twin.
///
/// MEASURED, oden 2026-08-30 (lane AV): mint this Containerfile twice into two
/// fresh empty stores WITHOUT handing the builder this number and the two mints
/// disagree —
/// `359d4c48b6f8…` vs `8086d7ee318d…` — while WITH it, four mints spread over
/// fifteen minutes all produced `3f632569f198…`. It is load-bearing, and that
/// is why it is refused rather than defaulted when the line is absent.
pub fn kernel_toolchain_source_date_epoch() -> Result<i64> {
source_date_epoch_of(KERNEL_TOOLCHAIN_CONTAINERFILE)
}
/// The `ENV SOURCE_DATE_EPOCH=<seconds>` line of a Containerfile, as a number.
/// Split out from [`kernel_toolchain_source_date_epoch`] so the parse — the
/// absent line, the non-numeric value — is provable without a Containerfile of
/// its own. Since 2026-09-05 it serves BOTH toolchain Containerfiles (the cargo
/// one reads it through
/// [`crate::cargo_container::cargo_toolchain_source_date_epoch`]), which is why
/// the messages no longer say "kernel".
pub(crate) fn source_date_epoch_of(containerfile: &str) -> Result<i64> {
let raw = containerfile
.lines()
.map(str::trim)
.find_map(|l| l.strip_prefix("ENV SOURCE_DATE_EPOCH="))
.ok_or_else(|| {
anyhow!(
"this toolchain Containerfile has no `ENV SOURCE_DATE_EPOCH=<seconds>` \
line. That line is the ONE writer of the instant the image is minted at: the \
builder is handed it as `podman build --timestamp`, and without it two mints \
of the same recipe get two different image ids (measured 2026-08-30) — so a \
default here would silently turn a reproducible pin back into a minting event."
)
})?;
let v = raw.split_whitespace().next().unwrap_or("").trim_matches('"');
v.parse::<i64>().map_err(|e| {
anyhow!(
"this toolchain Containerfile's `ENV SOURCE_DATE_EPOCH={v}` is not a whole \
number of seconds since the epoch: {e}"
)
})
}
/// **Every tool the toolchain image must carry for a kernel row that runs EVERY
/// step**, and the step that needs it — `(tool, feature)`.
///
/// DERIVED, since lane AF (2026-08-30), from
/// [`crate::toolchain_requires::kernel_tool_requirements`], which is the
/// closure a kernel row actually places on the toolchain image behind its
/// `toolchain` edge. It was a flat `const` here, and the const was the whole
/// argument for making the toolchain a real SURFACE: every line of it is a
/// requirement with a reason, and being a const it was UNCONDITIONAL (a
/// `toolchain = "gcc"` row with no solver still demanded clang, lld, kmax and
/// qemu) and owned by NOBODY (no `toolchain.why.toml` could say which step
/// wanted a tool). One writer now; this is the maximal row's answer, which is
/// what an image built once for every kernel row must satisfy.
pub fn kernel_toolchain_tools() -> Vec<(String, String)> {
let every_step = KernelBuild {
name: "every-step".into(),
toolchain: "clang".into(),
lto: true,
solver: "kmax".into(),
probe_timeout_secs: DEFAULT_PROBE_TIMEOUT_SECS,
..Default::default()
};
crate::toolchain_requires::kernel_tool_requirements(&every_step)
.into_iter()
.map(|(feature, req)| (req.tool, feature))
.collect()
}
/// The default toolchain container: the debian:13 (trixie) family the glibc-law
/// notes settled on for build containers. Pin it per-declaration with
/// `toolchain_image_sha256` — a named default is a convenience, not a pin.
pub const DEFAULT_TOOLCHAIN_IMAGE: &str = "docker.io/library/debian:trixie";
/// The packages the default (bare-distro) toolchain image needs before it can
/// compile a kernel. A self-contained toolchain image declares
/// `toolchain_packages = []` and the build then runs airgapped (`--network none`).
pub const DEFAULT_TOOLCHAIN_PACKAGES: &[&str] =
&["gcc", "make", "flex", "bison", "bc", "libelf-dev", "libssl-dev", "xz-utils"];
/// The default build bound: 2 hours. A kernel compile that has not produced a
/// `bzImage` in two hours on this constellation's hardware is a hang, and a hang
/// is a loud refusal, never a wait.
pub const DEFAULT_TIMEOUT_SECS: u64 = 2 * 60 * 60;
/// The one make target wired end-to-end (x86 boot image).
pub const DEFAULT_MAKE_TARGET: &str = "bzImage";
/// The one arch wired end-to-end.
pub const DEFAULT_ARCH: &str = "x86_64";
/// The named hardening profile a [`KernelBuild`] picks. The profiles are
/// first-class committed kconfig fragments in THIS crate (one authority) —
/// see the module docs table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HardeningProfile {
/// defconfig + the measured constellation boot contract ([`BASE_FRAGMENT`]).
Vanilla,
/// [`Vanilla`](Self::Vanilla) + the KSPP-style overlay ([`HARDENED_FRAGMENT`]).
Hardened,
/// [`Hardened`](Self::Hardened) + the one reversal a Kubernetes node needs
/// ([`K8S_HARDENED_FRAGMENT`]: `bpf(2)`, JIT-only, unprivileged off).
#[serde(rename = "k8s-hardened")]
K8sHardened,
}
impl HardeningProfile {
/// Parse the declared profile name. NOT total on purpose: a typo'd
/// `"hardned"` silently degrading to vanilla would be a hardening claim the
/// artifact does not honour — unknown names are refused (by the caller,
/// naming the variant).
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "vanilla" => Some(HardeningProfile::Vanilla),
"hardened" | "kspp" => Some(HardeningProfile::Hardened),
"k8s-hardened" => Some(HardeningProfile::K8sHardened),
_ => None,
}
}
/// Stable label.
pub fn as_str(&self) -> &'static str {
match self {
HardeningProfile::Vanilla => "vanilla",
HardeningProfile::Hardened => "hardened",
HardeningProfile::K8sHardened => "k8s-hardened",
}
}
/// **Every profile** — what the settings tree offers and what a refusal of
/// an unknown name lists, so the list and [`parse`](Self::parse) cannot
/// drift apart (the [`Preset::ALL`] discipline).
pub const ALL: [HardeningProfile; 3] =
[HardeningProfile::Vanilla, HardeningProfile::Hardened, HardeningProfile::K8sHardened];
/// The declared names, `vanilla · hardened · k8s-hardened` — the one
/// spelling every "unknown profile" refusal prints.
pub fn names() -> String {
Self::ALL.map(|p| p.as_str()).join(" · ")
}
/// The committed fragments this profile contributes, merged AFTER the
/// preset's (later wins), so hardening LAYERS on whatever contents were
/// chosen and a hardened kernel never loses the preset's boot contract.
///
/// The boot contract itself moved to [`Preset`] when the preset axis landed:
/// this enum is now the HARDENING axis only, and `vanilla` honestly means
/// "no hardening overlay" rather than secretly also meaning "the measured
/// contract". The default declaration (`preset = "normal"`, `profile =
/// "vanilla"`) still merges exactly `[BASE_FRAGMENT]`, and `hardened` still
/// merges exactly `[BASE_FRAGMENT, HARDENED_FRAGMENT]` — asserted as a
/// regression guard, because that byte-parity is what makes this a
/// refactor rather than a behaviour change.
pub fn fragments(&self) -> Vec<&'static str> {
match self {
HardeningProfile::Vanilla => vec![],
HardeningProfile::Hardened => vec![HARDENED_FRAGMENT],
// LAYERED, not copied: hardened's every line, then the reversal,
// which replaces hardened's `BPF_SYSCALL` veto in place (later wins).
HardeningProfile::K8sHardened => vec![HARDENED_FRAGMENT, K8S_HARDENED_FRAGMENT],
}
}
}
/// **What is IN the kernel** — the axis a person actually picks, orthogonal to
/// [`HardeningProfile`] (every preset × profile pair composes).
///
/// | preset | base | contents |
/// |---|---|---|
/// | [`Full`](Self::Full) | `defconfig` | the generic-hardware kernel, untouched — no constellation contract at all |
/// | [`Normal`](Self::Normal) | `defconfig` | + the MEASURED boot contract ([`BASE_FRAGMENT`]). The default, and what the appliances ship today |
/// | [`Slim`](Self::Slim) | `tinyconfig` | the contract and nothing else: everything measured, nothing generic |
/// | [`Nuked`](Self::Nuked) | `tinyconfig` | slim − gfx/sound/input/USB/wireless/legacy storage ([`NUKED_FRAGMENT`]): serial console, virtio-net, TUN, virtio-blk |
///
/// **Honest status, kept current — and this paragraph has now been wrong
/// TWICE, in opposite directions.** First it called `slim` and `nuked` unbuilt
/// experiments while implying `full` was measured (the reverse of the truth);
/// then, hours after that was corrected to "`full`: never built", `full` WAS
/// built and booted. A table of receipts inside a doc comment decays every time
/// somebody runs a build, and it is read as a receipt, which is worse than
/// nothing. It stays because the numbers are load-bearing — but the authority
/// is `workspace_nordisk/.nornir/kernel-trim-design.md` §12/§15/§17/§19, and if
/// the two disagree, the design doc is right and this is stale again.
///
/// | preset | built | probe-booted |
/// |---|---|---|
/// | `full` | 13 693 952 B / 246 s | PID 1 alive, console + vda — **`tun` ABSENT** |
/// | `normal` | 13 714 432 B / 235 s | PID 1 alive, console + tun + vda |
/// | `slim` | 1 811 456 B / 80 s | PID 1 alive, console + tun + vda |
/// | `slim` + `solver` | 1 389 568 B / 103 s warm | PID 1 alive, console + tun + vda |
/// | `nuked` | 1 725 440 B / 78 s | PID 1 alive, console + tun + vda |
///
/// All on linux 6.12.104, airgapped, in the nordisk toolchain container.
///
/// **`full` boots and is not an appliance kernel.** On plain `defconfig`
/// `CONFIG_TUN` is not builtin (it is a module, and `MODULES=y` there), so an
/// appliance initramfs — which has no modules — gets no TUN device and
/// therefore no userspace-WireGuard data plane. The probe reports that absence
/// by OBSERVATION (PID 1 calling `stat()` in the booted guest), not by reading
/// a `.config`. The gap from `full` to `normal` is 20 480 bytes, and those
/// bytes are the entire boot contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preset {
/// The generic-hardware kernel, no constellation contract.
Full,
/// `defconfig` + the measured boot contract. The default.
Normal,
/// `tinyconfig` + the measured boot contract.
Slim,
/// [`Slim`](Self::Slim) + the absence overlay ([`NUKED_FRAGMENT`]).
Nuked,
}
impl Preset {
/// Parse the declared preset name. NOT total, for the same reason
/// [`HardeningProfile::parse`] is not: a typo'd `"slin"` silently building
/// a generic kernel is the opposite of what was asked for.
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"" | "normal" => Some(Preset::Normal),
"full" => Some(Preset::Full),
"slim" => Some(Preset::Slim),
"nuked" => Some(Preset::Nuked),
_ => None,
}
}
/// Stable label.
pub fn as_str(&self) -> &'static str {
match self {
Preset::Full => "full",
Preset::Normal => "normal",
Preset::Slim => "slim",
Preset::Nuked => "nuked",
}
}
/// **Every preset, in size order** — the named DEFAULT-BUNDLES the option
/// axis offers (`--opt kernel.preset=<name>`). A preset is not a separate
/// mechanism from the per-symbol rows: it is the bundle of defaults the
/// rows start from, and this list is what a refusal of an unknown name
/// prints, so the list and the parser cannot drift apart
/// ([`parse`](Self::parse) is the one reader of the names).
pub const ALL: [Preset; 4] = [Preset::Full, Preset::Normal, Preset::Slim, Preset::Nuked];
/// The kconfig starting point this preset implies. A declaration may still
/// name [`base`](KernelBuild::base) explicitly and override it — the
/// combination is legal and sometimes exactly what an experiment wants
/// (`slim` contents on a generic base), so it is resolved in ONE place
/// ([`KernelBuild::base`]) rather than refused.
pub fn base(&self) -> &'static str {
match self {
Preset::Full | Preset::Normal => "defconfig",
Preset::Slim | Preset::Nuked => "tinyconfig",
}
}
/// The committed fragments this preset contributes, in merge order.
///
/// **`Full` contributes NOTHING, and that has a consequence worth stating
/// where it is decided**: an empty fragment means `merge_config.sh -m` runs
/// against an empty file and the canary loop reads ZERO lines. A `full`
/// build is therefore structurally uncheckable by the canary — there is no
/// declared option for an option to fail to be. That is correct (a preset
/// with no contract has nothing to assert) and it is also why `full` is the
/// preset a probe-boot has the most to say about: measured, it boots and has
/// no `tun`, which no canary could ever have told us.
pub fn fragments(&self) -> Vec<&'static str> {
match self {
Preset::Full => vec![],
Preset::Normal => vec![BASE_FRAGMENT],
// The closure comes FIRST: it only enables parents, so the measured
// contract still has the last word over anything it also names.
Preset::Slim => vec![TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT],
Preset::Nuked => vec![TINYCONFIG_DEPS_FRAGMENT, BASE_FRAGMENT, NUKED_FRAGMENT],
}
}
}
fn default_toolchain_image() -> String {
DEFAULT_TOOLCHAIN_IMAGE.to_string()
}
fn default_toolchain_packages() -> Vec<String> {
DEFAULT_TOOLCHAIN_PACKAGES.iter().map(|s| s.to_string()).collect()
}
/// The default kconfig **starting point**: `defconfig` — the generic-hardware
/// kernel every distro starts from, which the profile fragments then add to.
pub const DEFAULT_BASE: &str = "defconfig";
/// The wired starting points. `tinyconfig` is the OTHER direction: it starts at
/// (almost) nothing, so the merged fragment becomes the WHOLE positive set and
/// the canary loop turns every missing dependency into a NAMED refusal instead
/// of a silent degrade — that refusal list IS how a minimal config is closed.
/// An unwired name is refused by name (never a silent `defconfig` fallback).
pub const WIRED_BASES: &[&str] = &["defconfig", "tinyconfig"];
/// The wired **minifiers** — the SAT pass over the Kconfig DAG (design §3's
/// second knife). `klocalizer` is kmax's solver: it reads the kernel's own
/// Kconfig through `kextract`/`kclause`, turns it into a z3 formula, and answers
/// "what else must be on for these symbols to be on". An unwired name is
/// refused BY NAME, exactly like every other axis here.
pub const WIRED_SOLVERS: &[&str] = &["klocalizer"];
/// The kmax release the toolchain image carries, and therefore HALF the formula
/// cache key: a kclause formula is a function of the tree AND of the extractor
/// that produced it, so two kmax versions must never share a cache entry.
pub const DEFAULT_SOLVER_VERSION: &str = "4.10";
/// The solver's own bound, seconds.
///
/// MEASURED 2026-08-23 on linux 6.12.104 in the nordisk toolchain image, stated
/// as END-TO-END WALLS because those are the only numbers that were actually
/// timed: a slim build with the solver takes **148 s cold** and **103 s warm**,
/// and the difference — ~45 s — is the kclause formula (kextract + kclause over
/// 17 704 symbols), which the cache then serves for free. What is left after
/// subtracting it is roughly constant either way, so the SOLVE itself is tens of
/// seconds, not the ~50 s this comment previously claimed for the warm case.
/// That number was a subtraction dressed up as a measurement, and it did not
/// reconcile with the arms beside it.
///
/// 1800 s is the end-to-end wall with an order of magnitude of room. An overrun
/// is a NAMED refusal (`KERNEL-SOLVER-REFUSED`, exit 91) — proven live: a 5 s
/// budget came back in 19 s naming the variant, the step, the knob and the
/// budget — never a container that sits there until the build timeout kills it
/// with no idea which step hung.
pub const DEFAULT_SOLVER_TIMEOUT_SECS: u64 = 1800;
/// The exit code the in-container script uses for a solver refusal — distinct
/// from the canary's 90, so the orchestrator can say WHICH step refused instead
/// of handing back an anonymous non-zero.
pub const SOLVER_REFUSED_EXIT: i64 = 91;
/// The marker the solver step prints before it exits [`SOLVER_REFUSED_EXIT`].
pub const SOLVER_REFUSED_MARKER: &str = "KERNEL-SOLVER-REFUSED";
/// The exit code the in-container CANARY uses when a line of the merged
/// fragment did not survive `olddefconfig` — the solver's twin, spelled once so
/// the orchestrator's named arm and the shell that raises it cannot drift.
pub const KERNEL_CONFIG_REFUSED_EXIT: i64 = 90;
/// The marker the canary prints per refused line before it exits
/// [`KERNEL_CONFIG_REFUSED_EXIT`] (the `-TOTAL` line carries it as a prefix too).
pub const KERNEL_CONFIG_REFUSED: &str = "KERNEL-CONFIG-REFUSED";
/// The default floor of FREE DISK a kernel build demands before it starts, MiB.
///
/// MEASURED 2026-08-22 on linux 6.12.104: a full `normal` build's workdir peaks
/// at **2.5 GB** (150 MB tarball + unpacked tree + objects + `bzImage`), a
/// `slim` one at **1.9 GB**. 6 GiB is that with room for a second variant and
/// the cache copy — a build that dies at 95% because the disk filled has spent
/// the whole compile to tell you something a `statvfs` knew before it started.
pub const DEFAULT_MIN_FREE_DISK_MB: u32 = 6144;
/// The default memory bound for a build container: 8 GiB.
///
/// **Why there is a bound at all** (2026-08-20, measured, expensive): an
/// unbounded build process on this constellation reached 33 GB, the OOM killer
/// took the service, and the box went down with it. A kernel compile is a
/// BOUNDED GUEST, never a host process that may grow until something dies.
/// `0` is not "unlimited" here — it is a refusal ([`KernelBuild::validate`]).
pub const DEFAULT_MEM_LIMIT_MB: u32 = 8192;
/// The default cpu bound for a build container: 8 cores. `make -j` is derived
/// from THIS, never from the host's `nproc` — a build must not size itself to
/// whichever machine happens to run it (that is how a laptop build swaps and a
/// 64-core box starves everything else).
pub const DEFAULT_BUILD_CPUS: u32 = 8;
fn default_preset() -> String {
"normal".to_string()
}
fn is_default_preset(s: &str) -> bool {
s == "normal"
}
fn default_mem_limit_mb() -> u32 {
DEFAULT_MEM_LIMIT_MB
}
fn is_default_mem_limit_mb(m: &u32) -> bool {
*m == DEFAULT_MEM_LIMIT_MB
}
fn default_build_cpus() -> u32 {
DEFAULT_BUILD_CPUS
}
/// The probe's boot bound: 120 s. A kernel that has not reached PID 1 in two
/// minutes under TCG is not slow, it is stuck.
pub const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 120;
fn default_toolchain() -> String {
"gcc".to_string()
}
fn is_default_toolchain(s: &str) -> bool {
s == "gcc"
}
fn default_probe_timeout_secs() -> u64 {
DEFAULT_PROBE_TIMEOUT_SECS
}
fn default_solver_version() -> String {
DEFAULT_SOLVER_VERSION.to_string()
}
fn is_default_solver_version(s: &str) -> bool {
s == DEFAULT_SOLVER_VERSION
}
fn default_solver_timeout_secs() -> u64 {
DEFAULT_SOLVER_TIMEOUT_SECS
}
fn is_default_solver_timeout(t: &u64) -> bool {
*t == DEFAULT_SOLVER_TIMEOUT_SECS
}
fn is_default_probe_timeout(t: &u64) -> bool {
*t == DEFAULT_PROBE_TIMEOUT_SECS
}
fn default_min_free_disk_mb() -> u32 {
DEFAULT_MIN_FREE_DISK_MB
}
fn is_default_min_free_disk_mb(m: &u32) -> bool {
*m == DEFAULT_MIN_FREE_DISK_MB
}
fn is_default_build_cpus(c: &u32) -> bool {
*c == DEFAULT_BUILD_CPUS
}
fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
fn default_make_target() -> String {
DEFAULT_MAKE_TARGET.to_string()
}
fn default_arch() -> String {
DEFAULT_ARCH.to_string()
}
fn default_profile() -> String {
HardeningProfile::Vanilla.as_str().to_string()
}
fn is_default_toolchain_image(s: &str) -> bool {
s == DEFAULT_TOOLCHAIN_IMAGE
}
fn is_default_toolchain_packages(v: &[String]) -> bool {
v.iter().map(String::as_str).eq(DEFAULT_TOOLCHAIN_PACKAGES.iter().copied())
}
fn is_default_timeout(t: &u64) -> bool {
*t == DEFAULT_TIMEOUT_SECS
}
fn is_default_make_target(s: &str) -> bool {
s == DEFAULT_MAKE_TARGET
}
fn is_default_arch(s: &str) -> bool {
s == DEFAULT_ARCH
}
fn is_default_profile(s: &str) -> bool {
s == HardeningProfile::Vanilla.as_str()
}
/// **A declared kernel build** — one `[[build.kernel]]` row: a variant whose
/// artifact is a built Linux kernel (`bzImage` + `.config` + a [`KernelPin`]
/// of sha256s). See the module docs for the TOML shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelBuild {
/// The declared handle (the variant name a human types).
pub name: String,
/// The upstream kernel version, e.g. `"6.12.4"`.
pub version: String,
/// Source tarball URL. Empty ⇒ derived from `version` on the kernel.org CDN
/// ([`source_url`](Self::source_url)).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub source_url: String,
/// **REQUIRED**: the lowercase-hex sha256 of the source tarball. A kernel
/// fetched unpinned is refused by name ([`validate`](Self::validate)).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub tarball_sha256: String,
/// The hardening profile name (`"vanilla"` default, `"hardened"` or
/// `"k8s-hardened"`).
/// Held as the declared string and resolved via [`HardeningProfile::parse`]
/// so an unknown name is a NAMED refusal, never a silent vanilla downgrade.
#[serde(default = "default_profile", skip_serializing_if = "is_default_profile")]
pub profile: String,
/// Extra repo-relative kconfig fragment, merged LAST (over the profile).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kconfig_fragment: Option<String>,
/// **The operator's kernel DECISION, in memory** — the veto fragment a
/// resolved [`crate::kernel_features::KernelDecision`] rendered
/// (`--opt kernel.<symbol>=off` through the pane's own `try_toggle` door),
/// merged after even the repo's own `kconfig_fragment` so the person at the
/// terminal has the last word. NOT part of the TOML shape (`#[serde(skip)]`):
/// a declaration file states the repo's defaults, and this is precisely what
/// was decided ON TOP of them for one build. Set via
/// [`apply_decision`](Self::apply_decision), read via
/// [`user_fragment_in`](Self::user_fragment_in), and hashed into
/// [`canonical_bytes`](Self::canonical_bytes) — two different decisions must
/// never share a content address.
#[serde(skip)]
pub decision_fragment: Option<String>,
/// **What this kernel's CONSUMERS require of it, in memory** — the union the
/// planner collected over every enabled feature of every consumer in the
/// chain ([`crate::kernel_requires::KernelRequirements`], the forward
/// index). Merged AFTER the preset/profile fragments and BEFORE the repo's
/// veto file and the operator's decision; a contradiction at either side
/// is a named refusal ([`apply_requirements`](Self::apply_requirements),
/// [`merged_fragment`]). NOT part of the TOML shape, for the same reason
/// `decision_fragment` is not: a declaration states the donor's defaults,
/// this is what its consumers asked of it for one build. A requirement that
/// repeats a fragment line merges to identical bytes, so a kernel whose
/// consumers ask only what the preset already grants keeps its pin.
#[serde(skip)]
pub requirements: Option<crate::kernel_requires::KernelRequirements>,
/// **A DEV-ONLY probe kernel** (`--opt kernel.coverage=gcov`, the measured
/// kernel trim's coverage half, [`crate::kernel_trim::coverage`]): its
/// fragment ([`crate::kernel_trim::coverage::KernelProbe::fragment`]) is
/// merged after every other layer, it is hashed into
/// [`canonical_bytes`](Self::canonical_bytes), and a build of it is refused
/// BY NAME for a release or signed medium
/// ([`crate::kernel_trim::coverage::refuse_probe_from_env`]). Not part of
/// the TOML shape: no declaration ships a probe kernel.
#[serde(skip)]
pub probe: Option<crate::kernel_trim::coverage::KernelProbe>,
/// The make target (only `bzImage` is wired).
#[serde(default = "default_make_target", skip_serializing_if = "is_default_make_target")]
pub make_target: String,
/// The target arch (only `x86_64` is wired).
#[serde(default = "default_arch", skip_serializing_if = "is_default_arch")]
pub arch: String,
/// The toolchain container image ([`DEFAULT_TOOLCHAIN_IMAGE`] default).
#[serde(default = "default_toolchain_image", skip_serializing_if = "is_default_toolchain_image")]
pub toolchain_image: String,
/// Optional sha256 digest pin for the toolchain image; when set the
/// effective reference is `<toolchain_image>@sha256:<digest>`.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub toolchain_image_sha256: String,
/// Packages the container installs before compiling
/// ([`DEFAULT_TOOLCHAIN_PACKAGES`] default). Empty ⇒ the image is
/// self-contained and the build runs airgapped.
#[serde(default = "default_toolchain_packages", skip_serializing_if = "is_default_toolchain_packages")]
pub toolchain_packages: Vec<String>,
/// The build bound in seconds ([`DEFAULT_TIMEOUT_SECS`] default). Overrun
/// stops the container and refuses loudly.
#[serde(default = "default_timeout_secs", skip_serializing_if = "is_default_timeout")]
pub timeout_secs: u64,
/// **What is in the kernel** ([`Preset`]): `full` / `normal` (default) /
/// `slim` / `nuked`. Held as the declared string and resolved via
/// [`Preset::parse`], so an unknown name is a NAMED refusal.
#[serde(default = "default_preset", skip_serializing_if = "is_default_preset")]
pub preset: String,
/// The kconfig **starting point** override: `defconfig` or `tinyconfig`
/// ([`WIRED_BASES`]). Unset (the normal case) means "whatever the preset
/// implies" ([`Preset::base`]) — resolved in ONE place, [`base()`](Self::base).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
/// The container **memory bound** in MiB ([`DEFAULT_MEM_LIMIT_MB`]).
/// `0` is refused: unbounded is not a bound (see the const's measured why).
#[serde(default = "default_mem_limit_mb", skip_serializing_if = "is_default_mem_limit_mb")]
pub mem_limit_mb: u32,
/// The container **cpu bound** in whole cores ([`DEFAULT_BUILD_CPUS`]), and
/// therefore the `make -j` width. `0` is refused.
#[serde(default = "default_build_cpus", skip_serializing_if = "is_default_build_cpus")]
pub cpus: u32,
/// **The shared source cache** — where fetched kernel tarballs live so the
/// next build of ANY variant on the same pin does not re-download 150 MB.
/// Unset ⇒ resolved from the environment ([`kernel_cache_dir`]). Absolute
/// paths are fine here: this is a machine-local cache, not a repo input, and
/// putting it on the big disk is exactly the point.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_dir: Option<String>,
/// **Build a MEASURING kernel** ([`PROFILE_FRAGMENT`]): ftrace's function
/// profiler, so a RUN can say which functions a workload actually executed.
/// Never ship one — the instrumentation costs text and time. This is the
/// only instrument that sees a RUN; LTO and dead-code elimination are static
/// and know nothing about what was called.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub profile_run: bool,
/// **The linker's knife**: layer [`MINIMAL_FRAGMENT`] over the preset.
/// Orthogonal to preset and profile — it removes no FEATURE, only
/// unreachable code inside the features already chosen, so it cannot cost a
/// boot-contract line.
///
/// REFUSED on `x86_64`, which is the only arch this kind wires: the option
/// it exists to set does not exist there (see [`MINIMAL_FRAGMENT`]). On this
/// arch the linker's knife is `toolchain = "clang"` with `lto = true` — and
/// that one was measured to make a slim kernel ELEVEN PERCENT BIGGER, so it
/// is a speed knife, not a size knife.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub minimal: bool,
/// The compiler the kernel is built with ([`WIRED_TOOLCHAINS`]): `gcc`
/// (default) or `clang`, which renders `LLVM=1`.
#[serde(default = "default_toolchain", skip_serializing_if = "is_default_toolchain")]
pub toolchain: String,
/// Full clang LTO. REQUIRES `toolchain = "clang"` — refused otherwise BY
/// NAME, because the symbol would not survive `olddefconfig` under gcc and
/// the build would then fail the canary for a reason that has nothing to do
/// with what was declared.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lto: bool,
/// **MINIFY — the SAT pass over the Kconfig DAG** ([`WIRED_SOLVERS`]).
/// Empty (the default) means OFF: the build is exactly what it was, and the
/// canary is the only oracle. `solver = "klocalizer"` runs kmax's solver in
/// the same bounded container, BETWEEN the fragment merge and
/// `olddefconfig`, and hands `olddefconfig` a `.config` that already closes
/// every `depends on` / `select` chain the fragment's `=y` symbols need.
///
/// An unwired name is a NAMED refusal — a typo'd solver silently building
/// the un-minified kernel is the same silent degrade every other axis here
/// refuses.
///
/// **MEASURED 2026-08-23**, linux 6.12.104, airgapped, nordisk toolchain
/// image, `-j8` / 8192 MiB, on dwarf:
///
/// | arm | bzImage | wall | note |
/// |---|---|---|---|
/// | `slim`, no solver | 1 811 456 B | 80 s | the hand-closed baseline |
/// | `slim` + `solver` | **1 389 568 B** | 148 s (formula COLD) | **23.3 % smaller**, and it BOOTS: PID 1 alive, console + tun + virtio-blk |
/// | `tinyconfig`+`normal` + `solver`, hand closure REMOVED | 1 369 088 B | 106 s (formula warm) | canary-clean and a **BRICK** — see below |
///
/// The formula is 19 949 986 bytes and costs ~46 s to build; every later
/// build on the same pin gets it from the cache for nothing.
///
/// **The limit, measured rather than assumed.** The solver closes only what
/// the FRAGMENT NAMES. With the hand-closed fragment removed it found 13 of
/// its 16 lines by itself and missed `BINFMT_ELF`, `MULTIUSER`, `UNIX` —
/// exactly the three the canary cannot catch either, for the identical
/// reason. That kernel passed every canary line and could not execute PID 1.
/// So MINIFY is a knife, not an oracle: it makes a declared contract
/// minimal, it does not discover an undeclared one. Probe-boot ([§15],
/// [`probe_kernel_with`]) is still what says a kernel runs.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub solver: String,
/// The kmax release the toolchain image carries ([`DEFAULT_SOLVER_VERSION`]).
/// It is HALF the formula cache key, and it is CHECKED against what the
/// container reports: a formula attributed to the wrong extractor is a
/// cache entry that lies, which is worse than no cache at all.
#[serde(default = "default_solver_version", skip_serializing_if = "is_default_solver_version")]
pub solver_version: String,
/// The solver's own bound in seconds ([`DEFAULT_SOLVER_TIMEOUT_SECS`]).
/// Overrun prints [`SOLVER_REFUSED_MARKER`] and exits
/// [`SOLVER_REFUSED_EXIT`], so a hung solve names ITSELF instead of riding
/// the build's timeout and arriving as "the container died".
#[serde(default = "default_solver_timeout_secs", skip_serializing_if = "is_default_solver_timeout")]
pub solver_timeout_secs: u64,
/// How long the probe boot may take, seconds. A boot that has not printed
/// its marker in this long is a HANG, and a hang is a refusal.
#[serde(default = "default_probe_timeout_secs", skip_serializing_if = "is_default_probe_timeout")]
pub probe_timeout_secs: u64,
/// The **free-disk floor** in MiB ([`DEFAULT_MIN_FREE_DISK_MB`]) checked
/// BEFORE the fetch. `0` disables the check — and, stated plainly because
/// this doc used to claim otherwise, NOTHING ON THE RECEIPT SAYS SO: the pin
/// has no disk field, so a build that skipped the check and one that passed
/// it are indistinguishable afterwards.
///
/// **This check is necessary and NOT sufficient, measured the hard way.**
/// It asks `statvfs`, which answers for the FILESYSTEM. On 2026-08-23 it saw
/// 23 GiB free, passed, and the build died ninety seconds later on
/// `Disk quota exceeded` — `EDQUOT`, a quota against the writing user, which
/// free filesystem space says nothing about. Same blind spot for a storage
/// driver's own limit and for a backing store that fills after the check.
/// [`out_of_room`] catches what gets past this, at the only other place it
/// can be caught: the container's own output.
#[serde(default = "default_min_free_disk_mb", skip_serializing_if = "is_default_min_free_disk_mb")]
pub min_free_disk_mb: u32,
/// **Consumption edges** (`[build.kernel.edges.<feature>]`) — a root
/// feature bound to another build thing's artifact, `repo/variant : typ`,
/// type-checked at load against the donor's declaration (design f3, the
/// same edge every kind carries). Empty for every existing declaration;
/// the kernel's OWN axis (`preset` / `profile` / symbols) is not an edge.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub edges: std::collections::BTreeMap<String, crate::tree::EdgeDecl>,
}
impl Default for KernelBuild {
fn default() -> Self {
KernelBuild {
name: String::new(),
version: String::new(),
source_url: String::new(),
tarball_sha256: String::new(),
profile: default_profile(),
kconfig_fragment: None,
decision_fragment: None,
requirements: None,
probe: None,
make_target: default_make_target(),
arch: default_arch(),
toolchain_image: default_toolchain_image(),
toolchain_image_sha256: String::new(),
toolchain_packages: default_toolchain_packages(),
timeout_secs: default_timeout_secs(),
preset: default_preset(),
base: None,
mem_limit_mb: default_mem_limit_mb(),
cpus: default_build_cpus(),
cache_dir: None,
min_free_disk_mb: default_min_free_disk_mb(),
probe_timeout_secs: default_probe_timeout_secs(),
solver: String::new(),
solver_version: default_solver_version(),
solver_timeout_secs: default_solver_timeout_secs(),
profile_run: false,
minimal: false,
toolchain: default_toolchain(),
lto: false,
edges: std::collections::BTreeMap::new(),
}
}
}
fn is_hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
impl KernelBuild {
/// The declared [`Preset`], or `None` if the name is unknown (refused by
/// [`validate`](Self::validate), naming the variant).
pub fn preset(&self) -> Option<Preset> {
Preset::parse(&self.preset)
}
/// **THE resolved kconfig starting point**: the explicit
/// [`base`](Self::base) override when declared, else the preset's own
/// ([`Preset::base`]). One resolution, used by the script, the receipt and
/// the tests alike — two spellings of "what did we start from" is how a
/// picture and a build come to disagree.
pub fn base(&self) -> &str {
if let Some(b) = self.base.as_deref() {
return b;
}
self.preset().map(|p| p.base()).unwrap_or(DEFAULT_BASE)
}
/// Whether the MINIFY pass rides this build ([`solver`](Self::solver) names
/// a wired solver). One resolution, so the script, the picture, the content
/// id and the receipt cannot disagree about whether a kernel was minified.
pub fn solves(&self) -> bool {
!self.solver.is_empty()
}
/// `"LLVM=1 "` when the declaration asks for clang, empty for gcc — the ONE
/// place the toolchain reaches a make line, so a clang build cannot end up
/// half-clang (a `defconfig` generated by one compiler and a `bzImage` built
/// by the other is not a kernel anybody measured).
pub fn llvm_flag(&self) -> &'static str {
if self.toolchain == "clang" { "LLVM=1 " } else { "" }
}
/// The resolved shared cache directory: the declared one, else
/// [`kernel_cache_dir`]'s environment resolution.
pub fn cache_dir(&self, repo_root: &Path) -> std::path::PathBuf {
match self.cache_dir.as_deref() {
Some(d) => std::path::PathBuf::from(d),
None => kernel_cache_dir(repo_root),
}
}
/// The effective source tarball URL: the declared one, or the kernel.org CDN
/// derived from `version` (`v<major>.x/linux-<version>.tar.xz`).
pub fn source_url(&self) -> String {
if !self.source_url.is_empty() {
return self.source_url.clone();
}
let major = self.version.split('.').next().unwrap_or("");
format!(
"https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{}.tar.xz",
self.version
)
}
/// The tarball's file name inside the workdir (the URL's last segment).
pub fn tarball_file_name(&self) -> String {
let url = self.source_url();
url.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("linux-{}.tar.xz", self.version))
}
/// The effective toolchain image reference — digest-pinned when a
/// `toolchain_image_sha256` is declared.
pub fn toolchain_image_ref(&self) -> String {
if self.toolchain_image_sha256.is_empty() {
return self.toolchain_image.clone();
}
// ★ CORRECTED 2026-08-23, and the first diagnosis was WRONG in a way
// worth keeping. On 08-22 `localhost/nordisk-kernel-toolchain@sha256:…`
// died on "pinging container registry localhost: connection refused",
// and I concluded that digest pinning is registry-only. It is not: I had
// pasted the IMAGE ID where a MANIFEST DIGEST belongs. They are both
// 64 hex characters and they are different things.
//
// image id 388a7816cde2… `podman run <id>` works
// manifest digest 85df1a16e5fc… `podman run name@sha256:<d>` works
// image id AS a digest name@sha256:<image id> REGISTRY PULL, fails
//
// So `toolchain_image_sha256` should carry the MANIFEST digest
// (`podman image inspect --format '{{.Digest}}'`), which is addressable
// locally AND in a registry — one spelling, no special case. The empty
// name path stays, because an image id is still a legal way to run a
// local image, but it is the fallback and not the answer.
if self.toolchain_image.is_empty() {
return self.toolchain_image_sha256.clone();
}
format!("{}@sha256:{}", self.toolchain_image, self.toolchain_image_sha256)
}
/// **Why a digest-pinned toolchain image can be absent on a machine that
/// has everything, and the exact command that fixes it.**
///
/// ## What this used to say, and what changed on 2026-08-30
///
/// `toolchain_image_sha256` names an image **we build ourselves**, from this
/// crate's own embedded `kernel-toolchain.Containerfile`. Until lane AV that
/// made it a per-BOX fact — two machines running the same `podman build` on
/// the same Containerfile got two different digests, because the digest
/// covers the built layers and those carried the build's own timestamps and
/// whatever the package index said that day. **That is no longer true of
/// this recipe.** MEASURED, oden 2026-08-30: with the base pinned by digest,
/// apt reading the Debian snapshot the base image itself records, exact pip
/// versions, `SOURCE_DATE_EPOCH`, no logs or caches left in the layer that
/// makes them, and the builder handed the Containerfile's own instant, four
/// mints into four fresh empty stores over fifteen minutes produced ONE
/// image id (`3f632569f198…`) and ONE manifest digest (`78eaa55a651d…`).
/// The gate is `tests/toolchain_images_live.rs`,
/// `two_mints_of_the_kernel_toolchain_are_measured_against_each_other`, and
/// it fails if that stops being so.
///
/// So a pin that does not resolve now means one of three things, and they
/// want different actions: the box has never minted this image (mint it, or
/// stage the archive a courier brought — the offline path); the RECIPE moved
/// since the pin was taken (re-pin, and it is a mechanical edit now, not a
/// decision about which box is authoritative); or an input moved out from
/// under a recipe that claims to pin it, which is what the gate above
/// catches. On a machine that has simply never built it the run still dies
/// as
///
/// ```text
/// nordisk-kernel-toolchain@sha256:f573ca0a…: image not known
/// ```
///
/// which says nothing about pins, nothing about which command mints one,
/// and reads like a broken installation. A build cannot start until
/// somebody re-pins by hand, and this string is how they find out how.
///
/// `None` when there is no pin to be wrong about.
///
/// # Is a `.gitignore`d per-box override file the honest fix?
///
/// **No — it is an honest STOPGAP, and only if it is loud.** The pin exists
/// so that two builds reporting the same provenance really did use the same
/// toolchain. A local file the build silently prefers keeps every build
/// green and makes exactly that guarantee unverifiable: the receipts still
/// say `f573ca0a…` while the compiler is whatever this box happened to
/// bake. That is the failure the pin was created to refuse, converted from
/// a refusal into a silence.
///
/// The real fix is that the digest must stop being per-box — **publish the
/// toolchain image** to a registry the constellation pulls from, and the
/// committed digest becomes true on every machine, which is also the only
/// way an airgapped or a CI box can get it at all (see `#14`, the offline
/// kernel path).
///
/// ⚠ This paragraph once ended "failing that, pin the image's **inputs** …
/// so the pin is reproducible by construction". Lane AT measured that and it
/// was not enough: two mints 90 s apart on one box — same base layer, same
/// mirror, same package versions, 0 files added or removed and 0 sizes
/// changed — still produced two different image ids, because pinning the
/// inputs removes the drift that comes from the WORLD and leaves the drift
/// that comes from the CLOCK, and an image id is a digest over both. Lane AV
/// then closed the second axis as well, and the four normalisations are each
/// load-bearing: remove any ONE of `podman build --timestamp`,
/// `ENV SOURCE_DATE_EPOCH`, the log/aux-cache scrub, or kmax's fixed build
/// directory, and two mints disagree again (the four measured pairs are
/// tabulated in the gate's doc comment). Both halves are needed; neither
/// alone is worth anything.
///
/// If an override lands before either of those, it must be (a) explicit —
/// named on the command line or in a file this refusal names, never
/// discovered; (b) **loud** — one row per build saying the declared pin was
/// NOT the one used, and which was; and (c) carried into the build record,
/// so the provenance says "overridden here" instead of quietly lying. Drift
/// you can see is a different thing from drift you cannot.
pub fn toolchain_repin_hint(&self) -> Option<String> {
if self.toolchain_image_sha256.is_empty() || self.toolchain_image.is_empty() {
return None;
}
Some(format!(
"the toolchain image is digest-pinned to `{img}@sha256:{pin}`, and this box cannot \
resolve it — so the run fails with `image not known` and NOT with anything about \
pinning.\n\
\n\
Since 2026-08-30 that is NOT the old 'the digest is a per-box fact' problem: the \
embedded kernel-toolchain.Containerfile is reproducible (measured — four mints \
into four fresh empty stores, one image id and one manifest digest), so two \
machines building it agree. Which leaves three causes, and they want different \
actions:\n\
\n\
\x20 * this box has never MINTED the image — mint it (below), or stage the \
archive a courier brought (`toolchain_archive::stage_toolchain_archive`);\n\
\x20 * the RECIPE moved since the pin was taken — re-pin (below); it is a \
mechanical edit now, not a decision about which box is authoritative;\n\
\x20 * an input moved out from under a recipe that claims to pin it — run \
`two_mints_of_the_kernel_toolchain_are_measured_against_each_other`, which \
names the files.\n\
\n\
To mint and re-pin:\n\
\n\
\x20 1. build the image (once): the `[[build.image]]` kind, or\n\
\x20 `DraupnirKernelRunner::build_toolchain_image(<ctx>, \"{img}\")`\n\
\x20 2. read its MANIFEST DIGEST (not its image id — both are 64 hex characters\n\
\x20 and only the manifest digest works as `name@sha256:…`):\n\
\x20 podman image inspect --format '{{{{.Digest}}}}' {img}\n\
\x20 3. put those 64 hex characters (without the `sha256:` prefix) in\n\
\x20 `toolchain_image_sha256` of the `[[build.kernel]]` row named '{name}' —\n\
\x20 in the repository's `*-build-thing` CODE, which is the one writer.\n\
\x20 Do NOT edit `.nornir/build-variants.toml`: since the emitter moved into\n\
\x20 the engine the toml is EMITTED from that code and a parity test holds\n\
\x20 the two byte-identical, so a hand edit is reverted by the next emit and\n\
\x20 fails the gate meanwhile. Re-emit instead:\n\
\x20 cargo run -p <repo>-build-thing -- emit\n\
\n\
A `.gitignore`d per-box override is a stopgap, not the fix: it keeps builds green \
by making \"the same pin means the same toolchain\" unverifiable.\n\
\n\
⚠ A manifest digest still does not survive a COURIER: `podman save`+`load` keeps \
the image id and re-synthesises the manifest (measured 2026-08-30, lane AO). So \
if this box has no route to a registry, carry the image as a file and pin its \
ID — see `toolchain_archive::stage_toolchain_archive` and \
.nornir/kernel-guide.md, \"An OFFLINE kernel build\".",
img = self.toolchain_image,
pin = self.toolchain_image_sha256,
name = self.name,
))
}
/// The resolved [`HardeningProfile`], when the declared name is a known one.
pub fn hardening_profile(&self) -> Option<HardeningProfile> {
HardeningProfile::parse(&self.profile)
}
/// The build bound as a [`Duration`].
pub fn timeout(&self) -> Duration {
Duration::from_secs(self.timeout_secs)
}
/// Whether the container needs a network: only to `apt-get` the toolchain
/// packages. A self-contained image (empty `toolchain_packages`) builds
/// airgapped.
pub fn needs_network(&self) -> bool {
!self.toolchain_packages.is_empty()
}
/// Cargo variants this build chains — none: a kernel build compiles no
/// sibling crate (the analogue of [`IsoBuild`](crate::iso::IsoBuild)'s
/// `variant_deps`, empty here by construction).
pub fn variant_deps(&self) -> Vec<&str> {
Vec::new()
}
/// The one-line human rendering a picker shows (the analogue of
/// [`IsoBuild::command_line`](crate::iso::IsoBuild::command_line)); `engine`
/// names the container engine label.
pub fn command_line(&self, engine: &str) -> String {
format!(
// ★ This line used to say `defconfig` and name only the HARDENING
// profile, whatever the declaration actually asked for — so a `slim`
// build was rendered to a human as starting from the generic kernel
// it explicitly does not start from. That is the `picture-follows`
// defect (dwarves 01b9012) reproduced inside this crate: a second
// spelling of "what are we building" that can disagree with the
// build. It now goes through the SAME resolution the script does.
"{engine} run --rm -v <workdir>:/work {} sh -ec '{}{} + merge({}/{}){} + make {}-j {}'",
self.toolchain_image_ref(),
if self.lto { "LTO " } else { "" },
self.base(),
self.preset,
self.profile,
// The picture names the MINIFY pass when it rides: a solved kernel
// and an unsolved one are different artifacts, and a rendering that
// hid that is the `picture-follows` defect again.
if self.solves() { format!(" + minify({})", self.solver) } else { String::new() },
self.llvm_flag(),
self.make_target,
)
}
/// Validate the descriptor. Every refusal NAMES the kernel variant.
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(anyhow!("kernel build: empty name"));
}
crate::tree::validate_edge_rows(&format!("kernel '{}'", self.name), None, &self.edges, &[])?;
if self.version.trim().is_empty()
|| !self.version.split('.').all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
{
return Err(anyhow!(
"kernel '{}': `version` must be a dotted release like \"6.12.4\", got {:?}",
self.name,
self.version
));
}
// THE pin. A kernel fetched unpinned is refused BY NAME — the network's
// answer to a URL is not an input a build may trust unverified.
if self.tarball_sha256.is_empty() {
return Err(anyhow!(
"kernel '{}': `tarball_sha256` is REQUIRED — a kernel fetched unpinned is \
refused. Pin the source tarball's sha256 (kernel.org publishes signed sums: \
https://cdn.kernel.org/pub/linux/kernel/v{}.x/sha256sums.asc)",
self.name,
self.version.split('.').next().unwrap_or("6"),
));
}
if !is_hex64(&self.tarball_sha256) {
return Err(anyhow!(
"kernel '{}': `tarball_sha256` must be 64 lowercase hex chars, got {:?} — \
a malformed pin is not a pin",
self.name,
self.tarball_sha256
));
}
if self.hardening_profile().is_none() {
return Err(anyhow!(
"kernel '{}': unknown hardening profile {:?} — declare one of {} \
(an unknown name must not silently build vanilla)",
self.name,
self.profile,
HardeningProfile::names()
));
}
if self.arch != DEFAULT_ARCH {
return Err(anyhow!(
"kernel '{}': arch {:?} is not wired — only \"x86_64\" is \
(the bzImage collect path is arch-specific)",
self.name,
self.arch
));
}
if self.make_target != DEFAULT_MAKE_TARGET {
return Err(anyhow!(
"kernel '{}': make target {:?} is not wired — only \"bzImage\" is",
self.name,
self.make_target
));
}
if let Some(frag) = &self.kconfig_fragment {
let p = Path::new(frag);
if p.is_absolute() || frag.split('/').any(|c| c == "..") {
return Err(anyhow!(
"kernel '{}': `kconfig_fragment` must be repo-relative \
(no absolute path, no `..`), got {:?}",
self.name,
frag
));
}
}
if self.toolchain_image_ref().is_empty() {
return Err(anyhow!(
"kernel '{}': no toolchain image — `toolchain_image` is empty and so is \
`toolchain_image_sha256`, which leaves nothing for a container engine to \
run. Name an image, or put a local image id in `toolchain_image_sha256`",
self.name
));
}
if !self.toolchain_image_sha256.is_empty() && !is_hex64(&self.toolchain_image_sha256) {
return Err(anyhow!(
"kernel '{}': `toolchain_image_sha256` must be 64 lowercase hex chars, got {:?}",
self.name,
self.toolchain_image_sha256
));
}
if self.timeout_secs == 0 {
return Err(anyhow!(
"kernel '{}': `timeout_secs = 0` is not a bound — a kernel build \
without a budget is a hang waiting to be discovered",
self.name
));
}
if self.preset().is_none() {
return Err(anyhow!(
"kernel '{}': unknown preset {:?} — declare \"full\", \"normal\", \"slim\" \
or \"nuked\" (a typo must not silently build the generic kernel that is \
the opposite of what was asked for)",
self.name,
self.preset
));
}
if !WIRED_BASES.contains(&self.base()) {
return Err(anyhow!(
"kernel '{}': base {:?} is not wired — declare one of {:?} \
(an unknown starting point must not silently build `defconfig`, \
which is the OPPOSITE of what someone asking for `tinyconfig` wants)",
self.name,
self.base(),
WIRED_BASES
));
}
if !WIRED_TOOLCHAINS.contains(&self.toolchain.as_str()) {
return Err(anyhow!(
"kernel '{}': toolchain {:?} is not wired — declare one of {:?}",
self.name,
self.toolchain,
WIRED_TOOLCHAINS
));
}
// MEASURED 2026-08-23 by two refused builds, then read out of the tree:
// only arm/mips/riscv/powerpc select HAVE_LD_DEAD_CODE_DATA_ELIMINATION,
// so on x86_64 the `minimal` overlay has nothing to turn on. A flag that
// silently does nothing is worse than one that refuses: it reads on the
// receipt as a knife that was used.
if self.minimal && self.arch == "x86_64" {
return Err(anyhow!(
"kernel '{}': `minimal = true` has nothing to do on x86_64 — \
CONFIG_LD_DEAD_CODE_DATA_ELIMINATION is gated on \
HAVE_LD_DEAD_CODE_DATA_ELIMINATION, which only arm, mips, riscv and \
powerpc select. On this arch the linker's knife is clang LTO: declare \
`toolchain = \"clang\"` with `lto = true`",
self.name
));
}
if self.lto && self.toolchain != "clang" {
return Err(anyhow!(
"kernel '{}': `lto = true` needs `toolchain = \"clang\"`. Full kernel LTO \
exists only under LLVM=1 — gcc's own LTO is not a mainline kernel option — \
and setting {LTO_SYMBOL} against a gcc build would be dropped by \
olddefconfig and then refused by the canary, for a reason that has nothing \
to do with what was declared",
self.name
));
}
if self.solves() {
if !WIRED_SOLVERS.contains(&self.solver.as_str()) {
return Err(anyhow!(
"kernel '{}': solver {:?} is not wired — declare one of {:?}, or leave \
`solver` unset. A typo'd minifier that silently built the UN-minified \
kernel is the same silent degrade every other axis here refuses",
self.name,
self.solver,
WIRED_SOLVERS
));
}
if self.solver_version.trim().is_empty() {
return Err(anyhow!(
"kernel '{}': `solver_version` is empty — it is HALF the formula cache \
key, so an empty one makes every kmax release share one cache entry",
self.name
));
}
if self.solver_timeout_secs == 0 {
return Err(anyhow!(
"kernel '{}': `solver_timeout_secs = 0` is not a bound — a SAT solve \
with no budget is a container that sits there until the build's own \
timeout kills it, with nothing naming the step that hung",
self.name
));
}
if self.solver_timeout_secs >= self.timeout_secs {
return Err(anyhow!(
"kernel '{}': `solver_timeout_secs` ({}) is not under the build's own \
`timeout_secs` ({}) — the container would be killed before the solver's \
bound could name itself, which turns a solver refusal into \
\"the container died\"",
self.name,
self.solver_timeout_secs,
self.timeout_secs
));
}
// An image that apt-gets a bare distro's compiler has no kmax in it.
// Refuse HERE, not 148 MB and 1.7 GB of unpacked tree later, on
// `klocalizer: not found`.
if !self.toolchain_packages.is_empty()
&& !self.toolchain_packages.iter().any(|p| p == "kmax")
{
return Err(anyhow!(
"kernel '{}': `solver = {:?}` needs klocalizer IN the image, and this \
declaration apt-gets its toolchain ({:?}) with no kmax among it — the \
build would fetch the tarball, unpack the tree and then die on \
`klocalizer: not found`. Declare `toolchain_packages = []` on an image \
that carries kmax (the nordisk toolchain image does)",
self.name,
self.solver,
self.toolchain_packages
));
}
}
if self.probe_timeout_secs == 0 {
return Err(anyhow!(
"kernel '{}': `probe_timeout_secs = 0` is not a bound — its two sibling \
bounds refuse zero and this one accepted it, which is how a probe boot \
becomes an unbounded wait",
self.name
));
}
if self.mem_limit_mb == 0 {
return Err(anyhow!(
"kernel '{}': `mem_limit_mb = 0` is not a bound — an unbounded build \
container is how a 33 GB compile took this constellation's box down \
on 2026-08-20. Declare the MiB the build may have",
self.name
));
}
// The nordisk toolchain is OURS: we build it, so we know its digest, and
// an unpinned reference to an image we publish is a drift we chose to
// accept for no reason. (A third-party default image stays a
// convenience — that asymmetry is deliberate.)
if self.toolchain_image == KERNEL_TOOLCHAIN_IMAGE {
if self.toolchain_image_sha256.is_empty() {
return Err(anyhow!(
"kernel '{}': the nordisk toolchain image must be pinned — declare \
`toolchain_image_sha256` (build it through the image kind, then read \
its MANIFEST DIGEST: `podman image inspect --format '{{{{.Digest}}}}'`). \
An unpinned reference to an image WE publish is drift for free. Note \
the digest is NOT the image id — both are 64 hex characters and only \
the manifest digest works as `name@sha256:…`; an image id there is \
read as a registry pull and fails. An image id can still be used by \
leaving `toolchain_image` empty and putting the id here",
self.name
));
}
if !self.toolchain_packages.is_empty() {
return Err(anyhow!(
"kernel '{}': the nordisk toolchain image is SELF-CONTAINED — declare \
`toolchain_packages = []`. Naming packages here apt-gets inside it, \
which both says the image is wrong and forces a network on a build \
that would otherwise run airgapped: {:?}",
self.name,
self.toolchain_packages
));
}
}
if self.cpus == 0 {
return Err(anyhow!(
"kernel '{}': `cpus = 0` is not a bound — `make -j` is derived from \
this, and a build must not size itself to whichever host runs it",
self.name
));
}
Ok(())
}
/// [`validate`](Self::validate) plus the ON-DISK half: a declared
/// `kconfig_fragment` must exist in this checkout (refused BEFORE anything
/// is fetched or a container starts — the container-kind `validate_in`
/// precedent).
pub fn validate_in(&self, repo_root: &Path) -> Result<()> {
self.validate()?;
if let Some(frag) = &self.kconfig_fragment {
let p = repo_root.join(frag);
if !p.is_file() {
return Err(anyhow!(
"kernel '{}': kconfig_fragment {:?} is not in this checkout ({})",
self.name,
frag,
p.display()
));
}
}
Ok(())
}
/// **Apply a resolved CLI/pane decision to this declaration** — the one
/// writer for the seam between `--opt kernel.*` and a build:
///
/// * the preset becomes the decision's (a `kernel.preset=` override, or the
/// declared one echoed back);
/// * the veto fragment rides [`decision_fragment`](Self::decision_fragment)
/// when it actually vetoes anything
/// ([`KernelDecision::changes_anything`](crate::kernel_features::KernelDecision::changes_anything))
/// — a comment-only fragment is dropped so an idle decision leaves the
/// content id untouched.
///
/// The decision was already refused-or-resolved by
/// [`resolve_kernel_cli`](crate::kernel_features::resolve_kernel_cli); this
/// method only lands it, it judges nothing.
pub fn apply_decision(&mut self, d: &crate::kernel_features::KernelDecision) {
self.preset = d.preset.as_str().to_string();
self.decision_fragment = d.changes_anything().then(|| d.fragment.clone());
self.probe = d.probe;
}
/// **Land the consumers' requirements on this declaration** — after
/// [`apply_decision`](Self::apply_decision), because the preset the
/// requirements are judged against is the DECIDED one. Refuses BY NAME
/// (symbol · consumer · feature · reason · what stands against it) when two
/// requirers disagree, or when the preset/profile fragments VETO a required
/// symbol: a feature that needs `CONFIG_IPV6=y` on a `nuked` kernel
/// (`# CONFIG_IPV6 is not set`) is not merged over the veto, it is refused.
///
/// A NEGATIVE requirement (`=n`) over a preset `=y` is not a veto met, it
/// is a NARROWING — the model's "toggling a fact flips modules OUT of the
/// bzImage" — and it lands: the requirements fragment merges after the
/// preset layers (later wins), so `target.consoles = [vga]` takes
/// `SERIAL_8250` out of a `nuked` kernel whose base fragment had it in.
/// The `=n` still refuses what a LATER layer (the repo's veto file, the
/// operator's decision) turns back on — [`merged_fragment`] checks that by
/// name. An empty set clears the field.
pub fn apply_requirements(&mut self, r: &crate::kernel_requires::KernelRequirements) -> Result<()> {
if r.is_empty() {
self.requirements = None;
return Ok(());
}
let conflicts = r.conflicts();
if !conflicts.is_empty() {
return Err(anyhow!(
"kernel '{}': its consumers' requirements contradict each other — {}",
self.name,
conflicts.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
));
}
let preset = self.preset().ok_or_else(|| anyhow!("kernel '{}': unknown preset {:?}", self.name, self.preset))?;
let profile = self
.hardening_profile()
.ok_or_else(|| anyhow!("kernel '{}': unknown hardening profile {:?}", self.name, self.profile))?;
let mut frags = preset.fragments();
frags.extend(profile.fragments());
let layer = merge_kconfig(&frags);
// Only a POSITIVE row over a preset veto is a contradiction; a `=n`
// row over a preset `=y` narrows the kernel and merges (later wins).
let against: Vec<_> = r
.contradictions(&layer, &format!("the `{}` preset fragment", preset.as_str()))
.into_iter()
.filter(|c| c.wanted.0 != crate::kernel_requires::KconfigValue::N)
.collect();
if !against.is_empty() {
return Err(anyhow!(
"kernel '{}': a consumer requires what the preset vetoes — {} — pick a preset that grants it, \
or drop the feature; a requirement is never merged over a veto",
self.name,
against.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
));
}
self.requirements = Some(r.clone());
Ok(())
}
/// **The user fragment a build merges LAST** — the declared
/// `kconfig_fragment` file (read from this checkout) with the operator's
/// [`decision_fragment`](Self::decision_fragment) appended after it, so the
/// merge order is: preset, profile, the repo's committed veto file, and the
/// person at the terminal — later wins at every step
/// ([`merged_fragment`] applies this as its final fragment).
pub fn user_fragment_in(&self, repo_root: &Path) -> Result<Option<String>> {
let file = match &self.kconfig_fragment {
Some(rel) => Some(std::fs::read_to_string(repo_root.join(rel)).with_context(
|| format!("kernel '{}': read kconfig_fragment {rel}", self.name),
)?),
None => None,
};
Ok(match (file, &self.decision_fragment) {
(Some(f), Some(d)) => Some(format!("{f}\n{d}")),
(Some(f), None) => Some(f),
(None, Some(d)) => Some(d.clone()),
(None, None) => None,
})
}
/// The canonical bytes the content id hashes — every field that changes the
/// produced kernel (version, tarball pin, profile, fragments, toolchain,
/// make target/arch). Tagged, so an empty field and an absent one are the
/// same bytes (the [`IsoBuild::canonical_bytes`](crate::iso::IsoBuild::canonical_bytes)
/// discipline).
pub fn canonical_bytes(&self) -> Vec<u8> {
// ★ MEASURED 2026-08-23 by a reader, not a test: `preset` and `base`
// were MISSING here, so the same declaration flipped from `normal` to
// `nuked` produced the IDENTICAL content id — a 13 714 432-byte kernel
// and a 1 725 440-byte one sharing one content address, with
// `build_kernel` sealing an outcome keyed on it. Every axis that changes
// the ARTIFACT belongs in this string; `mem_limit_mb`, `cpus` and the
// disk floor stay out because they change only how a build is RUN.
let mut s = format!(
"kernel#name:{}#version:{}#url:{}#sha256:{}#preset:{}#base:{}#profile:{}#profile_run:{}#minimal:{}#toolchain:{}#lto:{}#target:{}#arch:{}#image:{}",
self.name,
self.version,
self.source_url(),
self.tarball_sha256,
self.preset,
self.base(),
self.profile,
self.profile_run,
self.minimal,
self.toolchain,
self.lto,
self.make_target,
self.arch,
self.toolchain_image_ref(),
);
// MINIFY changes the SYMBOL SET, so it changes the artifact. Appended
// only when it is on, so every kernel built before the axis existed
// keeps the content id it was sealed under.
if self.solves() {
s.push_str(&format!("#solver:{}#solverver:{}", self.solver, self.solver_version));
}
if let Some(frag) = &self.kconfig_fragment {
s.push_str(&format!("#fragment:{frag}"));
}
// The operator's decision changes the artifact, so it changes the id —
// hashed rather than inlined (the fragment is prose-commented and long).
// Appended only when present, so every kernel built before the axis
// existed keeps the content id it was sealed under.
if let Some(d) = &self.decision_fragment {
s.push_str(&format!("#decision:{}", nornir_hash::sha256_hex(d.as_bytes())));
}
if !self.toolchain_packages.is_empty() {
s.push_str(&format!("#packages:{}", self.toolchain_packages.join(",")));
}
// A probe kernel is another artifact; appended only when present, so
// every kernel built before the axis existed keeps its content id.
if let Some(p) = self.probe {
s.push_str(&format!("#probe:{}", p.word()));
}
s.into_bytes()
}
/// A content-addressed id (lowercase-hex SHA-256 over
/// [`canonical_bytes`](Self::canonical_bytes)).
pub fn content_id(&self) -> String {
nornir_hash::sha256_hex(&self.canonical_bytes())
}
}
// ── the pure kconfig merge ───────────────────────────────────────────────────
/// The kconfig symbol a fragment line sets, if any: `CONFIG_X=…` or the
/// `# CONFIG_X is not set` spelling. Comments and blanks carry no symbol.
pub fn kconfig_symbol(line: &str) -> Option<&str> {
let t = line.trim();
if let Some(rest) = t.strip_prefix("# ") {
if let Some(sym) = rest.strip_suffix(" is not set") {
if sym.starts_with("CONFIG_") && !sym.contains(char::is_whitespace) {
return Some(sym);
}
}
return None;
}
if t.starts_with("CONFIG_") {
if let Some(eq) = t.find('=') {
return Some(&t[..eq]);
}
}
None
}
/// **Merge kconfig fragments — a pure function over kconfig text.** Directive
/// lines (`CONFIG_X=…` / `# CONFIG_X is not set`) are kept in first-seen order;
/// a LATER fragment's directive for the same symbol REPLACES the earlier one in
/// place (so `hardened` can veto a base value, and the user fragment — merged
/// last — can veto both). Comments and blank lines are dropped: the output is
/// the machine half, the commentary lives in the committed fragment files.
pub fn merge_kconfig(fragments: &[&str]) -> String {
// First-seen order, later value wins IN PLACE: `order` holds the symbols as
// they first appeared, `value` the last directive seen for each.
let mut order: Vec<&str> = Vec::new();
let mut value: std::collections::BTreeMap<&str, &str> = std::collections::BTreeMap::new();
for frag in fragments {
for line in frag.lines() {
if let Some(sym) = kconfig_symbol(line) {
if !value.contains_key(sym) {
order.push(sym);
}
value.insert(sym, line.trim());
}
}
}
let mut out = String::new();
for sym in order {
out.push_str(value[sym]);
out.push('\n');
}
out
}
/// The one merged fragment a [`KernelBuild`] applies over `defconfig`: its
/// profile's committed fragments, then the declaration's own
/// `kconfig_fragment` text (merged LAST, so a repo can veto a profile line —
/// visibly, in its own committed file).
pub fn merged_fragment(k: &KernelBuild, user_fragment: Option<&str>) -> Result<String> {
let profile = k.hardening_profile().ok_or_else(|| {
anyhow!(
"kernel '{}': unknown hardening profile {:?}",
k.name,
k.profile
)
})?;
let preset = k.preset().ok_or_else(|| {
anyhow!("kernel '{}': unknown preset {:?}", k.name, k.preset)
})?;
// Preset first (what is IN it), hardening after (later wins), user veto
// last — so a hardened nuked kernel keeps the contract AND the absences.
let mut frags = preset.fragments();
frags.extend(profile.fragments());
if k.profile_run {
frags.push(PROFILE_FRAGMENT);
}
if k.minimal {
frags.push(MINIMAL_FRAGMENT);
}
// LTO is set HERE rather than in a fragment file, because it is only legal
// under the clang toolchain and `validate` has already refused the other
// combination — one place decides it, so the picture and the build agree.
if k.lto {
frags.push(LTO_SYMBOL);
}
// The consumers' requirements: after every preset layer (already judged
// not to contradict them — `apply_requirements`), before the repo's veto
// file and the operator's decision. A requirement that repeats a preset
// line changes no byte of the merge.
let requirements = k.requirements.as_ref().map(|r| r.fragment()).unwrap_or_default();
if !requirements.is_empty() {
frags.push(&requirements);
}
if let Some(u) = user_fragment {
frags.push(u);
}
let merged = merge_kconfig(&frags);
// The veto file / the decision merged LAST may have contradicted a
// requirement — later wins in the merge, so it is checked here BY NAME
// rather than silently honoured: `--opt kernel.tun=off` against a consumer
// whose `wireguard` needs TUN is a refusal, not a kernel that lies about it.
if let Some(r) = &k.requirements {
let broken = r.contradictions(&merged, "the repo's veto file / the operator's decision (merged last)");
if !broken.is_empty() {
return Err(anyhow!(
"kernel '{}': the last merge layer contradicts a consumer's requirement — {}",
k.name,
broken.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
));
}
}
// A DEV probe kernel's lines go on after the contradiction check: a probe
// measures the row, it does not ship, and a release build of it is refused
// by name before the container starts (`refuse_probe_from_env`).
if let Some(p) = k.probe {
return Ok(merge_kconfig(&[&merged, p.fragment()]));
}
Ok(merged)
}
// ── the in-container build script ────────────────────────────────────────────
/// The `/bin/sh -eu` script the build container runs. The CONTAINER may shell —
/// that is its purpose; the host only authors this text. Steps: install the
/// toolchain packages (only when declared — a self-contained image skips apt and
/// runs airgapped), unpack the staged tarball, `defconfig`, apply the merged
/// fragment (`scripts/kconfig/merge_config.sh -m` + `olddefconfig`), CANARY-CHECK
/// that every requested option survived (an option `olddefconfig` silently
/// dropped is a hardening claim the artifact would not honour — refused, exit
/// 90), then `make -j` and copy `bzImage` + `.config` to `/work/out/`.
///
/// `KBUILD_BUILD_*` (user, host, timestamp and the build number) and `SOURCE_DATE_EPOCH`
/// are pinned so two builds of the same declaration differ only where their inputs do,
/// also when the second runs in the first one's kept tree.
/// The starting `.config` (`tinyconfig`/`defconfig`) as the container saw it,
/// copied out beside `kernel.config`. The kconfig solver's `base` input.
pub const KERNEL_BASE_CONFIG: &str = "kernel.base.config";
pub fn build_script(k: &KernelBuild) -> String {
let mut s = String::new();
s.push_str("set -eu\n");
if !k.toolchain_packages.is_empty() {
s.push_str("export DEBIAN_FRONTEND=noninteractive\n");
s.push_str("apt-get update\n");
s.push_str(&format!(
"apt-get install -y --no-install-recommends {}\n",
k.toolchain_packages.join(" ")
));
}
s.push_str("mkdir -p /work/src /work/out\n");
s.push_str(&format!("tar -xf \"/work/{}\" -C /work/src\n", k.tarball_file_name()));
s.push_str(&format!("cd \"/work/src/linux-{}\"\n", k.version));
s.push_str("export KBUILD_BUILD_USER=nordisk KBUILD_BUILD_HOST=draupnir\n");
s.push_str("export KBUILD_BUILD_TIMESTAMP='Thu Jan 1 00:00:00 UTC 1970'\n");
// The build number: without it `make` counts `.version` up in a reused tree, and the
// same inputs give another bzImage (lane B4). The epoch agrees with the timestamp.
s.push_str("export KBUILD_BUILD_VERSION=1\n");
s.push_str("export SOURCE_DATE_EPOCH=0\n");
s.push_str(&format!("make ARCH={} {}{}\n", k.arch, k.llvm_flag(), k.base()));
// The STARTING point leaves the container too (lane AP, 2026-08-30). The
// kconfig solver has to tell a symbol the base already carried from one
// something in the fragment pulled in, and `olddefconfig` keeps a PROMPT
// symbol's incoming value — without this file that whole class of answer is
// a guess. It costs one `cp` of ~70 KB.
s.push_str(&format!("cp .config /work/out/{}\n", KERNEL_BASE_CONFIG));
s.push_str("scripts/kconfig/merge_config.sh -m .config /work/fragment.config\n");
// MINIFY rides HERE, between the merge and olddefconfig: a solver that ran
// after olddefconfig would be solving a config the kernel had already
// pruned, which is the very drop the pass exists to prevent.
s.push_str(&solver_script(k));
s.push_str(&format!("make ARCH={} {}olddefconfig\n", k.arch, k.llvm_flag()));
s.push_str(&solver_writeback_script(k));
// The canary loop: every requested `=y` must be in the final .config, and
// every `is not set` must not have crept back — loudly, by line.
// The canary reports EVERY failure, then exits once. It used to `exit 90`
// on the first line — fine for a `defconfig` build, where a refusal is a
// one-off surprise, and useless for a `tinyconfig` one, where the refusals
// ARE the deliverable: the list of dependencies the contract needs and the
// fragment does not yet name. MEASURED 2026-08-22: a slim build refused in
// 18 s naming CONFIG_EFI_STUB and nothing else, so closing the closure that
// way would have cost one container run per missing symbol.
s.push_str("kernel_config_refused=0\n");
s.push_str("while IFS= read -r line; do\n");
s.push_str(" case \"$line\" in\n");
s.push_str(" CONFIG_*=y)\n");
s.push_str(
&format!(" grep -qxF \"$line\" .config || {{ echo \"{KERNEL_CONFIG_REFUSED}: $line did not survive olddefconfig\" >&2; kernel_config_refused=$((kernel_config_refused+1)); }} ;;\n"),
);
s.push_str(" '# CONFIG_'*' is not set')\n");
s.push_str(" sym=${line#\"# \"}; sym=${sym%\" is not set\"}\n");
s.push_str(
&format!(" if grep -q \"^$sym=\" .config; then echo \"{KERNEL_CONFIG_REFUSED}: $line crept back in\" >&2; kernel_config_refused=$((kernel_config_refused+1)); fi ;;\n"),
);
s.push_str(" esac\n");
s.push_str("done < /work/fragment.config\n");
s.push_str(
&format!("if [ \"$kernel_config_refused\" -ne 0 ]; then echo \"{KERNEL_CONFIG_REFUSED}-TOTAL: $kernel_config_refused option(s) did not hold — the list above IS the dependency closure this fragment still owes\" >&2; exit {KERNEL_CONFIG_REFUSED_EXIT}; fi\n"),
);
// `-j` from the DECLARED cpu bound, never `$(nproc)`: the container is
// capped at `cpus` cores, so a host-sized `-j` would oversubscribe the cap
// and make the build's memory high-water mark depend on the machine.
s.push_str(&format!(
"make ARCH={} {}-j{} {}\n",
k.arch,
k.llvm_flag(),
k.cpus,
k.make_target
));
s.push_str("cp arch/x86/boot/bzImage /work/out/bzImage\n");
s.push_str("cp .config /work/out/kernel.config\n");
s
}
/// **The MINIFY pass — the SAT pass over the Kconfig DAG**, rendered as the
/// shell the container runs BETWEEN `merge_config.sh` and `olddefconfig`.
/// Empty when no solver is declared, so an ordinary build's script is
/// byte-identical to what it always was.
///
/// What it does, and why in this order:
///
/// 1. **the formula.** `klocalizer` reads the kernel's own Kconfig through
/// `kextract` and turns it into a z3 formula with `kclause`. That is the
/// expensive step (~46 s, ~20 MB on linux 6.12.104), so it is generated into
/// `/work/kclause` — a directory the HOST stages from, and publishes to, the
/// content-addressed cache ([`kclause_cache_entry`]) — and skipped entirely
/// when a formula is already staged there.
/// 2. **the match config.** klocalizer's `--repair` reads a `.config` as SOFT
/// assumptions and drops only the ones an unsat core says it must. A kernel
/// `.config` names a few thousand of ~17 400 boolean symbols, so everything
/// it does NOT name is free — and MEASURED 2026-08-23, that is the difference
/// between a closure of **78 symbols** and one of **472**, the latter full of
/// mlx5, i40e and reiserfs that nothing asked for. So the match config is the
/// merged `.config` CLOSED: every symbol the tree knows and the config never
/// mentions is written down as `is not set`.
/// 3. **the solve.** The fragment's own `=y` symbols go in as HARD constraints
/// (`-D`), the closed match config as soft ones, and what comes back is a
/// `.config` whose `depends on` / `select` chains are already closed.
/// `--disable-downloading-formulas` because this container is AIRGAPPED —
/// without it klocalizer reaches for a remote formula cache that is not
/// there.
/// 4. **the write-back.** After `olddefconfig`, whatever the solver added over
/// the pre-solve config, minus what the fragment already named, is the
/// dependency CLOSURE — written to `/work/out/deps-solved.config` with a
/// header, which is a fragment an operator can commit. That file is the
/// automation of `src/kconfig/deps-tinyconfig.config`, whose own header
/// describes the loop a person ran by hand.
///
/// Both klocalizer calls are wrapped in `timeout`, and an overrun prints
/// [`SOLVER_REFUSED_MARKER`] and exits [`SOLVER_REFUSED_EXIT`]: a hung solve
/// must name ITSELF, not ride the build's timeout and arrive as "the container
/// died".
/// One solver refusal, rendered at `indent`, reading `$<status>`.
///
/// It distinguishes a TIMEOUT (`timeout` exits 124) from any other non-zero,
/// because "did not answer in 1800 s" and "answered UNSAT in four seconds" are
/// different facts and must not arrive as the same one — the probe's own
/// 2026-08-22 lesson, one layer up.
///
/// Note what is NOT here: backticks. An earlier draft quoted the knob name as
/// `` `solver_timeout_secs` `` inside a DOUBLE-quoted `echo`, which is command
/// substitution — the refusal would have tried to RUN the field name. Every
/// message below is single-quoted shell.
fn refusal(indent: &str, status: &str, t: u64, what: &str) -> String {
format!(
"{indent}if [ \"${status}\" -eq 124 ]; then\n\
{indent} echo '{SOLVER_REFUSED_MARKER}: {what} within {t}s (solver_timeout_secs) — raise that bound, or drop solver and close the list from the canary refusals by hand' >&2\n\
{indent}else\n\
{indent} echo \"{SOLVER_REFUSED_MARKER}: {what} (exit ${status}, not a timeout) — an UNSAT answer means the fragment asks for symbols that cannot all be on together on this arch; the merged fragment is at /work/fragment.config\" >&2\n\
{indent}fi\n\
{indent}exit {SOLVER_REFUSED_EXIT}\n"
)
}
pub fn solver_script(k: &KernelBuild) -> String {
if !k.solves() {
return String::new();
}
let src = format!("/work/src/linux-{}", k.version);
let t = k.solver_timeout_secs;
let common = format!(
"-a {} --linux-ksrc {src} --formulas /work/kclause --disable-downloading-formulas",
k.arch
);
let mut s = String::new();
s.push_str("# ── MINIFY: the SAT pass over the Kconfig DAG ─────────────────────────\n");
s.push_str("mkdir -p /work/kclause /work/out\n");
// The container REPORTS its kmax; the host checks it against the declared
// half of the formula cache key before it publishes anything.
s.push_str("klocalizer --version > /work/out/solver-version\n");
s.push_str("grep -E '^CONFIG_[A-Za-z0-9_]+=y$' .config | sort > /work/out/kernel-presolve.sym\n");
s.push_str(
r"sed -n 's/^\(CONFIG_[A-Za-z0-9_]*\)=y$/\1/p' /work/fragment.config | sort > /work/out/kernel-wanted.sym",
);
s.push('\n');
// The fragment's `=y` set becomes the HARD constraints. `set --` keeps them
// as real argv words: a symbol list in a string would word-split.
s.push_str("set --\n");
s.push_str("while IFS= read -r sym; do set -- \"$@\" -D \"$sym\"; done < /work/out/kernel-wanted.sym\n");
s.push_str("if [ -z \"$(find /work/kclause -name kextract -type f)\" ]; then\n");
s.push_str(&format!(
" if timeout {t} klocalizer {common} \"$@\" --save-smt /work/out/kernel-formula.smt2; then :; else\n"
));
s.push_str(" kernel_solver_status=$?\n");
s.push_str(&refusal(
" ",
"kernel_solver_status",
t,
"kextract+kclause did not produce a Kconfig formula for this tree",
));
s.push_str(" fi\n");
s.push_str("fi\n");
s.push_str("kernel_kextract=\"$(find /work/kclause -name kextract -type f | head -1)\"\n");
s.push_str(&format!(
"[ -n \"$kernel_kextract\" ] || {{ echo \"{SOLVER_REFUSED_MARKER}: no kextract formula under /work/kclause after generating one — the solver has nothing to read the Kconfig DAG from\" >&2; exit {SOLVER_REFUSED_EXIT}; }}\n"
));
// The CLOSED match config — see the doc comment: what the .config never
// mentions is what a bare --repair leaves free, and free is how mlx5 gets in.
s.push_str(
r#"sed -n 's/^config \(CONFIG_[A-Za-z0-9_]*\) \(bool\|tristate\)$/\1/p' "$kernel_kextract" | sort -u > /work/out/kernel-universe.sym"#,
);
s.push('\n');
s.push_str(
r"sed -n -e 's/^\(CONFIG_[A-Za-z0-9_]*\)=.*$/\1/p' -e 's/^# \(CONFIG_[A-Za-z0-9_]*\) is not set$/\1/p' .config | sort -u > /work/out/kernel-mentioned.sym",
);
s.push('\n');
s.push_str("cp .config /work/out/kernel-match.config\n");
s.push_str(
"comm -23 /work/out/kernel-universe.sym /work/out/kernel-mentioned.sym | sed 's/^/# /; s/$/ is not set/' >> /work/out/kernel-match.config\n",
);
s.push_str(&format!(
"if timeout {t} klocalizer {common} -m /work/out/kernel-match.config \"$@\" -o /work/out/kernel-solved.config; then :; else\n"
));
s.push_str(" kernel_solver_status=$?\n");
s.push_str(&refusal(" ", "kernel_solver_status", t, "klocalizer did not solve the Kconfig DAG"));
s.push_str("fi\n");
s.push_str("cp /work/out/kernel-solved.config .config\n");
s
}
/// The closure write-back, rendered AFTER `olddefconfig` (so what it records is
/// what the kernel's own tooling agreed to) and BEFORE the canary (so a build
/// that then refuses still leaves the operator the list). Empty when no solver
/// is declared.
pub fn solver_writeback_script(k: &KernelBuild) -> String {
if !k.solves() {
return String::new();
}
let mut s = String::new();
s.push_str("grep -E '^CONFIG_[A-Za-z0-9_]+=y$' .config | sort > /work/out/kernel-postsolve.sym\n");
s.push_str("{\n");
s.push_str(&format!(
" echo '# nordisk SOLVED dependency closure — {} over the Kconfig DAG.'\n",
k.solver
));
s.push_str(&format!(
" echo '# kernel {} · base {} · preset {} · arch {}'\n",
k.version,
k.base(),
k.preset,
k.arch
));
s.push_str(" echo '#'\n");
s.push_str(" echo '# Every line below is a PARENT the solver had to turn on for the'\n");
s.push_str(" echo '# fragment above it to survive olddefconfig. This is the same list'\n");
s.push_str(" echo '# src/kconfig/deps-tinyconfig.config was closed by hand from twelve'\n");
s.push_str(" echo '# canary refusals — committing this file is that loop, automated.'\n");
s.push_str(" echo '#'\n");
s.push_str(" echo '# It carries the canary'\"'\"'s OWN blind spot: the solver closes only what'\n");
s.push_str(" echo '# the fragment NAMES, so an option nobody wrote down (CONFIG_BINFMT_ELF'\n");
s.push_str(" echo '# is the one that matters) is invisible to it too. Probe-boot, not this'\n");
s.push_str(" echo '# file, is what proves the kernel can execute PID 1.'\n");
s.push_str(
" comm -13 /work/out/kernel-presolve.sym /work/out/kernel-postsolve.sym | grep -vxFf /work/fragment.config || true\n",
);
s.push_str("} > /work/out/deps-solved.config\n");
s
}
/// **The shared kernel source cache directory.** Resolution, in order:
/// `$NORDISK_KERNEL_CACHE`, `$XDG_CACHE_HOME/nordisk/kernel`,
/// `$HOME/.cache/nordisk/kernel`, and finally `.nornir-cache/kernel` under the
/// repo root when a machine has no home at all (a CI container).
///
/// Tarballs live under `tarballs/<sha256>.tar.xz` — keyed by the PIN itself, so
/// the cache is content-addressed and a stale entry is not expressible: a file
/// whose bytes do not hash to its own name is not a hit, it is garbage that gets
/// re-fetched.
pub fn kernel_cache_dir(repo_root: &Path) -> std::path::PathBuf {
if let Some(d) = std::env::var_os("NORDISK_KERNEL_CACHE") {
return std::path::PathBuf::from(d);
}
if let Some(d) = std::env::var_os("XDG_CACHE_HOME") {
return std::path::PathBuf::from(d).join("nordisk").join("kernel");
}
if let Some(h) = std::env::var_os("HOME") {
return std::path::PathBuf::from(h).join(".cache").join("nordisk").join("kernel");
}
repo_root.join(".nornir-cache").join("kernel")
}
/// **The kclause formula cache entry** — the expensive half of the MINIFY pass,
/// content-addressed in the SAME shared cache the tarball lives in
/// (`<cache>/kclause/<tarball_sha256>.kmax-<version>/`).
///
/// The key is BOTH halves on purpose. A kclause formula is a function of the
/// tree *and* of the extractor that produced it: keying on the tarball sha
/// alone lets kmax 4.10 and a later kmax share one entry, and a formula
/// attributed to the wrong extractor is a cache that lies — worse than no cache,
/// because the build believes it. (Written first the wrong way, then fixed by
/// the test that named this.)
///
/// MEASURED 2026-08-23, linux 6.12.104, nordisk toolchain image: the formula is
/// `kextract` 7.7 MB + `kclause` 12.2 MB ≈ 20 MB, ~46 s to build cold, and it is
/// then reused by every variant on the same pin — the same argument the tarball
/// cache made on 2026-08-22.
pub fn kclause_cache_entry(cache_dir: &Path, tarball_sha256: &str, solver_version: &str) -> PathBuf {
cache_dir.join("kclause").join(format!("{tarball_sha256}.kmax-{solver_version}"))
}
/// Copy a directory tree, hard-linking each file where the filesystem allows it
/// and copying where it does not — the same "free when possible" staging the
/// tarball cache uses. Used to stage a cached formula INTO a build workdir and
/// to publish a freshly generated one back out.
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
std::fs::create_dir_all(to).with_context(|| format!("create {}", to.display()))?;
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
let entry = entry?;
let src = entry.path();
let dst = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&src, &dst)?;
} else {
let _ = std::fs::remove_file(&dst);
if std::fs::hard_link(&src, &dst).is_err() {
std::fs::copy(&src, &dst)
.with_context(|| format!("copy {} -> {}", src.display(), dst.display()))?;
}
}
}
Ok(())
}
/// Free bytes on the filesystem holding `path`, or `None` when this build cannot
/// ask (the dep-light default: `statvfs` rides the `kernel-build` feature, which
/// is the only configuration that runs a real build anyway).
///
/// `None` means NOT CHECKED, and the caller must not read it as "fine". It is
/// also, honestly, a SILENT skip today: the pin carries no disk field, so
/// nothing on the receipt distinguishes "checked and fine" from "could not
/// check". This doc used to claim such a line existed — see the note on
/// [`KernelBuild::min_free_disk_mb`].
#[cfg(feature = "kernel-build")]
pub fn available_disk_bytes(path: &Path) -> Option<u64> {
use std::os::unix::ffi::OsStrExt;
let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
// SAFETY: `c` is a valid NUL-terminated path and `st` is fully written by
// statvfs before it is read; nothing here outlives the call.
unsafe {
let mut st: libc::statvfs = std::mem::zeroed();
if libc::statvfs(c.as_ptr(), &mut st) != 0 {
return None;
}
Some((st.f_bavail as u64) * (st.f_frsize as u64))
}
}
/// Dep-light stand-in: the check is honestly UNAVAILABLE, not passing.
#[cfg(not(feature = "kernel-build"))]
pub fn available_disk_bytes(_path: &Path) -> Option<u64> {
None
}
/// **What a HEADROOM RESERVATION learned about a path** — the answer `statvfs`
/// structurally cannot give.
///
/// `statvfs` describes the FILESYSTEM. On 2026-08-22 it reported 23 GiB free, the
/// preflight passed, and ninety seconds later the build died on
/// `tar: Cannot write: Disk quota exceeded` — `EDQUOT`, a quota against the
/// WRITING USER, which no `statvfs` field mentions. The only way to learn whether
/// a path will take N bytes is to ask the kernel to set N bytes aside for it, and
/// see whether it says yes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiskReservation {
/// The bytes WERE reserved (and released again immediately) — the path can
/// take them, quota and storage-driver limit included.
Reserved(u64),
/// The kernel refused. `errno_name` is the refusal by name; `EDQUOT` is the
/// one the old preflight could never see, `ENOSPC` the one it usually could.
Refused { errno: i32, errno_name: &'static str },
/// **Nothing was asked, and this is NOT a pass.** Either the filesystem does
/// not implement preallocation (`EOPNOTSUPP` on tmpfs and some network
/// filesystems) or this build does not link the syscall (the dep-light
/// default — see [`DiskReserver`]).
NotChecked(&'static str),
}
impl DiskReservation {
/// The refusal, or `None` when nothing was refused. **A `NotChecked` is not a
/// refusal and not a pass** — a caller must not collapse the three states into
/// a bool, which is how the original blind spot was written.
pub fn refusal(&self) -> Option<&'static str> {
match self {
DiskReservation::Refused { errno_name, .. } => Some(errno_name),
_ => None,
}
}
/// The bytes actually reserved, for the receipt. `None` means the question was
/// not answered.
pub fn reserved_bytes(&self) -> Option<u64> {
match self {
DiskReservation::Reserved(n) => Some(*n),
_ => None,
}
}
}
/// **The seam that asks a path whether it can really take N bytes.**
///
/// A seam and not a free function for the reason every other leg of this
/// orchestrator is one: the refusal it drives is a REFUSAL TO BUILD, and a refusal
/// nothing can drive in a test is a refusal nobody has seen fire. The live leg
/// ([`FallocateReserver`]) needs `libc`, which rides the `kernel-build` feature;
/// the guard for the refusal must run in the DEFAULT test configuration, so it
/// runs against a fake.
pub trait DiskReserver {
/// Ask for `bytes` of real, allocated headroom under `dir`, then release it.
fn reserve(&self, dir: &Path, bytes: u64) -> DiskReservation;
}
/// The live reservation: `posix_fallocate` over a scratch file in `dir`, unlinked
/// immediately afterwards.
///
/// `posix_fallocate` ALLOCATES BLOCKS, so it is charged against the user's quota
/// and against a storage driver's own limit, and it returns `EDQUOT` / `ENOSPC`
/// straight away without writing a byte — the whole reservation costs one syscall
/// rather than 3 GiB of I/O. What it still cannot see is a backing store that
/// fills AFTER the check; that residue is what [`out_of_room`] catches.
pub struct FallocateReserver;
#[cfg(feature = "kernel-build")]
impl DiskReserver for FallocateReserver {
fn reserve(&self, dir: &Path, bytes: u64) -> DiskReservation {
use std::os::fd::AsRawFd;
if bytes == 0 {
return DiskReservation::Reserved(0);
}
let path = dir.join(".kernel-disk-reservation-probe");
let f = match std::fs::File::create(&path) {
Ok(f) => f,
Err(e) => {
let errno = e.raw_os_error().unwrap_or(0);
return DiskReservation::Refused { errno, errno_name: errno_name(errno) };
}
};
// SAFETY: `f` is an open, owned, writable file descriptor and the length
// is non-negative; posix_fallocate touches nothing else and returns the
// errno directly rather than setting the global one.
let rc = unsafe { libc::posix_fallocate(f.as_raw_fd(), 0, bytes as libc::off_t) };
drop(f);
let _ = std::fs::remove_file(&path);
match rc {
0 => DiskReservation::Reserved(bytes),
libc::EOPNOTSUPP | libc::ENOSYS | libc::EINVAL => {
DiskReservation::NotChecked("this filesystem does not implement preallocation")
}
errno => DiskReservation::Refused { errno, errno_name: errno_name(errno) },
}
}
}
/// Dep-light stand-in: the reservation is honestly UNAVAILABLE, not passing.
#[cfg(not(feature = "kernel-build"))]
impl DiskReserver for FallocateReserver {
fn reserve(&self, _dir: &Path, _bytes: u64) -> DiskReservation {
DiskReservation::NotChecked("built without the `kernel-build` feature")
}
}
/// The refusals worth naming, by name. A bare `errno 122` in a build refusal is a
/// number the reader then has to look up; `EDQUOT` is the whole diagnosis.
pub fn errno_name(errno: i32) -> &'static str {
// Spelled without `libc` so the DEFAULT build can name a refusal a fake
// produced. The four values are stable Linux ABI.
match errno {
28 => "ENOSPC",
122 => "EDQUOT",
13 => "EACCES",
30 => "EROFS",
27 => "EFBIG",
_ => "errno",
}
}
/// **The declaration that builds [`KERNEL_TOOLCHAIN_IMAGE`]** — file mode over a
/// materialized [`KERNEL_TOOLCHAIN_CONTAINERFILE`], so the image rides the
/// ORDINARY image kind (`build_container`) and gets the ordinary provenance
/// seal. `containerfile_rel` is the repo-relative path the text was written to
/// ([`materialize_kernel_toolchain_containerfile`]).
///
/// No second builder, no second engine, no shell: the kernel kind contributes
/// the TEXT and lets the existing container path do the building.
pub fn kernel_toolchain_container(containerfile_rel: &str) -> crate::container::ContainerBuild {
crate::container::ContainerBuild {
name: KERNEL_TOOLCHAIN_IMAGE.to_string(),
containerfile: containerfile_rel.to_string(),
tags: vec![format!("{KERNEL_TOOLCHAIN_IMAGE}:latest")],
..Default::default()
}
}
/// **The digest a container recipe must produce, read off the kernel rows that pin it**
/// (2026-09-22). Every `[[build.kernel]]` row of `set` whose `toolchain_image` is `image` and
/// that declares `toolchain_image_sha256` contributes its pin. `None` when no row pins it (the
/// image is not a kernel toolchain, or nothing is pinned — then there is nothing to be wrong
/// about); refused when two rows pin the SAME image to two digests, because a build that
/// satisfied one would move the tag under the other.
///
/// The kernel rows are the one authority: the container cell carries no pin of its own, so the
/// two can never drift apart.
pub fn toolchain_pin_for(set: &crate::variant::BuildVariantSet, image: &str) -> Result<Option<String>> {
let mut pins: Vec<(&str, String)> = set
.kernels
.iter()
.filter(|k| k.toolchain_image == image && !k.toolchain_image_sha256.trim().is_empty())
.map(|k| (k.name.as_str(), normalize_sha256(&k.toolchain_image_sha256)))
.collect();
pins.sort_by(|a, b| a.1.cmp(&b.1));
match pins.as_slice() {
[] => Ok(None),
[(_, first), rest @ ..] if rest.iter().all(|(_, p)| p == first) => Ok(Some(first.clone())),
_ => Err(anyhow!(
"the kernel rows pin the toolchain image '{image}' to different digests ({}); a build \
of it can satisfy at most one of them, so it is refused rather than tagged",
pins.iter().map(|(n, p)| format!("{n} → {}", &p[..p.len().min(12)])).collect::<Vec<_>>().join(", ")
)),
}
}
/// `sha256:ABC…` / `abc…` → `abc…`: the one spelling pins are compared in.
pub fn normalize_sha256(s: &str) -> String {
s.trim().strip_prefix("sha256:").unwrap_or(s.trim()).to_ascii_lowercase()
}
/// Write [`KERNEL_TOOLCHAIN_CONTAINERFILE`] into `dir` and return the path.
/// The crate SHIPS the text (one authority); a consumer materializes it where
/// its own build context lives.
pub fn materialize_kernel_toolchain_containerfile(dir: &Path) -> Result<std::path::PathBuf> {
std::fs::create_dir_all(dir)
.with_context(|| format!("kernel toolchain: create {}", dir.display()))?;
let p = dir.join("kernel-toolchain.Containerfile");
std::fs::write(&p, KERNEL_TOOLCHAIN_CONTAINERFILE)
.with_context(|| format!("kernel toolchain: write {}", p.display()))?;
Ok(p)
}
// ── the RUN loop: the only instrument that sees a WORKLOAD (design §4) ──────
/// The marker each profiled function line carries on the serial console.
pub const RUN_FN_MARKER: &str = "NORDISK-RUN-FN";
/// Printed once the profiler has been read out, so a truncated transcript is
/// distinguishable from a workload that touched nothing.
pub const RUN_DONE_MARKER: &str = "NORDISK-RUN-COMPLETE";
/// **The RUN's PID 1** — enables ftrace's function profiler, performs a small
/// declared workload, disables it, and prints every function's HIT COUNT.
///
/// The workload is deliberately the appliance's own shape rather than a
/// benchmark: mount the pseudo-filesystems, look at the devices the boot
/// contract promises, open and read a file, touch the network stack. What that
/// measures is "what does BOOTING AND EXISTING touch", which for an appliance
/// whose job is a tunnel is most of the truth. It is not a claim about any
/// other workload, and the report says so.
pub const RUN_INIT_C: &str = r#"
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/reboot.h>
#include <unistd.h>
#include <fcntl.h>
static int write_one(const char *path, const char *val) {
int fd = open(path, O_WRONLY);
if (fd < 0) return -1;
ssize_t n = write(fd, val, strlen(val));
close(fd);
return n > 0 ? 0 : -1;
}
/* The tracefs mount point differs across docs and kernels; try the modern one
first and fall back, then REPORT which worked. A path that silently was not
there would read as a workload that executed nothing. */
static const char *mount_tracing(void) {
/* MEASURED 2026-08-23: this used to mkdir /sys/kernel/tracing and mount
there, which needs CONFIG_SYSFS and a /sys/kernel that only exists once
sysfs is mounted. A tinyconfig kernel need not have sysfs at all, so the
mkdir failed, both mounts failed, and the guest correctly reported "no
tracefs and no debugfs" on a kernel that DID carry the profiler.
tracefs can be mounted anywhere; a plain top-level directory needs
nothing from the kernel but the filesystem itself. */
mkdir("/tracing", 0755);
if (mount("tracefs", "/tracing", "tracefs", 0, NULL) == 0)
return "/tracing";
mkdir("/debug", 0755);
if (mount("debugfs", "/debug", "debugfs", 0, NULL) == 0)
return "/debug/tracing";
/* Last resort: the conventional paths, for a kernel that HAS sysfs and a
reader who goes looking by hand. */
mkdir("/sys", 0755);
if (mount("sysfs", "/sys", "sysfs", 0, NULL) == 0) {
mkdir("/sys/kernel", 0755);
mkdir("/sys/kernel/tracing", 0755);
if (mount("tracefs", "/sys/kernel/tracing", "tracefs", 0, NULL) == 0)
return "/sys/kernel/tracing";
}
return NULL;
}
int main(void) {
char path[256];
struct stat st;
setvbuf(stdout, NULL, _IONBF, 0);
mkdir("/dev", 0755);
mount("devtmpfs", "/dev", "devtmpfs", 0, NULL);
mkdir("/proc", 0755);
mount("proc", "/proc", "proc", 0, NULL);
const char *tr = mount_tracing();
if (!tr) {
printf("NORDISK-RUN-REFUSED no tracefs and no debugfs - this kernel was not built with the profiler\n");
sync();
reboot(RB_POWER_OFF);
for (;;) pause();
}
printf("NORDISK-RUN-TRACING %s\n", tr);
snprintf(path, sizeof path, "%s/function_profile_enabled", tr);
if (write_one(path, "1") != 0) {
printf("NORDISK-RUN-REFUSED %s is not writable - CONFIG_FUNCTION_PROFILER is missing\n", path);
sync();
reboot(RB_POWER_OFF);
for (;;) pause();
}
/* the workload: an appliance being an appliance */
stat("/dev/console", &st);
stat("/dev/net/tun", &st);
stat("/dev/vda", &st);
int fd = open("/proc/version", O_RDONLY);
if (fd >= 0) { char b[512]; ssize_t r = read(fd, b, sizeof b); (void)r; close(fd); }
int sk = socket(AF_INET, SOCK_DGRAM, 0);
if (sk >= 0) close(sk);
sync();
write_one(path, "0");
/* ── THE FULL DATA GOES TO THE DISK, NOT THE CONSOLE ──
The guest has a virtio disk attached. A serial console is a terrible
transport for 18 000 names, but /dev/vda is right there, and the
container reads the image back afterwards. No filesystem is needed: the
payload is written raw at offset 0 behind a magic + length header, which
is the whole protocol. */
int disk = open("/dev/vda", O_WRONLY);
if (disk >= 0) {
char hdr[64];
/* Reserve the header, write the payload after it, then seek back and
stamp the real length — so a truncated write can never look complete. */
off_t body = 64;
lseek(disk, body, SEEK_SET);
unsigned long written = 0, count = 0;
snprintf(path, sizeof path, "%s/available_filter_functions", tr);
FILE *av2 = fopen(path, "r");
if (av2) {
char b3[512];
while (fgets(b3, sizeof b3, av2)) {
size_t n2 = strlen(b3);
while (n2 && (b3[n2-1] == 10 || b3[n2-1] == 13)) b3[--n2] = 0;
/* `symbol` or `symbol [module]`; the name is the first token. */
char *sp = strchr(b3, ' ');
if (sp) *sp = 0;
n2 = strlen(b3);
b3[n2] = 10;
ssize_t w = write(disk, b3, n2 + 1);
if (w > 0) { written += (unsigned long)w; count++; }
}
fclose(av2);
}
int hn = snprintf(hdr, sizeof hdr, "NORDISK-RUN-DISK1 %lu %lu\n", count, written);
while (hn < (int)sizeof hdr) hdr[hn++] = 32;
lseek(disk, 0, SEEK_SET);
ssize_t hw = write(disk, hdr, sizeof hdr);
(void)hw;
fsync(disk);
close(disk);
printf("NORDISK-RUN-DISK %lu\n", count);
} else {
printf("NORDISK-RUN-DISK-ABSENT /dev/vda could not be opened for writing\n");
}
/* How many functions COULD have been traced. The hit list below names only
functions that ran, so the zero-hit set — the drop candidates design §4
is after — is this count minus that list. Reported as a NUMBER because the
names are tens of thousands of lines and a serial console is not a
transport for them. */
snprintf(path, sizeof path, "%s/available_filter_functions", tr);
FILE *av = fopen(path, "r");
if (av) {
unsigned long n = 0;
char b2[512];
while (fgets(b2, sizeof b2, av)) n++;
fclose(av);
printf("NORDISK-RUN-TRACEABLE %lu\n", n);
}
/* the measurement */
snprintf(path, sizeof path, "%s/trace_stat/function0", tr);
FILE *f = fopen(path, "r");
if (!f) {
printf("NORDISK-RUN-REFUSED %s absent - the profiler ran but wrote no counters\n", path);
} else {
char line[512];
while (fgets(line, sizeof line, f)) {
size_t n = strlen(line);
while (n && (line[n-1] == 10 || line[n-1] == 13)) line[--n] = 0;
printf("NORDISK-RUN-FN %s\n", line);
}
fclose(f);
}
printf("NORDISK-RUN-COMPLETE\n");
sync();
reboot(RB_POWER_OFF);
for (;;) pause();
}
"#;
/// The in-container RUN script: compile the profiling PID 1, pack it, boot the
/// COLLECTED `bzImage` under QEMU, and let the orchestrator judge the
/// transcript. Same shape as the probe — the two differ only in their PID 1 and
/// in what they read out.
pub fn run_script(k: &KernelBuild) -> String {
let mut s = String::new();
s.push_str("set -eu
");
s.push_str("cd /work
");
s.push_str("mkdir -p run/initramfs
");
s.push_str("cat > run/init.c <<'RUN_EOF'
");
s.push_str(RUN_INIT_C);
s.push_str("
RUN_EOF
");
s.push_str("gcc -static -Os -o run/initramfs/init run/init.c
");
s.push_str("( cd run/initramfs && find . | cpio -o -H newc --quiet ) > run/initramfs.cpio
");
// 64 MiB: the traceable set is ~19 000 names, well under a megabyte, and a
// disk too small would truncate the ONE artifact this step exists to
// produce. Sparse, so it costs nothing until written.
s.push_str("dd if=/dev/zero of=run/traceable.img bs=1M count=64 status=none\n");
s.push_str(&format!(
"timeout {} qemu-system-x86_64 -nographic -no-reboot -m 512 \
",
k.probe_timeout_secs
));
s.push_str(" -kernel /work/out/bzImage -initrd run/initramfs.cpio \
");
s.push_str(" -append 'console=ttyS0 panic=1' \
");
s.push_str(" -device virtio-net-pci,netdev=n0 -netdev user,id=n0 \
");
s.push_str(" -drive file=run/traceable.img,if=virtio,format=raw \\\n");
s.push_str(" 2>&1 | tee /work/out/run-serial.log\n");
// Lift the payload off the disk image into plain files the host reads. The
// header is a fixed 64 bytes: `NORDISK-RUN-DISK1 <count> <bytes>`, so the
// body is everything from byte 65 for exactly <bytes>. A truncated write
// cannot look complete, because the length is stamped LAST.
s.push_str("head -c 64 run/traceable.img > /work/out/run-traceable.hdr\n");
s.push_str("tail -c +65 run/traceable.img | head -c \"$(cut -d' ' -f3 /work/out/run-traceable.hdr)\" > /work/out/run-traceable.txt\n");
// ── function -> .o -> obj-$(CONFIG_X): the mapping that turns a list of
// names into a list of SYMBOLS a person can click off ──
//
// `nm` over every built object gives symbol -> object; the object's own
// directory Makefile gives object -> the CONFIG that compiles it. Both are
// facts in the tree, so nothing here infers a symbol from a name. A function
// whose object no Makefile claims is emitted with `-` rather than dropped:
// an unexplained function is a finding, and silence would hide it.
//
// The tree is found by GLOB, not by the declared version: a RUN declaration
// describes a kernel that is ALREADY BUILT and legitimately carries no
// version, so `cd /work/src/linux-$version` sent the script somewhere that
// does not exist. MEASURED — the live run passes `0.0.0`.
//
// And the whole block is NON-FATAL BY CONSTRUCTION. It enriches a
// measurement that has already succeeded and already been written to disk;
// the first version ran under `set -eu`, failed, and threw away a perfectly
// good 18 760-function measurement. An optional step must never be able to
// discard the mandatory one.
s.push_str("map_ok=0\n");
s.push_str("for tree in /work/src/linux-*; do\n");
s.push_str(" [ -d \"$tree\" ] || continue\n");
s.push_str(" [ -f \"$tree/vmlinux\" ] || continue\n");
s.push_str(" (\n");
s.push_str(" cd \"$tree\" || exit 1\n");
s.push_str(" find . -name '*.o' ! -name '*.mod.o' -print0 \\\n");
s.push_str(" | xargs -0 -r nm -A --defined-only 2>/dev/null \\\n");
s.push_str(" | awk -F: '{split($2,a,\" \"); if (a[2] ~ /^[TtWw]$/) print a[3], $1}' \\\n");
s.push_str(" | sort -u > /work/out/sym-to-obj.txt\n");
s.push_str(" { find . -name Makefile; find . -name Kbuild; } \\\n");
s.push_str(" | while IFS= read -r mk; do d=$(dirname \"$mk\"); \\\n");
s.push_str(" sed -n 's/^[[:space:]]*obj-\\$(\\(CONFIG_[A-Z0-9_]*\\))[[:space:]]*+*=[[:space:]]*\\(.*\\)/\\1 \\2/p; s/^[[:space:]]*obj-y[[:space:]]*+*=[[:space:]]*\\(.*\\)/CONFIG_Y \\1/p' \"$mk\" \\\n");
s.push_str(" | while read -r cfg objs; do for o in $objs; do case \"$o\" in *.o) echo \"$d/$o $cfg\";; esac; done; done; \\\n");
s.push_str(" done | sort -u > /work/out/obj-to-config.txt\n");
s.push_str(" awk 'NR==FNR{c[$1]=$2; next} {print $1, $2, ($2 in c ? c[$2] : \"-\")}' \\\n");
s.push_str(" /work/out/obj-to-config.txt /work/out/sym-to-obj.txt > /work/out/fn-to-config.txt\n");
s.push_str(" ) && map_ok=1\n");
s.push_str(" break\n");
s.push_str("done\n");
s.push_str("if [ \"$map_ok\" = 1 ]; then\n");
s.push_str(" echo \"NORDISK-RUN-MAPPED $(wc -l < /work/out/fn-to-config.txt)\"\n");
s.push_str("else\n");
s.push_str(" echo 'NORDISK-RUN-MAP-ABSENT no built tree with a vmlinux under /work/src - the mapping needs the BUILD tree, not just the bzImage'\n");
s.push_str("fi\n");
// The measurement is already on disk; this step is an enrichment and must
// not be able to fail the run.
s.push_str("exit 0\n");
s
}
/// What the RUN measured: every function the workload executed, with its hit
/// count, plus the counters' own provenance.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KernelRun {
/// Where the counters were read from (`/sys/kernel/tracing` or the debugfs
/// fallback) — a path that silently was not there would read as a workload
/// that executed nothing.
pub tracing_path: String,
/// The profiler was read out to the end.
pub complete: bool,
/// `(function, hits)`, in the order the kernel reported them.
pub hits: Vec<(String, u64)>,
/// How many functions the kernel could have traced. The zero-hit set — the
/// drop candidates — is this MINUS [`hits`](Self::hits); the names are tens
/// of thousands of lines and a serial console is not a transport for them,
/// so the magnitude is reported and the names are not.
pub traceable: u64,
/// **Every traceable function's NAME**, lifted off the virtio disk rather
/// than the serial console. Empty when the guest could not write the disk —
/// which is a stated absence, not an empty measurement.
pub traceable_names: Vec<String>,
/// **`(function, object, CONFIG)`** — the mapping the container derived
/// from the BUILD TREE: `nm` over every object for symbol→object, and each
/// directory's Makefile for `obj-$(CONFIG_X) += foo.o`. Both are facts in
/// the tree; nothing here infers a symbol from a name. `CONFIG` is `-` when
/// no Makefile claims the object — an unexplained function is a finding, so
/// it is carried rather than dropped.
///
/// Empty when the run had only a `bzImage` and no build tree to read.
pub mapping: Vec<(String, String, String)>,
/// A refusal the guest printed for itself (missing profiler, absent
/// counters) — carried as a VALUE so the caller names it.
pub refused: Option<String>,
}
impl KernelRun {
/// Read a serial transcript. Pure, so the judging is testable without a
/// daemon or a kernel.
pub fn read(lines: &[String]) -> Self {
let mut r = KernelRun::default();
for l in lines {
if let Some(rest) = l.split("NORDISK-RUN-TRACING ").nth(1) {
r.tracing_path = rest.trim().to_string();
}
if let Some(rest) = l.split("NORDISK-RUN-REFUSED ").nth(1) {
r.refused = Some(rest.trim().to_string());
}
if let Some(rest) = l.split("NORDISK-RUN-TRACEABLE ").nth(1) {
if let Ok(n) = rest.trim().parse::<u64>() {
r.traceable = n;
}
}
if l.contains(RUN_DONE_MARKER) {
r.complete = true;
}
if let Some(rest) = l.split("NORDISK-RUN-FN ").nth(1) {
// ` Function Hit Time Avg s^2` — the header and the
// dashes are skipped by requiring a parsed count.
let mut it = rest.split_whitespace();
if let (Some(f), Some(h)) = (it.next(), it.next()) {
if let Ok(hits) = h.parse::<u64>() {
r.hits.push((f.to_string(), hits));
}
}
}
}
r
}
/// Total functions executed at least once.
pub fn executed(&self) -> usize {
self.hits.iter().filter(|(_, h)| *h > 0).count()
}
/// **The drop candidates' magnitude**: traceable functions this workload
/// never entered. `None` when the guest could not report the traceable set,
/// because "zero of an unknown total" is not a measurement.
pub fn never_executed(&self) -> Option<u64> {
(self.traceable > 0).then(|| self.traceable.saturating_sub(self.executed() as u64))
}
/// **THE REPORT §4 ASKED FOR**: every CONFIG symbol whose functions this
/// workload never entered once — the symbols a person could click OFF.
///
/// A symbol counts as TOUCHED if ANY function in ANY object it compiles was
/// hit. That direction matters: a symbol is a drop candidate only when
/// nothing under it ran, and one hit anywhere is enough to keep it.
///
/// Returns `(never_touched, touched)` counts alongside the names, because a
/// list of 150 symbols means nothing without the 156 it came from.
/// Empty when [`mapping`](Self::mapping) is empty — no tree, no report.
pub fn unused_configs(&self) -> (Vec<&str>, usize) {
use std::collections::{BTreeMap, BTreeSet};
let ran: BTreeSet<&str> = self
.hits
.iter()
.filter(|(_, h)| *h > 0)
.map(|(f, _)| f.as_str())
.collect();
let mut touched: BTreeMap<&str, bool> = BTreeMap::new();
for (func, _obj, cfg) in &self.mapping {
if cfg == "-" {
continue;
}
let e = touched.entry(cfg.as_str()).or_insert(false);
if ran.contains(func.as_str()) {
*e = true;
}
}
let hot = touched.values().filter(|v| **v).count();
(touched.iter().filter(|(_, v)| !**v).map(|(k, _)| *k).collect(), hot)
}
/// **The drop candidates THEMSELVES** — every traceable function this
/// workload never entered, by name.
///
/// This is what the RUN loop is FOR, and it exists because the names ride
/// the virtio disk the guest already has attached rather than the serial
/// console. Empty when the guest could not write that disk; a caller must
/// not read an empty vec as "everything ran".
pub fn never_executed_names(&self) -> Vec<&str> {
let ran: std::collections::BTreeSet<&str> = self
.hits
.iter()
.filter(|(_, h)| *h > 0)
.map(|(f, _)| f.as_str())
.collect();
self.traceable_names
.iter()
.map(String::as_str)
.filter(|f| !ran.contains(f))
.collect()
}
}
/// **Boot the built kernel and read what it EXECUTED** — design §4's RUN loop.
///
/// Requires a kernel built with `profile_run = true`; a kernel without the
/// profiler refuses from inside the guest, by name, rather than reporting an
/// empty measurement (which would read exactly like a workload that touched
/// nothing).
pub fn run_kernel_with(
runner: &dyn KernelContainerRunner,
k: &KernelBuild,
repo_root: &Path,
) -> Result<KernelRun> {
let out_dir = kernel_output_dir(repo_root, k);
let workdir = out_dir.join("work");
let bzimage = out_dir.join("bzImage");
if !bzimage.is_file() {
return Err(anyhow!(
"kernel '{}': nothing to run — {} is not on disk. Build it before running it",
k.name,
bzimage.display()
));
}
std::fs::create_dir_all(workdir.join("out"))
.with_context(|| format!("create {}", workdir.join("out").display()))?;
std::fs::copy(&bzimage, workdir.join("out").join("bzImage"))
.with_context(|| format!("kernel '{}': stage bzImage for the run", k.name))?;
let req = KernelRunRequest {
name: format!("kernel-run-{}", k.name),
image: k.toolchain_image_ref(),
script: run_script(k),
binds: vec![format!("{}:/work", workdir.display())],
timeout: Duration::from_secs(k.probe_timeout_secs.saturating_add(60)),
network: false,
mem_limit_mb: k.mem_limit_mb,
cpus: k.cpus,
};
let outcome = runner
.run(&req)
.map_err(|e| match k.toolchain_repin_hint() {
Some(hint) => anyhow!("{e:#}\n\n{hint}"),
None => e,
})
.with_context(|| format!("kernel '{}': profile run", k.name))?;
let transcript: Vec<String> =
outcome.stdout.iter().chain(outcome.stderr.iter()).cloned().collect();
// The probe's lesson, applied without having to learn it twice: a
// transcript is evidence only if the step that produced it SUCCEEDED, and
// this script's own source carries every marker the reader looks for.
if outcome.exit_code != Some(0) {
if out_of_room(&transcript) {
return Err(anyhow!(
"kernel '{}': the RUN ran out of room to write — see the build path's note; nothing was measured",
k.name
));
}
let tail: Vec<&str> = transcript.iter().rev().take(20).map(String::as_str).rev().collect();
return Err(anyhow!(
"kernel '{}': the run step itself failed (exit {:?}) — nothing was measured. Marker text below is the RUN'S OWN SOURCE quoted back, not a measurement:
{}",
k.name,
outcome.exit_code,
tail.join("
")
));
}
let mut run = KernelRun::read(&transcript);
// The names come off the DISK the guest wrote, not the console it printed
// to. Absence here is stated, never silently an empty measurement.
let lifted = workdir.join("out").join("run-traceable.txt");
if let Ok(text) = std::fs::read_to_string(&lifted) {
run.traceable_names =
text.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
}
// The tree-derived mapping, when the run had a build tree to read.
if let Ok(text) = std::fs::read_to_string(workdir.join("out").join("fn-to-config.txt")) {
run.mapping = text
.lines()
.filter_map(|l| {
let mut it = l.split_whitespace();
match (it.next(), it.next(), it.next()) {
(Some(f), Some(o), Some(c)) => {
Some((f.to_string(), o.to_string(), c.to_string()))
}
_ => None,
}
})
.collect();
}
if let Some(why) = &run.refused {
return Err(anyhow!(
"kernel '{}': the guest refused to measure itself — {why}. Build it with `profile_run = true`; a kernel without CONFIG_FUNCTION_PROFILER reports an EMPTY measurement, which reads exactly like a workload that executed nothing",
k.name
));
}
if !run.complete {
let tail: Vec<&str> = transcript.iter().rev().take(20).map(String::as_str).rev().collect();
return Err(anyhow!(
"kernel '{}': the run never reached {RUN_DONE_MARKER} — the counters were not read out, so a short list here is truncation and not a small workload. Serial tail:
{}",
k.name,
tail.join("
")
));
}
Ok(run)
}
/// **Sweep old kept build workdirs, newest-N-survive.** Returns what it removed.
///
/// ★ MEASURED 2026-08-23, expensively. `KERNEL_LIVE_KEEP` exists so an artifact
/// survives for inspection (applied output, not a green line) — and it had NO
/// retention policy at all. Eight kept builds at 1.9–2.5 GB each is ~15 GB;
/// nothing ever swept them, the scratch filesystem hit its quota, and the box's
/// entire tooling stopped working until a human ran `rm -rf` by hand. A
/// convenience with no retention policy is a slow leak that eventually takes
/// something unrelated down with it.
///
/// Only directories that LOOK like kept kernel builds are touched — a
/// `.kernel-out/` inside them — so pointing this at the wrong path removes
/// nothing. `keep_last == 0` is refused rather than treated as "remove
/// everything": that reading is how a sweep becomes a disaster.
pub fn sweep_kept_builds(root: &Path, keep_last: usize) -> Result<Vec<PathBuf>> {
if keep_last == 0 {
return Err(anyhow!(
"sweep_kept_builds({}): `keep_last = 0` is refused — a sweep that keeps nothing \
is a delete, and a delete should be spelled as one",
root.display()
));
}
let mut kept: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
let Ok(dir) = std::fs::read_dir(root) else {
return Ok(Vec::new());
};
for e in dir.flatten() {
let p = e.path();
// The marker of a kept BUILD, not of a directory that merely sits here.
if !p.join(".kernel-out").is_dir() {
continue;
}
let when = e
.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
kept.push((when, p));
}
// Newest first, so `skip(keep_last)` leaves exactly the newest N standing.
kept.sort_by_key(|(when, _)| std::cmp::Reverse(*when));
let mut removed = Vec::new();
for (_, p) in kept.into_iter().skip(keep_last) {
std::fs::remove_dir_all(&p)
.with_context(|| format!("sweep kept build {}", p.display()))?;
removed.push(p);
}
Ok(removed)
}
/// **Did this container die because it ran out of room to write?**
///
/// ★ MEASURED 2026-08-23, on the session that wrote this code. The disk
/// preflight ([`KernelBuild::min_free_disk_mb`]) called `statvfs`, saw 23 GiB
/// free, and waved the build through — and the build died ninety seconds later
/// with `tar: … Cannot write: Disk quota exceeded`. `statvfs` reports the
/// FILESYSTEM's free space; this was `EDQUOT`, a QUOTA against the writing
/// user or project, which no amount of free filesystem says anything about. The
/// same blind spot covers a container storage driver's own limit and a bind
/// mount whose backing store fills after the check.
///
/// So the preflight is necessary and NOT sufficient, and the honest place to
/// catch the rest is here: a build that fails this way must say the sentence
/// out loud instead of handing back thirty lines of `tar` and letting the
/// reader guess. Kept as a pure function over the transcript so a test can hand
/// it the exact stderr this box produced.
pub fn out_of_room(transcript: &[String]) -> bool {
// The kernel's own spellings, in the two errnos that mean "there was no
// room": ENOSPC and EDQUOT. Matched case-insensitively because tar, gcc,
// ld and the shell each phrase it differently.
const MARKS: &[&str] = &[
"disk quota exceeded",
"no space left on device",
"quota exceeded",
"write error: no space",
];
transcript.iter().any(|l| {
let l = l.to_ascii_lowercase();
MARKS.iter().any(|m| l.contains(m))
})
}
// ── the PROBE: the only step that can catch what the canary cannot ──────────
/// The marker the probe's PID 1 prints on the serial console. Deliberately not a
/// word the kernel or QEMU could print on their own.
pub const PROBE_MARKER: &str = "NORDISK-PROBE-PID1-ALIVE";
/// The marker printed after PID 1 has read `/proc/self/exe` and looked at the
/// devices the boot contract promises — a second, LATER line, so "it booted" and
/// "the machine it booted into is the declared one" cannot be the same claim.
pub const PROBE_DEVICES_MARKER: &str = "NORDISK-PROBE-DEVICES";
/// **The probe's PID 1** — a static C program, compiled by the toolchain image's
/// own gcc, that prints [`PROBE_MARKER`], reports the devices the boot contract
/// promises, and powers the guest off.
///
/// Why a C program and not a shell script: there is no shell in the initramfs,
/// and putting one there would test the shell. This binary IS the test of
/// `CONFIG_BINFMT_ELF` — the option the canary can NEVER catch, because the
/// canary only checks what the fragment names, and a kernel that cannot exec
/// PID 1 is not a smaller kernel, it is a brick.
pub const PROBE_INIT_C: &str = r#"
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <sys/mount.h>
#include <sys/stat.h>
#include <sys/reboot.h>
#include <unistd.h>
/* Report a device node the boot contract promises. Absence is NAMED, never
silent: a probe that only printed what it found would read the same on a
kernel with no virtio at all. */
static void look(const char *what, const char *path) {
struct stat st;
printf("NORDISK-PROBE-DEV %s %s\n", what, stat(path, &st) == 0 ? "present" : "ABSENT");
}
/* Leg (c): the consoles the KERNEL actually registered, read from its own
/proc/consoles. Applied output, not a claim: a `console=` on the cmdline that
pointed at a UART that is not there leaves no row here, which is exactly the
UpCloud dark boot with a name. */
static void consoles(void) {
FILE *f = fopen("/proc/consoles", "r");
char line[256];
if (!f) { printf("NORDISK-PROBE-CONSOLE (no /proc/consoles)\n"); return; }
while (fgets(line, sizeof line, f)) {
char *sp = strchr(line, ' ');
if (sp) *sp = 0;
printf("NORDISK-PROBE-CONSOLE %s\n", line);
}
fclose(f);
}
/* Leg (b): every interface the kernel's netdev core registered. A built-in
driver that bound its device is the ONLY way a name appears here — a driver
compiled as a module in an initramfs, or compiled out, leaves nothing, which
is the measured "e1000=m guest is dead metal" failure. */
static void nets(void) {
DIR *d = opendir("/sys/class/net");
struct dirent *e;
if (!d) { printf("NORDISK-PROBE-NET (no /sys/class/net)\n"); return; }
while ((e = readdir(d))) {
if (e->d_name[0] == '.') continue;
printf("NORDISK-PROBE-NET %s\n", e->d_name);
}
closedir(d);
}
int main(void) {
/* Unbuffered: a panic after this line must not eat the evidence. */
setvbuf(stdout, NULL, _IONBF, 0);
printf("%s\n", "NORDISK-PROBE-PID1-ALIVE");
mkdir("/dev", 0755);
mkdir("/proc", 0755);
mkdir("/sys", 0755);
/* devtmpfs is where PID 1 gets a /dev at all; CONFIG_DEVTMPFS(_MOUNT) is on
the boot contract, so a failure here is a finding, not an accident. */
mount("devtmpfs", "/dev", "devtmpfs", 0, NULL);
mount("proc", "/proc", "proc", 0, NULL);
mount("sysfs", "/sys", "sysfs", 0, NULL);
look("console", "/dev/console");
look("tun", "/dev/net/tun");
/* Leg (a), one node per bus the `target` group can spell. Every one is
reported present OR ABSENT, so the transcript answers the question for a
preset that wanted a different bus than the one this kernel got. */
look("virtio-blk", "/dev/vda");
look("scsi-sd", "/dev/sda");
look("nvme", "/dev/nvme0n1");
look("xen-blk", "/dev/xvda");
look("cdrom", "/dev/sr0");
consoles();
nets();
printf("%s\n", "NORDISK-PROBE-DEVICES");
sync();
reboot(RB_POWER_OFF);
for (;;) pause();
}
"#;
/// **The machine a closure probe boots a kernel on** — one cloud preset's
/// storage controller, NIC, chipset and CPU, as QEMU can render them.
///
/// Derived from the preset's per-facet [`MimicAxes`](crate::target::presets::MimicAxes)
/// rather than from [`TargetPreset::qemu_shape`](crate::target::presets::TargetPreset::qemu_shape),
/// because a closure proof needs LESS than a whole-machine mimic: `aws-nitro` has no
/// QEMU NIC, and its NVMe root disk is still exactly what QEMU models and exactly
/// the closure under test (the release-3 axes record the same fact, 2026-09-05).
///
/// Two rules, both MEASURED by lane AN on 2026-08-30 (the closures were composed,
/// built and booted on these shapes; edda tag `saved/kconfig-solver-2026-08-30`):
///
/// * **the storage leg must be the real device.** It is the floor — a guest with no
/// block device cannot be asked whether it found its root — and a SUBSTITUTE disk
/// proves nothing about a kernel composed for the real one: that kernel carries
/// the real driver and not the stand-in's.
/// * **a substitute NIC is NO NIC.** ENA and gVNIC were first given an `e1000e`
/// "for the same leg", and the kernel composed for `aws-nitro` (which carries
/// `ena` and not `e1000e`) booted with `/sys/class/net = [lo]`. A stand-in only
/// means anything when the kernel under test carries the stand-in's driver, and a
/// kernel that did would be a different kernel. So a NIC facet that is not
/// `Exact` renders `-nic none`, and leg (b) is reported UNPROVEN, by name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProbeMachine {
/// `-machine`.
pub machine: &'static str,
/// `-cpu`, when the preset names one.
pub cpu: Option<&'static str>,
/// The `-device` the boot disk is.
pub disk_device: &'static str,
/// The node the guest kernel presents for it — what PID 1 stats for leg (a).
pub disk_node: &'static str,
/// The NIC `-device`, or `None` when the cloud's NIC has no QEMU model: the
/// guest gets no network device and leg (b) is UNPROVEN, never failed.
pub nic_device: Option<&'static str>,
/// Every facet stood in for, as `(facet, missing, with)` — a green from this
/// machine must be quoted with it.
pub substitutions: Vec<(&'static str, &'static str, &'static str)>,
/// Facets this rendering creates NO device for — the boot says nothing about
/// them, in either direction.
pub unrendered: Vec<&'static str>,
}
impl ProbeMachine {
/// The `-cpu` argument, if any.
pub fn cpu_arg(&self) -> Option<String> {
self.cpu.map(str::to_string)
}
/// **The probe machine for a target preset**, or the sentence that says why
/// none can be rendered here.
pub fn for_preset(p: &crate::target::presets::TargetPreset) -> std::result::Result<Self, String> {
use crate::target::presets::{DiskBus, MimicAxis};
let axes = p.axes();
let mut substitutions = Vec::new();
let mut unrendered = Vec::new();
let disk_device = match axes.disk {
MimicAxis::Exact(d) => d,
MimicAxis::Substitute { missing, with } => {
return Err(format!(
"the boot disk is `{missing}` and QEMU can only stand `{with}` in for it — a kernel \
composed for `{missing}` carries that driver and not the stand-in's, so the storage \
leg cannot be proven here"
));
}
MimicAxis::Absent(why) => return Err(format!("the boot disk cannot be rendered: {why}")),
};
let disk_node = match p.boot_disk {
DiskBus::VirtioBlk => "/dev/vda",
DiskBus::Nvme => "/dev/nvme0n1",
DiskBus::VirtioScsi | DiskBus::Ide => "/dev/sda",
other => return Err(format!("no probe row knows the node `{}` presents", other.as_str())),
};
let machine = match axes.chipset {
MimicAxis::Exact(m) => m,
MimicAxis::Substitute { missing, with } => {
substitutions.push(("chipset", missing, with));
with
}
MimicAxis::Absent(why) => return Err(format!("the chipset cannot be rendered: {why}")),
};
let nic_device = match axes.nic {
MimicAxis::Exact(n) => Some(n),
MimicAxis::Substitute { .. } | MimicAxis::Absent(_) => {
unrendered.push("nic");
None
}
};
Ok(ProbeMachine { machine, cpu: p.cpu.qemu_arg(), disk_device, disk_node, nic_device, substitutions, unrendered })
}
}
/// The in-container probe script: compile PID 1 statically, pack a one-file
/// initramfs, boot the built `bzImage` under QEMU with a serial console, and let
/// the ORCHESTRATOR judge the transcript.
///
/// QEMU runs with `-no-reboot` and a bounded `-append panic=1` so a kernel that
/// panics ENDS instead of looping until the timeout — a hang and a panic are
/// different facts and must not arrive as the same one.
pub fn probe_script(k: &KernelBuild) -> String {
probe_script_for(k, None)
}
/// **[`probe_script`] shaped by a target preset** — the machine, CPU model,
/// storage controller and NIC the named cloud has ([`ProbeMachine::for_preset`]).
///
/// `None` renders the HISTORICAL argv byte for byte (no `-machine`, no `-cpu`,
/// virtio-blk + virtio-net), so every existing probe is unchanged.
///
/// # Why the shape has to come from the preset (lane AN, 2026-08-30)
///
/// The old script hardcoded `-device virtio-net-pci` and `-drive if=virtio`,
/// which is the `qemu-local` machine. Booting a kernel composed FOR AWS Nitro
/// on that machine proves nothing about Nitro: the guest would report
/// `/dev/vda present` from a driver Nitro does not have and say nothing at all
/// about the `nvme` closure under test. A closure proof has to run on the
/// machine the closure was composed for, or it is measuring the wrong box.
pub fn probe_script_for(k: &KernelBuild, shape: Option<&ProbeMachine>) -> String {
let mut s = String::new();
s.push_str("set -eu\n");
s.push_str("cd /work\n");
s.push_str("mkdir -p probe/initramfs\n");
s.push_str("cat > probe/init.c <<'PROBE_EOF'\n");
s.push_str(PROBE_INIT_C);
s.push_str("\nPROBE_EOF\n");
s.push_str("gcc -static -Os -o probe/initramfs/init probe/init.c\n");
s.push_str(
"( cd probe/initramfs && find . | cpio -o -H newc --quiet ) > probe/initramfs.cpio\n",
);
s.push_str(&format!(
"timeout {} qemu-system-x86_64 -nographic -no-reboot -m 512 \\\n",
k.probe_timeout_secs
));
match shape {
None => {
s.push_str(" -kernel /work/out/bzImage -initrd probe/initramfs.cpio \\\n");
s.push_str(" -append 'console=ttyS0 panic=1' \\\n");
// virtio-net + a virtio disk, so the contract's own devices are
// actually PRESENT to be found. A probe on a machine with no virtio
// would report ABSENT for a kernel that is perfectly correct.
s.push_str(" -device virtio-net-pci,netdev=n0 -netdev user,id=n0 \\\n");
s.push_str(" -drive file=probe/blank.img,if=virtio,format=raw \\\n");
}
Some(sh) => {
s.push_str(&format!(" -machine {} \\\n", sh.machine));
if let Some(cpu) = sh.cpu_arg() {
s.push_str(&format!(" -cpu {cpu} \\\n"));
}
s.push_str(" -kernel /work/out/bzImage -initrd probe/initramfs.cpio \\\n");
// `console=ttyS0` is the PROBE'S OWN channel and is added on top of
// whatever the preset declares — it is how the transcript exists at
// all. For a preset whose real console is VGA only, that means the
// kernel under test must still carry SERIAL_8250, and the caller is
// told so rather than the run silently proving a console the
// machine does not have.
s.push_str(" -append 'console=ttyS0 panic=1' \\\n");
match sh.nic_device {
Some(nic) => s.push_str(&format!(" -device {nic},netdev=n0 -netdev user,id=n0 \\\n")),
// No NIC device exists to create (Hyper-V, Xen). The guest gets
// none, and the judge must report the network leg UNPROVEN
// rather than read `no eth0` as a closure failure.
None => s.push_str(" -nic none \\\n"),
}
match sh.disk_device {
"nvme" => s.push_str(" -drive file=probe/blank.img,if=none,format=raw,id=d0 -device nvme,drive=d0,serial=nordiskprobe \\\n"),
"ide-hd" => s.push_str(" -drive file=probe/blank.img,if=none,format=raw,id=d0 -device ide-hd,drive=d0 \\\n"),
"virtio-blk-pci" => s.push_str(" -drive file=probe/blank.img,if=virtio,format=raw \\\n"),
"virtio-scsi-pci" => s.push_str(" -device virtio-scsi-pci,id=scsi0 -drive file=probe/blank.img,if=none,format=raw,id=d0 -device scsi-hd,drive=d0,bus=scsi0.0 \\\n"),
other => s.push_str(&format!(" -drive file=probe/blank.img,if=none,format=raw,id=d0 -device {other},drive=d0 \\\n")),
}
}
}
s.push_str(" 2>&1 | tee /work/out/probe-serial.log\n");
s
}
/// **Boot the built kernel and read the serial console** — the step that answers
/// what the canary structurally cannot.
///
/// The canary checks that every option the FRAGMENT NAMES survived
/// `olddefconfig`. It cannot check an option nobody wrote down: a `tinyconfig`
/// build with no `CONFIG_BINFMT_ELF` passes every canary line and cannot execute
/// PID 1. This is that check, and it is the reason qemu rides in the toolchain
/// image.
pub fn probe_kernel_with(
runner: &dyn KernelContainerRunner,
k: &KernelBuild,
repo_root: &Path,
) -> Result<KernelProbe> {
probe_kernel_shaped(runner, k, repo_root, None)
}
/// **[`probe_kernel_with`] on the machine a named target preset IS** — the door
/// that turns a derived Kconfig closure into a measured one.
///
/// `shape` comes from [`ProbeMachine::for_preset`]; `None` is the historical
/// `qemu-local` argv.
pub fn probe_kernel_shaped(
runner: &dyn KernelContainerRunner,
k: &KernelBuild,
repo_root: &Path,
shape: Option<&ProbeMachine>,
) -> Result<KernelProbe> {
let out_dir = kernel_output_dir(repo_root, k);
let workdir = out_dir.join("work");
let bzimage = out_dir.join("bzImage");
if !bzimage.is_file() {
return Err(anyhow!(
"kernel '{}': nothing to probe — {} is not on disk. Build it before booting it",
k.name,
bzimage.display()
));
}
// The probe boots the artifact that was COLLECTED, so what is judged is what
// a consumer would get, not a leftover in the build tree.
std::fs::create_dir_all(workdir.join("out"))
.with_context(|| format!("create {}", workdir.join("out").display()))?;
std::fs::copy(&bzimage, workdir.join("out").join("bzImage"))
.with_context(|| format!("kernel '{}': stage bzImage for the probe", k.name))?;
// A blank disk so the declared block device has something to be. `nvme`
// refuses a zero-length backing file, so the image is a real (small) one.
std::fs::create_dir_all(workdir.join("probe"))
.with_context(|| format!("kernel '{}': stage the probe disk", k.name))?;
for img in ["blank.img"] {
std::fs::write(workdir.join("probe").join(img), vec![0u8; 4 * 1024 * 1024])
.with_context(|| format!("kernel '{}': stage the probe disk {img}", k.name))?;
}
let req = KernelRunRequest {
name: format!("kernel-probe-{}", k.name),
image: k.toolchain_image_ref(),
script: probe_script_for(k, shape),
binds: vec![format!("{}:/work", workdir.display())],
timeout: Duration::from_secs(k.probe_timeout_secs.saturating_add(60)),
network: false,
mem_limit_mb: k.mem_limit_mb,
cpus: k.cpus,
};
let outcome = runner
.run(&req)
.map_err(|e| match k.toolchain_repin_hint() {
Some(hint) => anyhow!("{e:#}\n\n{hint}"),
None => e,
})
.with_context(|| format!("kernel '{}': probe boot", k.name))?;
let transcript: Vec<String> =
outcome.stdout.iter().chain(outcome.stderr.iter()).cloned().collect();
// ★ MEASURED 2026-08-22, a false green in this very function: the probe used
// to go straight to reading markers. `gcc` failed to compile PID 1 (a real
// newline had got into a C string literal), and gcc's diagnostics QUOTE THE
// OFFENDING SOURCE LINE — which contains the marker strings. So the compiler
// error was parsed as a successful boot, and a kernel that had never been
// started reported `booted: true`. A transcript is only evidence if the step
// that produced it SUCCEEDED.
if outcome.exit_code != Some(0) {
let tail: Vec<&str> = transcript.iter().rev().take(20).map(String::as_str).rev().collect();
return Err(anyhow!(
"kernel '{}': the probe step itself failed (exit {:?}) — nothing was booted, so \
nothing about this kernel is known. Marker text in the output below is the \
PROBE'S OWN SOURCE being quoted back, not a boot:\n{}",
k.name,
outcome.exit_code,
tail.join("\n")
));
}
let probe = KernelProbe::read(&transcript);
if !probe.booted {
let tail: Vec<&str> =
transcript.iter().rev().take(20).map(String::as_str).rev().collect();
return Err(anyhow!(
"kernel '{}': the built kernel did NOT reach PID 1 — no {PROBE_MARKER} on the \
serial console. This is what the canary cannot see: an option nobody wrote \
down (CONFIG_BINFMT_ELF is the usual one on a tinyconfig base) is missing, so \
the kernel boots and can execute nothing. Serial tail:\n{}",
k.name,
tail.join("\n")
));
}
Ok(probe)
}
/// What the serial console said — a VALUE, so the caller decides what a missing
/// device means (a `nuked` kernel is SUPPOSED to have no `/dev/vda` if nothing
/// declared virtio-blk).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KernelProbe {
/// PID 1 ran: the kernel booted AND could execute an ELF binary.
pub booted: bool,
/// PID 1 got as far as reporting devices (so it did not die mid-way).
pub reported_devices: bool,
/// Each device the contract promises, and whether it was there.
pub devices: Vec<(String, bool)>,
/// Leg (b): every interface `/sys/class/net` held — a built-in driver that
/// BOUND its device is the only way a name gets here.
pub nets: Vec<String>,
/// Leg (c): every console the kernel registered, off `/proc/consoles`.
pub consoles: Vec<String>,
}
impl KernelProbe {
/// Read a serial transcript. Pure — the judging happens away from the
/// container, so a test can hand it a transcript and no daemon exists.
pub fn read(lines: &[String]) -> Self {
let mut p = KernelProbe::default();
for l in lines {
if l.contains(PROBE_MARKER) {
p.booted = true;
}
if l.contains(PROBE_DEVICES_MARKER) {
p.reported_devices = true;
}
if let Some(rest) = l.split("NORDISK-PROBE-DEV ").nth(1) {
let mut it = rest.split_whitespace();
if let (Some(what), Some(state)) = (it.next(), it.next()) {
p.devices.push((what.to_string(), state == "present"));
}
}
if let Some(rest) = l.split("NORDISK-PROBE-NET ").nth(1) {
if let Some(n) = rest.split_whitespace().next() {
p.nets.push(n.to_string());
}
}
if let Some(rest) = l.split("NORDISK-PROBE-CONSOLE ").nth(1) {
if let Some(n) = rest.split_whitespace().next() {
p.consoles.push(n.to_string());
}
}
}
p
}
/// Whether a named device was reported present.
pub fn has(&self, what: &str) -> bool {
self.devices.iter().any(|(n, ok)| n == what && *ok)
}
/// Whether the netdev core registered an interface that is not `lo`.
pub fn has_net(&self) -> bool {
self.nets.iter().any(|n| n != "lo")
}
/// **Judge this transcript against the machine it was booted on** — the
/// three legs a closure has to close, each named.
///
/// * (a) the declared boot disk's node exists — the storage closure bound;
/// * (b) an interface other than `lo` registered — SKIPPED when the shape
/// created no NIC at all ([`ProbeMachine::nic_device`] is `None`), because
/// "no eth0 on a machine with no NIC" is not a finding;
/// * (c) at least one console registered.
///
/// `Err` names the leg and quotes what the guest actually reported, so a
/// failure is readable without the transcript.
pub fn closes(&self, who: &str, shape: &ProbeMachine) -> Result<()> {
if !self.booted {
return Err(anyhow!("{who}: leg (0) — PID 1 never ran"));
}
if !self.reported_devices {
return Err(anyhow!("{who}: leg (0) — PID 1 died between booting and reporting"));
}
let want = match shape.disk_node {
"/dev/vda" => "virtio-blk",
"/dev/sda" => "scsi-sd",
"/dev/nvme0n1" => "nvme",
"/dev/xvda" => "xen-blk",
"/dev/sr0" => "cdrom",
other => return Err(anyhow!("{who}: no probe row knows the node {other}")),
};
if !self.has(want) {
return Err(anyhow!(
"{who}: leg (a) FAILED — the machine was given a `{}` and the kernel never \
presented {} ({want} ABSENT). The declared storage closure did not bind. \
Devices: {:?}",
shape.disk_device,
shape.disk_node,
self.devices
));
}
if shape.nic_device.is_some() && !self.has_net() {
return Err(anyhow!(
"{who}: leg (b) FAILED — the machine was given a `{}` and no interface but `lo` \
registered. The NIC driver is missing or is a module an initramfs cannot load. \
/sys/class/net: {:?}",
shape.nic_device.unwrap_or("-"),
self.nets
));
}
if self.consoles.is_empty() {
return Err(anyhow!("{who}: leg (c) FAILED — the kernel registered no console at all"));
}
Ok(())
}
}
// ── the container-run + fetch seams ──────────────────────────────────────────
/// What the kernel builder asks a container engine to run — the WHOLE request,
/// pure data, so a fake runner can assert every field with no daemon.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KernelRunRequest {
/// The instance name (`kernel-<variant>`).
pub name: String,
/// The pinned toolchain image reference ([`KernelBuild::toolchain_image_ref`]).
pub image: String,
/// The `/bin/sh -eu` script ([`build_script`]).
pub script: String,
/// Host bind mounts, podman `-v` strings — exactly one: `<workdir>:/work`.
pub binds: Vec<String>,
/// The bound ([`KernelBuild::timeout`]); overrun must stop the container and
/// return an error.
pub timeout: Duration,
/// Whether the container gets a network ([`KernelBuild::needs_network`]);
/// `false` runs airgapped (`--network none`).
pub network: bool,
/// The memory bound in MiB ([`KernelBuild::mem_limit_mb`]) — podman
/// `--memory`, bollard `HostConfig.memory`. Never absent: a build container
/// that may grow without a ceiling is the 2026-08-20 defect.
pub mem_limit_mb: u32,
/// The cpu bound in whole cores ([`KernelBuild::cpus`]) — podman `--cpus`,
/// bollard `HostConfig.nano_cpus`. The same number the script's `-j` uses.
pub cpus: u32,
}
/// What came back from the container run — the exec-outcome shape
/// (a non-zero exit is a VALUE here; the ORCHESTRATOR turns it into a named
/// refusal, so the policy lives in one place).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KernelRunOutcome {
/// The container's exit code (`Some(0)` = clean), `None` if the engine
/// reported none.
pub exit_code: Option<i64>,
/// Captured stdout lines.
pub stdout: Vec<String>,
/// Captured stderr lines.
pub stderr: Vec<String>,
}
/// **The container seam** the kernel builder drives — one method, so a test
/// injects a fake and proves the orchestration calls (and a red arm) with no
/// daemon. The production impl is [`DraupnirKernelRunner`] (feature
/// `kernel-build`), over draupnir's `backend-oci` bollard engine.
pub trait KernelContainerRunner {
/// Run the build container to completion within `req.timeout`.
fn run(&self, req: &KernelRunRequest) -> Result<KernelRunOutcome>;
/// The engine label the provenance records.
fn engine(&self) -> &str {
"draupnir-oci"
}
/// **The image store behind this runner**, when it has one — the seam the
/// OFFLINE toolchain preflight
/// ([`resolve_toolchain`](crate::toolchain_archive::resolve_toolchain))
/// drives (`#14`, lane AO).
///
/// `None` is the honest default and NOT a fallback: a runner that cannot be
/// asked whether an image is present has nothing to say about it, so the
/// preflight records
/// [`ToolchainSource::Unchecked`](crate::toolchain_archive::ToolchainSource::Unchecked)
/// and claims nothing. A runner that CAN answer gets the airgap guarantee:
/// a missing toolchain becomes a named refusal instead of a registry fetch.
fn image_store(&self) -> Option<&dyn crate::toolchain_archive::ToolchainImageStore> {
None
}
}
/// **The download seam** — host-side, pure Rust (the pure-Rust law holds on the
/// host; the container only compiles). The production impl is [`UreqFetcher`]
/// (feature `kernel-build`). Verification is NOT the fetcher's job: the
/// orchestrator sha256-checks whatever lands, so a fetcher cannot vouch for
/// itself.
pub trait TarballFetcher {
/// Download `url` to `dest` (atomically enough that a partial file does not
/// masquerade as a tarball — write then rename, or fail).
fn fetch(&self, url: &str, dest: &Path) -> Result<()>;
}
/// Lowercase-hex sha256 of a file, streamed (a kernel tarball is ~150 MB — it
/// is hashed, not slurped). The shared `nornir_hash::sha256_file`, with the
/// path folded into the error.
pub fn sha256_file_hex(path: &Path) -> Result<String> {
nornir_hash::sha256_file(path).with_context(|| format!("sha256 {}", path.display()))
}
// ── the pin record ───────────────────────────────────────────────────────────
/// The pin-file schema tag.
pub const KERNEL_PIN_SCHEMA: &str = "nornir.kernel-pin/1";
/// **The pin file** written beside the built kernel
/// (`<name>.kernel-pin.json`): every sha256 that identifies what was built,
/// from what, with what — the receipt a later boot pins against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KernelPin {
/// Schema tag ([`KERNEL_PIN_SCHEMA`]).
pub schema: String,
/// The declared variant name.
pub name: String,
/// The upstream kernel version.
pub kernel_version: String,
/// The tarball URL actually fetched.
pub source_url: String,
/// The tarball's declared-and-verified sha256.
pub tarball_sha256: String,
/// The effective toolchain image reference (digest-pinned when declared).
pub toolchain_image: String,
/// **How the toolchain got onto the box this build ran on** — one of
/// [`ToolchainSource::as_str`](crate::toolchain_archive::ToolchainSource::as_str):
/// `already-present` · `staged-archive` · `unpinned` · `unchecked`
/// (`#14`, lane AO 2026-08-30).
///
/// Absent from every pin written before then, and `#[serde(default)]` for
/// exactly that reason. It exists because `toolchain_image` records WHICH
/// toolchain a build claims and nothing recorded whether that claim was
/// ever checked — and `unchecked` (a runner with no image store) and
/// `staged-archive` (verified, offline, by content address) are the two ends
/// of a range that used to read identically on a receipt.
#[serde(default)]
pub toolchain_source: String,
/// The toolchain image's **id** (config digest, bare hex) when the preflight
/// resolved one — the identity that survives a `docker-archive` round trip,
/// unlike the manifest digest in `toolchain_image`. Empty when no pin was
/// declared or no store could be asked.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub toolchain_image_id: String,
/// The hardening profile name.
pub profile: String,
/// The preset the kernel was built from — the axis that decides its
/// CONTENTS. Absent until 2026-08-23: the receipt recorded the hardening
/// profile and NOT the thing that decides whether this is a 13.7 MB kernel
/// or a 1.7 MB one, so an auditor holding the pin could not tell which was
/// in their hand.
#[serde(default)]
pub preset: String,
/// The RESOLVED kconfig starting point (`defconfig` / `tinyconfig`).
#[serde(default)]
pub base: String,
/// The compiler the kernel was built with.
#[serde(default)]
pub toolchain: String,
/// Whether full clang LTO was on.
#[serde(default)]
pub lto: bool,
/// **Whether the free-disk preflight actually ran, and what it saw.**
///
/// `None` when it could not run at all (the dep-light build cannot call
/// `statvfs`) or was disabled with `min_free_disk_mb = 0`. Present with the
/// observed free bytes when it ran. Added because the receipt could not
/// distinguish a build that PASSED the check from one that SKIPPED it —
/// and because the check has a blind spot ([`out_of_room`]), a reader needs
/// to know which of the two they are holding.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub free_disk_bytes_at_start: Option<u64>,
/// **Bytes the preflight actually RESERVED and released before building** —
/// the half `free_disk_bytes_at_start` cannot state.
///
/// `statvfs` describing 23 GiB of free filesystem is not the same claim as the
/// path agreeing to take 3 GiB: the first passed on 2026-08-22 and the build
/// died on `EDQUOT` ninety seconds later. `Some(n)` means a
/// [`DiskReservation::Reserved`] of `n` bytes succeeded against the actual
/// workdir — quota and storage-driver limit included. `None` means the
/// question was NOT answered (disabled with `min_free_disk_mb = 0`, a
/// filesystem with no preallocation, or a build without the `kernel-build`
/// feature) and must never be read as a pass.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_bytes_reserved: Option<u64>,
/// The MINIFY solver this kernel was passed through, empty when none. An
/// auditor holding the receipt must be able to tell a solved symbol set from
/// a declared one — the `preset` lesson of 2026-08-23, not repeated.
#[serde(default)]
pub solver: String,
/// The kmax release that produced the formula (empty when no solver ran).
#[serde(default)]
pub solver_version: String,
/// sha256 of the MERGED kconfig fragment that was applied.
pub fragment_sha256: String,
/// sha256 of the produced `bzImage`.
pub bzimage_sha256: String,
/// sha256 of the produced `.config`.
pub config_sha256: String,
}
/// The output directory a built kernel lands in:
/// `<repo_root>/.kernel-out/<name>/` (`bzImage`, `kernel.config`,
/// `<name>.kernel-pin.json`; the fetch/work area is `work/` inside it).
pub fn kernel_output_dir(repo_root: &Path, k: &KernelBuild) -> PathBuf {
repo_root.join(".kernel-out").join(&k.name)
}
/// The directory under [`kernel_output_dir`] that holds one slot per STEP PIN.
pub const KERNEL_PIN_SLOTS_DIR: &str = "pins";
/// **The slot a built kernel is ALSO filed under, keyed by its step pin**:
/// `<repo_root>/.kernel-out/<name>/pins/<ledger key>/` (`bzImage`,
/// `kernel.config`, `<name>.kernel-pin.json`, `kernel.why.toml`).
///
/// [`kernel_output_dir`] is one slot per kernel NAME and the build's workdir;
/// two consumers of one donor kernel with different requirement closures
/// (tunnr-etcd's XFS and NVMe media, 2026-10-29) are two merges, and the
/// second build overwrote the first. The pinned slot keeps both: the reader
/// ([`resolve_kernel_for`]) serves the name slot when it holds the chosen
/// merge and this slot otherwise.
pub fn kernel_pinned_dir(repo_root: &Path, k: &KernelBuild, ledger_key: &str) -> PathBuf {
kernel_output_dir(repo_root, k).join(KERNEL_PIN_SLOTS_DIR).join(ledger_key)
}
/// File a just-built kernel's artifacts from the name slot into its pinned
/// slot: staged beside it, then renamed into place, so a reader never sees a
/// half-filed slot.
fn file_under_pin(out_dir: &Path, pinned: &Path, files: &[&Path]) -> Result<()> {
let parent = pinned.parent().context("a pinned slot has a parent")?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let staging = parent.join(format!(
".{}.part",
pinned.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).with_context(|| format!("create {}", staging.display()))?;
for f in files {
let name = f.file_name().context("an artifact has a file name")?;
std::fs::copy(f, staging.join(name))
.with_context(|| format!("file {} under its pin {}", f.display(), pinned.display()))?;
}
let base = out_dir.join(KERNEL_BASE_CONFIG);
if base.is_file() {
let _ = std::fs::copy(&base, staging.join(KERNEL_BASE_CONFIG));
}
let _ = std::fs::remove_dir_all(pinned);
std::fs::rename(&staging, pinned)
.with_context(|| format!("move {} into place at {}", staging.display(), pinned.display()))?;
Ok(())
}
/// Whether the kernel slot `dir` holds a pin whose merge is `fragment_sha256`.
fn slot_holds(dir: &Path, kernel_name: &str, fragment_sha256: &str) -> bool {
std::fs::read_to_string(dir.join(format!("{kernel_name}.kernel-pin.json")))
.ok()
.and_then(|t| serde_json::from_str::<KernelPin>(&t).ok())
.is_some_and(|p| p.fragment_sha256 == fragment_sha256)
&& dir.join("bzImage").is_file()
}
/// **A donor's built kernel, resolved and PROVEN** — what
/// [`resolve_built_kernel`] hands a consumer: the artifact paths plus the pin
/// they were verified against.
#[derive(Debug, Clone)]
pub struct ResolvedBuiltKernel {
/// The built `bzImage`, sha256-verified against the pin.
pub bzimage: PathBuf,
/// The built kernel's `.config` beside it.
pub config: PathBuf,
/// The pin the artifact was verified against — provenance for the consumer's
/// own receipt.
pub pin: KernelPin,
}
/// **Resolve a `[[build.kernel]]` another cell CONSUMES** — the read side of
/// [`crate::installer::KernelDonor`], and the one place "the built kernel IS the
/// chosen kernel" is checked.
///
/// `donor_root` is the donor checkout (resolved beside the consumer by the
/// caller); `donor_label` is its name for refusal texts; `decision` is the
/// operator's `--opt kernel.*` veto, when one was typed — resolved against the
/// DONOR's declaration by the same door the kernel cell's own build uses.
///
/// Four refusals, each BY NAME, because each names a different repair:
///
/// * the donor declares no build space — wrong checkout, not a missing build;
/// * the donor declares no such kernel — `require_kernel`'s own listing;
/// * the kernel is declared but NOT BUILT — the refusal spells the exact
/// command that builds it (`dwarves buildthing <donor> --variant <kernel>`,
/// veto included). "A consumer never builds the donor's kernel" was the rule
/// until 2026-08-26; it is retired as a DECISION by [`resolve_kernel`], which
/// hands the same line back as a typed [`BuildStep`](crate::build_dag::BuildStep)
/// so a door holding `--build` can run it as a step (design f3) — this
/// reader, without `--build`, still refuses by name;
/// * the built artifact DISAGREES with the choice — the pin's fragment sha (the
/// merge of preset + profile + committed veto + the operator's decision, the
/// same bytes [`build_kernel_with`] pinned) does not match what the chosen
/// declaration merges to, or the bzImage on disk no longer matches its own
/// pin. Handing over that file anyway would be a medium whose receipt lies.
pub fn resolve_built_kernel(
donor_root: &Path,
donor_label: &str,
kernel_name: &str,
decision: Option<&crate::kernel_features::KernelDecision>,
) -> Result<ResolvedBuiltKernel> {
resolve_built_kernel_with(donor_root, donor_label, kernel_name, decision, &NoLedger)
}
/// **The LEDGER a built kernel may be looked up in BY PIN** — the second,
/// preferred source [`resolve_built_kernel_with`] asks before the donor's local
/// `.kernel-out/`: the suite warehouse / dwarves' build ledger, where a kernel
/// built once (on any box) sits behind the key its pin makes. A preset with no
/// vetoes always merges to the same fragment sha → the same key → the same
/// bzImage, so picking it costs no build. The seam is a trait because this
/// crate is dep-light and owns no store: dwarves fills it with its action cache
/// + artifact store; [`NoLedger`] answers "nothing" and the local fallback
/// stands.
pub trait KernelLedger {
/// The built kernel filed under `key` ([`ledger_key`]), materialised on
/// disk: `Ok(None)` when the ledger holds nothing for it (the caller falls
/// back), `Err` when the ledger could not be asked — which is NOT a miss.
fn lookup(&self, key: &str) -> Result<Option<ResolvedBuiltKernel>>;
}
/// The ledger that holds nothing — [`resolve_built_kernel`]'s default.
pub struct NoLedger;
impl KernelLedger for NoLedger {
fn lookup(&self, _key: &str) -> Result<Option<ResolvedBuiltKernel>> {
Ok(None)
}
}
/// The ledger-key schema tag — bump when the key's inputs change.
pub const KERNEL_LEDGER_KEY_SCHEMA: &str = "nornir.kernel-ledger-key/1";
/// **The key a built kernel is filed under** — sha256 over everything that
/// decides the bzImage's bytes: the upstream version and tarball pin, the
/// toolchain image, the hardening profile, and the MERGED fragment's sha
/// (preset + profile + the repo's veto file + the operator's decision — the
/// same bytes the pin records as `fragment_sha256`). Two kernels with the same
/// fragment but different versions never collide; a preset with no vetoes
/// keys the same on every box.
pub fn ledger_key(k: &KernelBuild, fragment_sha256: &str) -> String {
let canonical = format!(
"{KERNEL_LEDGER_KEY_SCHEMA}\u{2}version={}\u{2}tarball={}\u{2}toolchain={}\u{2}profile={}\u{2}fragment={fragment_sha256}",
k.version,
k.tarball_sha256,
k.toolchain_image_ref(),
k.profile,
);
nornir_hash::sha256_hex(canonical.as_bytes())
}
/// **The key a DECLARED kernel keys as** — the merged fragment of `k` in
/// `repo_root` (decision applied by the caller), hashed into [`ledger_key`].
/// What a build FILES under after it produced the bzImage, and what a consumer
/// LOOKS UP before building — one function, so they cannot disagree.
pub fn ledger_key_of(k: &KernelBuild, repo_root: &Path) -> Result<String> {
let fragment = merged_fragment(k, k.user_fragment_in(repo_root)?.as_deref())?;
Ok(ledger_key(k, &nornir_hash::sha256_hex(fragment.as_bytes())))
}
/// **What a kernel must carry to boot a nordisk PID-1 at all** — the options
/// the installer / appliance boot chain reads before it has done anything:
///
/// | option | who dies without it |
/// |---|---|
/// | `CONFIG_PROC_FS` | `korp-start`/`korp-installer` read `korp.*` selectors off `/proc/cmdline` |
/// | `CONFIG_SYSFS` | the installer's volume finder walks `/sys/class/block`; hwdetect walks `/sys/block` |
/// | `CONFIG_DEVTMPFS` | PID-1 opens `/dev/console`, the medium and the target disk |
/// | `CONFIG_BLK_DEV_INITRD` | the initramfs whose `/init` IS the PID-1 |
/// | `CONFIG_BINFMT_ELF` | exec of PID-1 itself (tinyconfig leaves ELF off) |
/// | `CONFIG_SMP` | every vCPU the plan pays for (tinyconfig leaves SMP off; UpCloud 2xCPU served on one core, 2026-08-29) |
///
/// A `[[build.kernel]]` that merges to a `.config` missing any of these is a
/// brick with a receipt. MEASURED 2026-08-27: gunnar-installer-…-nowg.iso
/// shipped on tunnr's `nuked` kernel whose `.config` read `# CONFIG_PROC_FS is
/// not set` / `# CONFIG_SYSFS is not set`; under OVMF the installer printed
/// `mounts proc=MISSING sys=MISSING dev=OK` and died on a payload it could not
/// see. The pin verified (bzImage sha, fragment sha) — every check passed and
/// none of them asked whether the kernel could boot the thing it was for. This
/// list is that question, asked once, where a consumer resolves a donor.
///
/// The check reads the built `.config` (the `kernel.config` beside the
/// bzImage, `config_sha256`-pinned), not the fragment: what survived
/// `olddefconfig` is what boots.
pub const PID1_BOOT_CONTRACT: &[&str] = &[
"CONFIG_PROC_FS",
"CONFIG_SYSFS",
"CONFIG_DEVTMPFS",
"CONFIG_BLK_DEV_INITRD",
"CONFIG_BINFMT_ELF",
// Not a "cannot boot" option, but a "boots and wastes the machine" one:
// MEASURED 2026-08-29, a two-vCPU UpCloud plan served on one core because
// tinyconfig had SMP off. Refused by name so it can never ship again.
"CONFIG_SMP",
];
/// The [`PID1_BOOT_CONTRACT`] options a `.config` text does NOT set to `y`.
/// Pure over the text so a test can hand it any `.config`.
pub fn pid1_boot_contract_missing(config_text: &str) -> Vec<&'static str> {
let set: std::collections::HashSet<&str> = config_text
.lines()
.filter_map(|l| l.strip_suffix("=y"))
.collect();
PID1_BOOT_CONTRACT.iter().copied().filter(|sym| !set.contains(sym)).collect()
}
/// **Refuse a built kernel that cannot boot a PID-1** — [`PID1_BOOT_CONTRACT`]
/// against the built `.config`. Called by [`verify_built_kernel`] on both the
/// ledger road and the `.kernel-out/` road, so no consumer composes a medium
/// on a kernel that will print `mounts sys=MISSING` at its customer.
pub fn check_pid1_boot_contract(config: &Path, kernel_name: &str, where_from: &str) -> Result<()> {
let text = std::fs::read_to_string(config)
.with_context(|| format!("kernel '{kernel_name}': read the built .config {}", config.display()))?;
let missing = pid1_boot_contract_missing(&text);
if missing.is_empty() {
return Ok(());
}
Err(anyhow!(
"kernel '{kernel_name}' ({where_from}) cannot boot a PID-1: its built .config lacks {} — \
an initramfs PID-1 mounts /proc, /sys and /dev itself and reads its selectors off \
/proc/cmdline, so a medium composed on this kernel dies at `mounts … MISSING` on the \
customer's console (measured 2026-08-27, gunnar-installer-…-nowg.iso). The measured boot \
contract (kconfig/base.config) + the tinyconfig closure (kconfig/deps-tinyconfig.config) declare \
every option in this contract; rebuild the kernel from a fragment that carries them ({})",
missing.iter().map(|s| format!("{s}=y")).collect::<Vec<_>>().join(", "),
config.display()
))
}
/// The [`PID1_BOOT_CONTRACT`] as `.config` text — every option `=y`, one per
/// line. What a fixture that fabricates a built kernel writes as its
/// `kernel.config`, so the fabricated state passes the same gate a real build's
/// output does; a fixture writing `CONFIG_TEST=y` alone is now refused by name.
pub fn pid1_boot_contract_config() -> String {
let mut s = String::new();
for sym in PID1_BOOT_CONTRACT {
s.push_str(sym);
s.push_str("=y\n");
}
s
}
#[cfg(test)]
mod pid1_boot_contract_tests {
use super::*;
/// RED: the exact shape that shipped on 2026-08-27 — a tinyconfig kernel with
/// procfs and sysfs `not set` — is refused, and the refusal names BOTH.
#[test]
fn a_nuked_config_without_sysfs_is_refused_by_name() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("kernel.config");
std::fs::write(
&cfg,
"CONFIG_DEVTMPFS=y\nCONFIG_BLK_DEV_INITRD=y\nCONFIG_BINFMT_ELF=y\n\
# CONFIG_PROC_FS is not set\n# CONFIG_SYSFS is not set\n",
)
.unwrap();
let err = check_pid1_boot_contract(&cfg, "tunnr-kernel", "the directory").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("CONFIG_SYSFS=y") && msg.contains("CONFIG_PROC_FS=y"), "{msg}");
assert!(!msg.contains("CONFIG_DEVTMPFS=y"), "only the MISSING ones are named: {msg}");
assert!(msg.contains("mounts"), "the symptom the operator saw is in the sentence: {msg}");
}
/// GREEN: the contract text passes itself, and `=m` is NOT `=y` — a module
/// an initramfs cannot load is the same absence the whole crate is built
/// around (the AHCI/E1000 lesson), so it must not satisfy the gate.
#[test]
fn the_contract_passes_and_a_module_does_not_count() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("kernel.config");
std::fs::write(&cfg, pid1_boot_contract_config()).unwrap();
check_pid1_boot_contract(&cfg, "k", "here").unwrap();
assert!(pid1_boot_contract_missing(&pid1_boot_contract_config()).is_empty());
let as_module = pid1_boot_contract_config().replace("CONFIG_SYSFS=y", "CONFIG_SYSFS=m");
assert_eq!(pid1_boot_contract_missing(&as_module), vec!["CONFIG_SYSFS"]);
}
/// The tinyconfig closure now DECLARES the two options the shipped kernel
/// lacked, for every preset that starts from tinyconfig (slim, nuked) — so
/// the canary asserts them at build time and the gate above never fires on
/// a kernel this crate's own presets produced.
#[test]
fn every_tinyconfig_preset_declares_procfs_and_sysfs() {
for preset in [Preset::Slim, Preset::Nuked] {
let merged = merge_kconfig(&preset.fragments());
for line in ["CONFIG_PROC_FS=y", "CONFIG_SYSFS=y"] {
assert!(
merged.lines().any(|l| l == line),
"preset {} merges without `{line}`:\n{merged}",
preset.as_str()
);
}
// And the contract as a whole is met by what the presets declare.
let missing = pid1_boot_contract_missing(&merged);
assert!(missing.is_empty(), "preset {} leaves {missing:?} undeclared", preset.as_str());
}
}
}
/// **Verify a ledger hit the way a local build is verified** — the bzImage on
/// disk matches its own pin, the pin's fragment sha is the CHOSEN merge, and
/// the built `.config` carries the [`PID1_BOOT_CONTRACT`].
/// A mismatch anywhere is the same named refusal the local road gives, with
/// `where_from` naming the source (`the ledger` / the directory).
pub fn verify_built_kernel(
got: &ResolvedBuiltKernel,
kernel_name: &str,
expected_fragment_sha256: &str,
chosen_preset: &str,
build_cmd: &str,
where_from: &str,
) -> Result<()> {
let sha = sha256_file_hex(&got.bzimage)?;
if sha != got.pin.bzimage_sha256 {
return Err(anyhow!(
"kernel '{kernel_name}': {} ({where_from}) does not match its own pin (sha256 {sha}, pin \
says {}) — the artifact changed since it was built. Rebuild it: `{build_cmd}`",
got.bzimage.display(),
got.pin.bzimage_sha256
));
}
if expected_fragment_sha256 != got.pin.fragment_sha256 {
return Err(anyhow!(
"kernel '{kernel_name}': the built kernel ({where_from}) is not the CHOSEN kernel — the pin \
was built from preset '{}' (fragment {}), the choice here merges to preset '{}' \
(fragment {expected_fragment_sha256}). Build the chosen one first: `{build_cmd}`",
got.pin.preset,
got.pin.fragment_sha256,
chosen_preset
));
}
check_pid1_boot_contract(&got.config, kernel_name, where_from)?;
Ok(())
}
/// [`verify_built_kernel`] plus the consumers' REQUIREMENTS against the built
/// `.config` — the forward index enforced where a consumer resolves its donor:
/// a required `=y` that `olddefconfig` dropped, or a required `n` that crept
/// back, refuses by symbol · consumer · feature before any medium is composed.
pub fn verify_built_kernel_for(
got: &ResolvedBuiltKernel,
kernel_name: &str,
expected_fragment_sha256: &str,
chosen_preset: &str,
build_cmd: &str,
where_from: &str,
requires: Option<&crate::kernel_requires::KernelRequirements>,
) -> Result<()> {
// THE CLOSURE FIRST (lane AK, 2026-08-30). The fragment sha and the
// closure both refuse the same wrong donor, but they say different things,
// and only one of them is actionable: a `slim` kernel built standalone
// against a `data.fs = xfs` recipe refuses as *"the pin was built from
// preset 'slim' … the choice here merges to preset 'slim'"* — two hashes,
// the same preset name twice, and no word about `/data`. The closure check
// names CONFIG_XFS_FS · the consumer · `data.fs` · the measured reason ·
// the chained build that repairs it. So it is asked BEFORE the pin, and the
// pin refusal stays for what it alone can see (a veto file, an operator's
// decision, a different preset).
if let Some(r) = requires {
let text = std::fs::read_to_string(&got.config)
.with_context(|| format!("kernel '{kernel_name}': read the built .config {}", got.config.display()))?;
r.check_config(&text, kernel_name, where_from, Some(build_cmd))?;
}
verify_built_kernel(got, kernel_name, expected_fragment_sha256, chosen_preset, build_cmd, where_from)?;
Ok(())
}
/// [`resolve_built_kernel`] with a [`KernelLedger`] asked FIRST: the chosen
/// declaration's [`ledger_key_of`] is looked up, a hit is verified exactly as
/// a local build is ([`verify_built_kernel`]) and handed over — no build, no
/// `.kernel-out/` needed; a miss falls back to the donor's local `.kernel-out/`
/// with the four refusals the plain reader has always given.
///
/// The same reader as [`resolve_kernel`], with the
/// [`NeedsBuild`](KernelResolution::NeedsBuild) outcome turned back into the
/// refusal it has always been — a caller with no `--build` refuses by name with
/// the step's own line.
pub fn resolve_built_kernel_with(
donor_root: &Path,
donor_label: &str,
kernel_name: &str,
decision: Option<&crate::kernel_features::KernelDecision>,
ledger: &dyn KernelLedger,
) -> Result<ResolvedBuiltKernel> {
resolve_built_kernel_for(donor_root, donor_label, kernel_name, decision, None, ledger)
}
/// [`resolve_built_kernel_with`] for a consumer that KNOWS what it requires of
/// the kernel — the chosen merge includes the requirements (so the pin the
/// artifact must carry is the one a chained build with these consumers
/// produced) and the built `.config` is checked against them.
pub fn resolve_built_kernel_for(
donor_root: &Path,
donor_label: &str,
kernel_name: &str,
decision: Option<&crate::kernel_features::KernelDecision>,
requires: Option<&crate::kernel_requires::KernelRequirements>,
ledger: &dyn KernelLedger,
) -> Result<ResolvedBuiltKernel> {
match resolve_kernel_for(donor_root, donor_label, kernel_name, decision, requires, ledger)? {
KernelResolution::Built(k) => Ok(k),
KernelResolution::NeedsBuild { why, .. } => Err(anyhow!(why)),
}
}
/// **What the ONE kernel reader answered** — either the built, pin-verified
/// kernel, or the step that would build it and why it is needed.
///
/// "A consumer never builds the donor's kernel" is retired as a DECISION here,
/// not yet as behaviour: the reader no longer decides; it hands the caller a
/// typed [`BuildStep`] whose `Display` is the exact line
/// (`dwarves buildthing tunnr --variant tunnr-kernel --opt kernel.preset=nuked
/// --opt kernel.unix=off`). A caller holding `--build` runs the step; a caller
/// without it refuses by name with `why` — the same line in both.
#[derive(Debug, Clone)]
pub enum KernelResolution {
/// The built kernel, verified against its pin and the chosen merge.
Built(ResolvedBuiltKernel),
/// No acceptable build exists — not built, or built from another choice, or
/// the artifact drifted from its own pin. `step` builds exactly the chosen
/// kernel (its `pin` is what the result must satisfy); `why` is the refusal
/// a door without `--build` speaks, verbatim.
NeedsBuild {
/// The step that repairs it.
step: crate::build_dag::BuildStep,
/// The named refusal, `step`'s line included.
why: String,
},
}
impl KernelResolution {
/// The built kernel, or the refusal — what a door without `--build` does.
pub fn built(self) -> Result<ResolvedBuiltKernel> {
match self {
KernelResolution::Built(k) => Ok(k),
KernelResolution::NeedsBuild { why, .. } => Err(anyhow!(why)),
}
}
}
/// **The step that builds exactly the chosen kernel** — the coordinate
/// `donor_label/kernel_name`, typ kernel, the decision as the door's `kernel.*`
/// flags (the HANDLE, not the `CONFIG_` symbol — a repair the door refuses is
/// no repair), and the pin the result must satisfy. `Display` is the repair
/// line the reader has always spelled.
pub fn kernel_build_step(
donor_label: &str,
kernel_name: &str,
decision: Option<&crate::kernel_features::KernelDecision>,
pin: Option<crate::build_dag::StepPin>,
) -> crate::build_dag::BuildStep {
kernel_build_step_for(donor_label, kernel_name, decision, None, pin)
}
/// [`kernel_build_step`] carrying the consumers' requirements — the step a
/// runner hands the kernel arm, so the build merges what the consumers asked.
pub fn kernel_build_step_for(
donor_label: &str,
kernel_name: &str,
decision: Option<&crate::kernel_features::KernelDecision>,
requires: Option<&crate::kernel_requires::KernelRequirements>,
pin: Option<crate::build_dag::StepPin>,
) -> crate::build_dag::BuildStep {
use crate::build_dag::{BuildStep, Coordinate};
use crate::flags::Flag;
let mut step = BuildStep::new(Coordinate::new(donor_label, kernel_name), crate::variant::ArtifactKind::Kernel);
if let Some(d) = decision {
step.opts.push(Flag::choice("kernel.preset", d.preset.as_str()));
for row in &d.clicked_off {
step.opts.push(Flag::opt(format!("kernel.{}", crate::kernel_features::option_name(row)), false));
}
The file goes on; the console shows all of it.