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

nordisk/edda

crates/wg-appliance-core/src/ceremony.rs

//! The appliance **identity ceremony** — the one implementation every product
//! (holger, gunnar, korp) and every shape (ISO-installed metal, container)
//! routes through.
//!
//! The ceremony is two movements, and only two:
//!
//! 1. **The hub's public key comes IN** as a file ([`HUB_CONF_FILE`]) in the
//!    appliance's durable directory — the same filename and the same dialect
//!    whether that directory is P3 on an installed disk or a mounted container
//!    volume. No second dialect, ever.
//! 2. **The appliance's own public key goes OUT**: minted once
//!    ([`load_or_mint_identity`]), printed as ONE canonical line
//!    ([`identity_line`]) every consumer and every test watches, and written to
//!    [`IDENTITY_PUB_FILE`] so it can be read back without parsing logs.
//!
//! # Durability laws (the reason this module exists)
//!
//! * **IDEMPOTENT** — an existing key is NEVER silently re-minted. A re-mint
//!   stales the operator's hub config; it is the primary failure mode this
//!   design exists to prevent. Minting is `O_CREAT | O_EXCL`: the filesystem,
//!   not a read-then-write race, decides who minted.
//! * **FAIL CLOSED** — a corrupt, truncated, unreadable or over-permissive key
//!   file REFUSES **by name** ([`CeremonyError`]); the appliance never invents
//!   an identity. A refusal is recoverable by an operator; a silently swapped
//!   identity is not.
//! * **LOUD ON CHANGE** — when a key IS newly minted the caller prints
//!   [`minted_line`] before the canonical line. The word `MINTED` appears in no
//!   other output, so "a new identity appeared" is one grep.
//! * **SECRETS DISCIPLINE** — the hub file carries PUBLIC data only:
//!   `PrivateKey` and `PresharedKey` are refused *by name*, because a hub file
//!   is baked into per-site ISOs and copied freely. The appliance's private key
//!   is written at mode 0600, never printed, and never appears in any line this
//!   module formats.
//!
//! # What is deliberately NOT here
//!
//! This is not a general wg-quick schema (see the crate root: gunnar's and
//! tunnr's config surfaces stay their own). [`parse_hub_config`] is the
//! ceremony's OWN dialect — a single `[Peer]` with `PublicKey`, `AllowedIPs`,
//! optional `Endpoint` and `PersistentKeepalive`, plus at most one
//! `[Interface]` carrying EXACTLY `Address` (the site-assigned tunnel
//! address: config, not identity, not secret), everything else refused by
//! name — shared verbatim by every appliance product, which is exactly why it
//! lives here and not in any one of them.

use std::fmt;
use std::fs;
use std::io::{self, Read as _, Write as _};
use std::net::{Ipv4Addr, SocketAddrV4};
use std::path::{Path, PathBuf};

use zeroize::{Zeroize as _, Zeroizing};

use crate::dataplane::PeerSpec;
use crate::keys::{
    decode_key, encode_key, generate_keypair, public_from_private_bytes, KeyError, KEY_LEN, REDACTED,
};

/// The appliance's private key, base64 + newline, mode 0600. The file IS the
/// identity: everything else is derived from it.
pub const IDENTITY_KEY_FILE: &str = "identity.key";

/// The appliance's public key, base64 + newline — derived data, re-derived
/// from [`IDENTITY_KEY_FILE`] whenever missing or stale, and readable by a
/// one-shot `cat` so no consumer has to parse logs.
pub const IDENTITY_PUB_FILE: &str = "identity.pub";

/// The hub's declared side of the tunnel — the file the operator (or a
/// per-site ISO build) places next to the identity.
pub const HUB_CONF_FILE: &str = "hub.conf";

/// The one regex that matches [`identity_line`] output — published here so
/// every watcher (test, robot, UI pane) greps the SAME shape instead of
/// inventing its own. Capture 1 is the product, capture 2 the padded base64
/// public key.
pub const IDENTITY_LINE_REGEX: &str =
    r"^wg-appliance identity: product=(\S+) pubkey=([A-Za-z0-9+/]{43}=)$";

/// Why the ceremony refused. Every variant names the thing that failed — a
/// path, a config key, a line number — because the operator's next move is
/// always "go look at exactly that".
#[derive(Debug)]
pub enum CeremonyError {
    /// The filesystem said no. `op` is what was being attempted.
    Io {
        /// What the ceremony was doing when the filesystem refused.
        op: &'static str,
        /// The file or directory it was doing it to.
        path: PathBuf,
        /// The OS error, verbatim.
        source: io::Error,
    },
    /// The identity file exists but does not hold a key. REFUSED, never
    /// re-minted: a truncated write or a corrupted disk must surface to the
    /// operator, because the hub still trusts the identity this file held.
    CorruptIdentity {
        /// The identity file that failed to decode.
        path: PathBuf,
        /// What was wrong with it, from the key codec.
        reason: KeyError,
    },
    /// The identity file is readable by group or world. Refused rather than
    /// repaired: silently chmodding would hide that the secret may already
    /// have been read.
    IdentityPermissions {
        /// The over-permissive identity file.
        path: PathBuf,
        /// Its current mode bits (the low nine).
        mode: u32,
    },
    /// The hub config refused a line — unknown key, forbidden key, bad value.
    HubConfig {
        /// Where the text came from (a path, or a caller-supplied name).
        source: String,
        /// 1-based line, 0 when the complaint is about the file as a whole.
        line: usize,
        /// What was wrong, naming the key or value.
        reason: String,
    },
}

impl fmt::Display for CeremonyError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CeremonyError::Io { op, path, source } => {
                write!(f, "wg-appliance ceremony: {op} {}: {source}", path.display())
            }
            CeremonyError::CorruptIdentity { path, reason } => write!(
                f,
                "wg-appliance ceremony: {} is not a WireGuard key ({reason}) — refusing to \
                 re-mint: the hub may still trust the identity this file held; restore it \
                 from backup or delete it DELIBERATELY to mint a new one",
                path.display()
            ),
            CeremonyError::IdentityPermissions { path, mode } => write!(
                f,
                "wg-appliance ceremony: {} is mode {mode:03o} (group/world-readable) — a \
                 private key this open may already be copied; refusing to use it",
                path.display()
            ),
            CeremonyError::HubConfig { source, line, reason } => {
                if *line == 0 {
                    write!(f, "wg-appliance ceremony: {source}: {reason}")
                } else {
                    write!(f, "wg-appliance ceremony: {source}:{line}: {reason}")
                }
            }
        }
    }
}

