gunnar-common 3.0.0

Shared command line, logging, and build identity for the gunnar binaries
Documentation
//! **Where the metered appliance's verifying key comes from** — a file, or the
//! key itself, spelled inline.
//!
//! `--monetize-pubkey` took a filesystem PATH and nothing else. On an appliance
//! that is a dead end: the installer only stages files onto `/data` when it is
//! told `korp.reinstall=wipe` (`edda/crates/korp-installer/src/install.rs`
//! skips the staging branch by name on the default keep-upgrade), and the ESP
//! is mounted read-only once the box is running. So the only writable surface a
//! keep-upgrade can reach a booting gunnar through is the kernel command line —
//! and a path on the cmdline names a file that nothing was allowed to put
//! there. A metered appliance therefore could not be given monetize's key at
//! all without wiping `/data`.
//!
//! A monetize verifying key is an Ed25519 **public** key: 32 bytes whose whole
//! job is to be handed out. Putting it on a cmdline that `/proc/cmdline` shows
//! every process is disclosing something already public, which is why this is
//! the right value to inline and a private key never would be.
//!
//! # The shape rule
//!
//! There is no second flag. One value, and the SHAPE of it decides — because a
//! key and a path do not look alike:
//!
//! * It is read as a **literal key** when it contains no `/` **and** either
//!   every character is an ASCII hex digit, or it is exactly
//!   [`HEX_LEN`] characters long.
//! * Anything else is a **path**, exactly as before.
//!
//! The second clause of the literal test is what keeps a near-miss honest. A
//! 63-character key is all-hex, so it is a *key that is wrong* and says so,
//! rather than becoming a filename nothing will ever find; and a 64-character
//! value with a typo in it is key-shaped by length, so it names the character
//! that is not hexadecimal instead of failing as a missing file. Every real
//! path keeps working: an absolute one has a `/`, and a relative one
//! (`monetize.pub`, `keys/monetize`) has a `/` or a character that is not a hex
//! digit.

use std::fmt;
use std::path::{Path, PathBuf};
use std::str::FromStr;

/// An Ed25519 public key is 32 bytes, so 64 hexadecimal characters.
pub const HEX_LEN: usize = 64;

/// What `--monetize-pubkey` was given: the key itself, or a file holding it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MonetizePubkey {
    /// The 32 verifying-key bytes, spelled inline as hex.
    Literal([u8; 32]),
    /// A file to read the key out of, in any of the spellings the reader
    /// accepts (raw bytes, hex, or `ed25519:<base64>`).
    Path(PathBuf),
    /// **[`FED`]: the installer's door feeds the key** (2026-09-24 evening,
    /// the constant medium). monetize-server mints a fresh signing key with
    /// every front, so a medium that carried the key was a medium per lay.
    /// A medium composed with `monetize_pubkey=fed` carries NO key and says
    /// so: the metered server starts enforcing nothing until the door's feed
    /// arrives, and the pairing loop restarts the box once paired so the fed
    /// key is in force from then on (`gunnar-server` `feed`, `pairing`).
    /// A word, not an absence, because an absent value falls back to the key
    /// the variant bakes — which is exactly the stale key this replaces.
    Fed,
}

/// The spelling of [`MonetizePubkey::Fed`] — the whole value, nothing else.
pub const FED: &str = "fed";

impl MonetizePubkey {
    /// Whether the key is the door's to feed ([`FED`]).
    pub fn is_fed(&self) -> bool {
        matches!(self, MonetizePubkey::Fed)
    }

    /// The key bytes, when the value was spelled inline. `None` for a path,
    /// which has to be read before it is anything, and for [`FED`].
    pub fn literal(&self) -> Option<[u8; 32]> {
        match self {
            MonetizePubkey::Literal(k) => Some(*k),
            MonetizePubkey::Path(_) | MonetizePubkey::Fed => None,
        }
    }

    /// The file to read, when the value was a path.
    pub fn path(&self) -> Option<&Path> {
        match self {
            MonetizePubkey::Literal(_) | MonetizePubkey::Fed => None,
            MonetizePubkey::Path(p) => Some(p),
        }
    }
}

