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

nordisk/Skidbladnir

crates/installer/nordisk-pairing/src/seed.rs

//! **The seed: what a MEDIUM is told at compose time, and nothing more.**
//!
//! The owner's ruling (2026-09-24): one seed per medium, nothing per box. An
//! estate installs the same medium on every gunnar box, so a seed that named
//! a box's own address would need a per-box channel that does not exist.
//! Instead the seed names the ESTATE and how to reach its installer: the
//! tunnel kind, the join door on the utility network, the installer CA the
//! box pins, and the bootstrap client certificate it joins with. The door
//! assigns each box its NAME and OVERLAY ADDRESS by the utility IP it joins
//! from, and FEEDS it every estate value (monetize, clock and admin keys, the
//! front's tunnel key and endpoint, …) — so one medium serves any estate.
//!
//! **The medium is CONSTANT across lays** (owner, 2026-09-24 evening): nothing
//! in a seed may change from one lay of the estate to the next, or every
//! rebuild recomposes, re-uploads and re-gates the ISO. So the seed carries
//! only what reaches the door — and the front's tunnel identity (its relay key
//! is minted with the front, its endpoint is the front's public address) is
//! NOT here: the door hands it out in the join reply, as the `front` peer
//! ([`crate::join::JoinReply::front`]). A seed from 0.1 that still carries
//! `front.tunnel_pubkey` / `front.endpoint` parses; the two are ignored.
//!
//! The bootstrap certificate and its key are on the medium, and a key on a
//! medium is only as secret as the medium (it is uploaded to the cloud's
//! storage). So they are valid during bootstrap ONLY (owner 2026-09-24): they
//! may only join, only for a slot that has not joined, and the door withdraws
//! them the moment every slot has joined. That rule is the DOOR's (the ledger,
//! [`crate::join`]), not an expiry: the installer CA is minted per MEDIUM and
//! lives as long as the medium is used (the caller's choice), because a CA
//! that died in hours made every lay a new medium. See [`crate::mtls`].

use serde::{Deserialize, Serialize};
use std::fmt;

/// Which tunnel the estate meets inside. WireGuard is the default (owner
/// 2026-09-24); `none` means the boxes speak over the addresses the cloud
/// gave them (UpCloud's utility network).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TunnelKind {
    #[default]
    Wireguard,
    Tailscale,
    Mullvad,
    None,
}

impl TunnelKind {
    pub fn as_str(self) -> &'static str {
        match self {
            TunnelKind::Wireguard => "wireguard",
            TunnelKind::Tailscale => "tailscale",
            TunnelKind::Mullvad => "mullvad",
            TunnelKind::None => "none",
        }
    }
}

/// What kind of box this medium installs. The front is the rendezvous; a
/// PRODUCT box's role (primary or twin) is not in the seed — it is elected.
///
/// `Holger` (0.5.4, 2026-09-27): holger's estate is gunnar's shape — vm1 the
/// front (ui + monetize + plugin door), vm2 the primary, vm3 the twin — so a
/// holger box is admitted, elected and fed exactly as a gunnar box is. The
/// two kinds never pair with each other: an election is between two boxes of
/// ONE product.
///
/// `Box(role)` (P3a, 2026-10-25, A28 H13): **any product names its own box
/// kind**, so a product that is neither Gunnar nor Holger (the Lake: `fe`,
/// `be`, `ingestor`, …) pairs. It is spelled on the wire by its own name
/// (`kind = "fe"`); the three old kinds keep their spellings, and a reserved
/// name always reads as its old kind ([`Kind::named`]). A box kind is a
/// product box ([`Kind::is_box`]) but is NOT elected ([`Kind::is_elected`]):
/// it waits for no twin and stands alone as its own primary. `Kind` stays
/// `Copy` ([`BoxRole`] is a fixed-capacity name), so every caller that copies
/// a kind is unchanged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    Front,
    Gunnar,
    Holger,
    /// A product's own box kind (P3a), by its role's name.
    Box(BoxRole),
}

