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/params.rs

//! **Declared build parameters** — the seam that makes a per-site installer
//! ISO a DECLARED, repeatable build instead of a hand-edited image.
//!
//! The declaration owns WHICH parameters a cell accepts and WHERE each value
//! lands; the CLI supplies the values at build time; the applied values ride
//! the cell's own serialized descriptor into the existing attestation path —
//! so months later the build record says WHICH ISO holds WHICH hub key
//! without booting it, and no side file was invented.
//!
//! ```toml
//! [[build.installer]]
//! name = "holger-installer"
//! # …
//! [[build.installer.params]]
//! name = "site"
//! label = "Site name"
//! kind = "text"
//! required = true
//! boot_param = "korp.site"          # lands as korp.site=<value>
//!
//! [[build.installer.params]]
//! name = "hub_conf"
//! label = "Hub declaration (the ceremony hub.conf)"
//! kind = "text"
//! required = true
//! staged_file = "hub.conf"          # lands as a file on the boot medium
//! ```
//!
//! # The mechanism is `--param key=value`, and why
//!
//! The brief allowed a repeatable `--param` OR a `--site <file>`; this
//! implements `--param` (with `@path` values for multi-line content, the curl
//! convention) because it is the smaller contract that the other composes
//! over: a site file is one `xargs`/wrapper away from `--param`s, while a
//! file-only mechanism forces every script and CI job through a temp file.
//! One parser, one validation path, and the UI later renders the same
//! declaration — CLI and panel cannot drift.
//!
//! # Secrets are refused BY NAME
//!
//! Only non-secret values may travel this way — the cmdline, the TOML and the
//! build record are all readable. A parameter whose NAME says it carries a
//! secret (`private`, `secret`, `password`, `token`, `psk`, `preshared`) is
//! refused at DECLARATION time and again at SUPPLY time, with the secrecy
//! named — never accepted quietly.
//!
//! # Declared fixtures — `@fixture`
//!
//! A cell may DECLARE that a parameter can be filled by a **fixture** the
//! forge mints itself, instead of a value an operator pastes:
//!
//! ```toml
//! [build.installer.fixtures]
//! hub_conf = "wg-hub"      # a userspace WireGuard hub: one UDP socket, no root
//! ```
//!
//! The declaration is a CELL-level table keyed by parameter name (not a field
//! on the `[[…params]]` row) on purpose: `ParamSpec` is
//! `deny_unknown_fields`, so a new row key would make the recipe unloadable
//! for every consumer still on a published 0.1.x reader — gunnar's parity
//! mirror reads its own recipe through the registry 0.1.7 — while an unknown
//! cell-level table is ignored by those readers and READ by this one. It is
//! validated at declaration time ([`validate_fixtures`]): the name must be a
//! declared parameter, the parameter may not name a secret, and the fixture's
//! kind must be able to land where the parameter lands (a `wg-hub` renders a
//! `hub.conf` FILE, so the parameter must be `kind = "text"` with a
//! `staged_file`). At supply time the operator — CLI or panel — passes the
//! sentinel value [`FIXTURE_VALUE`] (`--param hub_conf=@fixture`); exactly one
//! of a literal value / `@path` / `@fixture` applies. The sentinel is resolved
//! through a [`FixtureSource`] the composition injects (the one that knows how
//! to mint a hub is `korp_installer::wgfixture`, reached through
//! `ForgeComposer`), the resolved text is validated like any other value, and
//! the [`AppliedParam`] record says — never silently — that the value came from
//! fixture `<kind>`, plus where the fixture's private half was kept. The
//! private half never travels as a parameter: only the rendered PUBLIC text
//! (`[Peer]` data) does.

use std::collections::BTreeMap;
use std::net::{Ipv4Addr, SocketAddrV4};

use anyhow::{anyhow, bail, Result};
use serde::{Deserialize, Serialize};

/// Name fragments that mean "this value is a secret" — checked lowercased,
/// substring. `pub` so a UI can grey the same names the CLI refuses.
pub const SECRET_NAME_FRAGMENTS: &[&str] =
    &["private", "secret", "passw", "token", "psk", "preshared"];

/// Does this parameter name claim to carry a secret?
pub fn names_a_secret(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    SECRET_NAME_FRAGMENTS.iter().any(|f| lower.contains(f))
}

/// The sentinel a CLI or panel supplies in place of a value to say "fill this
/// parameter from the fixture the recipe declared". Exactly this spelling; it
/// is NOT an `@path` file read (a file literally named `fixture` cannot be
/// supplied with the bare `@` form — spell its path).
pub const FIXTURE_VALUE: &str = "@fixture";