impl std::error::Error for CeremonyError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            CeremonyError::Io { source, .. } => Some(source),
            CeremonyError::CorruptIdentity { reason, .. } => Some(reason),
            _ => None,
        }
    }
}

/// The appliance's identity, loaded or freshly minted.
///
/// `Debug` is implemented BY HAND and redacts the private key: a derived impl
/// would put the secret one `{:?}` away from every log line and panic message.
pub struct Identity {
    /// The private key, raw. Stays in this struct and in the 0600 file —
    /// nothing in this module formats it into any string.
    pub private: [u8; KEY_LEN],
    /// The public key as the operator spells it: 44 characters of padded
    /// base64.
    pub public_b64: String,
    /// `true` ONLY when this very call created the key file. The caller owes
    /// the operator a [`minted_line`] when this is set.
    pub minted: bool,
}

impl fmt::Debug for Identity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Identity")
            .field("private", &REDACTED)
            .field("public_b64", &self.public_b64)
            .field("minted", &self.minted)
            .finish()
    }
}

// **`Identity` deliberately does NOT zeroize on drop, and that is a NAMED gap
// rather than an oversight (F41).**
//
// The obvious fix — `impl Drop for Identity { self.private.zeroize() }` — makes
// the type impossible to move a field out of, and `public_b64` is a `String`
// that shipping call sites move straight out of it: `korp-installer`
// (`datahome.rs:658`, `install.rs:380`) and `korp/src/appliance.rs` (twice),
// with holger's tier built the same way. Adding `Drop` turns a hardening change
// into a compile break in three repos, for a secret that is also sitting in the
// 0600 file this struct was just read from. Measured 2026-09-05 by doing it and
// reading the E0509, not assumed.
//
// What IS closed here: the two heap buffers that hold the key in the CLEAR on
// the way in and out — the minted `String` and the file read — are `Zeroizing`,
// `Debug` has masked the key since this type was written, and
// `forget_private_key` gives a caller that is finished with an identity a way
// to say so. Closing the rest wants `private` behind an accessor, which is a
// deliberate API revision rather than a security patch.

/// **Erase an identity's private key in place**, for a caller that is done with
/// it — the appliance has handed the bytes to `TunnelSpec` and will not need
/// them again until the next boot reads the file.
///
/// Additive on purpose: no existing caller has to change, and the alternative
/// (`impl Drop for Identity`) is a compile break in three repos — see the
/// comment above this function for the four call sites that would break.
pub fn forget_private_key(identity: &mut Identity) {
    identity.private.zeroize();
}

/// Load the appliance identity from `wg_dir`, minting it on first boot.
///
/// The decision is made by `O_CREAT | O_EXCL` on [`IDENTITY_KEY_FILE`], not by
/// a lookup: whoever creates the file mints; everyone else loads. A loser of
/// that race falls back to the load path. The key file is written at mode
/// 0600 and fsynced; [`IDENTITY_PUB_FILE`] is derived data, (re)written via
/// staged-temp + atomic rename whenever missing or stale.
///
/// Fail-closed corollary: a mint interrupted between `create_new` and the
/// fsync leaves a file that does not decode — the next boot REFUSES it
/// ([`CeremonyError::CorruptIdentity`]) instead of quietly minting identity
/// number two under the operator's feet.
pub fn load_or_mint_identity(wg_dir: &Path) -> Result<Identity, CeremonyError> {
    fs::create_dir_all(wg_dir).map_err(|source| CeremonyError::Io {
        op: "create directory",
        path: wg_dir.to_path_buf(),
        source,
    })?;
    let key_path = wg_dir.join(IDENTITY_KEY_FILE);

    let mut open = fs::OpenOptions::new();
    open.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt as _;
        open.mode(0o600);
    }
    match open.open(&key_path) {
        Ok(mut file) => {
            // We are the mint. Write, flush to disk, THEN derive.
            let (private, public) = generate_keypair();
            // The base64 private key, on the heap, on the way to a 0600 file.
            // `Zeroizing` erases it when this scope ends (F41): it used to be
            // an ordinary `String`, freed verbatim on every first boot.
            let mut line = Zeroizing::new(encode_key(&private));
            line.push('\n');
            file.write_all(line.as_bytes())
                .and_then(|()| file.sync_all())
                .map_err(|source| CeremonyError::Io {
                    op: "write freshly minted key to",
                    path: key_path.clone(),
                    source,
                })?;
            // The KEY's dirent, durable before anything derives from it — the
            // private key is the identity, so it must be findable after a
            // crash even if the .pub write below never happens (it is
            // re-derivable; the private key is not).
            sync_dir(wg_dir)?;
            let public_b64 = encode_key(&public);
            write_pub_file(wg_dir, &public_b64)?;
            Ok(Identity { private, public_b64, minted: true })
        }
        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => load_identity(wg_dir, &key_path),
        Err(source) => Err(CeremonyError::Io { op: "create", path: key_path, source }),
    }
}

/// The load half: permissions gate, decode gate, pub-file re-derivation.
fn load_identity(wg_dir: &Path, key_path: &Path) -> Result<Identity, CeremonyError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let meta = fs::metadata(key_path).map_err(|source| CeremonyError::Io {
            op: "stat",
            path: key_path.to_path_buf(),
            source,
        })?;
        let mode = meta.permissions().mode() & 0o777;
        if mode & 0o077 != 0 {
            return Err(CeremonyError::IdentityPermissions {
                path: key_path.to_path_buf(),
                mode,
            });
        }
    }
    // The private key file's contents. `Zeroizing` because this IS the
    // appliance's identity in the clear, and it is read on every boot (F41).
    let mut text = Zeroizing::new(String::new());
    fs::File::open(key_path)
        .and_then(|mut f| f.read_to_string(&mut text))
        .map_err(|source| CeremonyError::Io {
            op: "read",
            path: key_path.to_path_buf(),
            source,
        })?;
    let private = decode_key(&text).map_err(|reason| CeremonyError::CorruptIdentity {
        path: key_path.to_path_buf(),
        reason,
    })?;
    let public_b64 = encode_key(&public_from_private_bytes(&private));

    // identity.pub is DERIVED: absent or disagreeing, it is rewritten from the
    // key file — one writer, and the .pub can never overrule the .key.
    let pub_path = wg_dir.join(IDENTITY_PUB_FILE);
    let stale = match fs::read_to_string(&pub_path) {
        Ok(existing) => existing.trim() != public_b64,
        Err(_) => true,
    };
    if stale {
        write_pub_file(wg_dir, &public_b64)?;
    }
    Ok(Identity { private, public_b64, minted: false })
}