impl fmt::Display for MonetizePubkey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            // A public key, printed whole: it is what the operator typed, and
            // eliding it would make the refusal impossible to act on.
            MonetizePubkey::Literal(k) => {
                for b in k {
                    write!(f, "{b:02x}")?;
                }
                Ok(())
            }
            MonetizePubkey::Path(p) => write!(f, "{}", p.display()),
            MonetizePubkey::Fed => f.write_str(FED),
        }
    }
}

impl FromStr for MonetizePubkey {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // BEFORE the shape rule: `fed` is all-hex (f, e, d), so it would be
        // read as a three-character key that is wrong.
        if s == FED {
            return Ok(MonetizePubkey::Fed);
        }
        if looks_like_an_inline_key(s) {
            return parse_hex32(s).map(MonetizePubkey::Literal).map_err(|why| {
                format!(
                    "`{s}` is key-shaped, so it was read as monetize's public key spelled \
                     inline rather than as a filename, and {why}. A path is anything with a \
                     `/` in it (`/data/monetize.pub`) or any name that is not all hexadecimal \
                     (`monetize.pub`)."
                )
            });
        }
        Ok(MonetizePubkey::Path(PathBuf::from(s)))
    }
}

/// The SHAPE rule, in one place: no `/`, and either all-hex or key-length.
///
/// Both clauses matter. All-hex alone would let a 64-character typo become a
/// filename; key-length alone would let a 63-character key become one.
fn looks_like_an_inline_key(s: &str) -> bool {
    if s.is_empty() || s.contains('/') {
        return false;
    }
    s.chars().all(|c| c.is_ascii_hexdigit()) || s.len() == HEX_LEN
}