/// The fixtures a forge can mint for a declared parameter. One kind today.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FixtureKind {
    /// A USERSPACE WireGuard hub — `wg_appliance_core::hubfixture::HubFixture`:
    /// one UDP socket, no tun, no root, no kernel WireGuard. The fixture's
    /// rendered value is the ceremony-dialect `hub.conf` (PUBLIC data: the
    /// hub's public key, its endpoint, AllowedIPs, the appliance's address);
    /// its private key stays in the forge's own state dir, 0600, named in the
    /// applied record.
    WgHub,
}

impl FixtureKind {
    /// The recipe spelling (`"wg-hub"`), for messages and the UI.
    pub fn label(self) -> &'static str {
        match self {
            FixtureKind::WgHub => "wg-hub",
        }
    }

    /// Can this fixture land where `spec` lands? A `wg-hub` renders a FILE of
    /// text, so its parameter must be `kind = "text"` with a `staged_file`.
    pub fn fits(self, spec: &ParamSpec) -> Result<()> {
        match self {
            FixtureKind::WgHub => {
                if spec.kind != ParamKind::Text || spec.staged_file.trim().is_empty() {
                    bail!(
                        "fixture '{}' on parameter '{}': a wg-hub renders a hub.conf FILE, so \
                         the parameter must be `kind = \"text\"` with a `staged_file` (it is \
                         kind {:?}{})",
                        self.label(),
                        spec.name,
                        spec.kind,
                        if spec.boot_param.trim().is_empty() {
                            String::new()
                        } else {
                            format!(", landing on the cmdline as {}", spec.boot_param)
                        }
                    );
                }
                Ok(())
            }
        }
    }
}

impl std::fmt::Display for FixtureKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.label())
    }
}

/// Validate a cell's declared fixtures against its declared parameters: every
/// key names a declared parameter, and the kind fits where it lands. (A
/// secret-shaped name is already refused by [`validate_specs`].)
pub fn validate_fixtures(
    specs: &[ParamSpec],
    fixtures: &BTreeMap<String, FixtureKind>,
) -> Result<()> {
    for (name, kind) in fixtures {
        let spec = specs.iter().find(|s| &s.name == name).ok_or_else(|| {
            anyhow!(
                "fixture '{kind}' is declared for '{name}', which is not a declared parameter \
                 of this cell (declared: {})",
                if specs.is_empty() {
                    "none".to_string()
                } else {
                    specs.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", ")
                }
            )
        })?;
        kind.fits(spec)?;
    }
    Ok(())
}

/// What a [`FixtureSource`] hands back: the rendered PUBLIC value, and a note
/// for the record (the hub's public key, its endpoint, where the private half
/// was kept) — so the receipt names the fixture instead of a bare value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedFixture {
    /// The value the parameter takes — validated like a typed one.
    pub value: String,
    /// One line for the applied record (`AppliedParam::fixture_note`).
    pub note: String,
}

/// **The seam that mints a fixture.** This crate owns the declaration and the
/// `@fixture` grammar but cannot own the minting (a WireGuard hub lives in
/// `wg-appliance-core`, which this dep-light crate must not pull); the
/// composition injects the source — `InstallerComposer::fixtures` — exactly as
/// it injects the ISO composition itself.
pub trait FixtureSource {
    /// Mint the fixture of `kind` for `spec`. Every refusal names the parameter.
    fn resolve(&self, kind: FixtureKind, spec: &ParamSpec) -> Result<ResolvedFixture>;
}

/// The source that can mint nothing — what a path with no composition has.
/// `@fixture` through it is refused BY NAME, never silently left literal.
pub struct NoFixtures;

impl FixtureSource for NoFixtures {
    fn resolve(&self, kind: FixtureKind, spec: &ParamSpec) -> Result<ResolvedFixture> {
        bail!(
            "parameter '{}': {FIXTURE_VALUE} asks for a '{kind}' fixture, but this build path \
             carries no fixture source (it composes nothing that could mint one) — drive the \
             installer door (`buildthing --installer`), which does",
            spec.name
        )
    }
}