impl Kind {
    /// A product box that is ELECTED primary or twin (not the front): Gunnar and Holger.
    /// Unchanged since 0.5.4; a [`Kind::Box`] is not one (see [`Kind::is_box`]).
    pub fn is_product(self) -> bool {
        matches!(self, Kind::Gunnar | Kind::Holger)
    }

    /// The same as [`Kind::is_product`], by the name that says what it decides (P3a).
    pub fn is_elected(self) -> bool {
        self.is_product()
    }

    /// Any product box (not the front): the elected kinds and every [`Kind::Box`] (P3a).
    pub fn is_box(self) -> bool {
        !matches!(self, Kind::Front)
    }

    /// **The kind a name spells** (P3a): `front`, `gunnar` and `holger` are the old kinds;
    /// any other well-formed name is that product's [`Kind::Box`]. Refused by name:
    /// `REFUSED [seed-kind]`.
    pub fn named(name: &str) -> Result<Kind, SeedError> {
        Ok(match name {
            "front" => Kind::Front,
            "gunnar" => Kind::Gunnar,
            "holger" => Kind::Holger,
            other => Kind::Box(BoxRole::new(other)?),
        })
    }

    /// The kind's one spelling on the wire.
    pub fn as_str(&self) -> &str {
        match self {
            Kind::Front => "front",
            Kind::Gunnar => "gunnar",
            Kind::Holger => "holger",
            Kind::Box(r) => r.as_str(),
        }
    }
}

impl Serialize for Kind {
    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(self.as_str())
    }
}

impl<'de> Deserialize<'de> for Kind {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Kind, D::Error> {
        let name = String::deserialize(d)?;
        Kind::named(&name).map_err(serde::de::Error::custom)
    }
}

/// **A product's own box kind, by name** (P3a): lowercase ASCII letters, digits and `-`, a
/// letter first, at most [`BoxRole::MAX`] bytes, and not one of the old kinds' names. Held
/// inline so [`Kind`] stays `Copy`.
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct BoxRole {
    len: u8,
    name: [u8; BoxRole::MAX],
}

impl BoxRole {
    /// The longest box kind name, in bytes.
    pub const MAX: usize = 32;

    pub fn new(name: &str) -> Result<BoxRole, SeedError> {
        let bad = |why: String| Err(SeedError::Bad("kind", why));
        if matches!(name, "front" | "gunnar" | "holger") {
            return bad(format!("`{name}` is an old kind, not a product's box kind"));
        }
        let b = name.as_bytes();
        if b.is_empty() || b.len() > Self::MAX {
            return bad(format!("`{name}` is not 1 to {} bytes", Self::MAX));
        }
        if !b[0].is_ascii_lowercase() || !b.iter().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == b'-') {
            return bad(format!("`{name}` is not a box kind (lowercase letters, digits and `-`, a letter first)"));
        }
        let mut role = BoxRole { len: b.len() as u8, name: [0; Self::MAX] };
        role.name[..b.len()].copy_from_slice(b);
        Ok(role)
    }

    pub fn as_str(&self) -> &str {
        // Only ASCII is ever stored (`new`).
        std::str::from_utf8(&self.name[..self.len as usize]).unwrap_or_default()
    }
}

impl fmt::Debug for BoxRole {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Debug::fmt(self.as_str(), f)
    }
}