/// 64 hexadecimal characters to the 32 bytes they spell.
///
/// The error names what was wrong — the length that was not 64, or the
/// character that was not a hex digit and where it sat — because this is read
/// off a kernel command line nobody can `ls`.
pub fn parse_hex32(text: &str) -> Result<[u8; 32], String> {
    if text.len() != HEX_LEN {
        return Err(format!(
            "an Ed25519 public key is 32 bytes, so exactly {HEX_LEN} hexadecimal characters; \
             this is {} character(s) long",
            text.len()
        ));
    }
    if let Some((i, c)) = text.char_indices().find(|(_, c)| !c.is_ascii_hexdigit()) {
        return Err(format!(
            "character {} of the key is {c:?}, which is not hexadecimal (0-9, a-f)",
            i + 1
        ));
    }
    let mut out = [0u8; 32];
    for (i, slot) in out.iter_mut().enumerate() {
        *slot = u8::from_str_radix(&text[2 * i..2 * i + 2], 16)
            .map_err(|e| format!("byte {} of the key: {e}", i + 1))?;
    }
    Ok(out)
}

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

    /// monetize's live verifying key, the one an appliance is actually given.
    const LIVE: &str = "1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e6";

    /// The 32 bytes `LIVE` spells, written out by hand rather than by calling
    /// the parser — a round-trip through the thing under test would agree with
    /// itself no matter which byte order it used.
    const LIVE_BYTES: [u8; 32] = [
        0x1c, 0x36, 0x63, 0x24, 0x8d, 0x60, 0x90, 0x0e, 0x29, 0x96, 0x38, 0x9f, 0xba, 0x4e, 0xfa,
        0x1d, 0x71, 0x79, 0xd6, 0xdb, 0xd0, 0x86, 0x27, 0xd0, 0x42, 0x6f, 0x16, 0xf9, 0x4e, 0x44,
        0xc4, 0xe6,
    ];

    /// **The blocker, measured.** The live key on the command line must arrive
    /// as the 32 verifying-key bytes — not as `Ok`, not as a path that a
    /// read-only appliance can never produce.
    #[test]
    fn the_live_key_spelled_inline_is_the_thirty_two_bytes() {
        let parsed: MonetizePubkey = LIVE.parse().expect("the live key parses");
        assert_eq!(
            parsed.literal(),
            Some(LIVE_BYTES),
            "the inline key must yield monetize's 32 bytes; got {parsed:?}"
        );
        assert_eq!(parsed.path(), None, "an inline key is not a path");
    }

    /// Measured on the fixture itself: `LIVE_BYTES` is not a value every byte
    /// order agrees on, so the assertion above can tell a correct key from a
    /// mangled one.
    #[test]
    fn the_fixture_can_tell_a_correct_key_from_a_mangled_one() {
        let mut reversed = LIVE_BYTES;
        reversed.reverse();
        assert_ne!(
            reversed, LIVE_BYTES,
            "a palindromic fixture cannot distinguish byte order"
        );
        // And one nibble's difference is a different key.
        let mut typo: Vec<char> = LIVE.chars().collect();
        typo[0] = '5';
        let typo: String = typo.into_iter().collect();
        let other: MonetizePubkey = typo.parse().expect("still key-shaped");
        assert_ne!(
            other.literal(),
            Some(LIVE_BYTES),
            "a key differing by one nibble must not parse to the same bytes"
        );
    }

    /// `fed` — the door feeds the key (the constant medium). All-hex, so it
    /// must be recognised BEFORE the shape rule, and it is neither a key nor
    /// a path; anything else spelled near it stays what it was.
    #[test]
    fn fed_is_the_door_feeds_it_and_is_neither_a_key_nor_a_path() {
        let parsed: MonetizePubkey = FED.parse().expect("`fed` parses");
        assert!(parsed.is_fed(), "{parsed:?}");
        assert_eq!((parsed.literal(), parsed.path()), (None, None));
        assert_eq!(parsed.to_string(), "fed", "said back as it was spelled");
        assert!(
            "fed0".parse::<MonetizePubkey>().is_err(),
            "a hex near-miss is a wrong KEY, not `fed`"
        );
        assert!(!LIVE.parse::<MonetizePubkey>().unwrap().is_fed());
    }

    /// The path arm is untouched: a value with a `/` is a path, whether or not
    /// it exists, and it is NOT swallowed by the literal arm.
    #[test]
    fn a_path_is_still_a_path_even_when_it_does_not_exist() {
        let parsed: MonetizePubkey = "/no/such/dir/monetize.pub"
            .parse()
            .expect("a path always parses");
        assert_eq!(
            parsed.path(),
            Some(Path::new("/no/such/dir/monetize.pub")),
            "the literal arm has swallowed the path arm"
        );
        assert_eq!(parsed.literal(), None);
        // A relative path with an extension, and one with a directory.
        for p in ["monetize.pub", "keys/monetize"] {
            let parsed: MonetizePubkey = p.parse().unwrap();
            assert_eq!(
                parsed.path(),
                Some(Path::new(p)),
                "{p} stopped being a path"
            );
        }
    }

    /// A key of the wrong length is REFUSED by length, not truncated to 32
    /// bytes and not turned into a filename.
    #[test]
    fn a_hex_key_of_the_wrong_length_is_refused_by_length() {
        for (text, n) in [(&LIVE[..63], 63usize), (&*format!("{LIVE}0"), 65)] {
            let err = text
                .parse::<MonetizePubkey>()
                .expect_err("a near-miss key must be refused, not read as a file");
            assert!(
                err.contains(&n.to_string()) && err.contains("64"),
                "the refusal must name the length that was wrong and the one wanted: {err}"
            );
        }
    }

    /// A 64-character value that is not hex names the offending character.
    #[test]
    fn a_key_shaped_value_that_is_not_hex_names_the_bad_character() {
        let mut chars: Vec<char> = LIVE.chars().collect();
        chars[7] = 'z';
        let text: String = chars.into_iter().collect();
        let err = text
            .parse::<MonetizePubkey>()
            .expect_err("a non-hex key must be refused");
        assert!(
            err.contains('z') && err.contains('8'),
            "the refusal must name the character and where it sat: {err}"
        );
    }

    /// `Display` round-trips a literal back to the hex it was given, so a
    /// refusal printed by the server names the key the operator typed.
    #[test]
    fn display_prints_a_literal_back_as_its_hex() {
        let parsed: MonetizePubkey = LIVE.parse().unwrap();
        assert_eq!(parsed.to_string(), LIVE);
        let parsed: MonetizePubkey = "/etc/monetize.pub".parse().unwrap();
        assert_eq!(parsed.to_string(), "/etc/monetize.pub");
    }
}