/// What shape a parameter's VALUE must have — validated at supply time, and
/// what a UI renders as the input affordance.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ParamKind {
    /// Any non-empty line of text.
    #[default]
    Text,
    /// A 44-character padded base64 key (a WireGuard PUBLIC key's spelling).
    Base64Key,
    /// An IPv4 socket address, `a.b.c.d:port`.
    Endpoint,
    /// A comma-separated list of IPv4 CIDRs, `10.77.0.0/16, 10.99.0.1/32`.
    CidrList,
    /// A bare IPv4 address, `a.b.c.d` — a default gateway, a resolver.
    ///
    /// Added 2026-08-22 for the installer OPTIONS axis (a DHCP-off static
    /// address). **Declare it through
    /// [`OptionSpec::param_kinds`](crate::options::OptionSpec::param_kinds),
    /// not on a `[[…params]]` row**, until the published loader every parity
    /// mirror reads through knows the spelling: `ParamSpec` is
    /// `deny_unknown_fields` and `ParamKind` is a closed enum, so `kind =
    /// "ipv4"` on a row makes the whole recipe unloadable for a 0.1.7 reader,
    /// while an unknown table is skipped whole.
    Ipv4,
    /// A single IPv4 CIDR, `a.b.c.d/prefix` — an interface address with its
    /// prefix length, which is what a static network plan is given as. The
    /// singular of [`CidrList`](Self::CidrList); a comma makes it a list and is
    /// refused, because an interface takes exactly one address. Same
    /// declaration caveat as [`Ipv4`](Self::Ipv4).
    Ipv4Cidr,
}

/// **Where a staged file's value lands** (lane B1, 2026-10-21).
///
/// `Data` (the default, every row before this existed): a first-boot file for `/data` — laid
/// by a FRESH install only; a keep medium never writes it, because `/data` is the site's.
/// `Release`: the file rides the RELEASE — the composer folds it into one bundle signed with
/// the app (`korp_installer::release`), and every install of the medium (keep included) and
/// every live update lays it. A declaration the product's release owns (Holger's
/// `holger.ron`, the korp root it trusts) is `Release`; what a site owns (a pairing seed, a
/// hub declaration) stays `Data`. A cmdline token already rides the release, so only a
/// `staged_file` may say `Release`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Landing {
    #[default]
    Data,
    Release,
}

impl Landing {
    /// The default — not written, so a row without `lands` reads and writes as before.
    pub fn is_data(&self) -> bool {
        *self == Landing::Data
    }
}

/// One declared parameter of an installer/iso cell.
///
/// Exactly ONE landing must be declared: `boot_param = "<key>"` (the value
/// becomes `<key>=<value>` on the ISO's kernel command line) or
/// `staged_file = "<path>"` (the value becomes that file's CONTENTS on the
/// boot medium's staging tree).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ParamSpec {
    /// The `--param <name>=…` name. Refused if it names a secret.
    pub name: String,
    /// What a human is asked for — the UI's field label, the CLI's error text.
    #[serde(default)]
    pub label: String,
    /// The value's declared shape.
    #[serde(default)]
    pub kind: ParamKind,
    /// Whether a build without this parameter must refuse.
    #[serde(default)]
    pub required: bool,
    /// Landing: a kernel-cmdline token `<boot_param>=<value>`.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub boot_param: String,
    /// Landing: a staging-tree file whose contents ARE the value.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub staged_file: String,
    /// **Where the staged file lands** ([`Landing`]): `/data` on the first boot (the
    /// default), or the release (`lands = "release"`).
    #[serde(default, skip_serializing_if = "Landing::is_data")]
    pub lands: Landing,
}