/// The one box every other box can reach before the tunnel exists.
///
/// Only its DOOR: an address fixed before the front exists (the estate
/// declares it), so a medium composed before the lay reaches the front of any
/// lay. The front's tunnel key and endpoint change with every lay and are FED
/// at the join ([`crate::join::FrontTunnel`]).
///
/// **Unused since 0.3.0** (owner 2026-09-24): the installer connects OUT to
/// every VM, so no VM dials a join service on the front. A 0.3 medium leaves
/// `door` empty; a 0.2 seed that names one still parses.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Front {
    /// The 0.2 join service on the utility network, `ip:port` — empty in 0.3.
    #[serde(default)]
    pub door: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seed {
    /// The estate's name, e.g. `gunnar.rs`. A box refuses a join reply for
    /// another estate.
    pub estate: String,
    #[serde(default)]
    pub tunnel: TunnelKind,
    #[serde(default)]
    pub front: Front,
    pub kind: Kind,
    /// The installer CA (PEM, public): the ONLY root a box trusts for the door.
    pub installer_ca_pem: String,
    /// The bootstrap client certificate and its key (PEM), the same for every
    /// box of the medium — valid during bootstrap only (see the module doc).
    pub bootstrap_cert_pem: String,
    pub bootstrap_key_pem: String,
    /// How many boxes the estate has.
    pub peers_expected: u8,
    /// **Mullvad** (`tunnel = "mullvad"`): which relay to leave through. The
    /// ACCOUNT NUMBER is not here — a seed carries nothing private; the door
    /// FEEDS it (`feed.mullvad_account`) over mutual TLS at the join.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mullvad: Option<MullvadSeed>,
    /// **Tailscale** (`tunnel = "tailscale"`): which coordination server. The
    /// AUTH KEY is fed (`feed.tailscale_authkey`), never on the medium.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tailscale: Option<TailscaleSeed>,
    /// **The installer's link into the tunnel** (0.3.0; required when the
    /// tunnel is `wireguard` or `mullvad`): the installer is a TEMPORARY PEER,
    /// and every VM raises this link from its first boot so the installer's
    /// mutual TLS never touches a public interface. See
    /// [`crate::installer_link`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub installer_link: Option<LinkSeed>,
}

/// **The medium's half of the installer's WireGuard link** (0.3.0). Both keys
/// are per MEDIUM and constant across lays, like the installer CA:
///
/// * `installer_pubkey` — the installer's public key. Its private half stays
///   on the operator's machine (0600, beside the CA);
/// * `vm_private` — the key EVERY VM of the medium answers the link with.
///   The installer (behind NAT) initiates, so it must know the key it dials
///   before the VM exists — one VM key per medium. Like the bootstrap
///   certificate it is only as secret as the medium, and it is good for
///   bootstrap only: each VM drops the link when the installer closes it, and
///   the mutual TLS inside still demands the installer's own certificate.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LinkSeed {
    pub installer_pubkey: String,
    pub vm_private: String,
    /// The UDP port each VM's end of the link listens on.
    #[serde(default = "link_default_port")]
    pub port: u16,
    /// **The link's WireGuard pre-shared key** (0.5.29, lane WG-PSK-3): base64 of 32 bytes,
    /// minted with the link's keys for this medium (`installer::mint_wg_psk`) and held by the
    /// installer too (the link file, [`crate::installer::Material::link_file_with_psk`]).
    /// WireGuard's handshake is X25519 alone; mixed in, a recording of the link cannot be
    /// opened later by a quantum computer. `None`: a seed from before 0.5.29, whose link runs
    /// as before (the installer holds none either).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub psk: Option<String>,
}

fn link_default_port() -> u16 {
    crate::installer_link::LINK_PORT
}

fn looks_like_key(s: &str) -> bool {
    let t = s.trim();
    t.len() == 44 && t.ends_with('=') && t[..43].chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')
}

/// The feed key the door hands a box its Mullvad account number under.
pub const FEED_MULLVAD_ACCOUNT: &str = "mullvad_account";
/// The feed key the door hands a box its Tailscale auth key under.
pub const FEED_TAILSCALE_AUTHKEY: &str = "tailscale_authkey";

/// The public half of a Mullvad choice — no credential.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct MullvadSeed {
    /// A relay location (`se-sto`) or a country (`se`); empty = any usable relay.
    #[serde(default)]
    pub location: String,
    /// The relay port (one of Mullvad's measured ranges); default 51820.
    #[serde(default = "mullvad_default_port")]
    pub port: u16,
}

fn mullvad_default_port() -> u16 {
    51820
}

/// The public half of a Tailscale choice — no credential.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TailscaleSeed {
    /// The coordination server: Tailscale's own, or a self-hosted headscale.
    #[serde(default = "tailscale_default_control")]
    pub control_url: String,
}

impl Default for TailscaleSeed {
    fn default() -> Self {
        TailscaleSeed { control_url: tailscale_default_control() }
    }
}

