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