impl ParamSpec {
    /// Declaration-time validation — every refusal names the parameter.
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            bail!("a parameter with an empty name");
        }
        if names_a_secret(&self.name) {
            bail!(
                "parameter '{}' NAMES A SECRET ({}) — secret values may not travel as build \
                 parameters: the cmdline, the declaration and the build record are all readable",
                self.name,
                SECRET_NAME_FRAGMENTS
                    .iter()
                    .find(|f| self.name.to_ascii_lowercase().contains(**f))
                    .unwrap_or(&"secret"),
            );
        }
        let has_boot = !self.boot_param.trim().is_empty();
        let has_staged = !self.staged_file.trim().is_empty();
        if !has_boot && !has_staged {
            bail!(
                "parameter '{}' declares no landing — give it `boot_param = \"…\"` or \
                 `staged_file = \"…\"`",
                self.name
            );
        }
        if has_boot && has_staged {
            bail!(
                "parameter '{}' declares BOTH landings — a value lands in exactly one place",
                self.name
            );
        }
        if self.lands == Landing::Release && !has_staged {
            bail!(
                "parameter '{}' lands on the release but is a cmdline token — `lands = \"release\"` \
                 is for a `staged_file` (a cmdline token already rides the release)",
                self.name
            );
        }
        if !self.staged_file.trim().is_empty()
            && (self.staged_file.starts_with('/') || self.staged_file.contains(".."))
        {
            bail!(
                "parameter '{}': staged_file '{}' must be a relative path inside the staging \
                 tree (no leading '/', no '..')",
                self.name,
                self.staged_file
            );
        }
        Ok(())
    }

    /// Supply-time validation of one value against the declared kind.
    pub fn validate_value(&self, value: &str) -> Result<()> {
        let label = if self.label.is_empty() { &self.name } else { &self.label };
        match self.kind {
            ParamKind::Text => {
                if value.trim().is_empty() {
                    bail!("parameter '{}' ({label}): an empty value", self.name);
                }
            }
            ParamKind::Base64Key => {
                let v = value.trim();
                let ok = v.len() == 44
                    && v.ends_with('=')
                    && v[..43].bytes().all(|b| {
                        b.is_ascii_alphanumeric() || b == b'+' || b == b'/'
                    });
                if !ok {
                    bail!(
                        "parameter '{}' ({label}): '{value}' is not a 44-character padded \
                         base64 key (a WireGuard public key's spelling)",
                        self.name
                    );
                }
            }
            ParamKind::Endpoint => {
                value.trim().parse::<SocketAddrV4>().map_err(|_| {
                    anyhow!(
                        "parameter '{}' ({label}): '{value}' is not an IPv4 endpoint \
                         `a.b.c.d:port`",
                        self.name
                    )
                })?;
            }
            ParamKind::Ipv4 => {
                if value.trim().parse::<Ipv4Addr>().is_err() {
                    bail!(
                        "parameter '{}' ({label}): '{value}' is not an IPv4 address `a.b.c.d`",
                        self.name
                    );
                }
            }
            ParamKind::Ipv4Cidr => {
                let v = value.trim();
                // A comma would make it a LIST, and an interface takes one
                // address — refused here rather than silently taking the head.
                let ok = !v.contains(',')
                    && v.split_once('/')
                        .and_then(|(a, p)| {
                            Some((a.trim().parse::<Ipv4Addr>().ok()?, p.trim().parse::<u8>().ok()?))
                        })
                        .is_some_and(|(_, p)| p <= 32);
                if !ok {
                    bail!(
                        "parameter '{}' ({label}): '{value}' is not a single IPv4 CIDR \
                         `a.b.c.d/prefix` (an interface takes exactly one address)",
                        self.name
                    );
                }
            }
            ParamKind::CidrList => {
                for part in value.split(',') {
                    let part = part.trim();
                    let ok = part
                        .split_once('/')
                        .and_then(|(a, p)| {
                            Some((a.parse::<Ipv4Addr>().ok()?, p.parse::<u8>().ok()?))
                        })
                        .is_some_and(|(_, p)| p <= 32);
                    if !ok {
                        bail!(
                            "parameter '{}' ({label}): '{part}' is not an IPv4 CIDR \
                             `a.b.c.d/prefix`",
                            self.name
                        );
                    }
                }
            }
        }
        Ok(())
    }
}

/// Validate a cell's whole parameter roster: each spec, and unique names.
pub fn validate_specs(specs: &[ParamSpec]) -> Result<()> {
    let mut seen = std::collections::BTreeSet::new();
    for s in specs {
        s.validate()?;
        if !seen.insert(s.name.as_str()) {
            bail!("parameter '{}' is declared twice", s.name);
        }
    }
    Ok(())
}

/// One applied `(name, value)` pair — recorded ON the cell so the serialized
/// descriptor (and therefore the attestation and the content id) carries
/// exactly which values produced the artifact.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppliedParam {
    /// The declared parameter name.
    pub name: String,
    /// The supplied value, verbatim — non-secret by construction.
    pub value: String,
    /// **Where the value came from, when not the operator**: the declared
    /// fixture that rendered it. `None` for a typed / `@path` value. Absent
    /// from the serialized record unless set, so older records read unchanged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fixture: Option<FixtureKind>,
    /// The fixture's own line for the record — public key, endpoint, and the
    /// path of the 0600 file its private half was kept in. Empty when `fixture`
    /// is `None`.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub fixture_note: String,
}

impl AppliedParam {
    /// A value the operator supplied.
    pub fn supplied(name: impl Into<String>, value: impl Into<String>) -> Self {
        AppliedParam { name: name.into(), value: value.into(), fixture: None, fixture_note: String::new() }
    }