fn tailscale_default_control() -> String {
    "https://controlplane.tailscale.com".into()
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeedError {
    NotToml(String),
    /// `field`, `why`
    Bad(&'static str, String),
}

impl fmt::Display for SeedError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SeedError::NotToml(e) => write!(f, "REFUSED [seed-not-toml] the seed is not TOML: {e}"),
            SeedError::Bad(field, why) => write!(f, "REFUSED [seed-{field}] the seed's `{field}` is unusable: {why}"),
        }
    }
}

impl std::error::Error for SeedError {}

impl Seed {
    pub fn parse(text: &str) -> Result<Seed, SeedError> {
        let s: Seed = toml::from_str(text).map_err(|e| SeedError::NotToml(e.to_string()))?;
        s.check()?;
        Ok(s)
    }

    pub fn render(&self) -> Result<String, SeedError> {
        self.check()?;
        toml::to_string(self).map_err(|e| SeedError::NotToml(e.to_string()))
    }

    /// The door's address.
    pub fn door_addr(&self) -> Result<std::net::SocketAddrV4, SeedError> {
        self.front.door.parse().map_err(|_| SeedError::Bad("front.door", format!("`{}` is not ipv4:port", self.front.door)))
    }

    /// **Every field is checked before a box acts on it**, by name.
    pub fn check(&self) -> Result<(), SeedError> {
        if self.estate.trim().is_empty() {
            return Err(SeedError::Bad("estate", "empty".into()));
        }
        let pem = |field: &'static str, text: &str, begin: &str| -> Result<(), SeedError> {
            if text.contains(begin) {
                Ok(())
            } else {
                Err(SeedError::Bad(field, format!("has no `{begin}`")))
            }
        };
        pem("installer_ca_pem", &self.installer_ca_pem, "-----BEGIN CERTIFICATE-----")?;
        pem("bootstrap_cert_pem", &self.bootstrap_cert_pem, "-----BEGIN CERTIFICATE-----")?;
        pem("bootstrap_key_pem", &self.bootstrap_key_pem, "PRIVATE KEY-----")?;
        if self.peers_expected < 2 {
            return Err(SeedError::Bad("peers_expected", "an estate is at least a front and one box".into()));
        }
        // The front's WireGuard key and endpoint are NOT checked here: they are
        // the door's to feed (`JoinReply::front`), because they change with
        // every lay and a seed must not.
        if !self.front.door.trim().is_empty() {
            self.door_addr()?;
        }
        if let Some(m) = &self.mullvad {
            if m.location.chars().any(|c| !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')) {
                return Err(SeedError::Bad("mullvad.location", format!("`{}` is not a Mullvad location code (`se-sto`) or country (`se`)", m.location)));
            }
        }
        if let Some(t) = &self.tailscale {
            if !t.control_url.starts_with("https://") {
                return Err(SeedError::Bad("tailscale.control_url", format!("`{}` is not an https:// URL", t.control_url)));
            }
        }
        // 0.4.0 (owner 2026-09-25): WireGuard + mutual TLS ALWAYS — a medium
        // with no installer link, or `tunnel = none`, is refused by name.
        if let Err(e @ crate::tunnel::TunnelError::InstallerLinkNone) = crate::installer_link::reach(self.tunnel) {
            return Err(SeedError::Bad("tunnel", e.to_string()));
        }
        match &self.installer_link {
            Some(l) => {
                if !looks_like_key(&l.installer_pubkey) {
                    return Err(SeedError::Bad("installer_link.installer_pubkey", "not a WireGuard key (44 base64)".into()));
                }
                if !looks_like_key(&l.vm_private) {
                    return Err(SeedError::Bad("installer_link.vm_private", "not a WireGuard key (44 base64)".into()));
                }
                if l.port == 0 {
                    return Err(SeedError::Bad("installer_link.port", "0".into()));
                }
                // 0.5.29: a PSK, when the medium carries one, is a 32-byte key; a blank one is
                // refused, never read as "none" (that would raise the link without it).
                if let Some(p) = &l.psk {
                    if !looks_like_key(p) {
                        return Err(SeedError::Bad("installer_link.psk", "not a WireGuard pre-shared key (44 base64)".into()));
                    }
                }
            }
            None => {
                return Err(SeedError::Bad(
                    "installer_link",
                    format!("REFUSED [installer-link-none] a `{}` medium carries no installer link: the installer reaches every VM through its WireGuard link only", self.tunnel.as_str()),
                ))
            }
        }
        Ok(())
    }
}