/// Write [`IDENTITY_PUB_FILE`] via staged temp + atomic rename, fsynced — a
/// reader sees the old content or the new, never a torn line.
///
/// Public because the private key does not always live in
/// [`IDENTITY_KEY_FILE`] any more: a TPM-sealed appliance keeps it in a sealed
/// blob and still owes the operator the same readable `identity.pub`, written
/// by the same writer with the same FAT-hardened durability. A second copy of
/// these thirty lines is exactly the twin this crate exists to prevent — the
/// staged-rename-then-fsync-at-the-final-name shape below was learned from a
/// real appliance that wrote a ZERO-length public key, and that lesson must not
/// have to be learned twice.
pub fn write_identity_pub(wg_dir: &Path, public_b64: &str) -> Result<(), CeremonyError> {
    write_pub_file(wg_dir, public_b64)
}

/// Write [`IDENTITY_PUB_FILE`] via staged temp + atomic rename, fsynced.
fn write_pub_file(wg_dir: &Path, public_b64: &str) -> Result<(), CeremonyError> {
    let final_path = wg_dir.join(IDENTITY_PUB_FILE);
    let tmp_path = wg_dir.join(".identity.pub.tmp");
    let io_err = |op: &'static str, path: &Path, source| CeremonyError::Io {
        op,
        path: path.to_path_buf(),
        source,
    };
    let mut tmp = fs::File::create(&tmp_path).map_err(|e| io_err("stage", &tmp_path, e))?;
    tmp.write_all(format!("{public_b64}\n").as_bytes())
        .and_then(|()| tmp.sync_all())
        .map_err(|e| io_err("write staged public key to", &tmp_path, e))?;
    fs::rename(&tmp_path, &final_path)
        .map_err(|e| io_err("rename staged public key onto", &final_path, e))?;
    // Fsync the file AT ITS FINAL NAME. Syncing the staging file before the
    // rename is not enough on every filesystem: MEASURED 2026-08-13 on a real
    // appliance boot writing to a FAT data partition, the renamed file landed
    // with the right name and a size of ZERO — on FAT the size lives in the
    // directory entry, and the entry the rename copied still carried the size
    // the staging file had when it was created. An empty identity.pub is worse
    // than a missing one: it reads as "this appliance has no public key" while
    // the private key exists and the operator already registered the printed
    // one.
    fs::File::open(&final_path)
        .and_then(|f| f.sync_all())
        .map_err(|e| io_err("fsync the renamed public key", &final_path, e))?;
    // Fsync the DIRECTORY, not just the files. `sync_all` on a file makes its
    // CONTENTS durable; the directory entry that lets anyone FIND it is a
    // separate write, and a rename is a directory operation with no file to
    // sync at all.
    //
    // MEASURED 2026-08-13, a real first boot: the appliance minted, printed
    // its public key, and the machine went down moments later — and the key
    // was NOT on the partition when the disk was read back. The consequence is
    // not a lost file: the operator registers the PRINTED key with the hub, so
    // a mint that does not survive means the next boot mints a DIFFERENT key
    // and the machine can never authenticate against the registration it
    // already caused. The ceremony's whole promise is that the key survives.
    sync_dir(wg_dir)
}

/// Make `dir`'s entries durable (the directory's own metadata write).
fn sync_dir(dir: &Path) -> Result<(), CeremonyError> {
    fs::File::open(dir)
        .and_then(|d| d.sync_all())
        .map_err(|source| CeremonyError::Io { op: "fsync the directory", path: dir.to_path_buf(), source })
}

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

    /// The mint must make the DIRECTORY durable, not just the files: a
    /// machine prints its public key, the operator registers it with the hub,
    /// and the machine may lose power before anything else syncs. If the
    /// dirents were still in page cache the next boot finds an empty wg dir,
    /// mints a DIFFERENT key, and the registration is dead.
    ///
    /// fsync is invisible from userspace after the fact, so this asserts the
    /// property it exists to protect: after a mint, EVERY file the ceremony
    /// promised is present, findable by directory read (not by name alone),
    /// and re-readable as the same identity. Measured 2026-08-13: a real
    /// first boot minted, printed, and died — and the host readback of the
    /// partition found no identity.pub.
    #[test]
    fn a_mint_leaves_every_promised_file_findable_in_the_directory() {
        let dir = std::env::temp_dir().join(format!("wg-durability-{}", std::process::id()));
        let _ = fs::remove_dir_all(&dir);
        let wg = dir.join("wg");
        let minted = load_or_mint_identity(&wg).expect("mint");
        assert!(minted.minted);

        // Findable by ENUMERATION — the directory entry is what a crash loses.
        let names: Vec<String> = fs::read_dir(&wg)
            .expect("read the wg dir")
            .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
            .collect();
        assert!(names.iter().any(|n| n == IDENTITY_KEY_FILE), "key in dirents: {names:?}");
        assert!(names.iter().any(|n| n == IDENTITY_PUB_FILE), "pub in dirents: {names:?}");
        assert!(
            !names.iter().any(|n| n.ends_with(".tmp")),
            "no staging leftovers: {names:?}"
        );

        // And the stored public key is the one that was returned/printed.
        let stored = fs::read_to_string(wg.join(IDENTITY_PUB_FILE)).expect("read pub");
        assert_eq!(stored.trim(), minted.public_b64, "stored == printed");

        // A second load finds the SAME identity (no re-mint).
        let again = load_or_mint_identity(&wg).expect("load");
        assert!(!again.minted, "the second call loads, never re-mints");
        assert_eq!(again.public_b64, minted.public_b64);
        let _ = fs::remove_dir_all(&dir);
    }
}

/// THE canonical identity line — byte-stable across boots, matched by
/// [`IDENTITY_LINE_REGEX`], identical for every product and both shapes.
pub fn identity_line(product: &str, public_b64: &str) -> String {
    format!("wg-appliance identity: product={product} pubkey={public_b64}")
}

/// The loud first-boot line, printed BEFORE the canonical line and ONLY when
/// [`Identity::minted`] is set. `MINTED` appears in no other output this
/// module or its consumers format — a new identity is one grep.
pub fn minted_line(product: &str, key_path: &Path) -> String {
    format!(
        "wg-appliance identity: MINTED new keypair at {} (product={product}) — \
         register the public key below with the hub",
        key_path.display()
    )
}