    /// **One line for a human** — the CLI's stderr and the pane's result
    /// head print the SAME sentence: `hub_conf ← fixture wg-hub (wg-hub pub=…
    /// endpoint=… key=…)` for a fixture-filled value, `site = stockholm-7` for
    /// a typed one (a multi-line value is summarised as its first line + size,
    /// never dumped). Never silent about the source.
    pub fn describe(&self) -> String {
        match self.fixture {
            Some(kind) => format!("{} ← fixture {kind} ({})", self.name, self.fixture_note),
            None => {
                let lines = self.value.lines().count();
                if lines > 1 {
                    format!(
                        "{} = {} … ({lines} lines, {} B)",
                        self.name,
                        self.value.lines().next().unwrap_or(""),
                        self.value.len()
                    )
                } else {
                    format!("{} = {}", self.name, self.value)
                }
            }
        }
    }
}

/// Parse repeatable `--param name=value` pairs (already split off the argv).
/// A value of `@<path>` reads the file at `<path>` — the curl convention, for
/// multi-line content like a staged hub.conf.
pub fn parse_cli_params<S: AsRef<str>>(pairs: &[S]) -> Result<BTreeMap<String, String>> {
    let mut out = BTreeMap::new();
    for p in pairs {
        let p = p.as_ref();
        let (name, value) = p.split_once('=').ok_or_else(|| {
            anyhow!("--param takes name=value, got '{p}'")
        })?;
        let name = name.trim();
        if name.is_empty() {
            bail!("--param with an empty name: '{p}'");
        }
        let value = match value.strip_prefix('@') {
            // The fixture sentinel is a word, not a file.
            _ if value == FIXTURE_VALUE => value.to_string(),
            Some(path) => std::fs::read_to_string(path)
                .map_err(|e| anyhow!("--param {name}=@{path}: read {path}: {e}"))?,
            None => value.to_string(),
        };
        if out.insert(name.to_string(), value).is_some() {
            bail!("--param '{name}' was supplied twice");
        }
    }
    Ok(out)
}

/// The landings one application produced.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AppliedLandings {
    /// Tokens to append to the cell's `boot_params`.
    pub boot_params: Vec<String>,
    /// `(relative path, contents)` files to place on the staging tree — the `/data` landing.
    pub staged_files: Vec<(String, String)>,
    /// `(relative path, contents)` files that land on the RELEASE ([`Landing::Release`]).
    pub release_files: Vec<(String, String)>,
    /// The `(name, value)` record for the descriptor/attestation.
    pub record: Vec<AppliedParam>,
}

/// **Apply supplied values to declared specs.** Every refusal is loud and
/// names its parameter: an unknown name (with the secrecy called out when the
/// name claims one), a missing required value, a value of the wrong shape.
pub fn apply_params(
    specs: &[ParamSpec],
    supplied: &BTreeMap<String, String>,
) -> Result<AppliedLandings> {
    apply_params_with(specs, supplied, &BTreeMap::new(), &NoFixtures)
}