/// **The staged seed of a release medium** (lane B4, A9 row 18): a medium the shop lays from
/// carries no seed of its own, only this line; the box reads its seed off the provider's
/// metadata on its first boot. The one spelling every product composes and reads.
pub const SEED_FROM_METADATA: &str = "nordisk-pairing-seed-from = \"upcloud-metadata\"";

/// Pure: **a staged seed file is still a release medium's marker** — the marker line and
/// nothing else (comments and blank lines aside). The ONE parser (Holger's exact-line one):
/// the box's seed reader and its narrator never disagree on what is a marker.
pub fn is_metadata_marker(text: &str) -> bool {
    let lines: Vec<&str> = text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect();
    lines == [SEED_FROM_METADATA]
}

/// **The ONE seed reader** (0.5.26, P4a): a release medium's seed off the provider's metadata.
pub mod metadata;

#[cfg(test)]
pub(crate) mod tests {

    /// ★ **One parser of the metadata marker** (lane B4, A9 row 18; Holger's version): a
    /// release medium's staged seed is the marker line alone (comments and blank lines aside);
    /// a seed beside it, or a seed alone, is not the marker.
    #[test]
    fn the_metadata_marker_is_its_one_line() {
        assert_eq!(SEED_FROM_METADATA, "nordisk-pairing-seed-from = \"upcloud-metadata\"");
        assert!(is_metadata_marker(&format!("{SEED_FROM_METADATA}\n")));
        assert!(is_metadata_marker(&format!("# a release medium\n\n  {SEED_FROM_METADATA}  \n")));
        assert!(!is_metadata_marker("estate = \"acme\"\n[installer_link]\ninstaller_pubkey = \"x\"\n"));
        assert!(!is_metadata_marker(&format!("{SEED_FROM_METADATA}\nestate = \"acme\"\n")), "a seed beside the marker is a seed");
        assert!(!is_metadata_marker(""));
        assert!(!is_metadata_marker("nordisk-pairing-seed-from = \"elsewhere\"\n"), "another source is not this marker");
    }
    use super::*;

    /// ★ **A seed carries the installer link's pre-shared key** (0.5.29, lane WG-PSK-3): it
    /// renders and parses back; a seed from before 0.5.29 has none (and renders no `psk`
    /// line); a PSK that is not a 32-byte base64 key is refused by name at the check.
    #[test]
    fn a_seed_carries_the_links_preshared_key_and_a_seed_from_before_carries_none() {
        let (mut s, _) = a_seed(Kind::Gunnar);
        let text = s.render().unwrap();
        assert!(!text.contains("psk"), "{text}");
        assert_eq!(Seed::parse(&text).unwrap().installer_link.unwrap().psk, None, "a seed from before 0.5.29");
        let psk = crate::installer::mint_wg_psk().unwrap();
        s.installer_link.as_mut().unwrap().psk = Some(psk.clone());
        s.check().unwrap();
        assert_eq!(Seed::parse(&s.render().unwrap()).unwrap().installer_link.unwrap().psk, Some(psk));
        for bad in ["", "  ", "short", "not-base64-not-base64-not-base64-not-base64!"] {
            s.installer_link.as_mut().unwrap().psk = Some(bad.into());
            assert!(matches!(s.check(), Err(SeedError::Bad("installer_link.psk", _))), "{bad:?} must be refused by name");
        }
    }

    /// A link with well-formed (not usable) keys.
    pub(crate) fn test_link() -> LinkSeed {
        LinkSeed { installer_pubkey: format!("{}=", "A".repeat(43)), vm_private: format!("{}=", "B".repeat(43)), port: 51821, psk: None }
    }

