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