Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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",
        );
    }
}