crates/nornir-build-thing/src/toolchain_archive.rs
//! **The OFFLINE toolchain courier** — a staged OCI archive of the kernel
//! toolchain image, content-addressed, verified before use, and refused BY NAME
//! when it is absent or holds something else (`#14`, lane AO 2026-08-30).
//!
//! # The gap this closes
//!
//! A kernel build compiles airgapped already: the container runs with
//! `--network none` whenever `toolchain_packages` is empty
//! ([`KernelBuild::needs_network`](crate::kernel::KernelBuild::needs_network)),
//! and the tarball plus the kclause formula both ride a content-addressed cache
//! a courier can fill by hand. **The whole remaining gap was the toolchain image
//! itself**: absent from the local store, podman's only remaining move is to
//! reach for a registry — on the one box where reaching out is the failure.
//!
//! So the image travels as BYTES, in the same cache, addressed the same way:
//!
//! ```text
//! <cache_dir>/tarballs/<tarball_sha256>.tar.xz the kernel source (already)
//! <cache_dir>/kclause/<tarball_sha256>.kmax-<v>/ the solver formula (already)
//! <cache_dir>/images/<toolchain_image_sha256>.tar the TOOLCHAIN (this module)
//! ```
//!
//! # ⚠ The correction to the spec — MEASURED, oden 2026-08-30
//!
//! edda `.nornir/kernel-guide.md` (lane AH, §"An OFFLINE kernel build") specified
//! answer **(b)**, an OCI archive, and said it *"makes the committed pin true on
//! every box because the bytes travel rather than the recipe"*. **That is false
//! for the pin the format actually declares.** A `podman save` → `podman load`
//! round trip of `nordisk-kernel-toolchain:live` on this box:
//!
//! ```text
//! format image id (config digest) manifest digest (.Digest)
//! ────────────────────────────────────────────────────────────────────────────
//! on the origin box ce66a0100cb8… f573ca0a86c0…
//! docker-archive → ce66a0100cb8… SAME 5e628ed94b35… CHANGED
//! oci-archive → ac4606f05153… CHANGED 85189a0753c9… CHANGED
//! ```
//!
//! and on the receiving store, asked directly:
//!
//! ```text
//! ce66a0100cb8… EXISTS (bare image id)
//! sha256:ce66a0100cb8… EXISTS
//! nordisk-kernel-toolchain@sha256:f573ca0a… ABSENT ← the committed pin
//! nordisk-kernel-toolchain@sha256:5e628ed9… EXISTS (the RECEIVER's own)
//! ```
//!
//! A **manifest digest does not survive the courier**: podman re-synthesises the
//! manifest on load, so the digest the origin box committed names nothing on the
//! receiving one. The **image id — the config digest — does** survive, through a
//! `docker-archive`, and is a legal image reference (`podman run <id>`).
//!
//! Hence the two departures from the spec, both deliberate:
//!
//! 1. **`docker-archive`, not `oci-archive`.** The OCI conversion rewrites the
//! config too, so it preserves *neither* identity; it is the one format that
//! makes the archive unverifiable against anything the recipe can hold. (It
//! is also what draupnir's `export_image`/`import_image` already speak, so
//! this module adds no second way to move an image — LAW 5.)
//! 2. **The pin that an archive is verified against is the IMAGE ID.** A
//! declaration whose `toolchain_image_sha256` is a per-box MANIFEST digest is
//! refused against a staged archive by name, with the id it should carry
//! instead. This is the same conclusion the guide reached from the other side
//! (*"the fix is to publish the image … or to pin the image's INPUTS rather
//! than a locally-built manifest"*) — pinning the id is the third option, and
//! it is the only one that needs no registry.
//!
//! # The rule, and the failure it refuses to have
//!
//! [`resolve_toolchain`] runs **before the build container is created**, and its
//! whole job is that a missing toolchain can never become a network fetch:
//!
//! | the box has | this module does |
//! |---|---|
//! | the pinned image already | nothing — [`ToolchainSource::AlreadyPresent`] |
//! | a staged archive whose id == the pin | verifies, loads, runs it BY ID |
//! | a staged archive holding something else | [`ARCHIVE_MISMATCH`] — refuses, does **not** load |
//! | neither, and a pin is declared | [`TOOLCHAIN_MISSING`] — refuses, naming the exact path |
//! | neither, and NO pin is declared | nothing: an unpinned image is an online declaration, and this module does not invent a policy the recipe never stated |
//!
//! The last row is the honest limit. `resolve_toolchain` cannot make an
//! *unpinned* build airgapped, because there is no address for a courier to
//! deliver to; it says so via [`ToolchainSource::Unpinned`] and the receipt
//! records which of the five rows a build took.
//!
//! # Producing the archive
//!
//! [`ToolchainImageStore::export_archive`] is the door, and
//! [`stage_toolchain_archive`] is the one-call form that writes the entry under
//! its own content address. A door nobody can execute reads exactly like a law
//! being obeyed — the whole reason `toolchain_images_live.rs` exists — so the
//! export lands with the import in the same commit.
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
/// The subdirectory of the shared kernel cache staged toolchain archives live
/// in — beside `tarballs/` and `kclause/`, same courier, same cache.
pub const TOOLCHAIN_ARCHIVE_DIR: &str = "images";
/// The named refusal when neither the image nor a staged archive is on the box.
/// Grep-able on purpose: the airgap claim is only testable if its failure has a
/// name a test can assert instead of a sentence a rewording can silently change.
pub const TOOLCHAIN_MISSING: &str = "NORDISK-TOOLCHAIN-MISSING";
/// The named refusal when a staged archive holds an image that is not the
/// pinned one. **Never** a fallback: a toolchain that can be silently
/// substituted makes every provenance record that names it worthless.
pub const ARCHIVE_MISMATCH: &str = "NORDISK-TOOLCHAIN-ARCHIVE-MISMATCH";
/// The named refusal when the load reported success and the store still does not
/// have the image — the third door, asserted because an engine that answers
/// "loaded" and an engine that has the image are two different claims.
pub const LOAD_UNCONFIRMED: &str = "NORDISK-TOOLCHAIN-LOAD-UNCONFIRMED";
/// Where a staged toolchain archive for `image_pin` lives inside `cache_dir`.
///
/// Keyed by the declared pin itself — exactly like
/// [`kclause_cache_entry`](crate::kernel::kclause_cache_entry) and the tarball
/// cache — so a courier knows the filename from the recipe alone, with nothing
/// to look up and no index to fall out of date.
pub fn toolchain_archive_entry(cache_dir: &Path, image_pin: &str) -> PathBuf {
cache_dir.join(TOOLCHAIN_ARCHIVE_DIR).join(format!("{image_pin}.tar"))
}
/// A reference that addresses an image by its **id** (config digest). Legal for
/// podman and Docker alike, and — unlike `name@sha256:<manifest digest>` — it
/// still resolves after the image has travelled as an archive.
pub fn image_id_ref(config_digest: &str) -> String {
format!("sha256:{config_digest}")
}
/// What an image archive turns out to hold, read from the archive itself.
///
/// The shape of draupnir's `ImageArchive`, restated here so the dep-light build
/// (and every test fake) can name it without pulling `bollard`/`tar`. The live
/// store fills it straight from `draupnir::container::inspect_image_archive`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StagedImage {
/// Archive size in bytes.
pub bytes: u64,
/// Every `RepoTags` entry in the archive, in archive order. May be empty:
/// an archive can name its content by digest alone.
pub tags: Vec<String>,
/// The image config digest as bare lowercase hex, no `sha256:` prefix —
/// **the image id**, and the one identity a `docker-archive` round trip
/// preserves.
pub config_digest: String,
}
/// Which of the five rows in the module table a build took — recorded on the
/// [`KernelPin`](crate::kernel::KernelPin) so a receipt says how the toolchain
/// got onto the box, not merely which one it claims to be.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolchainSource {
/// The store already had the pinned image; nothing was staged or fetched.
AlreadyPresent,
/// A staged archive was verified and loaded (or was already loaded) — the
/// offline path.
StagedArchive,
/// The declaration pins no image digest, so no archive can be addressed and
/// no offline claim is made.
Unpinned,
/// The runner cannot answer presence questions at all (a fake, a
/// dep-light build). Nothing was checked and nothing is claimed.
Unchecked,
}
impl ToolchainSource {
/// The stable string a receipt carries.
pub fn as_str(self) -> &'static str {
match self {
ToolchainSource::AlreadyPresent => "already-present",
ToolchainSource::StagedArchive => "staged-archive",
ToolchainSource::Unpinned => "unpinned",
ToolchainSource::Unchecked => "unchecked",
}
}
/// Whether this build's toolchain was proven to be on the box before the
/// container was created — i.e. whether the run can be claimed airgapped.
pub fn verified(self) -> bool {
matches!(self, ToolchainSource::AlreadyPresent | ToolchainSource::StagedArchive)
}
}
/// What [`resolve_toolchain`] decided: the reference the run must use, how the
/// toolchain got there, and the archive it came from when it came from one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolchainResolution {
/// The image reference the [`KernelRunRequest`](crate::kernel::KernelRunRequest)
/// must carry. Identical to the declared one on every path but the staged
/// archive, where it becomes the image id (the declared
/// `name@sha256:<manifest digest>` names nothing after the courier).
pub image_ref: String,
/// Which row of the table this was.
pub source: ToolchainSource,
/// The archive that satisfied it, when one did.
pub archive: Option<PathBuf>,
/// The image id actually resolved, when it is known.
pub config_digest: Option<String>,
}
/// **The image-store seam** — presence, inspect, load, export — so the whole
/// decision tree above is drivable by a fake with no daemon, and so the live
/// impl is nothing but four calls into draupnir's existing image verbs.
///
/// Deliberately NOT part of [`KernelContainerRunner`](crate::kernel::KernelContainerRunner):
/// a runner that cannot answer these questions is a legitimate runner (that is
/// the `Unchecked` row), and folding them in with a default `Err` would make
/// every fake in the crate carry a method it has no opinion about.
pub trait ToolchainImageStore {
/// Is this reference resolvable in the local store? `Ok(false)` must be
/// reserved for the engine answering *no such image*; a broken socket is an
/// `Err`, never a `false` — mistaking one for the other is how a build
/// decides to rebuild (or re-fetch) the world.
fn image_present(&self, image_ref: &str) -> Result<bool>;
/// Read an archive's identity **with no daemon** — the check that must
/// happen before any byte is handed to an engine.
fn inspect_archive(&self, path: &Path) -> Result<StagedImage>;
/// Load an archive into the local store, returning what was loaded.
fn load_archive(&self, path: &Path) -> Result<StagedImage>;
/// Write the local image out as a `docker-archive` — the staging door.
fn export_archive(&self, image_ref: &str, dest: &Path) -> Result<StagedImage>;
}
/// **Stage the toolchain for an offline box**: export `image_ref` from this
/// store into `<cache_dir>/images/<image id>.tar`, named by what it actually
/// holds rather than by what the caller hoped for.
///
/// Returns the path written. The caller copies that one file to the airgapped
/// machine's cache, and puts the id (the file's stem) in
/// `toolchain_image_sha256` — the whole recipe, and the reason the entry is
/// named by its content instead of by the exporter's intent.
pub fn stage_toolchain_archive(
store: &dyn ToolchainImageStore,
image_ref: &str,
cache_dir: &Path,
) -> Result<PathBuf> {
if !store.image_present(image_ref)? {
bail!(
"{TOOLCHAIN_MISSING}: cannot stage `{image_ref}` — this box does not have it. \
Build it first (`DraupnirKernelRunner::build_toolchain_image`, or the \
`[[build.image]]` kind), then stage."
);
}
let dir = cache_dir.join(TOOLCHAIN_ARCHIVE_DIR);
std::fs::create_dir_all(&dir)
.map_err(|e| anyhow!("create toolchain archive cache {}: {e}", dir.display()))?;
// Write to a per-process staging name and RENAME on success, the same rule
// the tarball cache follows: a half-written archive must never be able to
// look like a cache entry to a concurrent build.
let staging = dir.join(format!(".stage.{}.part", std::process::id()));
let got = store
.export_archive(image_ref, &staging)
.map_err(|e| {
let _ = std::fs::remove_file(&staging);
// MEASURED 2026-08-30: podman normalises a short name on the way
// OUT (`nordisk-kernel-toolchain` comes back as
// `docker.io/library/nordisk-kernel-toolchain:latest`), and
// draupnir's export refuses to file an archive under a tag the
// archive does not carry — correctly, but the message says nothing
// about normalisation, so the operator reads it as "my image is
// wrong" rather than "spell it the way podman prints it".
anyhow!(
"{e:#}\n\nIf that says the archive carries a DIFFERENT tag than the one you \
asked for, the two are the same image under podman's normalisation: pass the \
reference exactly as `podman images` prints it (repository AND tag, e.g. \
docker.io/library/<name>:latest), not the short name."
)
})?;
if got.config_digest.is_empty() {
let _ = std::fs::remove_file(&staging);
bail!(
"{ARCHIVE_MISMATCH}: the archive exported from `{image_ref}` names no image \
config, so it has no content address and nothing could ever verify it"
);
}
let dest = dir.join(format!("{}.tar", got.config_digest));
std::fs::rename(&staging, &dest)
.map_err(|e| anyhow!("publish toolchain archive {}: {e}", dest.display()))?;
Ok(dest)
}
/// **The preflight.** Decide — before a container is created — which image
/// reference this build runs, and refuse by name rather than let a missing
/// toolchain become a registry fetch.
///
/// `declared_ref` is
/// [`KernelBuild::toolchain_image_ref`](crate::kernel::KernelBuild::toolchain_image_ref),
/// `image_name` and `pin` are the two halves it is built from, and `kernel_name`
/// is only used to make the refusals name the row an operator has to edit.
pub fn resolve_toolchain(
store: Option<&dyn ToolchainImageStore>,
declared_ref: &str,
image_name: &str,
pin: &str,
cache_dir: &Path,
kernel_name: &str,
) -> Result<ToolchainResolution> {
let unchecked = |source| ToolchainResolution {
image_ref: declared_ref.to_string(),
source,
archive: None,
config_digest: None,
};
let Some(store) = store else {
return Ok(unchecked(ToolchainSource::Unchecked));
};
if pin.is_empty() {
// Nothing to address an archive by. An unpinned image is an ONLINE
// declaration by construction (`debian:trixie` resolves by pulling), and
// inventing a refusal here would break every such build for a promise
// the recipe never made. Said out loud on the receipt instead.
return Ok(unchecked(ToolchainSource::Unpinned));
}
let archive = toolchain_archive_entry(cache_dir, pin);
let by_id = image_id_ref(pin);
if archive.is_file() {
// 1. WITH NO DAEMON: what does this file actually hold?
let held = store
.inspect_archive(&archive)
.map_err(|e| anyhow!("kernel '{kernel_name}': staged toolchain archive {}: {e:#}", archive.display()))?;
// 2. Is it the pinned toolchain? Measured: a docker-archive round trip
// preserves the image id and NOT the manifest digest, so this is the
// one comparison that can be true on a box that did not mint it.
if held.config_digest != pin {
bail!("{}", mismatch_refusal(kernel_name, &archive, pin, image_name, &held));
}
// 3. Load only if it is not already there — a 1.2 GB load per build is
// a courier bill nobody asked for.
let already = store.image_present(&by_id)?;
if !already {
let loaded = store.load_archive(&archive)?;
if !loaded.config_digest.is_empty() && loaded.config_digest != pin {
bail!(
"{ARCHIVE_MISMATCH}: kernel '{kernel_name}': the archive {} inspected as \
image id {pin} but the engine says it loaded {}. Two readers of one file \
disagree; nothing here is safe to build with.",
archive.display(),
loaded.config_digest
);
}
// A DIFFERENT door than the one that just claimed success: "the
// load streamed cleanly" and "the store has the image" are two
// claims, and only the second one is the one the build needs.
if !store.image_present(&by_id)? {
bail!(
"{LOAD_UNCONFIRMED}: kernel '{kernel_name}': loaded {} and the engine then \
said it has no image `{by_id}`. The archive is not usable on this box; \
re-stage it.",
archive.display()
);
}
}
return Ok(ToolchainResolution {
image_ref: by_id,
source: ToolchainSource::StagedArchive,
archive: Some(archive),
config_digest: Some(pin.to_string()),
});
}
// No archive. The image may still be here — either under the declared
// reference (the box that minted the pin) or under the pin read as an id.
if store.image_present(declared_ref)? {
return Ok(ToolchainResolution {
image_ref: declared_ref.to_string(),
source: ToolchainSource::AlreadyPresent,
archive: None,
config_digest: None,
});
}
if store.image_present(&by_id)? {
return Ok(ToolchainResolution {
image_ref: by_id,
source: ToolchainSource::AlreadyPresent,
archive: None,
config_digest: Some(pin.to_string()),
});
}
bail!("{}", missing_refusal(kernel_name, &archive, declared_ref, image_name, pin));
}
/// The [`TOOLCHAIN_MISSING`] sentence. Split out so a guard can render it
/// without a store, and so the wording lives in exactly one place.
pub fn missing_refusal(
kernel_name: &str,
archive: &Path,
declared_ref: &str,
image_name: &str,
pin: &str,
) -> String {
let name = if image_name.is_empty() { "<the pinned image>" } else { image_name };
format!(
"{TOOLCHAIN_MISSING}: kernel '{kernel_name}': the toolchain image `{declared_ref}` is \
not in this box's image store, and no staged archive is either.\n\
\n\
The build STOPS here on purpose. The alternative is for the container engine to go \
and fetch a toolchain, which is exactly what an airgapped build may not do — and \
what makes an airgap claim untestable when it happens quietly.\n\
\n\
Two ways forward:\n\
\n\
\x20 OFFLINE — stage the archive (the answer this cache exists for):\n\
\x20 on a box that HAS the image:\n\
\x20 podman save --format docker-archive -o <id>.tar {name}\n\
\x20 # <id> = podman image inspect --format '{{{{.Id}}}}' {name}\n\
\x20 copy it to this box as:\n\
\x20 {archive}\n\
\x20 and pin `toolchain_image_sha256 = \"<id>\"` for the [[build.kernel]] row\n\
\x20 named '{kernel_name}' — in the repo's `*-build-thing` CODE, which is the\n\
\x20 one writer; the .nornir/build-variants.toml is EMITTED from it (re-run\n\
\x20 `cargo run -p <repo>-build-thing -- emit`), so a hand edit there is\n\
\x20 reverted by the next emit and fails the parity gate meanwhile.\n\
\x20 MEASURED: an image id survives a save/load round trip and a manifest\n\
\x20 digest does NOT, so the id is the only 64 hex characters that mean the\n\
\x20 same image on two machines. It is still a MINTING EVENT, not a property\n\
\x20 of the recipe: two mints of one Containerfile 90 s apart on one box give\n\
\x20 two different ids (lane AT, 2026-08-30).\n\
\n\
\x20 ONLINE — mint the image on this box and re-pin:\n\
\x20 DraupnirKernelRunner::build_toolchain_image(<ctx>, \"{name}\")\n\
\n\
(the declaration currently pins {pin})",
archive = archive.display(),
)
}
/// The [`ARCHIVE_MISMATCH`] sentence — the one an operator reads when the
/// committed pin is a per-box MANIFEST digest, which is the state every
/// declaration in the constellation was in on 2026-08-30.
pub fn mismatch_refusal(
kernel_name: &str,
archive: &Path,
pin: &str,
image_name: &str,
held: &StagedImage,
) -> String {
let name = if image_name.is_empty() { "<the pinned image>" } else { image_name };
let tags = if held.tags.is_empty() { "<none>".to_string() } else { held.tags.join(", ") };
format!(
"{ARCHIVE_MISMATCH}: kernel '{kernel_name}': the staged toolchain archive\n\
\x20 {archive}\n\
holds image id {got} (tags: {tags}), but this declaration pins {pin}.\n\
\n\
Refusing to load it. A toolchain that can be swapped for another one without \
anybody noticing makes every provenance record naming it worthless, so this is a \
stop and never a warning.\n\
\n\
If {pin} is a MANIFEST DIGEST, that is the bug, and it is not yours: a manifest \
digest is a PER-BOX fact that does not survive the courier. Measured on oden \
2026-08-30, `podman save --format docker-archive` + `podman load` of \
nordisk-kernel-toolchain:\n\
\x20 image id ce66a0100cb8… -> ce66a0100cb8… SAME\n\
\x20 manifest digest f573ca0a86c0… -> 5e628ed94b35… CHANGED\n\
so `{name}@sha256:<manifest digest>` names nothing on the receiving box while the \
id resolves. Pin the ID:\n\
\x20 toolchain_image_sha256 = \"{got}\"\n\
for the [[build.kernel]] row named '{kernel_name}', in the repository's \
*-build-thing CODE — the one writer. Do NOT edit .nornir/build-variants.toml: \
it is EMITTED from that code and held byte-identical by a parity gate, so a hand \
edit is reverted by the next `cargo run -p <repo>-build-thing -- emit` and fails \
the gate meanwhile.",
archive = archive.display(),
got = held.config_digest,
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
/// The pin used everywhere below — 64 lowercase hex, the shape a real image
/// id has.
const ID: &str = "ce66a0100cb84cd83aab99c0d37648e82ad4ef80bf4a536c736574d1ef7c61f0";
/// A DIFFERENT 64 hex characters, standing in for the per-box manifest
/// digest the constellation actually commits today.
const MANIFEST: &str = "f573ca0a86c020810baff6085b65408cf38c35b045ce45919e106eb6bb005146";
/// A store with a scripted local image set, recording every call so a test
/// can assert what did NOT happen (that a mismatched archive was never
/// loaded is the whole point, and only a call log can say it).
#[derive(Default)]
struct FakeStore {
present: RefCell<Vec<String>>,
held: RefCell<Option<StagedImage>>,
calls: RefCell<Vec<String>>,
inspect_fails: bool,
load_lies: bool,
}
impl FakeStore {
fn with_image(refs: &[&str]) -> Self {
FakeStore {
present: RefCell::new(refs.iter().map(|s| s.to_string()).collect()),
..Default::default()
}
}
fn holding(mut self, digest: &str) -> Self {
self.held = RefCell::new(Some(StagedImage {
bytes: 42,
tags: vec!["nordisk-kernel-toolchain:live".into()],
config_digest: digest.into(),
}));
self
}
fn calls(&self) -> Vec<String> {
self.calls.borrow().clone()
}
}
impl ToolchainImageStore for FakeStore {
fn image_present(&self, image_ref: &str) -> Result<bool> {
self.calls.borrow_mut().push(format!("present({image_ref})"));
Ok(self.present.borrow().iter().any(|r| r == image_ref))
}
fn inspect_archive(&self, path: &Path) -> Result<StagedImage> {
self.calls.borrow_mut().push(format!("inspect({})", path.display()));
if self.inspect_fails {
bail!("not an image archive: no manifest.json at the tar root");
}
self.held
.borrow()
.clone()
.ok_or_else(|| anyhow!("fake store holds no archive identity"))
}
fn load_archive(&self, path: &Path) -> Result<StagedImage> {
self.calls.borrow_mut().push(format!("load({})", path.display()));
let held = self
.held
.borrow()
.clone()
.ok_or_else(|| anyhow!("fake store holds no archive identity"))?;
if !self.load_lies {
self.present.borrow_mut().push(image_id_ref(&held.config_digest));
}
Ok(held)
}
fn export_archive(&self, image_ref: &str, dest: &Path) -> Result<StagedImage> {
self.calls.borrow_mut().push(format!("export({image_ref} -> {})", dest.display()));
let held = self
.held
.borrow()
.clone()
.ok_or_else(|| anyhow!("fake store holds no archive identity"))?;
std::fs::write(dest, b"a docker-archive would be here").unwrap();
Ok(held)
}
}
fn cache() -> tempfile::TempDir {
tempfile::tempdir().expect("cache dir")
}
fn stage(dir: &Path, pin: &str) -> PathBuf {
let p = toolchain_archive_entry(dir, pin);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, b"pretend this is 1.2 GB of docker-archive").unwrap();
p
}
/// ★ **THE RED the whole item exists for**: no image, no archive, and the
/// build stops with a name instead of quietly asking a registry.
///
/// The assertion is on the REFUSAL's content, not on `is_err()`: a build
/// that fails for a different reason is not this guard passing.
#[test]
fn a_missing_toolchain_and_a_missing_archive_refuse_by_name() {
let dir = cache();
let store = FakeStore::with_image(&[]);
let err = resolve_toolchain(
Some(&store),
&format!("nordisk-kernel-toolchain@sha256:{MANIFEST}"),
"nordisk-kernel-toolchain",
MANIFEST,
dir.path(),
"lil-kernel",
)
.expect_err("no image and no archive must refuse");
let msg = format!("{err:#}");
assert!(msg.contains(TOOLCHAIN_MISSING), "{msg}");
// It names the EXACT path a courier must fill…
assert!(
msg.contains(&toolchain_archive_entry(dir.path(), MANIFEST).display().to_string()),
"the refusal must name the path, not describe one:\n{msg}"
);
// …the runnable command that produces it…
assert!(msg.contains("podman save --format docker-archive"), "{msg}");
// …and the row to edit.
assert!(msg.contains("lil-kernel") && msg.contains("toolchain_image_sha256"), "{msg}");
// Nothing was fetched, because nothing CAN be: only presence was asked.
assert!(
store.calls().iter().all(|c| c.starts_with("present(")),
"the preflight must not touch the archive when there is none: {:?}",
store.calls()
);
}
/// ★ **The GREEN under the same denial**: the identical call, with the
/// archive staged, resolves — and the reference it hands back is the ID,
/// because the declared `name@sha256:<manifest>` is the thing that does not
/// survive the courier.
#[test]
fn a_staged_archive_satisfies_the_same_build_the_missing_one_refused() {
let dir = cache();
let archive = stage(dir.path(), ID);
let store = FakeStore::with_image(&[]).holding(ID);
let r = resolve_toolchain(
Some(&store),
&format!("nordisk-kernel-toolchain@sha256:{ID}"),
"nordisk-kernel-toolchain",
ID,
dir.path(),
"lil-kernel",
)
.expect("a staged, matching archive is the offline path");
assert_eq!(r.source, ToolchainSource::StagedArchive);
assert!(r.source.verified());
assert_eq!(r.image_ref, format!("sha256:{ID}"), "the run must address the image by ID");
assert_eq!(r.archive.as_deref(), Some(archive.as_path()));
assert_eq!(r.config_digest.as_deref(), Some(ID));
// Inspected BEFORE loaded, and confirmed through a second presence call
// AFTER the load — the load's own "ok" is not the evidence.
let calls = store.calls();
let inspect = calls.iter().position(|c| c.starts_with("inspect(")).expect("inspected");
let load = calls.iter().position(|c| c.starts_with("load(")).expect("loaded");
assert!(inspect < load, "the archive must be read before it is handed to an engine: {calls:?}");
assert!(
calls.iter().skip(load).any(|c| c.starts_with("present(")),
"the load must be confirmed through a different door: {calls:?}"
);
}
/// ★ The failure mode this must not have: a staged archive holding a
/// DIFFERENT image is refused, and — asserted through the call log — is
/// never handed to the engine.
///
/// This is also the state every declaration in the constellation is in
/// today, because the committed pin is a manifest digest: the refusal has
/// to say so and hand over the id to pin instead, or it is a dead end.
#[test]
fn an_archive_that_is_not_the_pinned_toolchain_is_refused_and_never_loaded() {
let dir = cache();
stage(dir.path(), MANIFEST);
// The file staged under the manifest-digest name holds, as it must, an
// image whose id is something else entirely.
let store = FakeStore::with_image(&[]).holding(ID);
let err = resolve_toolchain(
Some(&store),
&format!("nordisk-kernel-toolchain@sha256:{MANIFEST}"),
"nordisk-kernel-toolchain",
MANIFEST,
dir.path(),
"lil-kernel",
)
.expect_err("an archive holding another image must refuse");
let msg = format!("{err:#}");
assert!(msg.contains(ARCHIVE_MISMATCH), "{msg}");
assert!(msg.contains(ID) && msg.contains(MANIFEST), "both digests must be named:\n{msg}");
// The measured explanation, not a shrug.
assert!(msg.contains("manifest digest") && msg.contains("CHANGED"), "{msg}");
assert!(msg.contains(&format!("toolchain_image_sha256 = \"{ID}\"")), "{msg}");
assert!(
!store.calls().iter().any(|c| c.starts_with("load(")),
"a mismatched archive must never reach the engine: {:?}",
store.calls()
);
}
/// A store that says "loaded" while the image is still not there must not
/// pass. Without this, the whole preflight rests on one engine's word about
/// its own success.
#[test]
fn a_load_that_does_not_produce_the_image_is_caught() {
let dir = cache();
stage(dir.path(), ID);
let mut store = FakeStore::with_image(&[]).holding(ID);
store.load_lies = true;
let err = resolve_toolchain(
Some(&store),
&format!("nordisk-kernel-toolchain@sha256:{ID}"),
"nordisk-kernel-toolchain",
ID,
dir.path(),
"lil-kernel",
)
.expect_err("a load the store cannot confirm must refuse");
assert!(format!("{err:#}").contains(LOAD_UNCONFIRMED), "{err:#}");
}
/// The image is already here: no archive is needed, none is looked for, and
/// the declared reference is used unchanged. (The row that keeps every
/// existing build byte-identical.)
#[test]
fn an_image_already_on_the_box_needs_no_courier() {
let dir = cache();
let declared = format!("nordisk-kernel-toolchain@sha256:{MANIFEST}");
let store = FakeStore::with_image(&[&declared]);
let r = resolve_toolchain(
Some(&store),
&declared,
"nordisk-kernel-toolchain",
MANIFEST,
dir.path(),
"lil-kernel",
)
.expect("the box that minted the pin still builds");
assert_eq!(r.source, ToolchainSource::AlreadyPresent);
assert_eq!(r.image_ref, declared);
assert!(r.archive.is_none());
}
/// ★ **The row a RE-PINNED declaration takes on the box that minted it** —
/// the one thing that has to hold before anybody changes a
/// `toolchain_image_sha256` from a manifest digest to an image id.
///
/// After a re-pin the declared reference becomes
/// `name@sha256:<image id>`, and that spelling resolves to **nothing**: a
/// `name@sha256:` reference names a MANIFEST digest, so podman reads an id
/// there as a registry pull (measured 2026-08-23, the diagnosis recorded on
/// [`KernelBuild::toolchain_image_ref`](crate::kernel::KernelBuild::toolchain_image_ref)).
/// The minting box must therefore be carried by the *second* presence
/// question — the pin read as an id — or a re-pin would break the builds of
/// the very box that produced the image, with no archive anywhere to explain
/// why.
///
/// Watched red 2026-08-30 by deleting the `image_present(&by_id)` arm:
/// `NORDISK-TOOLCHAIN-MISSING`, on a box holding the image.
#[test]
fn a_declaration_repinned_to_an_image_id_still_builds_on_the_box_that_minted_it() {
let dir = cache();
// What the recipe composes after the re-pin — and what podman cannot
// resolve. The store deliberately does NOT hold this spelling.
let declared = format!("nordisk-kernel-toolchain@sha256:{ID}");
let store = FakeStore::with_image(&[&image_id_ref(ID)]);
let r = resolve_toolchain(
Some(&store),
&declared,
"nordisk-kernel-toolchain",
ID,
dir.path(),
"lil-kernel",
)
.expect("the minting box has the image under its id and must still build");
assert_eq!(r.source, ToolchainSource::AlreadyPresent);
assert_eq!(
r.image_ref,
image_id_ref(ID),
"the run must be handed the ID, not the `name@sha256:<id>` the declaration \
composes — that one is a registry pull, which is the failure this whole module \
refuses to have"
);
assert_eq!(r.config_digest.as_deref(), Some(ID));
assert!(r.archive.is_none(), "nothing was staged: {:?}", r.archive);
// And the declared spelling really was asked for and really did answer
// no — otherwise this test passes on a store that answers `true` to
// everything and proves nothing.
assert!(
store.calls().iter().any(|c| c.contains(&declared)),
"the declared reference must be tried FIRST: {:?}",
store.calls()
);
}
/// An unpinned declaration is an ONLINE one and this module says so rather
/// than inventing a refusal the recipe never asked for. The identity-value
/// trap in reverse: a `verified()` that answered `true` here would make
/// every unpinned build look airgapped.
#[test]
fn an_unpinned_declaration_makes_no_offline_claim() {
let dir = cache();
let store = FakeStore::with_image(&[]);
let r = resolve_toolchain(
Some(&store),
"docker.io/library/debian:trixie",
"docker.io/library/debian",
"",
dir.path(),
"lil-kernel",
)
.expect("an unpinned image is not this module's business");
assert_eq!(r.source, ToolchainSource::Unpinned);
assert!(!r.source.verified(), "an unpinned build may never read as airgapped");
assert_eq!(r.image_ref, "docker.io/library/debian:trixie");
assert!(store.calls().is_empty(), "nothing to ask: {:?}", store.calls());
}
/// No store at all (a fake runner, a dep-light build): nothing is checked
/// and — critically — nothing is CLAIMED. `Unchecked.verified()` is false.
#[test]
fn no_store_means_no_claim() {
let dir = cache();
let r = resolve_toolchain(None, "whatever:latest", "whatever", ID, dir.path(), "k")
.expect("a runner with no image store still builds");
assert_eq!(r.source, ToolchainSource::Unchecked);
assert!(!r.source.verified());
assert_eq!(r.image_ref, "whatever:latest");
}
/// An unreadable archive is a refusal that names the file, not a fall
/// through to a fetch. (draupnir's `inspect_image_archive` is what
/// distinguishes truncated from not-an-archive; this asserts the path the
/// fault travels, not the fault taxonomy, which is draupnir's own test.)
#[test]
fn an_unreadable_archive_refuses_naming_the_file() {
let dir = cache();
let archive = stage(dir.path(), ID);
let mut store = FakeStore::with_image(&[]).holding(ID);
store.inspect_fails = true;
let err = resolve_toolchain(
Some(&store),
"x",
"nordisk-kernel-toolchain",
ID,
dir.path(),
"lil-kernel",
)
.expect_err("an archive that cannot be read is not a green");
let msg = format!("{err:#}");
assert!(msg.contains(&archive.display().to_string()), "{msg}");
assert!(msg.contains("no manifest.json"), "{msg}");
assert!(!store.calls().iter().any(|c| c.starts_with("load(")), "{:?}", store.calls());
}
/// The staging door writes the entry under the ID it actually exported —
/// never under the name the caller passed, which is how an archive ends up
/// filed as something it is not.
#[test]
fn staging_files_the_archive_under_what_it_holds() {
let dir = cache();
let store = FakeStore::with_image(&["nordisk-kernel-toolchain:live"]).holding(ID);
let p = stage_toolchain_archive(&store, "nordisk-kernel-toolchain:live", dir.path())
.expect("stage");
assert_eq!(p, toolchain_archive_entry(dir.path(), ID));
assert!(p.is_file());
// And the staging name is gone: no `.part` may survive a success.
let leftovers: Vec<_> = std::fs::read_dir(dir.path().join(TOOLCHAIN_ARCHIVE_DIR))
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains(".part"))
.collect();
assert!(leftovers.is_empty(), "{leftovers:?}");
}
/// Staging an image the box does not have refuses by name instead of
/// writing a zero-byte file that later "verifies" as present.
#[test]
fn staging_an_absent_image_refuses() {
let dir = cache();
let store = FakeStore::with_image(&[]);
let err = stage_toolchain_archive(&store, "nordisk-kernel-toolchain:live", dir.path())
.expect_err("nothing to stage");
assert!(format!("{err:#}").contains(TOOLCHAIN_MISSING), "{err:#}");
assert!(!dir.path().join(TOOLCHAIN_ARCHIVE_DIR).join("x").exists());
}
}