/// Parse a line back into `(product, public_b64)` — the read half of
/// [`identity_line`], so watchers share ONE writer instead of a private regex.
pub fn parse_identity_line(line: &str) -> Option<(&str, &str)> {
    // NOT anchored at column 0 — the marker is found ANYWHERE in the line.
    //
    // Measured 2026-08-15, on the installed-disk drive: this function was
    // `strip_prefix`-anchored, and korp-start re-emits the ceremony's line
    // through its own console prefix as
    //   `INSTALLER: wg-appliance identity: product=gunnar pubkey=…`
    // so the ONE shared watcher MISSED the exact line it exists to watch, and
    // `isoproof::minted_pubkey` returned `None` on the reboot leg of a machine
    // that had in fact announced its key correctly.
    //
    // That is the twin this function was written to prevent, arriving by the
    // back door: an anchored reader forces every caller with a log prefix to
    // grow its own private regex, and then "the appliance printed its key" has
    // two spellings again. Finding the marker instead keeps ONE writer and ONE
    // reader across bare stdout, a serial console and a prefixed log.
    //
    // Nothing else is loosened: the product/pubkey shape below is unchanged, so
    // the key must still be exactly 44 padded base64 chars that DECODE, and it
    // must still end the line — AND the marker must begin the line or follow
    // WHITESPACE. That last clause is what keeps the pre-existing control
    // `x{line}` refused: a prefix glued straight onto the marker is a corrupted
    // marker, not a console prefix, and a matcher that accepted it would
    // green-chip a line that is not the ceremony's.
    const MARKER: &str = "wg-appliance identity: product=";
    let start = line
        .match_indices(MARKER)
        .find(|(i, _)| *i == 0 || line[..*i].ends_with(char::is_whitespace))
        .map(|(i, _)| i)?;
    let rest = &line[start + MARKER.len()..];
    let (product, rest) = rest.split_once(" pubkey=")?;
    if product.is_empty() || product.contains(' ') {
        return None;
    }
    // The same shape the regex pins: 44 chars, padded, base64 that decodes to
    // a key. (`decode_key` also tolerates whitespace; a LINE does not.)
    if rest.len() != 44 || rest.ends_with(char::is_whitespace) || decode_key(rest).is_err() {
        return None;
    }
    Some((product, rest))
}

/// The hub's declared side of the tunnel, parsed from the ceremony dialect.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HubPeer {
    /// The hub's public key, raw.
    pub public_key: [u8; KEY_LEN],
    /// Where to dial. Optional in the type (a passive listener is expressible)
    /// but in the fleet's shape the appliance dials out, so a hub file usually
    /// carries one.
    pub endpoint: Option<SocketAddrV4>,
    /// AllowedIPs, IPv4 `addr/prefix`. Required and non-empty: a peer with no
    /// routes is a tunnel that "came up" and carries nothing.
    pub allowed_ips: Vec<(Ipv4Addr, u8)>,
    /// Seconds between keepalives, when NAT needs holding open.
    pub persistent_keepalive: Option<u16>,
    /// The APPLIANCE's own tunnel address — `[Interface] Address = a.b.c.d/p`,
    /// the one non-secret thing the site assigns rather than the appliance
    /// minting. Optional: without it the tunnel still seals transport packets
    /// (the crossed-traffic floor), but IP THROUGH the tunnel needs it.
    /// Declare it with the site's real prefix: the kernel's connected-subnet
    /// route (a side effect of assigning it) is what answers the hub.
    pub appliance_address: Option<(Ipv4Addr, u8)>,
}

impl HubPeer {
    /// The [`PeerSpec`] the data plane consumes — the ceremony's output wired
    /// straight into [`crate::dataplane::spawn`] / [`crate::dataplane::probe_handshake`].
    pub fn peer_spec(&self) -> PeerSpec {
        PeerSpec {
            public_key: self.public_key,
            preshared_key: None,
            endpoint: self.endpoint,
            allowed_ips: self.allowed_ips.clone(),
            persistent_keepalive: self.persistent_keepalive,
        }
    }
}

/// Read and parse [`HUB_CONF_FILE`] (or any path holding the dialect).
pub fn read_hub_config(path: &Path) -> Result<HubPeer, CeremonyError> {
    let text = fs::read_to_string(path).map_err(|source| CeremonyError::Io {
        op: "read hub config",
        path: path.to_path_buf(),
        source,
    })?;
    parse_hub_config(&path.display().to_string(), &text)
}