    /// A seed with a real installer CA and bootstrap certificate.
    pub(crate) fn a_seed(kind: Kind) -> (Seed, crate::mtls::InstallerCa) {
        let ca = crate::mtls::InstallerCa::mint("gunnar.rs", std::time::Duration::from_secs(3600)).unwrap();
        let boot = ca.issue_bootstrap(std::time::Duration::from_secs(3600)).unwrap();
        let s = Seed {
            estate: "gunnar.rs".into(),
            tunnel: TunnelKind::Wireguard,
            front: Front { door: "10.13.8.179:8474".into() },
            kind,
            installer_ca_pem: ca.cert_pem.clone(),
            bootstrap_cert_pem: boot.cert_pem,
            bootstrap_key_pem: boot.key_pem,
            peers_expected: 3,
            mullvad: None,
            tailscale: None,
            installer_link: Some(test_link()),
        };
        (s, ca)
    }

    #[test]
    fn a_seed_round_trips_through_toml_names_no_box_and_wireguard_is_the_default() {
        let (s, _) = a_seed(Kind::Gunnar);
        let text = s.render().unwrap();
        assert!(!text.contains("overlay") && !text.contains("\nname"), "nothing per box in a seed");
        assert_eq!(Seed::parse(&text).unwrap(), s);
        let without = text.replace("tunnel = \"wireguard\"\n", "");
        assert_eq!(Seed::parse(&without).unwrap().tunnel, TunnelKind::Wireguard);
    }

    /// **Nothing per LAY in a seed** (owner 2026-09-24 evening: the medium is
    /// constant): the front's tunnel key and endpoint are the door's to feed.
    /// A 0.1 seed that still carries them parses, and they are dropped.
    #[test]
    fn a_seed_carries_no_tunnel_key_or_endpoint_and_an_old_one_still_parses() {
        let (s, _) = a_seed(Kind::Gunnar);
        let text = s.render().unwrap();
        assert!(!text.contains("tunnel_pubkey") && !text.contains("endpoint"), "per-lay values are fed, not seeded:\n{text}");
        let old = text.replace("[front]\n", "[front]\ntunnel_pubkey = \"xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg=\"\nendpoint = \"95.111.211.189:51820\"\n");
        assert!(old.contains("tunnel_pubkey"), "the fixture really is an old seed:\n{old}");
        assert_eq!(Seed::parse(&old).unwrap(), s, "the old fields are ignored");
    }

    #[test]
    fn a_missing_certificate_and_a_bad_door_are_refused_by_name_and_no_tunnel_key_is_asked_for() {
        let (mut s, _) = a_seed(Kind::Gunnar);
        s.installer_ca_pem = "nope".into();
        assert!(matches!(s.check(), Err(SeedError::Bad("installer_ca_pem", _))));
        let (mut s, _) = a_seed(Kind::Gunnar);
        s.bootstrap_key_pem.clear();
        assert!(matches!(s.check(), Err(SeedError::Bad("bootstrap_key_pem", _))));
        let (mut s, _) = a_seed(Kind::Gunnar);
        s.front.door = "front:8474".into();
        assert!(matches!(s.check(), Err(SeedError::Bad("front.door", _))));
        let (mut s, _) = a_seed(Kind::Front);
        assert_eq!(s.check(), Ok(()), "a WireGuard seed needs no front key: the door feeds it");
        s.tunnel = TunnelKind::None;
        assert!(matches!(s.check(), Err(SeedError::Bad("tunnel", ref why)) if why.starts_with("REFUSED [installer-link-none]")), "WireGuard + mutual TLS always");
        s.tunnel = TunnelKind::Wireguard;
        s.installer_link = None;
        assert!(matches!(s.check(), Err(SeedError::Bad("installer_link", _))), "every medium carries the link");
    }

