nordisk-pairing 0.5.25

The boxes of an estate find each other: a seed each box is installed with, a one-time join against the front, a tunnel (WireGuard by default) they meet inside, roles decided once, a pairing loop each box runs itself, and the signal it reports. Shared by gunnar and holger; the laptop's xtask only lays machines, writes seeds and reads the signal. Design: .nornir/pairing-design.md (2026-09-24).
Documentation
//! **A live update over the installer's door** (owner 2026-10-12): the kit reads what a box
//! runs and hands it a new signed app — the same link, the same mutual TLS, the same one-role
//! rule as the log door (the installer's own certificate and nobody else). The box decides
//! (korp-installer `live`): refused by name, or staged into its other app slot, then re-exec.
//!
//! * `GET` [`RUNNING_PATH`] — what the box runs: `base`, `app`, `slot`, `live_update`
//!   (TOML; [`Running`]).
//! * `POST` [`STAGE_PATH`] — a new app: [`Stage`] as [`Stage::encode`] writes it, with what
//!   the release declares beside it ([`Declared`], [`Stage::encode_declared`], lane B5).

use serde::{Deserialize, Serialize};

pub const RUNNING_PATH: &str = "/live/running";
pub const STAGE_PATH: &str = "/live/stage";
/// The largest live payload taken (a stage request, base64 included).
pub const MAX_STAGE_BYTES: usize = 256 * 1024 * 1024;

/// What a box reports it runs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Running {
    /// The BASE hash (korp-installer `live::running_base`).
    pub base: String,
    /// The running app's `app.blake3`.
    pub app: String,
    /// `a` | `b`, and the trial state (`a`, `b trial`, `b booted`).
    pub slot: String,
    /// `korp.live_update` on this box.
    pub live_update: bool,
}

impl Running {
    pub fn render(&self) -> String {
        toml::to_string(self).unwrap_or_default()
    }
    pub fn parse(text: &str) -> Result<Running, String> {
        toml::from_str(text).map_err(|e| format!("not a running report: {e}"))
    }
}

/// A new app for the box's other slot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stage {
    /// `app.blake3`, hex.
    pub pin_hex: String,
    /// `app.sig` (the framed, versioned signature), raw.
    pub signature: Vec<u8>,
    /// The BASE the app was built against.
    pub base: String,
    /// The app `.znippy`.
    pub archive: Vec<u8>,
}

/// **What a live update declares beside its app** (lane B5) — korp-installer
/// `live::Declaration` on the wire. Both parts are optional: an older ONE box or front sends
/// neither, and the box says so.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Declared {
    /// The release declaration: its bundle (korp-installer `release`) and its signature over
    /// the app's root (empty: unsigned, an unenrolled box). `None`: none rides this update.
    pub release: Option<(Vec<u8>, Vec<u8>)>,
    /// The release's own `korp.env.*` tokens (`korp.env.KEY=VALUE`, korp-installer
    /// `live::release_env`). `None`: undeclared.
    pub env: Option<Vec<String>>,
}

impl Declared {
    /// Nothing declared: the plain stage of before.
    pub fn is_empty(&self) -> bool {
        self.release.is_none() && self.env.is_none()
    }
}

/// **The refusals a front answers with a KEEP boot** (lane B5), never a failure: the release
/// moved what only a keep boot lays — the env on the cmdline (`[live-env-moved]`), a
/// declaration the box keeps that the update does not carry (`[live-release-absent]`) — or the
/// box takes no declaration live at all (`[live-declared-unsupported]`). A declaration that
/// does not hold (`[release-sig]`) is a failure: the release itself is wrong.
pub const KEEP_REFUSALS: &[&str] = &["[live-env-moved]", "[live-release-absent]", "[live-declared-unsupported]"];

/// Whether a box's refusal of a stage is one [`KEEP_REFUSALS`] names.
pub fn falls_to_keep(refusal: &str) -> bool {
    KEEP_REFUSALS.iter().any(|t| refusal.contains(t))
}

