crates/nornir-build-thing/src/assemble.rs
//! The unified **`assemble()` seam** (design §f7) — the ONE polymorphic entry
//! point that turns a declared [`BuildVariant`] into a single content-addressed
//! **receipt** ([`Assembled`]).
//!
//! f1 declares *what can be built*; f5 adds *what kind of thing* it is; f3 records
//! *what happened*; build-info streams *the record about it*. **f7 composes them**:
//! [`assemble`] reads a chosen variant, dispatches on its
//! [`ArtifactKind`](crate::variant::ArtifactKind), drives the matching *existing*
//! builder, and hands back ONE value binding the three halves — the produced thing
//! ([`ArtifactOutput`]), the proof ([`AssembledProof`]), and the build-info
//! lineage ([`crate::build_info::BuildInfoRow`]s carrying each artifact's
//! `content_id`).
//!
//! # Invents nothing (REUSE-FIRST)
//!
//! | f7 needs | reuses |
//! |---|---|
//! | dispatch discriminant | [`ArtifactKind`](crate::variant::ArtifactKind) + [`output_of`](crate::output::output_of) |
//! | the actual build | [`build_variant_with_info`](crate::provenance::build_variant_with_info) / [`build_container`](crate::image_build::build_container) / [`build_vm`](crate::image_build::build_vm) |
//! | proof | [`Attestation`](crate::provenance::Attestation) / [`TargetAttestation`](crate::image_build::TargetAttestation) |
//! | lineage | [`manifest_rows`](crate::build_info::manifest_rows) / [`target_manifest_rows`](crate::build_info::target_manifest_rows) / [`build_feed_rows`](crate::build_info::build_feed_rows) |
//!
//! # The composite payoff (the fat/thin axis, made executable)
//!
//! A fat variant that `embeds` a sibling (or a container that chains a cargo
//! `from_variant` layer) declares a sub-build DAG. [`assemble`] **topo-assembles**
//! the parts (the embedded lib first), threads each sub-artifact's `content_id`
//! into the parent's `dep` rows — so *"which artifact fed which build"* is a join,
//! not a guess — and rejects a cycle with a clear error.
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;
use std::sync::Mutex;
use std::time::Instant;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::analyze::ArtifactAnalyzer;
use crate::build_info::{
build_feed_rows, manifest_rows, now_micros, target_manifest_rows, BuildInfoKind, BuildInfoRow,
BuildInfoSink, NullBuildInfoSink,
};
use crate::container::ContainerBuild;
use crate::image_build::{
build_container as image_build_container, build_iso as image_build_iso,
build_vm as image_build_vm, ImageBuilder, IsoImageBuilder, TargetAttestation,
TargetBuildOutcome, VmImageBuilder,
};
use crate::iso::IsoBuild;
use crate::output::{output_of, ArtifactOutput};
use crate::provenance::{
build_variant_with_info, Attestation, BuildOutcome, BuildProvenance, Outcome, Toolchain,
};
use crate::variant::{ArtifactKind, BuildVariant, BuildVariantSet};
use crate::vm::VmBuild;
/// The **proof** half of an [`Assembled`] receipt — the smallest union that keeps
/// BOTH attestation flavours whole. A cargo build (Exe / Lib / Wasm / Service)
/// yields a [`crate::provenance::Attestation`]; an image target (Container / Vm)
/// yields a [`crate::image_build::TargetAttestation`]. Neither is lossily flattened
/// into the other — each seals over its own produced-artifact digest.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AssembledProof {
/// A cargo-build attestation (Exe / Lib / Wasm / Service).
Build(Attestation),
/// An image/VM target attestation (Container / Vm).
Target(TargetAttestation),
}
impl AssembledProof {
/// Lowercase-hex form of the canonical digest, whichever flavour.
pub fn digest_hex(&self) -> String {
match self {
AssembledProof::Build(a) => a.digest_hex(),
AssembledProof::Target(a) => a.digest_hex(),
}
}
/// True once a real cryptographic seal has been applied.
pub fn is_sealed(&self) -> bool {
match self {
AssembledProof::Build(a) => a.is_sealed(),
AssembledProof::Target(a) => a.is_sealed(),
}
}
/// Recompute the digest from the record and confirm it still matches (the
/// tamper check — a mutated artifact hash breaks this).
pub fn digest_matches_record(&self) -> Result<bool> {
match self {
AssembledProof::Build(a) => a.digest_matches_record(),
AssembledProof::Target(a) => a.digest_matches_record(),
}
}
/// The provenance record id — the `build_id` its lineage rows are keyed by.
pub fn build_id(&self) -> &str {
match self {
AssembledProof::Build(a) => &a.record.id,
AssembledProof::Target(a) => &a.record.id,
}
}
/// The [`ArtifactKind`] this proof attests to.
pub fn kind(&self) -> ArtifactKind {
match self {
AssembledProof::Build(a) => a.record.variant.kind,
AssembledProof::Target(a) => a.record.kind,
}
}
/// **Did the build this proof attests to actually succeed?**
///
/// Read off the sealed record's own `outcome`, whichever arm — not off a flag a
/// caller carried alongside it and not off whether [`assemble`] returned `Ok`.
/// `Ok` from [`assemble`] means *the build reached a terminal outcome and here is
/// its record*: a red cargo build has always returned `Ok` this way, and a red
/// container / ISO / VM build now does too, which is what lets a failure be
/// filed. A caller that renders or files a receipt must therefore ask THIS rather
/// than assume, and there is exactly one thing to ask so the two arms cannot
/// diverge again.
pub fn outcome(&self) -> Outcome {
match self {
AssembledProof::Build(a) => a.record.outcome,
AssembledProof::Target(a) => a.record.outcome,
}
}
/// **The one record a finished build FILES, whichever flavour it is.**
///
/// The warehouse holds a single build-record ledger (`build_provenance`) fed by a
/// single submit, so a producer needs one shape for both arms of this enum. A
/// cargo build hands over its record untouched; an image / ISO / VM build is
/// projected through
/// [`TargetProvenance::to_build_provenance`](crate::image_build::TargetProvenance::to_build_provenance).
///
/// This exists so a caller cannot get it half-right: before it, the only way to
/// file a target build was to reach into the `Build` arm and drop the `Target`
/// one, which is precisely what left ⚒ Build Thing's records pane empty while
/// containers and ISOs built perfectly well.
///
/// `variant` is the DECLARED cell — the target record carries the descriptor's
/// name, not the build point's — and is ignored for the cargo arm, whose record
/// already holds its own.
pub fn as_build_provenance(&self, variant: &BuildVariant) -> BuildProvenance {
match self {
AssembledProof::Build(a) => a.record.clone(),
AssembledProof::Target(a) => a.record.to_build_provenance(variant),
}
}
}
/// **What the dispatched builder actually reported**, carried out of [`dispatch`] so
/// the receipt can be ASKED whether it produced anything.
///
/// It exists because [`crate::image_build::capture_target_build`] deliberately RECORDS
/// a builder failure instead of propagating it — a red target build has to return `Ok`
/// so its provenance can still be filed. That contract is right, and until 2026-08-05
/// it had no second half: the three target arms of [`dispatch`] read
/// `TargetBuildOutcome::success` only to fill a feed row and dropped
/// `TargetBuildOutcome::error` on the floor. The reason a build failed was constructed,
/// formatted and then thrown away inside this crate, so no caller could print it and
/// none did.
///
/// MEASURED, gunnar's `gunnar-appliance-iso`, 2026-08-05: two runs, 38.5 s and 0.9 s,
/// two different digests, `BUILT` and exit 0 both times, and no `.iso` written by
/// either. The composer had failed on its first file and said so into this value.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProducedVerdict {
/// The builder's OWN error text when it failed — `None` on a green build.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// WHERE the produced thing was expected: a mastered ISO's output path, an image
/// reference, the binaries a cargo cell declares. Named by the arm that dispatched
/// the build, because that is the only code that knows — and printed by
/// [`Assembled::produced`] when nothing came out, so "produced nothing" is a
/// sentence with a path in it rather than a count.
#[serde(default)]
pub expected: String,
}
/// The **content-addressed receipt** [`assemble`] returns: ONE value binding the
/// produced thing, the proof, and the build-info lineage. "What I built" and
/// "proof + lineage of what I built" become a single receipt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Assembled {
/// WHAT — the produced artifact's output descriptor (kind-tagged).
pub output: ArtifactOutput,
/// PROOF — the (unsealed) attestation, folded to a canonical digest.
pub attestation: AssembledProof,
/// The build-info record ABOUT it — the `provenance` / `dep` / `artifact` +
/// live-feed rows, keyed by `build_id`, each artifact row carrying its
/// `content_id` (so lineage graphs across composite builds).
pub lineage: Vec<BuildInfoRow>,
/// WHETHER it produced anything, and what it was supposed to produce. Read it
/// through [`Assembled::produced`], never field by field.
#[serde(default)]
pub verdict: ProducedVerdict,
}
impl Assembled {
/// The [`ArtifactKind`] of the produced output.
pub fn kind(&self) -> ArtifactKind {
self.output.kind()
}
/// The canonical digest of the proof, lowercase-hex.
pub fn digest_hex(&self) -> String {
self.attestation.digest_hex()
}
/// **Did this build succeed?** Taken from the sealed record
/// ([`AssembledProof::outcome`]), which is the only thing that knows.
///
/// A receipt exists for a red build as well as a green one — that is the point of
/// it — so a UI that reports `success: true` because it holds a receipt is
/// reporting that it holds a receipt.
pub fn success(&self) -> bool {
self.attestation.outcome() == Outcome::Success
}
/// The `content_id`s (sha256 CAS ids) of every produced `artifact` row in the
/// lineage — the keys a parent build threads into its `dep` rows.
pub fn artifact_content_ids(&self) -> Vec<String> {
self.lineage
.iter()
.filter(|r| r.kind == BuildInfoKind::Artifact.as_str())
.map(|r| r.content_id.clone())
.filter(|c| !c.is_empty())
.collect()
}
/// The PATHS of every produced `artifact` row — the same rows
/// [`artifact_content_ids`](Self::artifact_content_ids) counts, read by their key
/// instead of their value, so a consumer can `stat` what the build says it wrote
/// rather than believe the count. Relative to the repo root when the file lives
/// under it (see [`crate::provenance::Artifact::path`]).
pub fn artifact_paths(&self) -> Vec<String> {
self.lineage
.iter()
.filter(|r| r.kind == BuildInfoKind::Artifact.as_str())
.map(|r| r.name.clone())
.filter(|p| !p.is_empty())
.collect()
}
/// **Did this receipt produce a thing?** — the ONE writer for that question, and
/// the answer every consumer of [`assemble`] must key on instead of on holding a
/// receipt at all.
///
/// Two reds, and they are different diagnoses:
///
/// * the builder FAILED, and its own words are in
/// [`ProducedVerdict::error`] — the value that used to be discarded inside this
/// crate, which is why `dwarves buildthing gunnar --atom gunnar-appliance-iso`
/// printed `BUILT … — 0 artifact(s)` and exited 0 over a composer that had
/// bailed on its first step;
/// * the builder reported SUCCESS and produced NOTHING. The artifact list is the
/// only evidence a build ever offers, so an empty one is a red — named with
/// [`ProducedVerdict::expected`], because "0 artifacts" is a number and
/// "no `<repo>/.iso-out/gunnar-appliance-iso.iso`" is a place to look.
///
/// A `lib` / `wasm` cell produces a real rlib cargo never reports as an
/// executable ([`ArtifactKind::produces_no_executable`]), and a `container`
/// image lives in the engine's store with no path to stat
/// ([`ArtifactKind::product_is_not_a_file`]) — for those the empty list is what a
/// perfectly good build looks like and only the first red applies. Asking the
/// KIND is what tells "nothing came out" apart from "nothing was ever going to".
/// An `iso` is neither: the media IS the artifact, and it is a file.
pub fn produced(&self) -> Result<()> {
if let Some(e) = &self.verdict.error {
bail!(
"{} '{}' FAILED and produced nothing: {e}",
self.kind().as_str(),
self.output.name(),
);
}
if !self.success() {
bail!(
"{} '{}' did not succeed (outcome {:?}) — no artifact was produced",
self.kind().as_str(),
self.output.name(),
self.attestation.outcome(),
);
}
// The two kinds for which an empty artifact list is what a GOOD build looks
// like, asked of the discriminant rather than listed here (L5): a `lib` /
// `wasm` cell produces a real rlib cargo never reports as an executable, and
// a `container` image lives in the engine's store with no path to stat. An
// `iso` is neither — the media IS the artifact, and it is a file.
if self.artifact_content_ids().is_empty()
&& !self.kind().produces_no_executable()
&& !self.kind().product_is_not_a_file()
{
bail!(
"{} '{}' reported SUCCESS and produced NO artifact — expected {}. \
A build that wrote nothing is not a build that succeeded.",
self.kind().as_str(),
self.output.name(),
if self.verdict.expected.is_empty() {
"an artifact this build point declares"
} else {
self.verdict.expected.as_str()
},
);
}
Ok(())
}
}
/// The **runtime gate** result — [`precheck`]'s answer. Where
/// [`BuildVariantSet::validate`] proves *config*-validity, this proves *reality*:
/// toolchain present, `--target` installed, engine socket up. Feeds f4's `valid`
/// flag; NEVER hard-fails — it RETURNS the reasons.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Precheck {
/// Everything the build needs is present.
Ready,
/// One or more runtime prerequisites are missing (human-readable reasons).
Missing(Vec<String>),
}
impl Precheck {
/// True when nothing is missing.
pub fn is_ready(&self) -> bool {
matches!(self, Precheck::Ready)
}
/// The missing-prerequisite reasons (empty when [`Ready`](Self::Ready)).
pub fn reasons(&self) -> &[String] {
match self {
Precheck::Ready => &[],
Precheck::Missing(r) => r,
}
}
}
/// The **builder-injection seam** — the verb-side of f7. `assemble` reaches the
/// three real builders through this thin trait so tests inject fakes (no real
/// cargo / docker / kvm). Every method has a real default; a test overrides only
/// what it exercises.
///
/// * cargo builds default to [`build_variant_with_info`];
/// * container / VM builds default to a clear "no builder injected" error — the
/// concrete [`Builders`] wires the real [`ImageBuilder`] / [`VmImageBuilder`];
/// * the availability probes drive [`precheck`].
pub trait Assemble {
/// Build a cargo variant (Exe / Lib / Wasm / Service). Default = the real
/// [`build_variant_with_info`]; the injected `sink` receives its build-info.
fn build_cargo(
&self,
set: &BuildVariantSet,
name: &str,
repo_root: &Path,
sink: &dyn BuildInfoSink,
) -> Result<BuildOutcome> {
build_variant_with_info(set, name, repo_root, sink)
}
/// Build a container image. Default errors (no builder injected); [`Builders`]
/// drives the real [`build_container`](crate::image_build::build_container).
fn build_container(
&self,
_image: &ContainerBuild,
_repo: &str,
_repo_root: &Path,
) -> Result<TargetBuildOutcome> {
bail!("assemble: kind 'container' needs an injected ImageBuilder (use `Builders::with_image`)")
}
/// Build a VM appliance. Default errors (no builder injected); [`Builders`]
/// drives the real [`build_vm`](crate::image_build::build_vm).
fn build_vm(&self, _vm: &VmBuild, _repo: &str, _repo_root: &Path) -> Result<TargetBuildOutcome> {
bail!("assemble: kind 'vm' needs an injected VmImageBuilder (use `Builders::with_vm`)")
}
/// Build a bootable ISO. Default errors (no builder injected); [`Builders`]
/// drives the real [`build_iso`](crate::image_build::build_iso).
fn build_iso(&self, _iso: &IsoBuild, _repo: &str, _repo_root: &Path) -> Result<TargetBuildOutcome> {
bail!("assemble: kind 'iso' needs an injected IsoImageBuilder (use `Builders::with_iso`)")
}
/// **Compose an installer ISO.** Default errors (no composer injected), and the
/// refusal keeps naming the door — this crate still links no composer, and must
/// not: composing needs a kernel and an app payload, which are the composer's
/// inputs to resolve and not facts an assembly descriptor carries.
///
/// What changed on 2026-08-16 is that the refusal is now a MISSING INJECTION
/// rather than a closed kind. `dispatch` used to `bail!` on
/// [`ArtifactKind::Installer`] before any builder could be consulted, so a
/// consumer that DOES link a composer (dwarves, through
/// `korp_installer::isocompose::ForgeComposer`) had no way to hand it in — the
/// declared cell was listable, plannable and unbuildable, and the ⚒ pane's BYGG
/// fell all the way through to the cargo door and reported
/// `no build variant 'gunnar-installer'`.
fn build_installer(
&self,
_cell: &crate::installer::InstallerBuild,
_repo: &str,
_repo_root: &Path,
) -> Result<TargetBuildOutcome> {
bail!(
"assemble: kind 'installer' is COMPOSED, and no composer is injected (use \
`Builders::with_installer`). This crate links none on purpose — master it \
through the product's compose verb (`dwarves buildthing <product> --installer`) \
or inject one."
)
}
/// **Compile a Linux kernel.** Default errors (no builder injected);
/// [`Builders`] drives the injected
/// [`KernelBuilder`](crate::kernel::KernelBuilder) through the existing
/// [`build_kernel`](crate::image_build::build_kernel) free fn. This crate
/// links no container engine by default — the production builder
/// ([`ContainerKernelBuilder`](crate::kernel::ContainerKernelBuilder) over
/// draupnir's `backend-oci`, feature `kernel-build`) is the consumer's to
/// hand in.
fn build_kernel(
&self,
_kernel: &crate::kernel::KernelBuild,
_repo: &str,
_repo_root: &Path,
) -> Result<TargetBuildOutcome> {
bail!(
"assemble: kind 'kernel' needs an injected KernelBuilder (use \
`Builders::with_kernel` — the production one is \
ContainerKernelBuilder over draupnir's container backend, feature \
`kernel-build`)"
)
}
/// **Assemble a distribution package.** Default errors (no builder injected).
///
/// The same shape as `build_installer` and for the same reason: assembling a
/// wheel means running the format's own tool inside a pinned container, and
/// this dep-light crate links no packaging toolchain and must not — a leaf's
/// xtask consuming these types would then be pulling a container engine to
/// read a declaration. What it DOES own is the declaration, the member set,
/// the `SHA256SUMS` writer and the produced-set verification
/// ([`crate::package`]), so every door that injects a builder leaves the same
/// release behind rather than each writing its own.
fn build_package(
&self,
_pkg: &crate::package::PackageBuild,
_repo: &str,
_repo_root: &Path,
) -> Result<TargetBuildOutcome> {
bail!(
"assemble: kind 'package' needs an injected PackageBuilder (use \
`Builders::with_package`). This crate links no packaging toolchain on \
purpose — a wheel is built by `python -m build` inside a pinned image, \
which is a capability, not a declaration. Build it through the \
product's own publish verb, or inject a builder."
)
}
/// Post-produce hook (task #65 slice 4b): analyse an artifact the dispatch just
/// wrote, keyed by its `content_id`, returning warehouse-ready [`BuildInfoRow`]s
/// (size / hardening / SBOM facts). Default = **no-op** so the leaf pays
/// nothing; [`Builders`] delegates to the injected
/// [`ArtifactAnalyzer`](crate::analyze::ArtifactAnalyzer) — the exact mirror of
/// the [`ImageBuilder`] injection.
fn analyze_artifact(
&self,
_artifact_path: &Path,
_content_id: &str,
_build_id: &str,
) -> Vec<BuildInfoRow> {
Vec::new()
}
/// Runtime probe: is the cargo/rustc toolchain present? Default probes
/// `rustc -vV` / `cargo -vV`.
fn cargo_available(&self) -> bool {
toolchain_present()
}
/// Runtime probe: is `triple` an installed rust target? Default probes
/// `rustup target list --installed`; if rustup is absent it degrades to `true`
/// (best-effort — never a false "missing").
fn target_installed(&self, triple: &str) -> bool {
rustup_target_installed(triple)
}
/// Runtime probe: is the container engine (podman/Docker socket) reachable?
/// Base default `false` (no builder injected).
fn container_available(&self) -> bool {
false
}
/// Runtime probe: is the VM builder (KVM / prebuilt artifacts) reachable?
/// Base default `false` (no builder injected).
fn vm_available(&self) -> bool {
false
}
/// Runtime probe: is the ISO builder (xorriso / prebuilt artifacts) reachable?
/// Base default `false` (no builder injected).
fn iso_available(&self) -> bool {
false
}
/// Runtime probe: is an installer composer reachable? Base default `false`.
fn installer_available(&self) -> bool {
false
}
/// Runtime probe: is a kernel builder (container toolchain) reachable?
/// Base default `false` (no builder injected).
fn kernel_available(&self) -> bool {
false
}
/// Whether a packaging toolchain is injected — the precheck's question, the
/// peer of [`kernel_available`](Self::kernel_available).
fn package_available(&self) -> bool {
false
}
}
/// The production [`Assemble`]: wires the real container / VM builder seams. Cargo
/// builds use the trait's real default; container / VM builds drive the injected
/// [`ImageBuilder`] / [`VmImageBuilder`] through the existing
/// [`build_container`](crate::image_build::build_container) /
/// [`build_vm`](crate::image_build::build_vm) free fns.
#[derive(Default)]
pub struct Builders<'a> {
/// The injected container image builder (e.g. `BollardImageBuilder`).
pub image: Option<&'a dyn ImageBuilder>,
/// The injected VM image builder (e.g. `PrebuiltVmBuilder`).
pub vm: Option<&'a dyn VmImageBuilder>,
/// The injected distribution-package builder — the seam
/// [`crate::package::PackageBuilder`] names. `None` on a forge that links no
/// packaging toolchain, which is the default and the honest state.
pub package: Option<&'a dyn crate::package::PackageBuilder>,
/// The injected ISO image builder (e.g. `PrebuiltIsoBuilder`).
pub iso: Option<&'a dyn IsoImageBuilder>,
/// The injected INSTALLER composer, as an [`IsoImageBuilder`] over the rendered
/// [`InstallerBuild::installer_iso`](crate::installer::InstallerBuild::installer_iso).
///
/// The same trait as `iso`, deliberately and in its own slot. The same trait
/// because what a composer hands back is what an ISO builder hands back — the
/// mastered medium's [`Artifact`](crate::image_build::Artifact) hashes — and
/// [`build_iso_as`](crate::image_build::build_iso_as) already exists to record
/// that as `TargetKind::Installer` rather than `Iso`, which is the distinction an
/// auditor needs (a live ISO *is* the appliance; an installer *produces* one). Its
/// own slot because the two are not interchangeable: a repo may declare both, and
/// a plain ISO builder handed an installer would master a medium that boots
/// nothing.
pub installer: Option<&'a dyn IsoImageBuilder>,
/// The injected KERNEL builder (e.g.
/// [`ContainerKernelBuilder`](crate::kernel::ContainerKernelBuilder) over
/// draupnir's container backend).
pub kernel: Option<&'a dyn crate::kernel::KernelBuilder>,
/// The injected post-produce artifact analyzer (nornir's `binscan`). `None`
/// = the no-op default (the leaf runs no analysis).
pub analyzer: Option<&'a dyn ArtifactAnalyzer>,
}
impl<'a> Builders<'a> {
/// An empty injection (cargo-only).
pub fn new() -> Self {
Builders {
image: None,
vm: None,
iso: None,
installer: None,
kernel: None,
package: None,
analyzer: None,
}
}
/// Inject the container image builder seam.
pub fn with_image(mut self, image: &'a dyn ImageBuilder) -> Self {
self.image = Some(image);
self
}
/// Inject the VM image builder seam.
pub fn with_vm(mut self, vm: &'a dyn VmImageBuilder) -> Self {
self.vm = Some(vm);
self
}
/// Inject the ISO image builder seam.
pub fn with_iso(mut self, iso: &'a dyn IsoImageBuilder) -> Self {
self.iso = Some(iso);
self
}
/// Inject the INSTALLER composer seam. Separate from
/// [`with_iso`](Self::with_iso) on purpose — see the field.
pub fn with_installer(mut self, installer: &'a dyn IsoImageBuilder) -> Self {
self.installer = Some(installer);
self
}
/// Inject the KERNEL builder seam.
pub fn with_kernel(mut self, kernel: &'a dyn crate::kernel::KernelBuilder) -> Self {
self.kernel = Some(kernel);
self
}
/// Inject the PACKAGE builder seam.
pub fn with_package(mut self, package: &'a dyn crate::package::PackageBuilder) -> Self {
self.package = Some(package);
self
}
/// Inject the post-produce artifact analyzer seam (mirrors
/// [`with_image`](Self::with_image)).
pub fn with_analyzer(mut self, analyzer: &'a dyn ArtifactAnalyzer) -> Self {
self.analyzer = Some(analyzer);
self
}
}
impl<'a> Assemble for Builders<'a> {
fn build_container(
&self,
image: &ContainerBuild,
repo: &str,
repo_root: &Path,
) -> Result<TargetBuildOutcome> {
let builder = self
.image
.context("assemble: no ImageBuilder injected into Builders")?;
image_build_container(builder, image, repo, repo_root)
}
fn build_vm(&self, vm: &VmBuild, repo: &str, repo_root: &Path) -> Result<TargetBuildOutcome> {
let builder = self
.vm
.context("assemble: no VmImageBuilder injected into Builders")?;
image_build_vm(builder, vm, repo, repo_root)
}
fn build_iso(&self, iso: &IsoBuild, repo: &str, repo_root: &Path) -> Result<TargetBuildOutcome> {
let builder = self
.iso
.context("assemble: no IsoImageBuilder injected into Builders")?;
image_build_iso(builder, iso, repo, repo_root)
}
/// The composer drives the SAME capture the ISO leg does, through
/// [`build_iso_as`](crate::image_build::build_iso_as) with
/// [`TargetKind::Installer`] — so the provenance says which of the two media this
/// is, and there is no second attestation path to keep in step.
fn build_installer(
&self,
cell: &crate::installer::InstallerBuild,
repo: &str,
repo_root: &Path,
) -> Result<TargetBuildOutcome> {
let builder = self
.installer
.context("assemble: no installer composer injected into Builders")?;
crate::image_build::build_iso_as(
builder,
&cell.installer_iso(),
crate::image_build::TargetKind::Installer,
repo,
repo_root,
)
}
fn build_kernel(
&self,
kernel: &crate::kernel::KernelBuild,
repo: &str,
repo_root: &Path,
) -> Result<TargetBuildOutcome> {
let builder = self
.kernel
.context("assemble: no KernelBuilder injected into Builders")?;
crate::image_build::build_kernel(builder, kernel, repo, repo_root)
}
fn build_package(
&self,
pkg: &crate::package::PackageBuild,
repo: &str,
repo_root: &Path,
) -> Result<TargetBuildOutcome> {
let builder = self
.package
.context("assemble: no PackageBuilder injected into Builders")?;
crate::image_build::build_package(builder, pkg, repo, repo_root)
}
fn container_available(&self) -> bool {
self.image.is_some()
}
fn iso_available(&self) -> bool {
self.iso.is_some()
}
fn installer_available(&self) -> bool {
self.installer.is_some()
}
fn kernel_available(&self) -> bool {
self.kernel.is_some()
}
fn package_available(&self) -> bool {
self.package.is_some()
}
fn vm_available(&self) -> bool {
self.vm.is_some()
}
fn analyze_artifact(
&self,
artifact_path: &Path,
content_id: &str,
build_id: &str,
) -> Vec<BuildInfoRow> {
match self.analyzer {
Some(a) => a.analyze(artifact_path, content_id, build_id),
None => Vec::new(),
}
}
}
/// The **content-addressed idempotence cache** seam: `assemble` skips the rebuild
/// when a receipt for the same variant digest already exists. The dep-light
/// [`InMemAssembleCache`] is the in-process default; a persistent impl (an on-disk
/// attestation ledger) is the production form.
pub trait AssembleCache {
/// Fetch a cached receipt by variant digest.
fn get(&self, key: &str) -> Option<Assembled>;
/// Store a receipt under its variant digest.
fn put(&self, key: &str, assembled: &Assembled);
}
/// An in-process [`AssembleCache`] — a keyed map of receipts, for a single box.
#[derive(Default)]
pub struct InMemAssembleCache {
map: Mutex<HashMap<String, Assembled>>,
}
impl InMemAssembleCache {
/// A fresh, empty cache.
pub fn new() -> Self {
Self::default()
}
}
impl AssembleCache for InMemAssembleCache {
fn get(&self, key: &str) -> Option<Assembled> {
self.map.lock().expect("assemble cache mutex").get(key).cloned()
}
fn put(&self, key: &str, assembled: &Assembled) {
self.map
.lock()
.expect("assemble cache mutex")
.insert(key.to_string(), assembled.clone());
}
}
/// The **context** [`assemble`] / [`precheck`] thread through: the repo identity +
/// roots, the build-info sink, the injected builders, and the idempotence knobs.
pub struct AssembleCtx<'a> {
/// The repo name recorded in provenance (defaults to the repo-root dir name).
pub repo: String,
/// The build context root (where cargo runs / the image build context lives).
pub repo_root: &'a Path,
/// Where a built binary / service lands (`output_of`'s `exec_dir`, e.g.
/// `/usr/local/bin`).
pub exec_dir: &'a Path,
/// The build-info sink every assemble emits its lineage to.
pub info: &'a dyn BuildInfoSink,
/// The injected builder seams (cargo / container / VM).
pub builders: &'a dyn Assemble,
/// When `true` (and [`cache`](Self::cache) is set), skip a rebuild whose
/// content-addressed receipt already exists.
pub idempotent: bool,
/// The idempotence cache (see [`idempotent`](Self::idempotent)).
pub cache: Option<&'a dyn AssembleCache>,
}
impl<'a> AssembleCtx<'a> {
/// A context with idempotence off. `repo` defaults to the repo-root dir name.
pub fn new(
repo_root: &'a Path,
exec_dir: &'a Path,
info: &'a dyn BuildInfoSink,
builders: &'a dyn Assemble,
) -> Self {
let repo = repo_root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| repo_root.display().to_string());
AssembleCtx {
repo,
repo_root,
exec_dir,
info,
builders,
idempotent: false,
cache: None,
}
}
/// Turn on content-addressed idempotence, backed by `cache`.
pub fn idempotent(mut self, cache: &'a dyn AssembleCache) -> Self {
self.idempotent = true;
self.cache = Some(cache);
self
}
}
/// **The f7 seam.** Read `variant`, dispatch on its
/// [`kind`](BuildVariant::kind), drive the matching *existing* builder, emit the
/// build-info lineage to `ctx.info`, and return ONE [`Assembled`] receipt.
///
/// Composite: if `variant` embeds siblings (or a container chains `from_variant`
/// layers) the sub-builds are topo-assembled first (embedded lib BEFORE the
/// parent) and each sub-artifact's `content_id` is threaded into the parent's
/// `dep` rows. A cycle in the embed graph is a clear error.
pub fn assemble(set: &BuildVariantSet, variant: &BuildVariant, ctx: &AssembleCtx) -> Result<Assembled> {
let mut stack: Vec<String> = Vec::new();
assemble_rec(set, variant, ctx, &mut stack)
}
fn assemble_rec(
set: &BuildVariantSet,
variant: &BuildVariant,
ctx: &AssembleCtx,
stack: &mut Vec<String>,
) -> Result<Assembled> {
// Cycle detection over the embed/from_variant DAG (a tiny local serial DFS, no
// extra deps): a variant already on the DFS stack is a cycle.
if stack.iter().any(|n| n == &variant.name) {
stack.push(variant.name.clone());
bail!(
"assemble: dependency cycle detected: {}",
stack.join(" -> ")
);
}
stack.push(variant.name.clone());
// Config gate (f1) before any build — kind × target × service × delegation.
set.validate(variant).with_context(|| {
format!("assemble: variant '{}' is not a valid build point", variant.name)
})?;
// Topo-assemble the sub-builds first (embedded lib BEFORE the parent), and
// collect each produced artifact's content_id to thread into the parent.
let subs = sub_build_names(set, variant)?;
let mut sub_deps: Vec<(String, String)> = Vec::new();
for sub in &subs {
let sub_variant = resolve_variant(set, sub)?.clone();
let assembled = assemble_rec(set, &sub_variant, ctx, stack)?;
for cid in assembled.artifact_content_ids() {
sub_deps.push((sub.clone(), cid));
}
}
stack.pop();
// Idempotence: skip the rebuild when this variant's content-addressed receipt
// already exists — still emit a `step` row noting the cache hit.
let key = variant_digest(variant);
if ctx.idempotent {
if let Some(cache) = ctx.cache {
if let Some(mut hit) = cache.get(&key) {
let ts = now_micros();
let row = BuildInfoRow::new(hit.attestation.build_id(), ts, BuildInfoKind::Step)
.step("assemble")
.level("cache-hit")
.kv("assemble", variant.name.clone());
let _ = ctx.info.emit(std::slice::from_ref(&row));
hit.lineage.push(row);
return Ok(hit);
}
}
}
// Dispatch to the matching existing builder + lower its record into lineage.
let (output, proof, build_id, mut lineage, verdict) = dispatch(set, variant, ctx)?;
// Thread each sub-artifact's content_id into the parent's `dep` rows, so the
// lineage graphs: this build consumed exactly these content-addressed inputs.
if !sub_deps.is_empty() {
let ts = now_micros();
let mut dep_rows = Vec::with_capacity(sub_deps.len());
for (name, cid) in sub_deps {
dep_rows.push(
BuildInfoRow::new(&build_id, ts, BuildInfoKind::Dep)
.kv("embeds", name)
.content_id(&cid),
);
}
let _ = ctx.info.emit(&dep_rows);
lineage.extend(dep_rows);
}
let assembled = Assembled { output, attestation: proof, lineage, verdict };
// A FAILED build is never cached. Idempotence answers "this exact variant was
// already produced, do not produce it again"; a failure produced nothing, and
// serving it back would turn one bad build into a permanent one — every retry a
// cache hit, with a receipt, having run no builder. This became reachable the
// moment a red target build started returning `Ok` so its record could be filed,
// which is exactly the kind of second-order cost a contract change buys.
//
// `produced()` and not `success()`: a build that reported success and wrote NO
// artifact is exactly as uncacheable, and caching it would serve the empty
// answer back forever.
if ctx.idempotent && assembled.produced().is_ok() {
if let Some(cache) = ctx.cache {
cache.put(&key, &assembled);
}
}
Ok(assembled)
}
/// Dispatch one variant to its builder and return
/// `(output, proof, build_id, base_lineage, verdict)`. The base lineage (feed +
/// manifest rows) is emitted to `ctx.info` here; the caller appends the composite dep
/// rows.
///
/// The fifth element is the half that used to be dropped: the builder's own error
/// text and the path the artifact was expected at. See [`ProducedVerdict`].
fn dispatch(
set: &BuildVariantSet,
variant: &BuildVariant,
ctx: &AssembleCtx,
) -> Result<(
ArtifactOutput,
AssembledProof,
String,
Vec<BuildInfoRow>,
ProducedVerdict,
)> {
let output = output_of(set, variant, ctx.exec_dir)?;
let ts = now_micros();
match variant.kind {
// An installer is a legal variant kind (see the crate-root vocabulary) and it
// is COMPOSED, not assembled: the boot chain is built, the kernel pinned and
// the payloads staged by korp-installer's isocompose, which this dep-light
// crate does not link and must not.
//
// It used to `bail!` right here, and that put the refusal one step too early:
// a consumer that DOES link a composer could not hand it in, so the door the
// message names was reachable only from a CLI. The refusal now lives on
// `Assemble::build_installer`'s default — same words, one injection later —
// and a `Builders::with_installer` consumer composes for real.
ArtifactKind::Installer => {
let cell = set.require_installer(&variant.name)?;
let outcome = ctx.builders.build_installer(cell, &ctx.repo, ctx.repo_root)?;
let rec = &outcome.attestation.record;
let mut rows = target_feed_rows(
&rec.id,
ts,
&rec.target,
outcome.success,
outcome.artifacts.len(),
rec.host.cores,
"installer-compose",
);
rows.extend(target_manifest_rows(rec, ts));
let _ = ctx.info.emit(&rows);
let build_id = rec.id.clone();
// The SAME path function the ISO leg names, applied to the rendered
// descriptor — so a red compose names the path a green one writes.
let verdict = ProducedVerdict {
error: outcome.error.clone(),
expected: crate::image_build::iso_output_path(
ctx.repo_root,
&cell.installer_iso(),
)
.display()
.to_string(),
};
Ok((
output,
AssembledProof::Target(outcome.attestation),
build_id,
rows,
verdict,
))
}
ArtifactKind::Exe | ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Service => {
// The build info is regenerated from the returned record (a Null sink
// to the inner build), so a fake `build_cargo` need only return an
// outcome — the lineage is fully controlled here.
let started = Instant::now();
let outcome =
ctx.builders
.build_cargo(set, &variant.name, ctx.repo_root, &NullBuildInfoSink)?;
let elapsed = started.elapsed().as_micros() as i64;
let rec = &outcome.attestation.record;
let mut rows = build_feed_rows(
&rec.id,
ts,
&variant.name,
outcome.success,
outcome.artifacts.len(),
rec.host.cores,
elapsed,
);
rows.extend(manifest_rows(rec, ts));
let _ = ctx.info.emit(&rows);
let build_id = rec.id.clone();
// Post-produce hook (task #65 slice 4b): analyse each produced artifact
// right after it is written, keyed by its `content_id` (= the artifact
// row's sha256). Default no-op; nornir injects the real binary scan.
// Only cargo kinds are analysed here — container/VM layers are a
// deferred path (design §4). The paths are resolved against `repo_root`
// (provenance records them relative when under it).
let mut analysis_rows: Vec<BuildInfoRow> = Vec::new();
for a in &rec.artifacts {
let ap = Path::new(&a.path);
let resolved = if ap.is_absolute() {
ap.to_path_buf()
} else {
ctx.repo_root.join(ap)
};
let extra = ctx
.builders
.analyze_artifact(&resolved, &a.sha256, &build_id);
analysis_rows.extend(extra);
}
if !analysis_rows.is_empty() {
let _ = ctx.info.emit(&analysis_rows);
rows.extend(analysis_rows);
}
let verdict = ProducedVerdict {
error: (!outcome.success).then(|| {
format!(
"cargo build of variant '{}' exited non-zero",
variant.name
)
}),
expected: expected_binaries(&output),
};
Ok((
output,
AssembledProof::Build(outcome.attestation),
build_id,
rows,
verdict,
))
}
ArtifactKind::Container => {
let cref = variant
.container_ref
.as_deref()
.context("assemble: container kind without container_ref")?;
let mut image = set.require_container(cref)?.clone();
// **A pinned image is checked before its tags move** (2026-09-22): the kernel rows
// that name this image as their toolchain are the pin, and the build refuses an
// image that is not it rather than moving `:latest` onto it.
if let Some(pin) = crate::kernel::toolchain_pin_for(set, &image.name)? {
image.expected_sha256 = pin;
}
let outcome = ctx.builders.build_container(&image, &ctx.repo, ctx.repo_root)?;
let rec = &outcome.attestation.record;
let mut rows = target_feed_rows(
&rec.id,
ts,
&rec.target,
outcome.success,
outcome.artifacts.len(),
rec.host.cores,
"image-build",
);
rows.extend(target_manifest_rows(rec, ts));
let _ = ctx.info.emit(&rows);
let build_id = rec.id.clone();
let verdict = ProducedVerdict {
error: outcome.error.clone(),
expected: format!(
"an image in the engine's store tagged {}",
image
.primary_tag()
.unwrap_or(image.name.as_str())
),
};
Ok((
output,
AssembledProof::Target(outcome.attestation),
build_id,
rows,
verdict,
))
}
ArtifactKind::Vm => {
let vref = variant
.vm_ref
.as_deref()
.context("assemble: vm kind without vm_ref")?;
let vm = set.require_vm(vref)?;
let outcome = ctx.builders.build_vm(vm, &ctx.repo, ctx.repo_root)?;
let rec = &outcome.attestation.record;
let mut rows = target_feed_rows(
&rec.id,
ts,
&rec.target,
outcome.success,
outcome.artifacts.len(),
rec.host.cores,
"vm-build",
);
rows.extend(target_manifest_rows(rec, ts));
let _ = ctx.info.emit(&rows);
let build_id = rec.id.clone();
let verdict = ProducedVerdict {
error: outcome.error.clone(),
expected: format!("the boot image {} declares ({})", vm.name, vm.kernel),
};
Ok((
output,
AssembledProof::Target(outcome.attestation),
build_id,
rows,
verdict,
))
}
// A `kind = "kernel"` variant carries its handle in its own name (the Iso
// rule); dispatch it through the kernel builder seam over the declared
// `[[build.kernel]]` of the same name.
ArtifactKind::Kernel => {
let cell = set.require_kernel(&variant.name)?;
let outcome = ctx.builders.build_kernel(cell, &ctx.repo, ctx.repo_root)?;
let rec = &outcome.attestation.record;
let mut rows = target_feed_rows(
&rec.id,
ts,
&rec.target,
outcome.success,
outcome.artifacts.len(),
rec.host.cores,
"kernel-build",
);
rows.extend(target_manifest_rows(rec, ts));
let _ = ctx.info.emit(&rows);
let build_id = rec.id.clone();
// The SAME path function the kernel builder collects to
// (`kernel_output_path`), so a red build names the path a green one
// writes.
let verdict = ProducedVerdict {
error: outcome.error.clone(),
expected: crate::image_build::kernel_output_path(ctx.repo_root, cell)
.display()
.to_string(),
};
Ok((
output,
AssembledProof::Target(outcome.attestation),
build_id,
rows,
verdict,
))
}
// A `kind = "package"` variant carries the cell handle in its own name (the
// Iso/Installer/Kernel rule, no delegating `package_ref`); dispatch it
// through the package builder seam over the declared `[[build.package]]` of
// the same name.
ArtifactKind::Package => {
let cell = set.require_package(&variant.name)?;
let outcome = ctx.builders.build_package(cell, &ctx.repo, ctx.repo_root)?;
let rec = &outcome.attestation.record;
let mut rows = target_feed_rows(
&rec.id,
ts,
&rec.target,
outcome.success,
outcome.artifacts.len(),
rec.host.cores,
"package-build",
);
rows.extend(target_manifest_rows(rec, ts));
let _ = ctx.info.emit(&rows);
let build_id = rec.id.clone();
// The DIRECTORY and the member set, not one path: a package that
// produced three of its four members failed, and a red build that named
// only the wheel would send the reader looking at the wrong file.
let verdict = ProducedVerdict {
error: outcome.error.clone(),
expected: format!(
"{} in {}",
cell.required_members()
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(" + "),
cell.out_dir(ctx.repo_root).display()
),
};
Ok((
output,
AssembledProof::Target(outcome.attestation),
build_id,
rows,
verdict,
))
}
// A `kind = "iso"` cargo variant carries the ISO handle in its own name (no
// delegating `iso_ref`); dispatch it through the ISO builder seam over the
// declared `[[build.iso]]` of the same name.
ArtifactKind::Iso => {
let iso = set.require_iso(&variant.name)?;
let outcome = ctx.builders.build_iso(iso, &ctx.repo, ctx.repo_root)?;
let rec = &outcome.attestation.record;
let mut rows = target_feed_rows(
&rec.id,
ts,
&rec.target,
outcome.success,
outcome.artifacts.len(),
rec.host.cores,
"iso-build",
);
rows.extend(target_manifest_rows(rec, ts));
let _ = ctx.info.emit(&rows);
let build_id = rec.id.clone();
// The conventional output path is the SAME function the ISO builders
// master to (`iso_output_path`), not a second spelling of the rule — so
// the path a red build names is the path a green build writes.
let verdict = ProducedVerdict {
error: outcome.error.clone(),
expected: crate::image_build::iso_output_path(ctx.repo_root, iso)
.display()
.to_string(),
};
Ok((
output,
AssembledProof::Target(outcome.attestation),
build_id,
rows,
verdict,
))
}
}
}
/// The install paths a cargo cell's [`ArtifactOutput`] intends — the `expected` half
/// of a [`ProducedVerdict`] for the four cargo kinds.
fn expected_binaries(output: &ArtifactOutput) -> String {
match output {
ArtifactOutput::Binary(b) => b.path.clone(),
ArtifactOutput::Service(s) => s.exec.clone(),
ArtifactOutput::Pages(p) => format!("a {} bundle for {}", p.target, p.module),
other => other.name().to_string(),
}
}
/// The **runtime gate** (§f7.1): does the host have what this variant needs to
/// build? RETURNS the reasons — never hard-fails — so f4's `valid` flag reflects
/// reality, not just config.
pub fn precheck(set: &BuildVariantSet, variant: &BuildVariant, ctx: &AssembleCtx) -> Precheck {
let mut missing: Vec<String> = Vec::new();
match variant.kind {
// See assemble(): an installer is COMPOSED, and the composer is injected. The
// gate is therefore whether one was — the same shape as every other kind's,
// rather than a flat "this kind cannot be built here".
ArtifactKind::Installer => {
if !ctx.builders.installer_available() {
missing.push(
"kind 'installer' is composed and no composer is injected — use the \
product's compose verb, or `Builders::with_installer`"
.to_string(),
);
}
}
ArtifactKind::Exe | ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Service => {
if !ctx.builders.cargo_available() {
missing.push("cargo/rustc toolchain not found on PATH".to_string());
}
if let Some(t) = variant.target.as_deref() {
if !ctx.builders.target_installed(t) {
missing.push(format!(
"rust target '{t}' is not installed (`rustup target add {t}`)"
));
}
}
}
ArtifactKind::Container => {
if !ctx.builders.container_available() {
missing.push(
"container engine (podman/Docker socket) is not available".to_string(),
);
}
// A `from_variant` layer needs the cargo toolchain to produce its payload.
if let Some(cref) = variant.container_ref.as_deref() {
if let Ok(c) = set.require_container(cref) {
if !c.variant_deps().is_empty() && !ctx.builders.cargo_available() {
missing.push(
"cargo/rustc toolchain not found (needed for from_variant layers)"
.to_string(),
);
}
}
}
}
ArtifactKind::Vm => {
if !ctx.builders.vm_available() {
missing
.push("vm builder (KVM / prebuilt artifacts) is not available".to_string());
}
}
ArtifactKind::Iso => {
if !ctx.builders.iso_available() {
missing.push(
"iso builder (xorriso / prebuilt artifacts) is not available".to_string(),
);
}
}
ArtifactKind::Kernel => {
if !ctx.builders.kernel_available() {
missing.push(
"kernel builder (container toolchain via draupnir backend-oci) is \
not available — inject one with `Builders::with_kernel`"
.to_string(),
);
}
}
ArtifactKind::Package => {
if !ctx.builders.package_available() {
missing.push(
"package builder (the format's toolchain in a pinned container) is \
not available — inject one with `Builders::with_package`"
.to_string(),
);
}
// A package that stages the output of a cargo variant needs the cargo
// toolchain to produce it — the container kind's `from_variant` rule,
// and the same reason: reporting "ready" and then failing to find an
// input several minutes in is a precheck that answered the wrong
// question.
if let Some(cell) = set.package(&variant.name) {
if !cell.from_variant.is_empty() && !ctx.builders.cargo_available() {
missing.push(
"cargo/rustc toolchain not found (needed for from_variant inputs)"
.to_string(),
);
}
}
}
}
if missing.is_empty() {
Precheck::Ready
} else {
Precheck::Missing(missing)
}
}
// ─── helpers ─────────────────────────────────────────────────────────────────
/// The names of the sub-builds a variant composes: its `embeds` (sibling members
/// compiled IN) plus, for a container, each cargo `from_variant` layer. Order:
/// embeds first, then container variant-deps; de-duplicated.
fn sub_build_names(set: &BuildVariantSet, variant: &BuildVariant) -> Result<Vec<String>> {
let mut names: Vec<String> = Vec::new();
for e in &variant.embeds {
if !names.contains(e) {
names.push(e.clone());
}
}
if variant.kind == ArtifactKind::Container {
if let Some(cref) = &variant.container_ref {
let c = set.require_container(cref)?;
for d in c.variant_deps() {
let d = d.to_string();
if !names.contains(&d) {
names.push(d);
}
}
}
}
Ok(names)
}
/// Resolve a sub-build name to a declared variant: first by variant name, then by
/// suite `member` (an `embeds` entry names a member). A clear error otherwise.
fn resolve_variant<'a>(set: &'a BuildVariantSet, name: &str) -> Result<&'a BuildVariant> {
if let Some(v) = set.get(name) {
return Ok(v);
}
if let Some(v) = set.variants.iter().find(|v| v.member.as_deref() == Some(name)) {
return Ok(v);
}
bail!(
"assemble: sub-build '{}' names no declared variant or member",
name
)
}
/// A stable content-addressed key for a variant descriptor — the idempotence
/// cache key. SHA-256 over the variant's serde JSON.
fn variant_digest(variant: &BuildVariant) -> String {
let bytes = serde_json::to_vec(variant).unwrap_or_default();
nornir_hash::sha256_hex(&bytes)
}
/// Live-feed rows for an image/VM target build — the target-kind peer of
/// [`build_feed_rows`], labelled with the target `step` (`image-build`/`vm-build`).
fn target_feed_rows(
build_id: &str,
ts: i64,
target: &str,
success: bool,
artifact_count: usize,
cores: u32,
step: &str,
) -> Vec<BuildInfoRow> {
let state = if success { "done" } else { "failed" };
vec![
BuildInfoRow::new(build_id, ts, BuildInfoKind::Step)
.step(step)
.level(state)
.kv(step, target.to_string()),
BuildInfoRow::new(build_id, ts, BuildInfoKind::Metric)
.step(step)
.kv("artifacts", artifact_count.to_string()),
BuildInfoRow::new(build_id, ts, BuildInfoKind::Metric)
.step(step)
.kv("cores", cores.to_string()),
]
}
/// Toolchain presence probe: `rustc -vV` + `cargo -vV` both answer.
fn toolchain_present() -> bool {
let tc = Toolchain::detect();
!tc.rustc.is_empty() && !tc.cargo.is_empty()
}
/// `rustup target list --installed` probe. Degrades to `true` when rustup is
/// absent (best-effort — never a false "missing").
fn rustup_target_installed(triple: &str) -> bool {
match Command::new("rustup")
.args(["target", "list", "--installed"])
.output()
{
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.any(|l| l.trim() == triple),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::build_info::InMemBuildInfoSink;
use crate::container::ContainerBuild;
use crate::image_build::{BuiltImage, ImageBuilder, VmImageBuilder};
use crate::provenance::{Artifact, BuildProvenance, HostInfo, Outcome};
use crate::variant::{ArtifactKind, Profile, ServiceShape};
use std::collections::BTreeSet;
/// A fake [`ImageBuilder`] returning a known [`BuiltImage`] (no daemon).
struct FakeImageBuilder {
built: BuiltImage,
}
impl ImageBuilder for FakeImageBuilder {
fn build(&self, _image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
Ok(self.built.clone())
}
}
/// A fake [`VmImageBuilder`] returning a known artifact hash (no KVM).
struct FakeVmBuilder;
impl VmImageBuilder for FakeVmBuilder {
fn build(&self, _vm: &VmBuild, _root: &Path) -> Result<Vec<Artifact>> {
Ok(vec![Artifact {
path: "artifacts/bzImage".into(),
bytes: 6,
sha256: "ab".repeat(32),
}])
}
}
/// A fake [`Assemble`] that stands in for real cargo: it synthesizes an
/// artifact file (deterministic bytes per variant name) and captures REAL
/// provenance over it — so the whole lineage + content_id machinery runs with
/// no `cargo`. Container/VM builds drive the injected fakes. Availability
/// probes are simulated via the two flags.
struct FakeAssemble<'a> {
image: Option<&'a dyn ImageBuilder>,
vm: Option<&'a dyn VmImageBuilder>,
iso: Option<&'a dyn IsoImageBuilder>,
analyzer: Option<&'a dyn ArtifactAnalyzer>,
cargo_ok: bool,
targets_ok: bool,
}
impl<'a> FakeAssemble<'a> {
fn cargo_only() -> Self {
FakeAssemble {
image: None,
vm: None,
iso: None,
analyzer: None,
cargo_ok: true,
targets_ok: true,
}
}
}
impl<'a> Assemble for FakeAssemble<'a> {
fn build_cargo(
&self,
set: &BuildVariantSet,
name: &str,
repo_root: &Path,
_sink: &dyn BuildInfoSink,
) -> Result<BuildOutcome> {
let v = set.require(name)?.clone();
let dir = repo_root.join("target/release");
std::fs::create_dir_all(&dir)?;
let bin = v
.bin
.clone()
.or_else(|| v.package.clone())
.unwrap_or_else(|| v.name.clone());
let art = dir.join(&bin);
std::fs::write(&art, format!("ARTIFACT {name}"))?;
let rec = BuildProvenance::capture(
&v,
"testrepo",
repo_root,
Toolchain {
rustc: "rustc 1.85.0".into(),
host: "x86_64-unknown-linux-gnu".into(),
cargo: "cargo 1.85.0".into(),
..Default::default()
},
HostInfo { cores: 8, memory_bytes: 0 },
Outcome::Success,
&[art.clone()],
)?;
Ok(BuildOutcome {
variant: name.to_string(),
artifacts: vec![art],
attestation: rec.attest()?,
success: true,
})
}
fn build_container(
&self,
image: &ContainerBuild,
repo: &str,
repo_root: &Path,
) -> Result<TargetBuildOutcome> {
let b = self.image.context("no image builder")?;
image_build_container(b, image, repo, repo_root)
}
fn build_vm(
&self,
vm: &VmBuild,
repo: &str,
repo_root: &Path,
) -> Result<TargetBuildOutcome> {
let b = self.vm.context("no vm builder")?;
image_build_vm(b, vm, repo, repo_root)
}
fn build_iso(
&self,
iso: &IsoBuild,
repo: &str,
repo_root: &Path,
) -> Result<TargetBuildOutcome> {
let b = self.iso.context("no iso builder")?;
image_build_iso(b, iso, repo, repo_root)
}
fn cargo_available(&self) -> bool {
self.cargo_ok
}
fn target_installed(&self, _triple: &str) -> bool {
self.targets_ok
}
fn container_available(&self) -> bool {
self.image.is_some()
}
fn vm_available(&self) -> bool {
self.vm.is_some()
}
fn iso_available(&self) -> bool {
self.iso.is_some()
}
fn analyze_artifact(
&self,
artifact_path: &Path,
content_id: &str,
build_id: &str,
) -> Vec<BuildInfoRow> {
match self.analyzer {
Some(a) => a.analyze(artifact_path, content_id, build_id),
None => Vec::new(),
}
}
}
/// A fake [`ArtifactAnalyzer`] that emits ONE metric row per artifact carrying
/// the `content_id` it was handed — proving dispatch fired the seam with the
/// artifact's real content id (no `object`/`gimli` needed for the wiring test).
struct FakeAnalyzer;
impl ArtifactAnalyzer for FakeAnalyzer {
fn analyze(
&self,
artifact_path: &Path,
content_id: &str,
build_id: &str,
) -> Vec<BuildInfoRow> {
vec![BuildInfoRow::new(build_id, now_micros(), BuildInfoKind::Metric)
.step("binary-scan")
.kv("analyzed_path", artifact_path.display().to_string())
.content_id(content_id)]
}
}
fn exe_variant(name: &str) -> BuildVariant {
BuildVariant {
name: name.to_string(),
bin: Some(name.to_string()),
features: vec!["core".to_string()],
profile: Profile::Release,
..Default::default()
}
}
/// (a) Each kind dispatches to the right builder and the receipt's
/// `output.kind()` matches the variant kind; cargo kinds prove `Build`, image
/// kinds prove `Target`.
#[test]
fn each_kind_dispatches_and_output_kind_matches() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let exec = Path::new("/usr/local/bin");
// Declare a container + a delegating container variant, a vm + delegate.
let toml = r#"
[build]
[[build.variant]]
name = "img"
kind = "container"
container_ref = "app-image"
[[build.variant]]
name = "boot"
kind = "vm"
vm_ref = "app-vm"
[[build.container]]
name = "app-image"
base = "scratch"
tags = ["app-image:1"]
[[build.vm]]
name = "app-vm"
kernel = "artifacts/bzImage"
rootfs = "artifacts/rootfs.cpio.gz"
"#;
let mut set = BuildVariantSet::from_toml_str(toml).unwrap();
set.variants.push(exe_variant("tool")); // Exe
set.variants.push(BuildVariant {
name: "corelib".into(),
package: Some("corelib".into()),
kind: ArtifactKind::Lib,
// A library is linked by whoever consumes it — the fat/thin axis has no
// answer for it, and it must SAY so rather than let the omitted key read
// as the 'fat' default.
shape: ServiceShape::NotApplicable,
..Default::default()
});
set.variants.push(BuildVariant {
name: "svc".into(),
bin: Some("svc".into()),
member: Some("svc".into()),
kind: ArtifactKind::Service,
shape: ServiceShape::FatService,
..Default::default()
});
set.variants.push(BuildVariant {
name: "web".into(),
package: Some("web".into()),
kind: ArtifactKind::Wasm,
target: Some("wasm32-unknown-unknown".into()),
..Default::default()
});
let fake_img = FakeImageBuilder {
built: BuiltImage {
image_ref: "app-image:1".into(),
digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111"
.into(),
bytes: 4096,
archive: None,
},
};
let fake_vm = FakeVmBuilder;
let builders = FakeAssemble {
image: Some(&fake_img),
vm: Some(&fake_vm),
iso: None,
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(root, exec, &sink, &builders);
for (name, want, is_target) in [
("tool", ArtifactKind::Exe, false),
("corelib", ArtifactKind::Lib, false),
("svc", ArtifactKind::Service, false),
("web", ArtifactKind::Wasm, false),
("img", ArtifactKind::Container, true),
("boot", ArtifactKind::Vm, true),
] {
let v = set.get(name).unwrap().clone();
let a = assemble(&set, &v, &ctx).unwrap_or_else(|e| panic!("assemble {name}: {e:#}"));
assert_eq!(a.kind(), want, "{name}: output.kind() matches variant kind");
assert_eq!(a.attestation.kind(), want, "{name}: proof kind matches");
match (&a.attestation, is_target) {
(AssembledProof::Target(_), true) => {}
(AssembledProof::Build(_), false) => {}
_ => panic!("{name}: wrong proof flavour"),
}
}
}
/// A container declaration + the delegating variant, as TOML — the fixture the
/// failed-build tests below and `each_kind_dispatches` both need.
fn container_set() -> BuildVariantSet {
BuildVariantSet::from_toml_str(
r#"
[build]
[[build.variant]]
name = "img"
kind = "container"
container_ref = "app-image"
[[build.container]]
name = "app-image"
base = "scratch"
tags = ["app-image:1"]
"#,
)
.unwrap()
}
/// An [`ImageBuilder`] whose engine refuses — the night the build broke.
struct RefusingImageBuilder;
impl ImageBuilder for RefusingImageBuilder {
fn build(&self, _image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
anyhow::bail!("engine refused: no such base image")
}
}
/// **A FAILED container assemble files a record, end to end.**
///
/// The whole chain a producer walks: `assemble` → `Assembled` →
/// `as_build_provenance` → the ONE `BuildProvenance` the ledger holds, plus the
/// `build_info` rows the sink received under the same `build_id`.
///
/// RED before the fix, at the first line — `build_container` discarded the
/// captured `Outcome::Failed` attestation with `built?`, so `assemble` returned
/// `Err` and there was no receipt to file at all:
/// > `a failed build must still produce a receipt: engine refused: no such base image`
#[test]
fn a_failed_container_assemble_files_a_readable_build_record() {
let dir = tempfile::tempdir().unwrap();
let set = container_set();
let builders = FakeAssemble {
image: Some(&RefusingImageBuilder),
vm: None,
iso: None,
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
let v = set.get("img").unwrap().clone();
let a = assemble(&set, &v, &ctx)
.unwrap_or_else(|e| panic!("a failed build must still produce a receipt: {e:#}"));
assert!(!a.success(), "the receipt must say the build failed");
// 1. The record the ledger stores. `as_build_provenance` is the seam the
// producer uses; asserting through it rather than reaching into the
// `Target` arm is what proves the filed row is the failed one.
let record = a.attestation.as_build_provenance(&v);
assert_eq!(record.outcome, Outcome::Failed, "the FILED record says Failed");
assert!(record.artifacts.is_empty(), "and claims no artifact it did not produce");
assert_eq!(record.variant.name, "img", "named by the declared cell");
// 2. The `build_info` rows the sink received, joined to that record by
// build_id. A record with no lineage under its own id is the disconnected
// pile this projection exists to prevent.
let rows = sink.rows();
assert!(!rows.is_empty(), "a failed build still emits its lineage");
assert!(
rows.iter().all(|r| r.build_id == record.id),
"every row joins the filed record by build_id"
);
let step = rows
.iter()
.find(|r| r.kind == "step" && r.step_id == "image-build")
.expect("an image-build step row");
assert_eq!(step.level, "failed", "and the step row says failed, not done");
assert!(
rows.iter().any(|r| r.kind == "provenance"),
"the provenance manifest rows are there too"
);
// The other side, without which a `Failed`-to-everything implementation
// passes: the SAME fixture with a working builder files a green record.
let ok = FakeImageBuilder {
built: BuiltImage {
image_ref: "app-image:1".into(),
digest: "sha256:3333333333333333333333333333333333333333333333333333333333333333"
.into(),
bytes: 4096,
archive: None,
},
};
let good_builders = FakeAssemble {
image: Some(&ok),
vm: None,
iso: None,
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let good_sink = InMemBuildInfoSink::new();
let good_ctx =
AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &good_sink, &good_builders);
let good = assemble(&set, &v, &good_ctx).unwrap();
assert!(good.success(), "a working builder still reports success");
assert_eq!(
good.attestation.as_build_provenance(&v).outcome,
Outcome::Success,
"and files a Success record"
);
}
/// **`assemble` hands a container the pin of the kernel rows that name it** (2026-09-22) —
/// the wiring half of the toolchain-tag fix: the `--build kernel` compose builds
/// `nordisk-kernel-toolchain` through THIS arm (dwarves `assemble_container`), so a pin the
/// arm did not pass is a check that never runs. A builder that answers to the 2026-09-15
/// rebuild is refused, and was never asked to build under `:latest`.
///
/// **Fails before the fix**: the arm passed the declaration as read, unpinned; the build
/// ran under `nordisk-kernel-toolchain:latest` and succeeded.
#[test]
fn assemble_pins_a_container_to_the_kernel_rows_that_name_it() {
struct Recorder(std::cell::RefCell<Vec<(Vec<String>, String)>>);
impl ImageBuilder for Recorder {
fn build(&self, image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
self.0.borrow_mut().push((image.tags.clone(), image.expected_sha256.clone()));
Ok(BuiltImage {
image_ref: image.primary_tag().unwrap_or_default().into(),
digest: "sha256:311bc06f56379229e2f037cee44dea50a8d7b07bdffdfa51963abe37aa5872f8".into(),
bytes: 1,
archive: None,
})
}
}
const PIN: &str = "78eaa55a651d5bd76aae36db0f3307e267a92d2a547ee338a17822a103e0320e";
let mut set = BuildVariantSet::from_toml_str(
r#"
[build]
[[build.variant]]
name = "nordisk-kernel-toolchain"
kind = "container"
container_ref = "nordisk-kernel-toolchain"
[[build.container]]
name = "nordisk-kernel-toolchain"
base = "scratch"
tags = ["nordisk-kernel-toolchain:latest"]
"#,
)
.unwrap();
set.kernels.push(crate::kernel::KernelBuild {
name: "tunnr-kernel".into(),
toolchain_image: crate::kernel::KERNEL_TOOLCHAIN_IMAGE.into(),
toolchain_image_sha256: PIN.into(),
..crate::kernel::KernelBuild::default()
});
let dir = tempfile::tempdir().unwrap();
let rec = Recorder(Default::default());
let builders = FakeAssemble { image: Some(&rec), vm: None, iso: None, analyzer: None, cargo_ok: true, targets_ok: true };
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
let v = set.get("nordisk-kernel-toolchain").unwrap().clone();
let a = assemble(&set, &v, &ctx).expect("a refused build still files its receipt");
assert!(!a.success(), "the 2026-09-15 rebuild is not the pinned toolchain");
let asked = rec.0.borrow();
assert_eq!(asked.len(), 1);
assert_eq!(asked[0].1, PIN, "the arm handed the builder the kernel row's pin");
assert_eq!(asked[0].0, vec![crate::image_build::staging_tag("nordisk-kernel-toolchain")], "and it built under the staging tag, never :latest");
}
/// **A failed build must never be served back from the idempotence cache.**
///
/// This only became reachable when a red target build started returning `Ok` so
/// its record could be filed: `cache.put` ran on every `Ok`, so the next request
/// for the same variant would hit the cache, return a receipt, and run no
/// builder — one bad build made permanent.
///
/// RED with the `&& assembled.success()` removed from the `cache.put` guard:
/// > `a FAILED build was served from the idempotence cache: the builder never ran`
#[test]
fn a_failed_build_is_not_cached_and_the_retry_really_rebuilds() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct CountingRefusal(AtomicUsize);
impl ImageBuilder for CountingRefusal {
fn build(&self, _image: &ContainerBuild, _root: &Path) -> Result<BuiltImage> {
self.0.fetch_add(1, Ordering::SeqCst);
anyhow::bail!("engine refused: no such base image")
}
}
let dir = tempfile::tempdir().unwrap();
let set = container_set();
let builder = CountingRefusal(AtomicUsize::new(0));
let builders = FakeAssemble {
image: Some(&builder),
vm: None,
iso: None,
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let sink = InMemBuildInfoSink::new();
let cache = InMemAssembleCache::new();
let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders)
.idempotent(&cache);
let v = set.get("img").unwrap().clone();
assemble(&set, &v, &ctx).unwrap();
assemble(&set, &v, &ctx).unwrap();
assert_eq!(
builder.0.load(Ordering::SeqCst),
2,
"a FAILED build was served from the idempotence cache: the builder never ran"
);
// The other side. Idempotence must still WORK, or "never cache" would pass
// this test while quietly disabling the feature.
let ok = FakeImageBuilder {
built: BuiltImage {
image_ref: "app-image:1".into(),
digest: "sha256:4444444444444444444444444444444444444444444444444444444444444444"
.into(),
bytes: 4096,
archive: None,
},
};
let good_builders = FakeAssemble {
image: Some(&ok),
vm: None,
iso: None,
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let good_sink = InMemBuildInfoSink::new();
let good_cache = InMemAssembleCache::new();
let good_ctx =
AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &good_sink, &good_builders)
.idempotent(&good_cache);
assemble(&set, &v, &good_ctx).unwrap();
let after_first = good_sink.rows().len();
assemble(&set, &v, &good_ctx).unwrap();
let cached = good_sink
.rows()
.into_iter()
.skip(after_first)
.any(|r| r.level == "cache-hit");
assert!(cached, "a SUCCESSFUL build is still cached — idempotence still works");
}
/// (b) The lineage carries provenance + dep + artifact rows, and the artifact
/// row's `content_id` equals the built artifact's hash (RED when broken).
#[test]
fn lineage_has_provenance_dep_artifact_and_content_id_is_the_hash() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut set = BuildVariantSet::default();
set.variants.push(exe_variant("tool"));
let builders = FakeAssemble::cargo_only();
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders);
let v = set.get("tool").unwrap().clone();
let a = assemble(&set, &v, &ctx).unwrap();
assert!(a.lineage.iter().any(|r| r.kind == "provenance"), "provenance rows present");
assert!(
a.lineage.iter().any(|r| r.kind == "dep" && r.value == "core"),
"a dep row for the enabled feature is present"
);
let artifact = a
.lineage
.iter()
.find(|r| r.kind == "artifact")
.expect("an artifact row");
// content_id == sha256 of the produced artifact bytes.
let expected = nornir_hash::sha256_hex(b"ARTIFACT tool");
assert_eq!(artifact.content_id, expected, "artifact content_id IS the hash");
// The sink saw the same rows the receipt carries (best-effort emission).
assert!(!sink.rows().is_empty(), "lineage emitted to the sink");
}
/// (c) `precheck` returns `Missing` with a target reason when the variant's
/// `--target` is not installed (simulated).
#[test]
fn precheck_missing_when_target_not_installed() {
let root = Path::new(".");
let mut set = BuildVariantSet::default();
let mut v = exe_variant("crosstool");
v.target = Some("aarch64-unknown-linux-gnu".into());
set.variants.push(v.clone());
// cargo present, but the target is NOT installed.
let builders =
FakeAssemble { image: None, vm: None, iso: None, analyzer: None, cargo_ok: true, targets_ok: false };
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders);
let pc = precheck(&set, &v, &ctx);
assert!(!pc.is_ready(), "not ready: {pc:?}");
assert!(
pc.reasons().iter().any(|r| r.contains("aarch64-unknown-linux-gnu")),
"the missing target is the reason: {pc:?}"
);
// With the target installed AND cargo present → Ready.
let ok = FakeAssemble::cargo_only();
let ctx_ok = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &ok);
assert!(precheck(&set, &v, &ctx_ok).is_ready());
}
/// (d) A composite fat-embeds-lib assembles the lib FIRST and the parent's
/// `dep` row `content_id` equals the lib's artifact `content_id` — the lineage
/// graphs by content id.
#[test]
fn composite_threads_lib_content_id_into_parent_dep_row() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut set = BuildVariantSet::default();
// The embedded lib.
set.variants.push(BuildVariant {
name: "corelib".into(),
package: Some("corelib".into()),
kind: ArtifactKind::Lib,
// A library is linked by whoever consumes it — the fat/thin axis has no
// answer for it, and it must SAY so rather than let the omitted key read
// as the 'fat' default.
shape: ServiceShape::NotApplicable,
..Default::default()
});
// The fat service that embeds it.
set.variants.push(BuildVariant {
name: "app".into(),
bin: Some("app".into()),
member: Some("app".into()),
kind: ArtifactKind::Service,
shape: ServiceShape::FatService,
embeds: BTreeSet::from(["corelib".to_string()]),
..Default::default()
});
let builders = FakeAssemble::cargo_only();
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(root, Path::new("/usr/local/bin"), &sink, &builders);
let parent = set.get("app").unwrap().clone();
let a = assemble(&set, &parent, &ctx).unwrap();
// The lib's artifact content_id, computed independently.
let lib_cid = nornir_hash::sha256_hex(b"ARTIFACT corelib");
let dep = a
.lineage
.iter()
.find(|r| r.kind == "dep" && r.name == "embeds" && r.value == "corelib")
.expect("the parent has an embeds dep row for corelib");
assert_eq!(
dep.content_id, lib_cid,
"parent dep row content_id == the lib's artifact content_id"
);
}
/// (e) A cycle in `embeds` is a clear error, not an infinite recursion.
#[test]
fn cycle_in_embeds_errors() {
let mut set = BuildVariantSet::default();
set.variants.push(BuildVariant {
name: "a".into(),
bin: Some("a".into()),
member: Some("a".into()),
kind: ArtifactKind::Service,
embeds: BTreeSet::from(["b".to_string()]),
..Default::default()
});
set.variants.push(BuildVariant {
name: "b".into(),
bin: Some("b".into()),
member: Some("b".into()),
kind: ArtifactKind::Service,
embeds: BTreeSet::from(["a".to_string()]),
..Default::default()
});
let builders = FakeAssemble::cargo_only();
let sink = InMemBuildInfoSink::new();
let dir = tempfile::tempdir().unwrap();
let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/bin"), &sink, &builders);
let a = set.get("a").unwrap().clone();
let err = assemble(&set, &a, &ctx).expect_err("a<->b embed cycle must error");
assert!(format!("{err:#}").contains("cycle"), "clear cycle error: {err:#}");
}
/// Idempotence: a second assemble of the same variant is a cache hit — it emits
/// a `cache-hit` step row and does not rebuild.
#[test]
fn idempotent_second_assemble_is_a_cache_hit() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut set = BuildVariantSet::default();
set.variants.push(exe_variant("tool"));
let builders = FakeAssemble::cargo_only();
let sink = InMemBuildInfoSink::new();
let cache = InMemAssembleCache::new();
let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders).idempotent(&cache);
let v = set.get("tool").unwrap().clone();
let first = assemble(&set, &v, &ctx).unwrap();
let second = assemble(&set, &v, &ctx).unwrap();
assert_eq!(first.digest_hex(), second.digest_hex(), "same receipt digest");
assert!(
second
.lineage
.iter()
.any(|r| r.kind == "step" && r.level == "cache-hit"),
"the cache hit emits a step row"
);
}
/// A container/VM kind without an injected builder is an honest error.
#[test]
fn container_without_injected_builder_errors() {
let toml = r#"
[build]
[[build.variant]]
name = "img"
kind = "container"
container_ref = "app-image"
[[build.container]]
name = "app-image"
base = "scratch"
tags = ["app-image:1"]
"#;
let set = BuildVariantSet::from_toml_str(toml).unwrap();
let builders = Builders::new(); // no image builder injected
let sink = InMemBuildInfoSink::new();
let dir = tempfile::tempdir().unwrap();
let ctx = AssembleCtx::new(dir.path(), Path::new("/"), &sink, &builders);
let v = set.get("img").unwrap().clone();
let err = assemble(&set, &v, &ctx).expect_err("no ImageBuilder → error");
assert!(format!("{err:#}").contains("ImageBuilder"), "{err:#}");
}
/// (slice 4b) RED-WHEN-BROKEN: `dispatch` fires the injected [`ArtifactAnalyzer`]
/// right after producing a cargo artifact, keyed by the artifact's `content_id`,
/// and threads its rows into BOTH the receipt lineage and the sink. Without an
/// analyzer injected the same assemble produces NO scan rows (the seam is
/// opt-in). Any regression that stops dispatch from calling the seam — or keys
/// it by the wrong id — turns this red.
#[test]
fn dispatch_runs_injected_analyzer_keyed_by_content_id() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut set = BuildVariantSet::default();
set.variants.push(exe_variant("tool"));
// The artifact's content_id is the sha256 of the bytes FakeAssemble writes.
let expected_cid = nornir_hash::sha256_hex(b"ARTIFACT tool");
// WITH an analyzer injected: a `binary-scan` metric row appears, carrying
// the artifact's content_id.
let analyzer = FakeAnalyzer;
let builders = FakeAssemble {
image: None,
vm: None,
iso: None,
analyzer: Some(&analyzer),
cargo_ok: true,
targets_ok: true,
};
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(root, Path::new("/usr/bin"), &sink, &builders);
let v = set.get("tool").unwrap().clone();
let a = assemble(&set, &v, &ctx).unwrap();
let scan = a
.lineage
.iter()
.find(|r| r.kind == "metric" && r.step_id == "binary-scan")
.expect("the analyzer's binary-scan row is threaded into the lineage");
assert_eq!(
scan.content_id, expected_cid,
"analyzer keyed by the artifact's content_id"
);
// The sink saw it too (produce → analyse → persist is one pass).
assert!(
sink.rows().iter().any(|r| r.kind == "metric" && r.step_id == "binary-scan"),
"the scan row was emitted to the sink"
);
// WITHOUT an analyzer: the seam is a no-op — no scan rows at all.
let bare = FakeAssemble::cargo_only();
let sink2 = InMemBuildInfoSink::new();
let ctx2 = AssembleCtx::new(root, Path::new("/usr/bin"), &sink2, &bare);
let a2 = assemble(&set, &v, &ctx2).unwrap();
assert!(
!a2.lineage.iter().any(|r| r.step_id == "binary-scan"),
"no analyzer injected → no scan rows"
);
}
// ── THE ARTIFACT GUARD ───────────────────────────────────────────────────
//
// gunnar's `gunnar-appliance-iso`, MEASURED 2026-08-05 on oden with the forge
// at dwarves d984df2 + edda 876e2aa, twice, in a fresh worktree:
//
// 'gunnar-appliance-iso' → iso gunnar-appliance-iso (digest e0bf876b22f44e95…)
// BUILT 'gunnar-appliance-iso' in 145.5s — 0 artifact(s)
// buildthing gunnar: built gunnar-appliance-iso ← exit 0
//
// 'gunnar-appliance-iso' → iso gunnar-appliance-iso (digest eb27d0b4d1a0039f…)
// BUILT 'gunnar-appliance-iso' in 1.0s — 0 artifact(s) ← exit 0
//
// No `.iso-out/gunnar-appliance-iso.iso` after either. Two digests over two runs
// that wrote nothing, because the digest is over the provenance RECORD (built_at,
// host, outcome) and was being presented as the artifact's identity.
//
// The composer had bailed on its third step both times and said so — into
// `TargetBuildOutcome::error`, which the three target arms of `dispatch` dropped.
/// A declared appliance-ISO cell: a `kind = "iso"` variant carrying the ISO handle
/// in its own name, over a `[[build.iso]]` that chains a cargo variant. gunnar's
/// shape, reduced to what the dispatch reads.
fn iso_set() -> BuildVariantSet {
BuildVariantSet::from_toml_str(
r#"
[build]
[[build.variant]]
name = "appliance-iso"
kind = "iso"
[[build.iso]]
name = "appliance-iso"
volume_id = "APPLIANCE"
format = "uefi"
staging = "iso-root"
"#,
)
.unwrap()
}
/// An [`IsoImageBuilder`] that fails the way the real appliance composer failed —
/// on a declared input that is not there.
struct RefusingIsoBuilder;
impl IsoImageBuilder for RefusingIsoBuilder {
fn engine(&self) -> &str {
"korp-appliance"
}
fn build(&self, iso: &IsoBuild, _root: &Path) -> Result<Vec<Artifact>> {
anyhow::bail!(
"iso '{}': declares `staging = \".iso-out/appliance-root\"` but it is not a \
directory",
iso.name
)
}
}
/// An [`IsoImageBuilder`] that reports SUCCESS and masters nothing — the second,
/// quieter red, and the one no error string can catch.
struct EmptyIsoBuilder;
impl IsoImageBuilder for EmptyIsoBuilder {
fn engine(&self) -> &str {
"korp-appliance"
}
fn build(&self, _iso: &IsoBuild, _root: &Path) -> Result<Vec<Artifact>> {
Ok(Vec::new())
}
}
/// An [`IsoImageBuilder`] that masters a real file — the GREEN side, without
/// which a `produced()`-says-no-to-everything implementation passes.
struct MasteringIsoBuilder;
impl IsoImageBuilder for MasteringIsoBuilder {
fn engine(&self) -> &str {
"korp-appliance"
}
fn build(&self, iso: &IsoBuild, root: &Path) -> Result<Vec<Artifact>> {
let out = crate::image_build::iso_output_path(root, iso);
std::fs::create_dir_all(out.parent().unwrap())?;
std::fs::write(&out, b"CD001 not really, but real bytes on disk")?;
Ok(vec![Artifact::hash_file(root, &out)?])
}
}
/// **A builder that FAILED must not come back as a receipt that says it built.**
///
/// The receipt still exists — a red target build files its provenance, which is
/// the contract `capture_target_build` was given deliberately. What is new is
/// that it can be ASKED, and that the builder's own sentence survives the trip.
///
/// SEEN RED before the fix: `produced()` did not exist, `Assembled` had no
/// verdict field, and the only thing a consumer could reach was
/// `artifact_content_ids().len() == 0` — the number dwarves printed beside
/// `BUILT`.
#[test]
fn an_iso_builder_that_failed_is_not_a_receipt_that_built() {
let dir = tempfile::tempdir().unwrap();
let set = iso_set();
let builders = FakeAssemble {
image: None,
vm: None,
iso: Some(&RefusingIsoBuilder),
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
let v = set.get("appliance-iso").unwrap().clone();
let a = assemble(&set, &v, &ctx).expect("a failed target build still files a receipt");
assert!(!a.success(), "the receipt says the build failed");
assert!(a.artifact_content_ids().is_empty(), "and it produced nothing");
let why = a.produced().expect_err("a failed build is not a produced thing");
let msg = format!("{why:#}");
assert!(
msg.contains(".iso-out/appliance-root"),
"the builder's OWN reason survives to the caller — this is the sentence that \
was being discarded inside nornir-build-thing: {msg}"
);
assert!(msg.contains("appliance-iso"), "and it names the cell: {msg}");
}
/// **A builder that reported SUCCESS and wrote nothing is a red that names the
/// path it did not write.**
///
/// This is the shape no error string can catch, and the one the guard exists for:
/// there is no failure to propagate, only an empty artifact list. The message has
/// to carry the expected path, because `0 artifact(s)` is a number and
/// `<repo>/.iso-out/appliance-iso.iso` is a place to look.
#[test]
fn an_iso_that_mastered_nothing_names_the_file_it_did_not_write() {
let dir = tempfile::tempdir().unwrap();
let set = iso_set();
let builders = FakeAssemble {
image: None,
vm: None,
iso: Some(&EmptyIsoBuilder),
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
let v = set.get("appliance-iso").unwrap().clone();
let a = assemble(&set, &v, &ctx).expect("the builder returned Ok, so there is a receipt");
// The trap this guard must not fall into: the build DID succeed by every
// signal the old code read.
assert!(a.success(), "the outcome is Success — nothing red to key on");
let why = a
.produced()
.expect_err("success with no artifact is not a produced thing");
let msg = format!("{why:#}");
let expected = crate::image_build::iso_output_path(dir.path(), set.iso("appliance-iso").unwrap());
assert!(
msg.contains(&expected.display().to_string()),
"the red names WHERE the ISO was expected ({}), not just how many came out: {msg}",
expected.display()
);
assert!(
!expected.exists(),
"and the file it names is genuinely absent (the guard is not reading a stale one)"
);
}
/// **The green side, asserted on the BYTES.** Without it the two reds above pass
/// against a `produced()` that always says no.
#[test]
fn an_iso_that_mastered_a_file_is_a_produced_thing() {
let dir = tempfile::tempdir().unwrap();
let set = iso_set();
let builders = FakeAssemble {
image: None,
vm: None,
iso: Some(&MasteringIsoBuilder),
analyzer: None,
cargo_ok: true,
targets_ok: true,
};
let sink = InMemBuildInfoSink::new();
let ctx = AssembleCtx::new(dir.path(), Path::new("/usr/local/bin"), &sink, &builders);
let v = set.get("appliance-iso").unwrap().clone();
let a = assemble(&set, &v, &ctx).unwrap();
a.produced().expect("a mastered ISO IS a produced thing");
// Applied output, off the disk — not the receipt's word for it. The path the
// receipt reports is what gets stat'd, so a receipt naming a file nobody wrote
// cannot pass this.
let paths = a.artifact_paths();
assert_eq!(paths.len(), 1, "one mastered ISO, one artifact row: {paths:?}");
let on_disk = dir.path().join(&paths[0]);
let bytes = std::fs::metadata(&on_disk)
.unwrap_or_else(|e| panic!("the receipt names {} and it is not there: {e}", on_disk.display()))
.len();
assert_eq!(bytes, 40, "the artifact row points at the bytes that were written");
}
}