    #[test]
    fn the_mullvad_and_tailscale_sections_are_optional_public_and_checked_by_name() {
        let (mut s, _) = a_seed(Kind::Gunnar);
        assert!(!s.render().unwrap().contains("[mullvad]"), "absent sections are not written");
        s.tunnel = TunnelKind::Mullvad;
        s.mullvad = Some(MullvadSeed { location: "se-sto".into(), port: 51820 });
        let text = s.render().unwrap();
        assert!(text.contains("[mullvad]") && !text.contains("account"), "no credential in a seed:\n{text}");
        assert_eq!(Seed::parse(&text).unwrap(), s);
        let bare = text.replace("port = 51820\n", "");
        assert_eq!(Seed::parse(&bare).unwrap().mullvad.unwrap().port, 51820, "the default port");
        s.mullvad = Some(MullvadSeed { location: "Stockholm; rm".into(), port: 51820 });
        assert!(matches!(s.check(), Err(SeedError::Bad("mullvad.location", _))));
        s.mullvad = None;
        assert_eq!(s.check(), Ok(()), "mullvad's mesh is WireGuard via the front, whose key the door feeds");
        s.tunnel = TunnelKind::Tailscale;
        assert_eq!(s.check(), Ok(()), "tailscale needs no front WireGuard key");
        s.tailscale = Some(TailscaleSeed { control_url: "http://plain".into() });
        assert!(matches!(s.check(), Err(SeedError::Bad("tailscale.control_url", _))));
    }

    /// ★ **A product names its own box kind** (P3a, A28 H13): `Kind::Box(role)` beyond
    /// {Front, Gunnar, Holger}. It is spelled by its own name on the wire (`kind = "fe"`), the
    /// three old kinds keep their spellings, and a reserved name always reads as its old kind.
    #[test]
    fn a_box_kind_is_named_by_its_product_and_round_trips_through_toml() {
        let fe = Kind::named("fe").unwrap();
        assert_eq!(fe, Kind::Box(BoxRole::new("fe").unwrap()));
        assert_eq!(fe.as_str(), "fe");
        let (mut s, _) = a_seed(fe);
        let text = s.render().unwrap();
        assert!(text.contains("kind = \"fe\""), "{text}");
        assert_eq!(Seed::parse(&text).unwrap().kind, fe);
        for (k, name) in [(Kind::Front, "front"), (Kind::Gunnar, "gunnar"), (Kind::Holger, "holger")] {
            s.kind = k;
            assert!(s.render().unwrap().contains(&format!("kind = \"{name}\"")), "{name} keeps its spelling");
            assert_eq!(Kind::named(name).unwrap(), k, "a reserved name is its old kind, never a box");
            assert_eq!(k.as_str(), name);
        }
        let copied = fe;
        assert_eq!(copied, fe, "Kind stays Copy");
    }

    /// The box kind's name is checked by name: lowercase letters, digits and `-`, a letter
    /// first, at most [`BoxRole::MAX`] bytes; a reserved name is not a box role.
    #[test]
    fn a_box_kind_name_is_checked_by_name() {
        for bad in ["", "Fe", "f e", "1fe", "-fe", "fé", &"a".repeat(BoxRole::MAX + 1)] {
            assert!(matches!(Kind::named(bad), Err(SeedError::Bad("kind", _))), "`{bad}` is refused");
        }
        assert!(BoxRole::new(&"a".repeat(BoxRole::MAX)).is_ok());
        for reserved in ["front", "gunnar", "holger"] {
            assert!(matches!(BoxRole::new(reserved), Err(SeedError::Bad("kind", _))), "`{reserved}` is an old kind");
        }
        let (s, _) = a_seed(Kind::Gunnar);
        let text = s.render().unwrap().replace("kind = \"gunnar\"", "kind = \"Lake\"");
        assert!(matches!(Seed::parse(&text), Err(SeedError::NotToml(e)) if e.contains("kind")), "a bad kind in a seed is refused");
    }

    /// A box kind is a product box, but it is NOT elected: it stands alone (no twin). The old
    /// kinds answer as before.
    #[test]
    fn a_box_kind_is_a_box_and_is_not_elected() {
        let lake = Kind::named("be").unwrap();
        assert!(lake.is_box() && !lake.is_product() && !lake.is_elected());
        assert!(Kind::Gunnar.is_product() && Kind::Holger.is_product() && !Kind::Front.is_product());
        assert!(Kind::Gunnar.is_box() && Kind::Holger.is_box() && !Kind::Front.is_box());
        assert!(Kind::Gunnar.is_elected() && Kind::Holger.is_elected());
        assert_eq!(format!("{lake:?}"), "Box(\"be\")");
    }
}