/// The refusal of a box (or product) that takes the plain stage only.
pub fn declared_unsupported() -> String {
    "REFUSED [live-declared-unsupported] this box takes no release declaration or release env live: a keep boot lays them".into()
}

fn hex(b: &[u8]) -> String {
    b.iter().map(|b| format!("{b:02x}")).collect()
}

fn unhex(s: &str) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(2) {
        return None;
    }
    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}

impl Stage {
    /// The request body: `pin`, `sig` (hex), `base`, one line each, a blank line, the archive
    /// base64.
    pub fn encode(&self) -> String {
        self.encode_declared(&Declared::default())
    }

    /// [`Stage::encode`] with what the release declares (lane B5): after `base`, `release`
    /// (the bundle, base64) and `release_sig` (hex), and `env` (the tokens, space-separated),
    /// each only when declared. A reader before B5 reads the three lines it knows and ignores
    /// these.
    pub fn encode_declared(&self, d: &Declared) -> String {
        let mut head = format!("pin {}\nsig {}\nbase {}\n", self.pin_hex.trim(), hex(&self.signature), self.base.trim());
        if let Some((bundle, sig)) = &d.release {
            head.push_str(&format!("release {}\nrelease_sig {}\n", b64_encode(bundle), hex(sig)));
        }
        if let Some(env) = &d.env {
            head.push_str(&format!("env {}\n", env.join(" ")));
        }
        format!("{head}\n{}", b64_encode(&self.archive))
    }

    pub fn decode(body: &str) -> Result<Stage, String> {
        Self::decode_declared(body).map(|(s, _)| s)
    }

    /// [`Stage::decode`] with what the release declares. A line this reader does not know is
    /// refused by name: what a release declares is never dropped silently.
    pub fn decode_declared(body: &str) -> Result<(Stage, Declared), String> {
        let bad = |w: &str| format!("[live-stage-body] {w}");
        let (head, archive) = body.split_once("\n\n").ok_or_else(|| bad("no blank line before the archive"))?;
        let mut lines = head.lines();
        let mut field = |k: &str| -> Result<String, String> {
            let l = lines.next().ok_or_else(|| bad(&format!("no `{k}` line")))?;
            l.strip_prefix(&format!("{k} ")).map(|v| v.trim().to_string()).ok_or_else(|| bad(&format!("`{k}` expected, `{l:.20}` found")))
        };
        let pin_hex = field("pin")?;
        let sig = field("sig")?;
        let base = field("base")?;
        let signature = unhex(&sig).ok_or_else(|| bad("the signature is not hex"))?;
        let mut d = Declared::default();
        let (mut bundle, mut bundle_sig) = (None, None);
        for l in lines {
            let (k, v) = l.split_once(' ').unwrap_or((l, ""));
            let v = v.trim();
            match k {
                "release" if bundle.is_none() => bundle = Some(b64_decode(v).ok_or_else(|| bad("the release declaration is not base64"))?),
                "release_sig" if bundle_sig.is_none() => bundle_sig = Some(unhex(v).ok_or_else(|| bad("the release signature is not hex"))?),
                "env" if d.env.is_none() => d.env = Some(v.split_whitespace().map(str::to_string).collect()),
                _ => return Err(bad(&format!("`{k:.20}`: a line this box does not know, or one given twice — never dropped"))),
            }
        }
        d.release = match (bundle, bundle_sig) {
            (Some(b), s) => Some((b, s.unwrap_or_default())),
            (None, None) => None,
            (None, Some(_)) => return Err(bad("a release signature without its declaration")),
        };
        let archive = b64_decode(archive.trim_end()).ok_or_else(|| bad("the archive is not base64"))?;
        Ok((Stage { pin_hex, signature, base, archive }, d))
    }
}

const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

fn b64_encode(b: &[u8]) -> String {
    let mut out = String::with_capacity(b.len().div_ceil(3) * 4);
    for c in b.chunks(3) {
        let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
        for (i, shift) in [18, 12, 6, 0].iter().enumerate() {
            out.push(if i <= c.len() { B64[((n >> shift) & 63) as usize] as char } else { '=' });
        }
    }
    out
}

