crates/nornir-build-thing/src/iso.rs
//! **Bootable ISO build** — a fourth build *target kind* alongside the cargo
//! [`BuildVariant`](crate::variant::BuildVariant), the
//! [`ContainerBuild`](crate::container::ContainerBuild) image, and the
//! [`VmBuild`](crate::vm::VmBuild) appliance boot.
//!
//! Where a [`VmBuild`] boots a `kernel` + `rootfs` **directly** (QEMU
//! `-kernel`/`-initrd`), an [`IsoBuild`] describes mastering a **bootable
//! ISO-9660 CD/DVD image** — the artifact a firmware/OVMF boot loads off a
//! `-cdrom` (tunnr's boot primitive already routes a `*.iso` payload to `-cdrom`
//! + a firmware boot). It carries a `kernel`/`rootfs` (El-Torito / EFI boot
//! payload), an optional `staging` directory of extra files to place on the
//! filesystem, a `volume_id`, the kernel `boot_params`, and a `format` (a
//! BIOS/El-Torito ISO, a UEFI ISO, or a hybrid isohybrid image).
//!
//! Like [`container`](crate::container) and [`vm`](crate::vm) it is *data* (pure
//! serde) and dependency-light: it materializes the **`xorriso` mastering argv**
//! ([`IsoBuild::master_args`]) and the **QEMU boot argv** ([`IsoBuild::qemu_args`])
//! but runs nothing itself. It is content-addressable ([`IsoBuild::content_id`])
//! so an ISO build carries provenance the same way the other kinds do; a
//! `kernel`/`rootfs`/`staging` layer can chain the artifact a cargo
//! [`variant`](crate::variant) produced via
//! [`from_variant`](IsoBuild::from_variant) — the "build the PID-1, then master an
//! installer ISO around it" chain.
//!
//! > **Honest scope.** This descriptor + its materialized plan (mastering argv,
//! > boot argv, content id) are REAL, and the real mastering is now WIRED **in
//! > pure Rust**: [`PureRustIsoBuilder`](crate::image_build::PureRustIsoBuilder)
//! > emits the ISO-9660 + El-Torito structures byte-for-byte via
//! > [`iso_writer::IsoWriter`](crate::iso_writer::IsoWriter) and seals provenance
//! > over the produced file — with **zero external process** (no
//! > `xorriso`/`mkisofs`/`genisoimage`, no `Command::new` on the mastering path;
//! > the constellation's pure-Rust / zero-shell law). The dep-light
//! > [`PrebuiltIsoBuilder`](crate::image_build::PrebuiltIsoBuilder) remains for
//! > sealing an ISO **already produced** on disk (mirroring
//! > [`PrebuiltVmBuilder`](crate::image_build::PrebuiltVmBuilder)). The mastered
//! > ISO is proven bootable by the ignored `iso_boot` heavy test, which boots it
//! > under QEMU/OVMF and asserts the payload reaches `KORP-APPLIANCE BOOT-OK`.
//!
//! ```
//! use nornir_build_thing::{IsoBuild, IsoFormat};
//!
//! let iso = IsoBuild {
//! name: "korp-installer".into(),
//! kernel: "artifacts/bzImage".into(),
//! rootfs: "artifacts/rootfs.cpio.gz".into(),
//! volume_id: "KORP_DEMO".into(),
//! boot_params: vec!["console=ttyS0".into(), "korp.smoke=1".into()],
//! format: IsoFormat::Hybrid,
//! ..Default::default()
//! };
//! assert!(iso.master_args("out/korp.iso").iter().any(|a| a == "-o"));
//! assert!(iso.qemu_args("out/korp.iso").iter().any(|a| a == "-cdrom"));
//! assert_eq!(iso.content_id().len(), 64);
//! ```
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::variant::BuildVariant;
/// The bootable-ISO flavor an [`IsoBuild`] masters. Deserializes from a bare TOML
/// string (`format = "hybrid"`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum IsoFormat {
/// A legacy BIOS El-Torito bootable ISO (isolinux-style) — the portable default.
Bios,
/// A UEFI-bootable ISO (an EFI boot image in the El-Torito catalog).
Uefi,
/// A hybrid isohybrid image bootable from both BIOS and UEFI, and writable
/// straight to a USB stick (`isohybrid`).
Hybrid,
}
impl Default for IsoFormat {
fn default() -> Self {
IsoFormat::Bios
}
}
impl From<String> for IsoFormat {
fn from(s: String) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"" | "bios" | "eltorito" | "el-torito" | "isolinux" => IsoFormat::Bios,
"uefi" | "efi" => IsoFormat::Uefi,
"hybrid" | "isohybrid" | "usb" => IsoFormat::Hybrid,
_ => IsoFormat::Bios,
}
}
}
impl From<IsoFormat> for String {
fn from(f: IsoFormat) -> Self {
f.as_str().to_string()
}
}
impl IsoFormat {
/// Stable label (matches the string form).
pub fn as_str(&self) -> &'static str {
match self {
IsoFormat::Bios => "bios",
IsoFormat::Uefi => "uefi",
IsoFormat::Hybrid => "hybrid",
}
}
/// Whether this flavor is bootable under UEFI/OVMF firmware (Uefi + Hybrid).
pub fn is_uefi(&self) -> bool {
matches!(self, IsoFormat::Uefi | IsoFormat::Hybrid)
}
}
/// One concrete **bootable-ISO build point** — the ISO sibling of a cargo
/// [`BuildVariant`](crate::variant::BuildVariant), a
/// [`ContainerBuild`](crate::container::ContainerBuild), and a
/// [`VmBuild`](crate::vm::VmBuild).
///
/// Serializes as a `[[build.iso]]` table:
/// ```toml
/// [[build.iso]]
/// name = "korp-installer"
/// kernel = "artifacts/bzImage"
/// rootfs = "artifacts/rootfs.cpio.gz"
/// staging = "iso-root"
/// volume_id = "KORP_DEMO"
/// boot_params = ["console=ttyS0", "korp.smoke=1"]
/// format = "hybrid"
/// from_variant = "korp-pid1" # the cargo build whose binary rides the ISO
/// boot-logo = "assets/customer-logo.webp" # per-ISO customer mark (⇒ KORP_BOOT_LOGO)
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IsoBuild {
/// Stable handle (`nornir build --iso <name>` / the test-boot row).
pub name: String,
/// The kernel placed on the ISO as its El-Torito / EFI boot payload
/// (`-kernel` when the built ISO is booted). Empty for a pure firmware/GRUB ISO
/// that carries its own bootloader.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub kernel: String,
/// The initramfs / rootfs the boot payload loads (`-initrd`). Empty for an ISO
/// whose root filesystem lives on the ISO-9660 volume itself.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub rootfs: String,
/// A staging directory whose contents are placed on the mastered ISO-9660
/// filesystem (extra data components, a korp basemap, GRUB config, …).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub staging: String,
/// The ISO-9660 volume id (`-V`), e.g. `KORP_DEMO`.
#[serde(default = "default_volume_id")]
pub volume_id: String,
/// Kernel command-line parameters the ISO boots with, joined with spaces.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub boot_params: Vec<String>,
/// The ISO flavor.
#[serde(default, skip_serializing_if = "is_bios")]
pub format: IsoFormat,
/// When set, the `kernel`/`rootfs`/`staging` payload is (or is produced by) the
/// artifact this cargo build variant produced — the "build it, then master an
/// ISO around it" chain. The host stages the variant's artifact before mastering.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_variant: Option<String>,
/// **This ISO cell's own customer boot-logo override** — `boot-logo =
/// "assets/customer-logo.webp"`, relative to the repo root (an absolute path is
/// taken as-is).
///
/// The ISO-target twin of [`BuildVariant::boot_logo`]. It rides the SAME wire —
/// exported as [`BuildVariant::BOOT_LOGO_ENV`] (`KORP_BOOT_LOGO`), the one thing
/// [`korp_installer::splash`] reads to bake the customer's mark instead of korp's
/// raven — so there is no second resolution path to keep in sync.
///
/// **Precedence.** When BOTH this ISO cell and its chained
/// [`from_variant`](Self::from_variant) declare a boot-logo, THIS per-ISO
/// declaration wins — it is the most specific ("this installer image gets this
/// customer's mark"). See [`IsoBuild::boot_logo_env`].
///
/// Omitted ⇒ nothing is exported and korp's own `assets/korp-logo.webp` (the
/// raven) is baked, which is the correct default and stays the default.
#[serde(rename = "boot-logo", alias = "boot_logo", default, skip_serializing_if = "Option::is_none")]
pub boot_logo: Option<String>,
/// **Declared build parameters** (`[[build.iso.params]]`) — the per-site
/// inputs this cell accepts (a hub public key, an endpoint, a site name)
/// and where each lands (a `boot_params` token or a staged file). The CLI
/// supplies values via repeatable `--param name=value`; see
/// [`crate::params`]. Secret-shaped names are refused at validation.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<crate::params::ParamSpec>,
/// **Declared fixtures** (`[build.iso.fixtures]`, parameter name → kind) —
/// the twin of `InstallerBuild::fixtures`; see [`crate::params`].
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub fixtures: std::collections::BTreeMap<String, crate::params::FixtureKind>,
/// **The applied values** ([`IsoBuild::apply_cli_params`]) — recorded ON
/// the cell so the serialized descriptor, the attestation and the content
/// id all carry exactly which values produced the artifact: the build
/// record answers "which ISO holds which hub key" without booting it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applied_params: Vec<crate::params::AppliedParam>,
/// **Declared build OPTIONS** (`[[build.iso.options]]`) — the twin of
/// [`InstallerBuild::options`](crate::installer::InstallerBuild::options).
/// An installer copies its own list onto the ISO it masters, so a
/// composition that only ever sees the rendered ISO still knows which
/// toggles produced it. See [`crate::options`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<crate::options::OptionSpec>,
/// The twin of [`InstallerBuild::requires`](crate::installer::InstallerBuild::requires):
/// row-level requirements of this live medium on its sub build-things ([`crate::requires`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<crate::requires::EdgeRequirement>,
/// The resolved option states this medium was built with — the twin of
/// [`InstallerBuild::applied_options`](crate::installer::InstallerBuild::applied_options).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applied_options: Vec<crate::options::AppliedOption>,
/// **The declared kernel this LIVE medium boots** (`[build.iso.kernel_donor]`)
/// — the `kernel` edge of typ kernel, the same
/// [`KernelDonor`](crate::installer::KernelDonor) an installer declares and
/// the same ONE reader ([`crate::kernel::resolve_built_kernel`]). `None` is
/// the honest spelling of "this medium has not chosen one": the composer's
/// environment knob or the host's own kernel, out loud (korp's product
/// decision, open). Benign to published 0.1.x readers (no
/// `deny_unknown_fields` here), so a consumer on an older pin guards the
/// declaration by reading the raw file — gunnar's `toml_parity` does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kernel_donor: Option<crate::installer::KernelDonor>,
/// **Every other consumption edge** (`[build.iso.edges.<feature>]`) — a
/// root feature bound to `repo/variant : typ`, type-checked at declaration
/// time. See [`EdgeDecl`](crate::tree::EdgeDecl).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub edges: std::collections::BTreeMap<String, crate::tree::EdgeDecl>,
/// **Declared PRESETS** (`[build.iso.presets.<name>]`) — the twin of
/// [`InstallerBuild::presets`](crate::installer::InstallerBuild::presets):
/// named bundles over this variant's tree, the root `preset` val.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub presets: crate::tree::Presets,
/// The preset this medium was built with — bundle AND expansion.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applied_preset: Option<crate::tree::AppliedPreset>,
}
fn default_volume_id() -> String {
"NORDISK".to_string()
}
fn is_bios(f: &IsoFormat) -> bool {
*f == IsoFormat::Bios
}
impl Default for IsoBuild {
fn default() -> Self {
IsoBuild {
name: String::new(),
kernel: String::new(),
rootfs: String::new(),
staging: String::new(),
volume_id: default_volume_id(),
boot_params: Vec::new(),
format: IsoFormat::default(),
from_variant: None,
boot_logo: None,
params: Vec::new(),
fixtures: std::collections::BTreeMap::new(),
applied_params: Vec::new(),
options: Vec::new(),
requires: Vec::new(),
applied_options: Vec::new(),
kernel_donor: None,
edges: std::collections::BTreeMap::new(),
presets: crate::tree::Presets::new(),
applied_preset: None,
}
}
}
impl IsoBuild {
/// This descriptor's artifact kind — always
/// [`ArtifactKind::Iso`](crate::variant::ArtifactKind::Iso). The unifying label
/// that lets one dispatcher / enumeration / provenance `kind` span ISOs
/// alongside the cargo variants, containers and vms.
pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
crate::variant::ArtifactKind::Iso
}
/// The cargo build variant this ISO chains as its payload (via
/// [`from_variant`](Self::from_variant)) — the build that must run first so its
/// artifact can be staged onto the ISO.
pub fn variant_deps(&self) -> Vec<&str> {
self.from_variant.as_deref().into_iter().collect()
}
/// The absolute boot-logo path THIS ISO cell declares, resolved against
/// `repo_root`. `None` when the cell declares no mark. Mirrors
/// [`BuildVariant::boot_logo_path`] so a relative `boot-logo` resolves the same
/// way whether it is declared on the variant or on the ISO.
pub fn boot_logo_path(&self, repo_root: &Path) -> Option<PathBuf> {
let declared = self.boot_logo.as_deref()?.trim();
if declared.is_empty() {
return None;
}
let p = Path::new(declared);
Some(if p.is_absolute() { p.to_path_buf() } else { repo_root.join(p) })
}
/// Resolve the customer boot-logo override to export when building this ISO's
/// payload, as the `(env var, absolute path)` pair to set on the child cargo —
/// the env is always [`BuildVariant::BOOT_LOGO_ENV`] (`KORP_BOOT_LOGO`), reused so
/// the ISO path and the variant path cannot drift.
///
/// **Precedence:** this ISO cell's own [`boot_logo`](Self::boot_logo) wins (most
/// specific); otherwise the chained `variant`'s
/// [`boot_logo`](BuildVariant::boot_logo) is used; `None` ⇒ no override, korp
/// bakes its default raven. Pass the resolved `from_variant` as `variant` (or
/// `None` when the chain carries no boot-logo).
pub fn boot_logo_env(
&self,
repo_root: &Path,
variant: Option<&BuildVariant>,
) -> Option<(&'static str, PathBuf)> {
let path = self
.boot_logo_path(repo_root)
.or_else(|| variant.and_then(|v| v.boot_logo_path(repo_root)))?;
Some((BuildVariant::BOOT_LOGO_ENV, path))
}
/// The kernel command line (`boot_params` joined with spaces).
pub fn cmdline(&self) -> String {
self.boot_params.join(" ")
}
/// **Resolve the declared `staging` tree against `repo_root`** — `Ok(None)`
/// when nothing is declared, `Err` when a tree IS declared and is not a
/// directory on disk, `Ok(Some(abs))` otherwise.
///
/// # Why a declaration that is not on disk is an error
///
/// The pure-Rust masterer used to write
///
/// ```text
/// if !iso.staging.is_empty() {
/// let staging = repo_root.join(&iso.staging);
/// if staging.is_dir() { w.add_dir_recursive("", &staging)?; }
/// // …and no else.
/// }
/// ```
///
/// so a `staging` that had been renamed, or never produced by the step
/// before, was skipped **in silence**. Measured through the real forge on
/// 2026-08-07: `staging = "no-such-staging-dir"` produced a valid
/// 47 104-byte ISO with nothing on it and reported `BUILT … 1 artifact(s)`,
/// exit 0. The correct build was 49 152 bytes. The entire difference between
/// "carries the payload" and "carries nothing" was 2 048 bytes and no change
/// of verdict — a failure discovered by whoever boots the media.
///
/// A declaration is a claim that the tree exists, so an absent tree is a
/// hard, named error at build time.
///
/// # One resolver, two consumers
///
/// `PureRustIsoBuilder` grafts this tree onto the ISO-9660 volume and korp's
/// `ApplianceIsoBuilder` grafts it into the initramfs instead (an appliance
/// never mounts the ISO-9660 volume). Where the bytes land differs; whether
/// the declaration is satisfied does not, so both route through here rather
/// than each carrying its own copy of the check.
pub fn staging_tree(&self, repo_root: &Path) -> Result<Option<PathBuf>> {
let declared = self.staging.trim();
if declared.is_empty() {
return Ok(None);
}
let tree = repo_root.join(declared);
if !tree.is_dir() {
return Err(anyhow!(
"iso '{}': declares `staging = \"{}\"` but {} is not a directory. A declared \
staging tree that is absent used to be SKIPPED IN SILENCE — the ISO mastered \
clean, reported success, and carried nothing, which is only discovered by \
whoever boots it. Produce the tree before mastering, or drop the declaration.",
self.name,
declared,
tree.display()
));
}
Ok(Some(tree))
}
/// Materialize the **`xorriso` mastering argv** that produces the bootable ISO
/// at `out` — the ISO analogue of
/// [`ContainerBuild::build_args`](crate::container::ContainerBuild::build_args)
/// and [`VmBuild::qemu_args`](crate::vm::VmBuild::qemu_args). Deterministic, so
/// the same descriptor always renders the same argv (the input a
/// content-addressed build hashes). The host prepends the `xorriso` binary and
/// drives it (or a future pure-Rust ISO master consumes this plan).
pub fn master_args(&self, out: &str) -> Vec<String> {
let mut a = vec![
"-as".to_string(),
"mkisofs".to_string(),
"-V".to_string(),
self.volume_id.clone(),
"-o".to_string(),
out.to_string(),
];
if !self.kernel.is_empty() {
a.push("-b".to_string()); // El-Torito boot image
a.push(self.kernel.clone());
a.push("-no-emul-boot".to_string());
}
if self.format.is_uefi() {
a.push("-eltorito-alt-boot".to_string());
a.push("-e".to_string()); // EFI boot image
a.push("efiboot.img".to_string());
a.push("-no-emul-boot".to_string());
}
if self.format == IsoFormat::Hybrid {
a.push("-isohybrid-gpt-basdat".to_string());
}
if !self.staging.is_empty() {
a.push(self.staging.clone());
}
a
}
/// Materialize the QEMU **argv** to boot this ISO as a firmware/OVMF CD-ROM
/// guest (`-cdrom <out> -boot d`) — the same firmware-boot path tunnr's boot
/// primitive routes a `*.iso` payload onto. Headless serial console, so a boot
/// test captures the console + asserts a `BOOT-OK` marker.
pub fn qemu_args(&self, out: &str) -> Vec<String> {
vec![
"-machine".to_string(),
"q35,accel=kvm:tcg".to_string(),
"-m".to_string(),
"512".to_string(),
"-nographic".to_string(),
"-no-reboot".to_string(),
"-cdrom".to_string(),
out.to_string(),
"-boot".to_string(),
"d".to_string(),
"-serial".to_string(),
"mon:stdio".to_string(),
]
}
/// A human-readable one-liner describing the master. **No external tool is invoked**
/// — the ISO is written by the pure-Rust `PureRustIsoBuilder` (ISO-9660 + El-Torito).
/// The mkisofs-shaped `master_args` are the content-addressed LAYOUT key (shown for
/// provenance/diffing), never executed as a command.
pub fn command_line(&self, out: &str) -> String {
format!("pure-Rust ISO-9660 + El-Torito → {out} (layout: {})", self.master_args(out).join(" "))
}
/// Validate the descriptor: a name, a volume id, and something to boot (a
/// `kernel`, a `staging` tree carrying a bootloader, or a chained `from_variant`).
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(anyhow!("iso build: empty name"));
}
if self.volume_id.trim().is_empty() {
return Err(anyhow!("iso '{}': empty volume_id", self.name));
}
if self.kernel.trim().is_empty()
&& self.staging.trim().is_empty()
&& self.from_variant.is_none()
{
return Err(anyhow!(
"iso '{}': needs a `kernel`, a `staging` tree, or a `from_variant` to be bootable",
self.name
));
}
// The fixture suffix is RESERVED. A fixture compose masters cell `x` to
// `x-fixture.iso` (see `image_build::iso_out_stem`), so a cell actually
// NAMED `x-fixture` would have a release medium land on exactly the path
// a fixture medium of `x` lands on — the same collision, one name over,
// that the suffix exists to prevent. Refused at the descriptor rather
// than discovered by two composes overwriting each other.
if self.name.trim().ends_with(crate::image_build::FIXTURE_ISO_SUFFIX) {
return Err(anyhow!(
"iso '{}': the suffix `{}` is reserved — a FIXTURE compose of cell `{}` masters \
to that name, so a release medium here would collide with it. Rename the cell.",
self.name,
crate::image_build::FIXTURE_ISO_SUFFIX,
self.name.trim().trim_end_matches(crate::image_build::FIXTURE_ISO_SUFFIX),
));
}
crate::params::validate_specs(&self.params)
.map_err(|e| anyhow!("iso '{}': {e:#}", self.name))?;
crate::params::validate_fixtures(&self.params, &self.fixtures)
.map_err(|e| anyhow!("iso '{}': {e:#}", self.name))?;
crate::options::validate_options(&self.options, &self.params)
.map_err(|e| anyhow!("iso '{}': {e:#}", self.name))?;
for r in &self.requires {
r.what.validate(&format!("iso '{}' (edge '{}')", self.name, r.edge))?;
}
if self.params.iter().any(|p| !p.staged_file.trim().is_empty())
&& self.staging.trim().is_empty()
{
return Err(anyhow!(
"iso '{}': a parameter lands as a staged file but the cell declares no \
`staging` tree to land it in",
self.name
));
}
// The edges: both halves of the kernel donor named; every other edge
// well-formed and not a second spelling of the donor, nor of an option.
let who = format!("iso '{}'", self.name);
if let Some(d) = &self.kernel_donor {
d.validate(&who)?;
if !self.kernel.trim().is_empty() {
return Err(anyhow!(
"{who}: declares BOTH `kernel = {:?}` (a file) and [build.iso.kernel_donor] \
{}/{} — one kernel per medium; the donor is the declaration, the file was \
the pre-donor shortcut",
self.kernel,
d.repo,
d.kernel
));
}
}
crate::tree::validate_edge_rows(&who, self.kernel_donor.as_ref(), &self.edges, &self.options)?;
crate::tree::SettingsTree::of_iso(self, None).validate_presets()?;
Ok(())
}
/// **Apply CLI-supplied parameter values** ([`crate::params`]): landed
/// `boot_params` tokens are appended, the `(name, value)` record is set,
/// and staged-file contents become available via
/// [`staged_param_files`](IsoBuild::staged_param_files). Refusals are the
/// params module's — unknown/secret/missing/mis-shaped, each by name.
pub fn apply_cli_params(
&mut self,
supplied: &std::collections::BTreeMap<String, String>,
) -> Result<()> {
self.apply_cli_params_with(supplied, &crate::params::NoFixtures)
}
/// [`apply_cli_params`](Self::apply_cli_params) with a [`FixtureSource`]
/// (`crate::params::FixtureSource`) for a `@fixture` value against this
/// cell's declared [`fixtures`](Self::fixtures).
pub fn apply_cli_params_with(
&mut self,
supplied: &std::collections::BTreeMap<String, String>,
source: &dyn crate::params::FixtureSource,
) -> Result<()> {
let landed =
crate::params::apply_params_with(&self.params, supplied, &self.fixtures, source)
.map_err(|e| anyhow!("iso '{}': {e:#}", self.name))?;
self.boot_params.extend(landed.boot_params);
self.applied_params = landed.record;
Ok(())
}
/// The `(relative path, contents)` files the applied parameters place on
/// the staging tree — derived from the SPECS (the one writer of where a
/// value lands), so a record cannot drift from its landing.
pub fn staged_param_files(&self) -> Vec<(String, String)> {
self.staged_param_files_landing().into_iter().map(|(p, v, _)| (p, v)).collect()
}
/// [`staged_param_files`](Self::staged_param_files) with each file's declared
/// [`Landing`](crate::params::Landing) — what an installer composition splits on: a
/// `Release` file rides the signed release (every install, keep included), a `Data` one
/// is a first-boot `/data` stage.
pub fn staged_param_files_landing(&self) -> Vec<(String, String, crate::params::Landing)> {
self.applied_params
.iter()
.filter_map(|ap| {
let spec = self.params.iter().find(|s| s.name == ap.name)?;
(!spec.staged_file.trim().is_empty())
.then(|| (spec.staged_file.clone(), ap.value.clone(), spec.lands))
})
.collect()
}
/// The canonical bytes an ISO build's content id is taken over: the materialized
/// mastering argv (with a stable placeholder output path) plus every other
/// declared field that changes the mastered image — a stable ISO-spec
/// fingerprint that survives serde round-trips.
///
/// # Why `rootfs` and `boot-logo` are appended here
///
/// The argv is not, on its own, the full set of inputs.
///
/// * **`rootfs`** renders no `mkisofs` argument at all — under `xorriso` the
/// initramfs rode in on the staging tree, so there was nothing to name.
/// [`PureRustIsoBuilder`](crate::image_build::PureRustIsoBuilder) places it
/// itself (`add_file(&iso.rootfs, …)`), so it changes the mastered bytes
/// while leaving the argv untouched. Two ISOs differing only in `rootfs`
/// therefore folded to ONE id: two different inputs, one content address,
/// and a cache keyed on it hands back the wrong image.
/// * **`boot-logo`** never reaches the argv either — it is exported as
/// [`BuildVariant::BOOT_LOGO_ENV`] to the chained cargo build, so it bakes
/// a different payload binary onto the ISO. Two customers' installers are
/// not one cache entry.
///
/// Each is tagged, so an empty field and an absent one are the same bytes and
/// a value cannot be confused with a neighbouring field's.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut s = self.master_args("<out>").join(" ");
s.push_str(&format!("#format:{}", self.format.as_str()));
s.push_str(&format!("#cmdline:{}", self.cmdline()));
if !self.rootfs.is_empty() {
s.push_str(&format!("#rootfs:{}", self.rootfs));
}
if let Some(v) = &self.from_variant {
s.push_str(&format!("#from_variant:{v}"));
}
if let Some(logo) = &self.boot_logo {
s.push_str(&format!("#boot_logo:{logo}"));
}
// Two media that boot DIFFERENT kernels — or consume different build
// things — are different artifacts. Only written when declared, so every
// edgeless medium keeps the id it already had.
if let Some(d) = &self.kernel_donor {
s.push_str(&format!("#kernel-donor:{}/{}", d.repo, d.kernel));
}
for (f, e) in &self.edges {
s.push_str(&format!("#edge:{f}={}:{}", e.coordinate(), e.typ.as_str()));
}
if let Some(p) = &self.applied_preset {
s.push_str(&format!("#preset:{}", p.bundle));
}
s.into_bytes()
}
/// A content-addressed id (lowercase-hex SHA-256 over [`canonical_bytes`]) —
/// the ISO-build analogue of a cargo/image/vm build's provenance digest.
pub fn content_id(&self) -> String {
nornir_hash::sha256_hex(&self.canonical_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn korp() -> IsoBuild {
IsoBuild {
name: "korp-installer".into(),
kernel: "artifacts/bzImage".into(),
rootfs: "artifacts/rootfs.cpio.gz".into(),
staging: "iso-root".into(),
volume_id: "KORP_DEMO".into(),
boot_params: vec!["console=ttyS0".into(), "korp.smoke=1".into()],
format: IsoFormat::Hybrid,
from_variant: Some("korp-pid1".into()),
boot_logo: None,
..Default::default()
}
}
#[test]
fn master_args_carry_volume_output_and_boot_image() {
let iso = korp();
let a = iso.master_args("out/korp.iso");
assert!(a.windows(2).any(|w| w == ["-V", "KORP_DEMO"]));
assert!(a.windows(2).any(|w| w == ["-o", "out/korp.iso"]));
assert!(a.windows(2).any(|w| w == ["-b", "artifacts/bzImage"]), "El-Torito boot image");
// hybrid → both an EFI alt-boot and the isohybrid GPT flag.
assert!(a.iter().any(|x| x == "-eltorito-alt-boot"), "UEFI alt-boot for a hybrid ISO");
assert!(a.iter().any(|x| x == "-isohybrid-gpt-basdat"));
assert_eq!(a.last().unwrap(), "iso-root", "the staging tree is the final mkisofs arg");
// deterministic
assert_eq!(iso.master_args("out/korp.iso"), korp().master_args("out/korp.iso"));
assert_eq!(
iso.command_line("k.iso"),
format!("pure-Rust ISO-9660 + El-Torito → k.iso (layout: {})", iso.master_args("k.iso").join(" "))
);
}
#[test]
fn qemu_args_boot_the_iso_as_a_firmware_cdrom() {
let a = korp().qemu_args("out/korp.iso");
assert!(a.windows(2).any(|w| w == ["-cdrom", "out/korp.iso"]), "ISO → -cdrom");
assert!(a.windows(2).any(|w| w == ["-boot", "d"]), "boot order prefers the CD/DVD");
assert!(a.windows(2).any(|w| w == ["-serial", "mon:stdio"]), "serial capture for the marker");
assert!(a.iter().any(|x| x == "-nographic"));
}
#[test]
fn variant_deps_lists_the_chained_cargo_build() {
assert_eq!(korp().variant_deps(), vec!["korp-pid1"]);
assert_eq!(korp().cmdline(), "console=ttyS0 korp.smoke=1");
}
#[test]
fn the_fixture_suffix_is_a_reserved_cell_name() {
// The release cell validates.
let mut ok = korp();
ok.name = "gunnar-appliance-iso".into();
assert!(ok.validate().is_ok(), "the ordinary cell name is fine: {:?}", ok.validate());
// The colliding one does not, and the refusal names the cell it would
// collide with rather than only the rule.
let mut clash = korp();
clash.name = "gunnar-appliance-iso-fixture".into();
let e = clash.validate().expect_err(
"a cell whose name ends in the reserved fixture suffix must be refused",
);
let msg = format!("{e:#}");
assert!(msg.contains("reserved"), "the refusal says why: {msg}");
assert!(
msg.contains("cell `gunnar-appliance-iso`"),
"the refusal names the cell it would collide with: {msg}"
);
}
#[test]
fn validate_requires_a_bootable_payload() {
assert!(korp().validate().is_ok());
// Nothing to boot (no kernel, no staging, no from_variant) is rejected.
let empty = IsoBuild { name: "ghost".into(), ..Default::default() };
assert!(empty.validate().is_err(), "an ISO with no bootable payload is rejected");
// A pure-staging ISO (its own bootloader on the tree) is fine.
let staged = IsoBuild {
name: "grub".into(),
staging: "iso-root".into(),
..Default::default()
};
assert!(staged.validate().is_ok());
// Empty volume id rejected.
let mut noid = korp();
noid.volume_id = String::new();
assert!(noid.validate().is_err(), "empty volume_id rejected");
}
#[test]
fn descriptor_and_content_id_round_trip() {
let iso = korp();
let id = iso.content_id();
assert_eq!(id.len(), 64);
let json = serde_json::to_string_pretty(&iso).unwrap();
let back: IsoBuild = serde_json::from_str(&json).unwrap();
assert_eq!(back, iso, "IsoBuild survives a JSON round-trip");
assert_eq!(back.content_id(), id, "content id is stable across round-trip");
// A distinct ISO (different format) has a distinct id.
let mut other = korp();
other.format = IsoFormat::Bios;
assert_ne!(other.content_id(), id, "distinct ISOs have distinct ids");
}
#[test]
fn iso_format_parses_and_round_trips_all_spellings() {
assert_eq!(IsoFormat::from("bios".to_string()), IsoFormat::Bios);
assert_eq!(IsoFormat::from("".to_string()), IsoFormat::Bios);
assert_eq!(IsoFormat::from("UEFI".to_string()), IsoFormat::Uefi);
assert_eq!(IsoFormat::from("hybrid".to_string()), IsoFormat::Hybrid);
assert_eq!(IsoFormat::from("usb".to_string()), IsoFormat::Hybrid);
assert_eq!(String::from(IsoFormat::Uefi), "uefi");
assert_eq!(IsoFormat::default(), IsoFormat::Bios);
assert!(IsoFormat::Hybrid.is_uefi() && IsoFormat::Uefi.is_uefi());
assert!(!IsoFormat::Bios.is_uefi());
}
#[test]
fn iso_build_from_toml() {
let toml = r#"
name = "facett-installer"
kernel = "artifacts/bzImage"
rootfs = "artifacts/rootfs.cpio.gz"
volume_id = "FACETT_DEMO"
boot_params = ["console=ttyS0", "facett.smoke=1"]
format = "uefi"
"#;
let iso: IsoBuild = toml::from_str(toml).unwrap();
assert_eq!(iso.format, IsoFormat::Uefi);
assert_eq!(iso.volume_id, "FACETT_DEMO");
assert!(iso.master_args("x.iso").iter().any(|a| a == "-e"), "a UEFI ISO gets an EFI boot image");
}
#[test]
fn iso_cell_boot_logo_override_parses_and_surfaces_on_the_same_wire() {
// An `[[build.iso]]` cell that declares `boot-logo` surfaces the override,
// resolved against the repo root and exported on `KORP_BOOT_LOGO` — the SAME
// env the variant path uses (reused via `BuildVariant::BOOT_LOGO_ENV`).
let toml = r#"
name = "korp-installer"
staging = "iso-root"
volume_id = "KORP_INSTALL"
boot-logo = "assets/customer-logo.webp"
format = "uefi"
"#;
let iso: IsoBuild = toml::from_str(toml).unwrap();
assert_eq!(iso.boot_logo.as_deref(), Some("assets/customer-logo.webp"));
let (env, path) = iso
.boot_logo_env(Path::new("/srv/customer"), None)
.expect("the ISO cell declares an override");
assert_eq!(env, BuildVariant::BOOT_LOGO_ENV, "rides KORP_BOOT_LOGO, no drift");
assert_eq!(path, PathBuf::from("/srv/customer/assets/customer-logo.webp"));
// No `boot-logo` (and no variant fallback) ⇒ None ⇒ korp bakes its default raven.
let stock: IsoBuild = toml::from_str(
"name = \"korp-installer\"\nstaging = \"iso-root\"\nvolume_id = \"KORP_INSTALL\"\nformat = \"uefi\"\n",
)
.unwrap();
assert_eq!(stock.boot_logo, None);
assert_eq!(stock.boot_logo_env(Path::new("/srv/customer"), None), None);
// A snake_case alias parses too (serde `alias = "boot_logo"`); an absolute
// path is taken as-is.
let alias: IsoBuild =
toml::from_str("name = \"i\"\nstaging = \"iso-root\"\nboot_logo = \"/opt/brand/mark.webp\"\n").unwrap();
assert_eq!(
alias.boot_logo_env(Path::new("/srv/customer"), None).map(|(_, p)| p),
Some(PathBuf::from("/opt/brand/mark.webp")),
);
}
#[test]
fn iso_cell_boot_logo_wins_over_the_from_variant_boot_logo() {
let repo = Path::new("/srv/customer");
// The chained variant declares a mark…
let variant = BuildVariant {
name: "korp-pid1".into(),
boot_logo: Some("brand/variant-mark.png".into()),
..Default::default()
};
// …and so does the ISO cell: the per-ISO (most specific) declaration wins.
let iso = IsoBuild {
name: "korp-installer".into(),
staging: "iso-root".into(),
boot_logo: Some("assets/iso-mark.webp".into()),
from_variant: Some("korp-pid1".into()),
..Default::default()
};
assert_eq!(
iso.boot_logo_env(repo, Some(&variant)).map(|(_, p)| p),
Some(PathBuf::from("/srv/customer/assets/iso-mark.webp")),
"ISO-cell boot-logo wins over the from_variant boot-logo",
);
// With NO override on the ISO cell, the chained variant's mark is the fallback.
let inherit = IsoBuild {
name: "korp-installer".into(),
staging: "iso-root".into(),
from_variant: Some("korp-pid1".into()),
..Default::default()
};
assert_eq!(
inherit.boot_logo_env(repo, Some(&variant)).map(|(_, p)| p),
Some(PathBuf::from("/srv/customer/brand/variant-mark.png")),
"an ISO with no own boot-logo inherits the from_variant's mark",
);
}
}