/// Parse the ceremony's hub dialect: exactly one `[Peer]` — keys `PublicKey`
/// (required), `AllowedIPs` (required, IPv4 CIDR list), `Endpoint`
/// (`a.b.c.d:port`) and `PersistentKeepalive` optional — plus at most one
/// `[Interface]` carrying EXACTLY `Address = a.b.c.d/prefix` (the appliance's
/// site-assigned tunnel address; not identity, not secret). Everything else —
/// every unknown key, every unknown section, and *by name* `PrivateKey` and
/// `PresharedKey` wherever they appear — is refused with its line number.
///
/// `source_name` is what error messages call the text (a path, or e.g.
/// `"--hub parameter"` when the dialect arrives as a parameter string —
/// same dialect, both doors, one parser).
pub fn parse_hub_config(source_name: &str, text: &str) -> Result<HubPeer, CeremonyError> {
    let err = |line: usize, reason: String| CeremonyError::HubConfig {
        source: source_name.to_string(),
        line,
        reason,
    };
    #[derive(PartialEq)]
    enum Section {
        None,
        Peer,
        Interface,
    }
    let mut section = Section::None;
    let mut seen_peer = false;
    let mut seen_interface = false;
    let mut public_key: Option<[u8; KEY_LEN]> = None;
    let mut endpoint: Option<SocketAddrV4> = None;
    let mut allowed_ips: Vec<(Ipv4Addr, u8)> = Vec::new();
    let mut persistent_keepalive: Option<u16> = None;
    let mut appliance_address: Option<(Ipv4Addr, u8)> = None;

    for item in crate::ini::items(text) {
        match item {
            crate::ini::Item::Section { line, name, raw_name } => match name.as_str() {
                "peer" => {
                    if seen_peer {
                        return Err(err(line, format!(
                            "a second [{raw_name}] — the ceremony declares ONE hub; a multi-peer \
                             topology is a different product surface"
                        )));
                    }
                    seen_peer = true;
                    section = Section::Peer;
                }
                "interface" => {
                    if seen_interface {
                        return Err(err(line, format!("a second [{raw_name}]")));
                    }
                    seen_interface = true;
                    section = Section::Interface;
                }
                _ => return Err(err(line, format!("unknown section [{raw_name}]"))),
            },
            crate::ini::Item::Pair { line, key, value } if section == Section::Interface => {
                match key.as_str() {
                    "address" => {
                        let (addr, prefix) = value
                            .split_once('/')
                            .and_then(|(a, p)| {
                                Some((a.trim().parse::<Ipv4Addr>().ok()?, p.trim().parse::<u8>().ok()?))
                            })
                            .filter(|(_, p)| *p <= 32)
                            .ok_or_else(|| {
                                err(line, format!(
                                    "Address '{value}' is not IPv4 `a.b.c.d/prefix` — ONE \
                                     address, with the site's real prefix (the connected-subnet \
                                     route it installs is what answers the hub); IPv6 and \
                                     multiple addresses are not in this dialect"
                                ))
                            })?;
                        appliance_address = Some((addr, prefix));
                    }
                    "privatekey" => {
                        return Err(err(line, format!(
                            "'{key}' is a SECRET and the appliance's own side is MINTED on the \
                             appliance, never configured in — refused by name"
                        )));
                    }
                    _ => {
                        return Err(err(line, format!(
                            "unknown key '{key}' under [Interface] — the ceremony dialect \
                             carries EXACTLY `Address` there; DNS/MTU and the rest belong to \
                             the product's own config, not to {HUB_CONF_FILE}"
                        )));
                    }
                }
            }
            crate::ini::Item::Pair { line, key, value } => {
                if section == Section::None {
                    return Err(err(line, format!(
                        "'{key}' before any section — the hub file starts with [Peer]"
                    )));
                }
                match key.as_str() {
                    "publickey" => {
                        let raw = decode_key(value)
                            .map_err(|e| err(line, format!("PublicKey: {e}")))?;
                        public_key = Some(raw);
                    }
                    "privatekey" | "presharedkey" => {
                        return Err(err(line, format!(
                            "'{key}' is a SECRET and {HUB_CONF_FILE} is public data (it is baked \
                             into per-site images and copied freely) — refused by name"
                        )));
                    }
                    "endpoint" => {
                        let ep: SocketAddrV4 = value.parse().map_err(|_| {
                            err(line, format!(
                                "Endpoint '{value}' is not `a.b.c.d:port` — a hostname needs a \
                                 resolver the appliance does not carry, and IPv6 is not in this \
                                 dialect; spell the IPv4 address"
                            ))
                        })?;
                        endpoint = Some(ep);
                    }
                    "allowedips" => {
                        for part in value.split(',') {
                            let part = part.trim();
                            let (addr, prefix) = part.split_once('/').ok_or_else(|| {
                                err(line, format!("AllowedIPs entry '{part}' is not `addr/prefix`"))
                            })?;
                            let addr: Ipv4Addr = addr.trim().parse().map_err(|_| {
                                err(line, format!(
                                    "AllowedIPs entry '{part}': '{addr}' is not an IPv4 address \
                                     (IPv6 is not in this dialect)"
                                ))
                            })?;
                            let prefix: u8 = prefix.trim().parse().ok().filter(|p| *p <= 32)
                                .ok_or_else(|| {
                                    err(line, format!(
                                        "AllowedIPs entry '{part}': '/{prefix}' is not a prefix \
                                         length 0–32"
                                    ))
                                })?;
                            allowed_ips.push((addr, prefix));
                        }
                    }
                    "persistentkeepalive" => {
                        let secs: u16 = value.parse().map_err(|_| {
                            err(line, format!(
                                "PersistentKeepalive '{value}' is not a number of seconds (0–65535)"
                            ))
                        })?;
                        persistent_keepalive = Some(secs);
                    }
                    _ => {
                        return Err(err(line, format!(
                            "unknown key '{key}' — the ceremony dialect is PublicKey, AllowedIPs, \
                             Endpoint, PersistentKeepalive; anything a product needs beyond that \
                             belongs in that product's own config, not in {HUB_CONF_FILE}"
                        )));
                    }
                }
            }
            crate::ini::Item::Malformed { line, text } => {
                return Err(err(line, format!("'{text}' is not `Key = value`")));
            }
        }
    }

    if !seen_peer {
        return Err(err(0, "no [Peer] section — an empty hub file declares nothing".into()));
    }
    if seen_interface && appliance_address.is_none() {
        return Err(err(0, "[Interface] with no Address declares nothing — drop the section or fill it".into()));
    }
    let public_key = public_key
        .ok_or_else(|| err(0, "[Peer] has no PublicKey — the hub's key is the whole point".into()))?;
    if allowed_ips.is_empty() {
        return Err(err(0, "[Peer] has no AllowedIPs — a tunnel that routes nothing proves nothing".into()));
    }
    Ok(HubPeer { public_key, endpoint, allowed_ips, persistent_keepalive, appliance_address })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dataplane::{build_tunn, probe_handshake, TunnelSpec};

    /// **An `Identity`'s `Debug` must not contain the private key.**
    ///
    /// The mask has been hand-written since the type existed and had never
    /// been asserted — so a derive put back in a refactor would have passed
    /// every test in this module. `{:?}` and `{:#?}` both, through the one
    /// probe the crate's secret-bearing types share.
    ///
    /// RED-WHEN-BROKEN: replace the `impl fmt::Debug for Identity` with a derive.
    #[test]
    fn an_identity_never_renders_its_private_key() {
        let private = decode_key("gEi6mvh6b5rKDg7UkleGpsJLlA9ueOKyWhjqJqFjWHg=").unwrap();
        let identity = Identity {
            private,
            public_b64: "R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=".into(),
            minted: true,
        };
        crate::keys::probe::assert_masks(
            &[("{:?}", format!("{identity:?}")), ("{:#?}", format!("{identity:#?}"))],
            &[("the private key", private)],
            &["R/1RsV4yjvIeEUApgRu3u584R5/WCKgKaPqHieam4TU=", "minted: true"],
        );
    }

    /// **The watcher must find the line it exists to watch, PREFIXED.**
    ///
    /// RED-when-broken: restore the `strip_prefix` anchor and the first
    /// assertion fails on the exact string korp-start writes to the serial
    /// console of an installed appliance. Measured 2026-08-15 — that anchor is
    /// why `isoproof::minted_pubkey` returned `None` for a machine that HAD
    /// announced its key.
    ///
    /// The negative half is what stops the fix from being a loosening: a line
    /// that merely mentions the marker inside other text still has to carry a
    /// well-formed product and a decodable 44-char key, or it is not a match.
    #[test]
    fn the_identity_line_is_found_behind_a_console_prefix_but_junk_is_still_refused() {
        let key = "RgjZruoBPRyHxNlSbQn+t3xfV9v67/dnQGgOdn+lySU=";
        let bare = format!("wg-appliance identity: product=gunnar pubkey={key}");

        // Bare, as the container and host forms print it.
        assert_eq!(parse_identity_line(&bare), Some(("gunnar", key)));

        // Behind korp-start's console prefix, as an INSTALLED disk prints it.
        // This is the case the anchored version missed.
        let prefixed = format!("INSTALLER: {bare}");
        assert_eq!(
            parse_identity_line(&prefixed),
            Some(("gunnar", key)),
            "the shared watcher must see the line through a console prefix",
        );

        // And behind a timestamped log prefix, the other shape a caller would
        // otherwise grow a private regex for.
        let logged = format!("2026-08-15T12:38:06.702056Z  INFO {bare}");
        assert_eq!(parse_identity_line(&logged), Some(("gunnar", key)));

        // NOT loosened — every one of these still refuses:
        assert_eq!(
            parse_identity_line("INSTALLER: wg-appliance identity: product=gunnar pubkey=short"),
            None,
            "a key that is not 44 chars is not a key",
        );
        assert_eq!(
            parse_identity_line(&format!(
                "INSTALLER: wg-appliance identity: product=two words pubkey={key}"
            )),
            None,
            "a product with a space is not a product",
        );
        assert_eq!(
            parse_identity_line(&format!(
                "INSTALLER: wg-appliance identity: product=gunnar pubkey={}",
                "!".repeat(44)
            )),
            None,
            "44 characters that do not DECODE are not a key",
        );
        // The boundary that keeps un-anchoring from being a loosening: a prefix
        // GLUED to the marker is a corrupted marker, not a console prefix.
        // (`identity_line_is_byte_stable_and_regex_matchable` asserts the same
        // control on `x{line}`; it is repeated here because it is the exact
        // property this test's first half could otherwise have destroyed.)
        assert_eq!(
            parse_identity_line(&format!("x{bare}")),
            None,
            "a prefix glued onto the marker is a corrupted marker",
        );
    }
    use base64ct::Encoding as _;
    use std::sync::atomic::{AtomicU32, Ordering};

    /// A std-only scratch dir — this crate takes no dev-dependencies for what
    /// twenty lines of std can do. Unique per test, removed on drop.
    struct Scratch(PathBuf);
    impl Scratch {
        fn new(tag: &str) -> Scratch {
            static N: AtomicU32 = AtomicU32::new(0);
            let dir = std::env::temp_dir().join(format!(
                "wg-ceremony-{tag}-{}-{}",
                std::process::id(),
                N.fetch_add(1, Ordering::Relaxed)
            ));
            fs::create_dir_all(&dir).unwrap();
            Scratch(dir)
        }
        fn path(&self) -> &Path {
            &self.0
        }
    }
    impl Drop for Scratch {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }

    fn running_as_root() -> bool {
        // Root ignores permission bits; the two chmod-based refusal tests
        // cannot see their condition and skip LOUDLY instead of passing green.
        // SAFETY: `geteuid(2)` takes no arguments, touches no memory of ours and
        // cannot fail; it is `unsafe` only for crossing the `extern "C"` boundary.
        unsafe { libc::geteuid() == 0 }
    }

    #[test]
    fn mint_then_load_is_idempotent() {
        let dir = Scratch::new("idem");
        let first = load_or_mint_identity(dir.path()).unwrap();
        assert!(first.minted, "an empty directory is first boot");
        let second = load_or_mint_identity(dir.path()).unwrap();
        assert!(!second.minted, "the second boot LOADS — a re-mint here is the primary defect");
        assert_eq!(first.public_b64, second.public_b64);
        assert_eq!(first.private, second.private);
    }

    #[cfg(unix)]
    #[test]
    fn minted_key_file_mode_is_0600() {
        use std::os::unix::fs::PermissionsExt as _;
        let dir = Scratch::new("mode");
        load_or_mint_identity(dir.path()).unwrap();
        let mode = fs::metadata(dir.path().join(IDENTITY_KEY_FILE))
            .unwrap()
            .permissions()
            .mode()
            & 0o777;
        assert_eq!(mode, 0o600, "the private key is the appliance's ONE secret");
    }

    /// FAIL CLOSED — and the file must be byte-identical afterwards. The
    /// refusal text names the path (the operator's next move) and the corrupt
    /// bytes stay in place as the evidence.
    #[test]
    fn corrupt_key_refuses_by_name_and_never_overwrites() {
        let dir = Scratch::new("corrupt");
        let key_path = dir.path().join(IDENTITY_KEY_FILE);
        for junk in ["not a key at all", "c2hvcnQ=", ""] {
            fs::write(&key_path, junk).unwrap();
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt as _;
                fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
            }
            let err = load_or_mint_identity(dir.path()).unwrap_err();
            let msg = err.to_string();
            assert!(
                matches!(err, CeremonyError::CorruptIdentity { .. }),
                "junk {junk:?} must refuse as corrupt, got: {msg}"
            );
            assert!(
                msg.contains(IDENTITY_KEY_FILE),
                "the refusal names the file: {msg}"
            );
            assert_eq!(
                fs::read_to_string(&key_path).unwrap(),
                junk,
                "the corrupt file is EVIDENCE — it is never rewritten"
            );
            fs::remove_file(&key_path).unwrap();
        }
        // The control that keeps the loop honest: with the junk gone, the same
        // call mints — so the refusals above were the guard, not a dead path.
        assert!(load_or_mint_identity(dir.path()).unwrap().minted);
    }

    /// A 33-byte base64 string is 44 characters — the right LENGTH and the
    /// wrong key. The decode gate must catch it (a char count cannot).
    #[test]
    fn a_44_char_non_key_still_refuses() {
        let dir = Scratch::new("len44");
        let key_path = dir.path().join(IDENTITY_KEY_FILE);
        let thirty_three = base64ct::Base64::encode_string(&[7u8; 33]);
        assert_eq!(thirty_three.len(), 44);
        fs::write(&key_path, format!("{thirty_three}\n")).unwrap();
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
        }
        assert!(matches!(
            load_or_mint_identity(dir.path()).unwrap_err(),
            CeremonyError::CorruptIdentity { .. }
        ));
    }

    #[cfg(unix)]
    #[test]
    fn group_readable_key_is_refused_by_name() {
        if running_as_root() {
            eprintln!("SKIP group_readable_key_is_refused_by_name: euid 0 ignores mode bits");
            return;
        }
        use std::os::unix::fs::PermissionsExt as _;
        let dir = Scratch::new("perm");
        load_or_mint_identity(dir.path()).unwrap();
        let key_path = dir.path().join(IDENTITY_KEY_FILE);
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o640)).unwrap();
        let err = load_or_mint_identity(dir.path()).unwrap_err();
        assert!(
            matches!(err, CeremonyError::IdentityPermissions { mode: 0o640, .. }),
            "0640 is one group away from copied: {err}"
        );
        // Restore and prove the guard was the only obstacle.
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
        assert!(!load_or_mint_identity(dir.path()).unwrap().minted);
    }

    #[test]
    fn pub_file_is_rederived_when_missing_or_stale() {
        let dir = Scratch::new("pub");
        let minted = load_or_mint_identity(dir.path()).unwrap();
        let pub_path = dir.path().join(IDENTITY_PUB_FILE);
        assert_eq!(
            fs::read_to_string(&pub_path).unwrap().trim(),
            minted.public_b64,
            "the pub file is the no-log readback"
        );
        fs::remove_file(&pub_path).unwrap();
        load_or_mint_identity(dir.path()).unwrap();
        assert_eq!(fs::read_to_string(&pub_path).unwrap().trim(), minted.public_b64);
        fs::write(&pub_path, "tampered\n").unwrap();
        load_or_mint_identity(dir.path()).unwrap();
        assert_eq!(
            fs::read_to_string(&pub_path).unwrap().trim(),
            minted.public_b64,
            "identity.pub can never overrule identity.key — one writer"
        );
    }

    #[cfg(unix)]
    #[test]
    fn unwritable_dir_is_io_error_naming_path() {
        if running_as_root() {
            eprintln!("SKIP unwritable_dir_is_io_error_naming_path: euid 0 writes anywhere");
            return;
        }
        use std::os::unix::fs::PermissionsExt as _;
        let dir = Scratch::new("ro");
        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap();
        let err = load_or_mint_identity(dir.path()).unwrap_err();
        fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap();
        let msg = err.to_string();
        assert!(matches!(err, CeremonyError::Io { .. }), "{msg}");
        assert!(msg.contains(IDENTITY_KEY_FILE), "the refusal names the path: {msg}");
    }

    /// The mint decision is the filesystem's: a file that appears between two
    /// calls (the create_new loser) is LOADED, never clobbered.
    #[test]
    fn create_new_loser_falls_back_to_load() {
        let dir = Scratch::new("race");
        let winner = load_or_mint_identity(dir.path()).unwrap();
        assert!(winner.minted);
        let loser = load_or_mint_identity(dir.path()).unwrap();
        assert!(!loser.minted);
        assert_eq!(loser.public_b64, winner.public_b64);
    }

    #[test]
    fn identity_line_is_byte_stable_and_regex_matchable() {
        let (_, public) = crate::keys::generate_keypair_b64();
        let line = identity_line("holger", &public);
        assert_eq!(line, format!("wg-appliance identity: product=holger pubkey={public}"));
        assert_eq!(
            parse_identity_line(&line),
            Some(("holger", public.as_str())),
            "the parser is the regex's one writer"
        );
        // Controls: the shapes the regex must NOT match — a 43-char key, a
        // 45-char key, a doctored prefix. A matcher that passes these would
        // green-chip a line that is not the ceremony's.
        assert_eq!(parse_identity_line(&line[..line.len() - 1]), None, "43 chars is not a key");
        assert_eq!(parse_identity_line(&format!("{line}=")), None, "45 chars is not a key");
        assert_eq!(parse_identity_line(&format!("x{line}")), None);
        assert_eq!(parse_identity_line(&minted_line("holger", Path::new("/data/wg/identity.key"))), None,
            "the MINTED line is NOT the canonical line");
        // And the published regex string agrees with the parser on the line.
        assert!(IDENTITY_LINE_REGEX.contains("wg-appliance identity"), "the regex pins the prefix");
    }

    // ── the hub dialect ──────────────────────────────────────────────────

    const HUB_KEY: &str = "HIgo9xNzJMWLKASShiTqIybxZ0U3wGLiUeJ1PKf8ykw=";

    fn hub_text() -> String {
        format!(
            "# per-site hub declaration\n\
             [Peer]\n\
             PublicKey = {HUB_KEY}\n\
             Endpoint = 192.0.2.7:51820\n\
             AllowedIPs = 10.77.0.0/16, 10.99.0.1/32\n\
             PersistentKeepalive = 25\n"
        )
    }

    #[test]
    fn a_hub_file_parses_into_the_peer_the_dataplane_consumes() {
        let hub = parse_hub_config("hub.conf", &hub_text()).unwrap();
        assert_eq!(encode_key(&hub.public_key), HUB_KEY);
        assert_eq!(hub.endpoint, Some("192.0.2.7:51820".parse().unwrap()));
        assert_eq!(
            hub.allowed_ips,
            vec![("10.77.0.0".parse().unwrap(), 16), ("10.99.0.1".parse().unwrap(), 32)]
        );
        assert_eq!(hub.persistent_keepalive, Some(25));
        assert_eq!(hub.appliance_address, None, "no [Interface] declared, none invented");
        let spec = hub.peer_spec();
        assert_eq!(spec.preshared_key, None, "the ceremony dialect carries no secret");
    }

    /// The optional `[Interface] Address` — the site-assigned tunnel address,
    /// in either section order.
    #[test]
    fn an_interface_address_parses_and_is_optional() {
        let text = format!("[Interface]\nAddress = 10.77.0.7/16\n\n{}", hub_text());
        let hub = parse_hub_config("hub.conf", &text).unwrap();
        assert_eq!(hub.appliance_address, Some(("10.77.0.7".parse().unwrap(), 16)));
        // Section order must not matter — the operator writes wg-quick's order.
        let text2 = format!("{}\n[Interface]\nAddress = 10.77.0.7/16\n", hub_text());
        assert_eq!(
            parse_hub_config("hub.conf", &text2).unwrap().appliance_address,
            Some(("10.77.0.7".parse().unwrap(), 16))
        );
    }

    /// The ceremony output feeds the REAL crypto: a parsed hub plus a minted
    /// identity produce the 148-byte WireGuard handshake initiation.
    #[test]
    fn parsed_hub_plus_minted_identity_initiates_a_real_handshake() {
        let dir = Scratch::new("probe");
        let id = load_or_mint_identity(dir.path()).unwrap();
        let hub = parse_hub_config("hub.conf", &hub_text()).unwrap();
        let spec = TunnelSpec {
            private_key: id.private,
            listen_port: None,
            peers: vec![hub.peer_spec()],
        };
        assert_eq!(
            probe_handshake(&spec).unwrap(),
            148,
            "a WireGuard initiation is 148 bytes; anything else is not WireGuard"
        );
    }

    /// RED — the WRONG hub key must fail the handshake, not look healthy. Two
    /// in-process `Tunn`s: the appliance initiates toward hub key A; a
    /// responder holding a DIFFERENT private key must refuse the packet, and
    /// the responder holding A must accept it (the control that proves the
    /// refusal is the crypto, not a broken harness).
    #[test]
    fn wrong_hub_public_key_never_handshakes_in_process() {
        use boringtun::noise::{Tunn, TunnResult};
        use boringtun::x25519::{PublicKey, StaticSecret};

        let (appliance_private, appliance_public) = crate::keys::generate_keypair();
        let (hub_private, hub_public) = crate::keys::generate_keypair();
        let (wrong_private, _) = crate::keys::generate_keypair();

        let spec = TunnelSpec {
            private_key: appliance_private,
            listen_port: None,
            peers: vec![HubPeer {
                public_key: hub_public,
                endpoint: None,
                allowed_ips: vec![("10.0.0.0".parse().unwrap(), 8)],
                persistent_keepalive: None,
                appliance_address: None,
            }
            .peer_spec()],
        };
        let mut initiator = build_tunn(&spec, 0).unwrap();
        let mut packet = [0u8; 256];
        let initiation = match initiator.format_handshake_initiation(&mut packet, false) {
            TunnResult::WriteToNetwork(p) => p.to_vec(),
            other => panic!("no initiation: {other:?}"),
        };

        let mut wrong_hub = Tunn::new(
            StaticSecret::from(wrong_private),
            PublicKey::from(appliance_public),
            None,
            None,
            7,
            None,
        );
        let mut buf = [0u8; 512];
        assert!(
            matches!(
                wrong_hub.decapsulate(None, &initiation, &mut buf),
                TunnResult::Err(_)
            ),
            "a hub with the wrong private key must REFUSE the initiation"
        );

        let mut right_hub = Tunn::new(
            StaticSecret::from(hub_private),
            PublicKey::from(appliance_public),
            None,
            None,
            8,
            None,
        );
        let mut buf2 = [0u8; 512];
        assert!(
            matches!(
                right_hub.decapsulate(None, &initiation, &mut buf2),
                TunnResult::WriteToNetwork(_)
            ),
            "the control: the RIGHT key answers the same bytes with a response"
        );
    }

    // ── the reds the dialect owes, each named ────────────────────────────

    #[test]
    fn hub_dialect_refusals_name_the_offense_and_the_line() {
        let cases: &[(&str, &str, &str)] = &[
            (
                "[Interface]\nAddress = 10.0.0.2/32\n",
                "[Peer]",
                "an interface alone declares no hub",
            ),
            (
                &format!("[Interface]\nPrivateKey = {HUB_KEY}\n{}", hub_text()),
                "privatekey",
                "a private key under [Interface] — minted, never configured in",
            ),
            (
                &format!("[Interface]\nDNS = 1.1.1.1\nAddress = 10.0.0.2/32\n{}", hub_text()),
                "unknown key 'dns' under [Interface]",
                "an unknown interface key",
            ),
            (
                &format!("[Interface]\nAddress = fd00::2/64\n{}", hub_text()),
                "Address",
                "an IPv6 address in the v4 dialect",
            ),
            (
                &format!("[Interface]\nAddress = 10.0.0.2\n{}", hub_text()),
                "Address",
                "an address with no prefix",
            ),
            (
                &format!("[Interface]\nAddress = 10.0.0.2/32\n[Interface]\nAddress = 10.0.0.3/32\n{}", hub_text()),
                "second [Interface]",
                "two interface sections",
            ),
            (
                &format!("[Interface]\n{}", hub_text()),
                "no Address",
                "an empty interface section",
            ),
            (
                &format!("[Peer]\nPrivateKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n"),
                "privatekey",
                "a private key in the hub file",
            ),
            (
                &format!("[Peer]\nPresharedKey = {HUB_KEY}\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n"),
                "presharedkey",
                "a preshared key is a secret",
            ),
            (
                &format!("[Peer]\nPublicKey = {HUB_KEY}\nDNS = 1.1.1.1\nAllowedIPs = 10.0.0.0/8\n"),
                "unknown key 'dns'",
                "an unknown key",
            ),
            (
                &format!("[Peer]\nPublicKey = {HUB_KEY}\nEndpoint = hub.example.com:51820\nAllowedIPs = 10.0.0.0/8\n"),
                "Endpoint",
                "a hostname endpoint",
            ),
            (
                &format!("[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/33\n"),
                "prefix",
                "an impossible prefix",
            ),
            (
                &format!("[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = fd00::/8\n"),
                "IPv4",
                "IPv6 in the v4 dialect",
            ),
            (
                &format!("[Peer]\nPublicKey = {HUB_KEY}\nthis line has no equals\nAllowedIPs = 10.0.0.0/8\n"),
                "not `Key = value`",
                "a malformed line",
            ),
            (
                "PublicKey = whatever\n",
                "before any section",
                "a pair outside any section",
            ),
            (
                &format!("[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n[Peer]\nPublicKey = {HUB_KEY}\nAllowedIPs = 10.0.0.0/8\n"),
                "second [Peer]",
                "two hubs",
            ),
            ("[Peer]\nPublicKey = short\nAllowedIPs = 10.0.0.0/8\n", "PublicKey", "a bad key value"),
            (&format!("[Peer]\nPublicKey = {HUB_KEY}\n"), "AllowedIPs", "no routes"),
            ("[Peer]\nAllowedIPs = 10.0.0.0/8\n", "PublicKey", "no hub key"),
            ("# nothing here\n", "[Peer]", "an empty file"),
        ];
        for (text, must_name, why) in cases {
            let err = parse_hub_config("hub.conf", text).unwrap_err();
            let msg = err.to_string();
            assert!(
                msg.contains(must_name),
                "{why}: the refusal must name '{must_name}', got: {msg}"
            );
        }
        // The control that keeps the loop honest: the clean text still parses.
        assert!(parse_hub_config("hub.conf", &hub_text()).is_ok());
    }

    /// Line numbers reach the operator: the offense on line 3 says `:3:`.
    #[test]
    fn refusals_carry_the_line_number() {
        let text = format!("[Peer]\nPublicKey = {HUB_KEY}\nDNS = 1.1.1.1\nAllowedIPs = 10.0.0.0/8\n");
        let msg = parse_hub_config("site-7/hub.conf", &text).unwrap_err().to_string();
        assert!(msg.contains("site-7/hub.conf:3:"), "source and line, exactly: {msg}");
    }
}