fn b64_decode(s: &str) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(4) {
        return None;
    }
    let val = |c: u8| B64.iter().position(|x| *x == c).map(|p| p as u32);
    let mut out = Vec::with_capacity(s.len() / 4 * 3);
    for q in s.as_bytes().chunks(4) {
        let pad = q.iter().rev().take_while(|c| **c == b'=').count();
        if pad > 2 {
            return None;
        }
        let mut n = 0u32;
        for (i, c) in q.iter().enumerate() {
            let v = if i >= 4 - pad { 0 } else { val(*c)? };
            n = n << 6 | v;
        }
        let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
        out.extend_from_slice(&bytes[..3 - pad]);
    }
    Some(out)
}

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

    #[test]
    fn a_stage_round_trips_and_a_broken_one_is_refused_by_name() {
        let s = Stage { pin_hex: "ab".repeat(32), signature: vec![1, 2, 3, 250], base: "cd".repeat(32), archive: (0..70_000u32).map(|i| (i % 251) as u8).collect() };
        let body = s.encode();
        assert!(body.starts_with("pin "), "{:.40}", body);
        assert_eq!(Stage::decode(&body).unwrap(), s);
        for bad in ["", "pin x\n\nAAAA", &body.replacen("base ", "bass ", 1), &format!("{body}!")] {
            assert!(Stage::decode(bad).unwrap_err().contains("[live-stage-body]"), "{bad:.60}");
        }
        let r = Running { base: "b".into(), app: "a".into(), slot: "b trial".into(), live_update: true };
        assert_eq!(Running::parse(&r.render()).unwrap(), r);
    }

    /// ★ **What a live update declares rides the stage** (lane B5): the release declaration
    /// (bundle and signature) and the release's env round-trip; an older body declares
    /// nothing; an older reader (`decode`) still reads the stage under the new lines; an
    /// unknown line is refused by name, never ignored.
    #[test]
    fn a_stage_carries_its_declaration_and_an_unknown_line_is_refused() {
        let s = Stage { pin_hex: "ab".repeat(32), signature: vec![1, 2], base: "cd".repeat(32), archive: vec![5; 4000] };
        let d = Declared { release: Some((b"korp-release-v1\nholger.ron 5\n(two)\n".to_vec(), vec![7; 72])), env: Some(vec!["korp.env.A=1".into(), "korp.env.B=x:y".into()]) };
        let body = s.encode_declared(&d);
        assert_eq!(Stage::decode_declared(&body).unwrap(), (s.clone(), d.clone()));
        assert_eq!(Stage::decode(&body).unwrap(), s, "the stage reads under the new lines");
        assert_eq!(Stage::decode_declared(&s.encode()).unwrap(), (s.clone(), Declared::default()), "an older body declares nothing");
        assert!(Declared::default().is_empty() && !d.is_empty());
        // An unsigned declaration (an unenrolled box) and an empty env are declared too.
        let bare = Declared { release: Some((b"korp-release-v1\n".to_vec(), vec![])), env: Some(vec![]) };
        assert_eq!(Stage::decode_declared(&s.encode_declared(&bare)).unwrap().1, bare);
        let unknown = body.replacen("\nbase ", "\nwhat 1\nbase ", 1);
        assert!(Stage::decode_declared(&body.replacen("\n\n", "\nwhat 1\n\n", 1)).unwrap_err().contains("[live-stage-body]"));
        assert!(Stage::decode_declared(&unknown).is_err());
        // The refusals a front answers with a keep boot.
        for w in ["REFUSED [live-release-absent] x", "[live-env-moved] y", "REFUSED [live-declared-unsupported] z"] {
            assert!(falls_to_keep(w), "{w}");
        }
        assert!(!falls_to_keep("[release-sig] the signature does not hold"), "a bad declaration is a failure, never a keep");
    }
}