nordisk-pairing 0.5.2

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 VM's boot log, kept for the installer** (0.3.0, owner 2026-09-24).
//!
//! The installer on the operator's machine collects each VM's FULL boot log
//! over the bootstrap connection — every line from the first one, including
//! the install phase the medium's installer wrote onto the disk. The VM keeps
//! the lines here until the installer has read them.
//!
//! **Bounded.** A VM that talks for an hour before the installer reaches it
//! must not grow without end: the log holds at most `cap` lines (each cut at
//! [`MAX_LINE`] bytes). When it is full the OLDEST line goes and is counted,
//! and a reader that asks for lines no longer held gets ONE marker line first
//! — `… N lines dropped …` — so a log that lost its start never reads as a
//! whole one.
//!
//! Every line has a sequence number, from 0 for the first line pushed. A
//! reader asks "everything from `n`" and is told where to continue
//! ([`Chunk::next`]), so a reader that reconnects neither loses nor repeats a
//! line.

use std::collections::VecDeque;
use std::sync::Mutex;

/// A line longer than this is cut (and says so).
pub const MAX_LINE: usize = 4096;

/// The default bound: 20 000 lines, a few megabytes at most.
pub const DEFAULT_CAP: usize = 20_000;

/// The most lines one read hands out; the reader asks again from `next`.
pub const MAX_READ: usize = 2_000;

#[derive(Debug)]
struct Ring {
    lines: VecDeque<String>,
    /// The sequence number of `lines[0]`.
    first: u64,
    cap: usize,
}

/// **The log.** `const`-constructible, so a process can keep it in a
/// `static` and push to it from its very first line.
#[derive(Debug)]
pub struct BootLog {
    ring: Mutex<Ring>,
}

/// One read: the lines from `from`, and where the next read starts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Chunk {
    /// How many lines between the asked-for `from` and the first line held
    /// were dropped (0 when none). The wire carries it as a marker line.
    pub dropped: u64,
    pub lines: Vec<String>,
    /// Ask from here next time.
    pub next: u64,
}

impl Default for BootLog {
    fn default() -> Self {
        BootLog::new(DEFAULT_CAP)
    }
}

impl BootLog {
    pub const fn new(cap: usize) -> BootLog {
        BootLog { ring: Mutex::new(Ring { lines: VecDeque::new(), first: 0, cap }) }
    }

    /// Push text; every `\n`-separated line is one entry (empty ones and a
    /// trailing `\r` are dropped).
    pub fn push(&self, text: &str) {
        let mut r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
        for line in text.split('\n') {
            let line = line.strip_suffix('\r').unwrap_or(line);
            if line.trim().is_empty() {
                continue;
            }
            let line = if line.len() > MAX_LINE {
                let mut cut = MAX_LINE;
                while !line.is_char_boundary(cut) {
                    cut -= 1;
                }
                format!("{} … (cut at {MAX_LINE} bytes)", &line[..cut])
            } else {
                line.to_string()
            };
            if r.cap == 0 {
                r.first += 1;
                continue;
            }
            if r.lines.len() == r.cap {
                r.lines.pop_front();
                r.first += 1;
            }
            r.lines.push_back(line);
        }
    }

    /// Lines ever pushed (held or dropped).
    pub fn total(&self) -> u64 {
        let r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
        r.first + r.lines.len() as u64
    }

    /// Lines dropped because the log was full.
    pub fn dropped(&self) -> u64 {
        self.ring.lock().unwrap_or_else(|p| p.into_inner()).first
    }

    /// **Everything from `from`**, at most `max` lines.
    pub fn since(&self, from: u64, max: usize) -> Chunk {
        let r = self.ring.lock().unwrap_or_else(|p| p.into_inner());
        let end = r.first + r.lines.len() as u64;
        let start = from.max(r.first).min(end);
        let dropped = start.saturating_sub(from.min(end));
        let skip = (start - r.first) as usize;
        let lines: Vec<String> = r.lines.iter().skip(skip).take(max).cloned().collect();
        let next = start + lines.len() as u64;
        Chunk { dropped, lines, next }
    }
}

/// The marker line a reader stores in place of lines that were dropped.
pub fn dropped_marker(n: u64) -> String {
    format!("… {n} lines dropped (the VM's log holds its newest lines only) …")
}

impl Chunk {
    /// The wire: `next = N` and `dropped = D`, a blank line, then the lines.
    pub fn render(&self) -> String {
        let mut out = format!("next = {}\ndropped = {}\n\n", self.next, self.dropped);
        for l in &self.lines {
            out.push_str(l);
            out.push('\n');
        }
        out
    }

    pub fn parse(text: &str) -> Result<Chunk, String> {
        let (head, body) = text.split_once("\n\n").ok_or("a log chunk without its header")?;
        let mut next = None;
        let mut dropped = 0;
        for l in head.lines() {
            match l.split_once('=').map(|(k, v)| (k.trim(), v.trim())) {
                Some(("next", v)) => next = v.parse().ok(),
                Some(("dropped", v)) => dropped = v.parse().map_err(|_| format!("`{l}` is not a count"))?,
                _ => {}
            }
        }
        let next = next.ok_or("a log chunk without `next`")?;
        Ok(Chunk { dropped, lines: body.lines().map(str::to_string).collect(), next })
    }

    /// The lines as they are stored: the dropped marker first, when any were.
    pub fn stored(&self) -> Vec<String> {
        let mut v = Vec::with_capacity(self.lines.len() + 1);
        if self.dropped > 0 {
            v.push(dropped_marker(self.dropped));
        }
        v.extend(self.lines.iter().cloned());
        v
    }
}

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

    #[test]
    fn a_reader_gets_every_line_once_and_continues_where_it_left_off() {
        let log = BootLog::new(10);
        log.push("one\ntwo\r\n\nthree");
        let c = log.since(0, 2);
        assert_eq!((c.lines.clone(), c.next, c.dropped), (vec!["one".to_string(), "two".to_string()], 2, 0));
        let c = log.since(c.next, 100);
        assert_eq!((c.lines, c.next), (vec!["three".to_string()], 3));
        assert_eq!(log.since(3, 100).lines.len(), 0, "nothing new");
        assert_eq!(log.since(99, 100).next, 3, "a reader ahead of the log is brought back to its end");
    }

    #[test]
    fn a_full_log_drops_the_oldest_and_counts_them_and_the_reader_is_told() {
        let log = BootLog::new(3);
        for i in 0..8 {
            log.push(&format!("line {i}"));
        }
        assert_eq!((log.total(), log.dropped()), (8, 5));
        let c = log.since(0, 100);
        assert_eq!(c.dropped, 5);
        assert_eq!(c.lines, vec!["line 5", "line 6", "line 7"]);
        assert_eq!(c.stored()[0], dropped_marker(5));
        assert!(c.stored()[0].contains("5 lines dropped"));
        let later = log.since(6, 100);
        assert_eq!((later.dropped, later.lines.len()), (0, 2), "a reader that kept up loses nothing");
    }

    #[test]
    fn a_long_line_is_cut_on_a_character_boundary_and_the_wire_round_trips() {
        let log = BootLog::new(4);
        log.push(&"å".repeat(MAX_LINE));
        let c = log.since(0, 10);
        assert!(c.lines[0].len() < MAX_LINE + 40 && c.lines[0].ends_with("(cut at 4096 bytes)"));
        let wire = c.render();
        assert_eq!(Chunk::parse(&wire).unwrap(), c);
        assert!(Chunk::parse("garbage").is_err());
    }
}