/// [`apply_params`] with the cell's DECLARED fixtures and the source that
/// mints them: a supplied value of [`FIXTURE_VALUE`] is resolved through
/// `source` for the kind the recipe declared on that name — refused by name
/// when no fixture is declared there — validated like a typed value, and
/// recorded as having come from that fixture.
pub fn apply_params_with(
    specs: &[ParamSpec],
    supplied: &BTreeMap<String, String>,
    fixtures: &BTreeMap<String, FixtureKind>,
    source: &dyn FixtureSource,
) -> Result<AppliedLandings> {
    validate_specs(specs)?;
    validate_fixtures(specs, fixtures)?;
    for name in supplied.keys() {
        if !specs.iter().any(|s| &s.name == name) {
            if names_a_secret(name) {
                bail!(
                    "--param '{name}' NAMES A SECRET and is not a declared parameter — secret \
                     values may not travel as build parameters at all (the cmdline and the \
                     build record are readable)"
                );
            }
            let declared: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect();
            bail!(
                "--param '{name}' is not a declared parameter of this cell (declared: {})",
                if declared.is_empty() { "none".to_string() } else { declared.join(", ") }
            );
        }
    }
    let mut out = AppliedLandings::default();
    for spec in specs {
        let Some(value) = supplied.get(&spec.name) else {
            if spec.required {
                let label = if spec.label.is_empty() { &spec.name } else { &spec.label };
                bail!(
                    "required parameter '{}' ({label}) was not supplied — pass \
                     --param {}=<value>",
                    spec.name,
                    spec.name
                );
            }
            continue;
        };
        // `@fixture` → the declared fixture's rendered value, or a refusal
        // that names the parameter and what to declare.
        let (value, fixture, fixture_note) = if value == FIXTURE_VALUE {
            let kind = *fixtures.get(&spec.name).ok_or_else(|| {
                anyhow!(
                    "parameter '{}': {FIXTURE_VALUE} was supplied but the recipe declares no \
                     fixture for it — declare `[build.installer.fixtures] {} = \"wg-hub\"` \
                     (or supply a value / @path)",
                    spec.name,
                    spec.name
                )
            })?;
            let resolved = source.resolve(kind, spec)?;
            (resolved.value, Some(kind), resolved.note)
        } else {
            (value.clone(), None, String::new())
        };
        let value = &value;
        spec.validate_value(value)?;
        if !spec.boot_param.trim().is_empty() {
            // A cmdline token must stay ONE token — a value with whitespace
            // would smuggle extra kernel parameters in.
            if value.split_whitespace().count() != 1 {
                bail!(
                    "parameter '{}' lands on the kernel cmdline and must be a single token, \
                     got: '{value}'",
                    spec.name
                );
            }
            out.boot_params.push(format!("{}={}", spec.boot_param, value.trim()));
        } else if spec.lands == Landing::Release {
            out.release_files.push((spec.staged_file.clone(), value.clone()));
        } else {
            out.staged_files.push((spec.staged_file.clone(), value.clone()));
        }
        out.record.push(AppliedParam {
            name: spec.name.clone(),
            value: value.clone(),
            fixture,
            fixture_note,
        });
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn spec(name: &str, kind: ParamKind, boot: &str, staged: &str, required: bool) -> ParamSpec {
        ParamSpec {
            name: name.into(),
            label: format!("the {name}"),
            kind,
            required,
            boot_param: boot.into(),
            staged_file: staged.into(),
            lands: Landing::Data,
        }
    }

    #[test]
    fn a_secret_shaped_name_is_refused_at_declaration_by_name() {
        for bad in ["hub_private_key", "SECRET_SAUCE", "api-token", "wg-psk", "PresharedKey"] {
            let err = spec(bad, ParamKind::Text, "k", "", false).validate().unwrap_err();
            assert!(
                err.to_string().contains("NAMES A SECRET"),
                "{bad}: {err}"
            );
        }
        // The control: the hub's PUBLIC key is exactly what this exists to carry.
        spec("hub_pubkey", ParamKind::Base64Key, "korp.hub", "", true).validate().unwrap();
    }

    /// **A staged parameter may land on the RELEASE** (lane B1): its value rides the signed
    /// medium and every install of it — fresh, keep or live — instead of a first-boot file on
    /// `/data`. Only a staged FILE can: a cmdline token already rides the release.
    #[test]
    fn a_release_landing_is_a_staged_file_that_round_trips() {
        let mut ron = spec("holger_ron", ParamKind::Text, "", "holger.ron", true);
        assert_eq!(ron.lands, Landing::Data, "the default is the old first-boot landing");
        ron.lands = Landing::Release;
        ron.validate().unwrap();
        let toml_row = toml::to_string(&ron).unwrap();
        assert!(toml_row.contains("lands = \"release\""), "{toml_row}");
        let back: ParamSpec = toml::from_str(&toml_row).unwrap();
        assert_eq!(back, ron);
        // The default is not written, so a row without it reads (and writes) as before.
        let data = spec("hub_conf", ParamKind::Text, "", "hub.conf", false);
        assert!(!toml::to_string(&data).unwrap().contains("lands"));
        let mut token = spec("site", ParamKind::Text, "korp.site", "", false);
        token.lands = Landing::Release;
        let err = token.validate().unwrap_err().to_string();
        assert!(err.contains("lands on the release") && err.contains("staged_file"), "{err}");
        // Applied: the landing rides the record's file list, by spec.
        let specs = vec![ron.clone(), data.clone()];
        let supplied: BTreeMap<String, String> =
            [("holger_ron".to_string(), "(x)".to_string()), ("hub_conf".to_string(), "[Peer]".to_string())]
                .into_iter()
                .collect();
        let landed = apply_params(&specs, &supplied).unwrap();
        assert_eq!(landed.release_files, vec![("holger.ron".to_string(), "(x)".to_string())]);
        assert_eq!(landed.staged_files, vec![("hub.conf".to_string(), "[Peer]".to_string())]);
    }

    #[test]
    fn a_landing_is_exactly_one_place() {
        let none = spec("x", ParamKind::Text, "", "", false).validate().unwrap_err();
        assert!(none.to_string().contains("no landing"), "{none}");
        let both = spec("x", ParamKind::Text, "k", "f", false).validate().unwrap_err();
        assert!(both.to_string().contains("BOTH landings"), "{both}");
        let escape = spec("x", ParamKind::Text, "", "../evil", false).validate().unwrap_err();
        assert!(escape.to_string().contains("relative path"), "{escape}");
    }

    #[test]
    fn cli_pairs_parse_with_the_at_file_convention() {
        let dir = tempfile::tempdir().unwrap();
        let f = dir.path().join("hub.conf");
        std::fs::write(&f, "[Peer]\nPublicKey = x\n").unwrap();
        let got = parse_cli_params(&[
            "site=stockholm-7".to_string(),
            format!("hub_conf=@{}", f.display()),
        ])
        .unwrap();
        assert_eq!(got["site"], "stockholm-7");
        assert!(got["hub_conf"].starts_with("[Peer]"), "@file reads the file");
        assert!(parse_cli_params(&["novalue"]).is_err());
        assert!(parse_cli_params(&["a=1", "a=2"]).unwrap_err().to_string().contains("twice"));
    }

    #[test]
    fn kinds_validate_their_shapes_green_and_red() {
        let key = spec("hub_pubkey", ParamKind::Base64Key, "korp.hub", "", true);
        key.validate_value("HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=").unwrap();
        for bad in ["short=", "notbase64!aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=", "x"] {
            assert!(key.validate_value(bad).is_err(), "{bad}");
        }
        let ep = spec("hub_endpoint", ParamKind::Endpoint, "korp.hubep", "", false);
        ep.validate_value("192.0.2.7:51820").unwrap();
        assert!(ep.validate_value("hub.example.com:51820").is_err(), "hostnames refused");
        let cidr = spec("allowed", ParamKind::CidrList, "korp.allowed", "", false);
        cidr.validate_value("10.77.0.0/16, 10.99.0.1/32").unwrap();
        assert!(cidr.validate_value("10.77.0.0/33").is_err());
        assert!(cidr.validate_value("fd00::/8").is_err());
    }

    /// A test source: renders a recognisable value and a note, records the
    /// kind it was asked for.
    struct FakeHub;
    impl FixtureSource for FakeHub {
        fn resolve(&self, kind: FixtureKind, spec: &ParamSpec) -> Result<ResolvedFixture> {
            Ok(ResolvedFixture {
                value: format!("[Peer]\nPublicKey = fake\n# {kind} for {}\n", spec.name),
                note: format!("{kind} pub=fake endpoint=127.0.0.1:1 key=/dev/null"),
            })
        }
    }

    /// A fixture is DECLARED against a parameter that can hold it: a text
    /// parameter landing as a staged file. Every other shape is refused by
    /// name — and so is a name no parameter declares.
    #[test]
    fn a_fixture_must_name_a_declared_text_staged_param() {
        let specs = vec![
            spec("hub_conf", ParamKind::Text, "", "hub.conf", false),
            spec("site", ParamKind::Text, "korp.site", "", false),
            spec("hub_pubkey", ParamKind::Base64Key, "", "hub.pub", false),
        ];
        let ok: BTreeMap<String, FixtureKind> =
            [("hub_conf".to_string(), FixtureKind::WgHub)].into_iter().collect();
        validate_fixtures(&specs, &ok).unwrap();
        for (bad, why) in [
            ("ghost", "not a declared parameter"),
            ("site", "hub.conf FILE"),
            ("hub_pubkey", "hub.conf FILE"),
        ] {
            let f: BTreeMap<String, FixtureKind> =
                [(bad.to_string(), FixtureKind::WgHub)].into_iter().collect();
            let err = validate_fixtures(&specs, &f).unwrap_err().to_string();
            assert!(err.contains(why) && err.contains(bad), "{bad}: {err}");
        }
        // The spelling round-trips through serde as the recipe writes it.
        assert_eq!(serde_json::to_string(&FixtureKind::WgHub).unwrap(), "\"wg-hub\"");
        assert_eq!(FixtureKind::WgHub.label(), "wg-hub");
    }

    /// `@fixture` is a WORD to the CLI parser (no file read), resolves through
    /// the injected source, is validated like a typed value, and the RECORD
    /// names the fixture — never a silent substitution. Reds: `@fixture` on a
    /// parameter with no declared fixture; `@fixture` through the no-source.
    #[test]
    fn at_fixture_resolves_through_the_source_and_the_record_says_so() {
        let parsed = parse_cli_params(&["hub_conf=@fixture", "site=x"]).unwrap();
        assert_eq!(parsed["hub_conf"], FIXTURE_VALUE, "the sentinel is not an @path read");

        let specs = vec![
            spec("site", ParamKind::Text, "korp.site", "", false),
            spec("hub_conf", ParamKind::Text, "", "hub.conf", true),
        ];
        let fixtures: BTreeMap<String, FixtureKind> =
            [("hub_conf".to_string(), FixtureKind::WgHub)].into_iter().collect();
        let got = apply_params_with(&specs, &parsed, &fixtures, &FakeHub).unwrap();
        let hub = got.record.iter().find(|r| r.name == "hub_conf").unwrap();
        assert_eq!(hub.fixture, Some(FixtureKind::WgHub), "the record names the fixture");
        assert!(hub.value.starts_with("[Peer]"), "the value IS the rendered fixture");
        assert!(hub.fixture_note.contains("pub=fake"), "and carries the fixture's note");
        assert_eq!(got.staged_files, vec![("hub.conf".to_string(), hub.value.clone())]);
        let site = got.record.iter().find(|r| r.name == "site").unwrap();
        assert_eq!(site.fixture, None, "a typed value records no fixture");
        assert!(hub.describe().starts_with("hub_conf ← fixture wg-hub (wg-hub pub=fake"), "{}", hub.describe());
        assert_eq!(site.describe(), "site = x");
        // The record serializes the source, and an old-shaped record still reads.
        let json = serde_json::to_string(hub).unwrap();
        assert!(json.contains("\"fixture\":\"wg-hub\""), "{json}");
        let old: AppliedParam = serde_json::from_str(r#"{"name":"a","value":"b"}"#).unwrap();
        assert_eq!(old.fixture, None);

        // RED 1: declared nowhere → refused by name, saying what to declare.
        let none = apply_params_with(&specs, &parsed, &BTreeMap::new(), &FakeHub).unwrap_err();
        assert!(
            none.to_string().contains("declares no fixture") && none.to_string().contains("hub_conf"),
            "{none}"
        );
        // RED 2: declared, but this path has no source → refused by name.
        let nosrc = apply_params_with(&specs, &parsed, &fixtures, &NoFixtures).unwrap_err();
        assert!(nosrc.to_string().contains("no fixture source"), "{nosrc}");
        // RED 3: the plain door never mints.
        assert!(apply_params(&specs, &parsed).is_err());
    }

    #[test]
    fn apply_lands_boot_params_staged_files_and_the_record() {
        let specs = vec![
            spec("site", ParamKind::Text, "korp.site", "", true),
            spec("hub_conf", ParamKind::Text, "", "hub.conf", true),
            spec("note", ParamKind::Text, "korp.note", "", false),
        ];
        let mut supplied = BTreeMap::new();
        supplied.insert("site".to_string(), "stockholm-7".to_string());
        supplied.insert("hub_conf".to_string(), "[Peer]\nPublicKey = x\n".to_string());
        let got = apply_params(&specs, &supplied).unwrap();
        assert_eq!(got.boot_params, vec!["korp.site=stockholm-7"]);
        assert_eq!(got.staged_files, vec![("hub.conf".to_string(), "[Peer]\nPublicKey = x\n".to_string())]);
        assert_eq!(got.record.len(), 2, "only supplied params are recorded");

        // Reds, each named: missing required (by LABEL), unknown, secret-shaped
        // unknown, multi-token cmdline value.
        let missing = apply_params(&specs, &BTreeMap::new()).unwrap_err();
        assert!(missing.to_string().contains("the site"), "{missing}");
        let mut unknown = supplied.clone();
        unknown.insert("ghost".into(), "x".into());
        assert!(apply_params(&specs, &unknown).unwrap_err().to_string().contains("not a declared parameter"));
        let mut secret = supplied.clone();
        secret.insert("router_password".into(), "x".into());
        assert!(apply_params(&specs, &secret).unwrap_err().to_string().contains("NAMES A SECRET"));
        let mut multi = supplied.clone();
        multi.insert("site".into(), "two words".into());
        assert!(apply_params(&specs, &multi).unwrap_err().to_string().contains("single token"));
    }
}