parley-core 0.2.0

Core types, signing, and proof-of-work primitives for the Parley agent-to-agent messaging protocol.
Documentation
//! Identifier newtypes.
//!
//! All identifiers are kept as strongly-typed newtypes to prevent accidental
//! cross-use (a `ChannelId` cannot be passed where a `MessageId` is expected).
//! Wire encoding for binary IDs is base64url without padding (RFC 4648 §5).

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::str::FromStr;

use crate::error::CoreError;

// ---------------------------------------------------------------------------
// NetworkId
// ---------------------------------------------------------------------------

/// Network identifier, e.g. `"parley-mainnet"`. Format: `[a-z0-9-]{1,64}`,
/// no leading or trailing hyphen. See spec §4.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NetworkId(String);

impl NetworkId {
    pub fn new(s: impl Into<String>) -> Result<Self, CoreError> {
        let s: String = s.into();
        validate_network_id(&s)?;
        Ok(Self(s))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for NetworkId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl FromStr for NetworkId {
    type Err = CoreError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::new(s)
    }
}

fn validate_network_id(s: &str) -> Result<(), CoreError> {
    let invalid = |reason: &str| CoreError::InvalidNetworkId(format!("{s:?}: {reason}"));
    if s.is_empty() {
        return Err(invalid("empty"));
    }
    if s.len() > 64 {
        return Err(invalid("longer than 64 bytes"));
    }
    if s.starts_with('-') || s.ends_with('-') {
        return Err(invalid("leading or trailing hyphen"));
    }
    if !s
        .bytes()
        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
    {
        return Err(invalid("contains characters outside [a-z0-9-]"));
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// AgentPubkey
// ---------------------------------------------------------------------------

/// Ed25519 public key (32 bytes). Wire format is base64url-no-pad (43 chars).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct AgentPubkey([u8; 32]);

impl AgentPubkey {
    pub fn from_bytes(bytes: [u8; 32]) -> Self {
        Self(bytes)
    }

    pub fn as_bytes(&self) -> &[u8; 32] {
        &self.0
    }
}

impl fmt::Display for AgentPubkey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&URL_SAFE_NO_PAD.encode(self.0))
    }
}

impl FromStr for AgentPubkey {
    type Err = CoreError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let bytes = URL_SAFE_NO_PAD.decode(s)?;
        let arr: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
            CoreError::InvalidAgentPubkey(format!("expected 32 bytes, got {}", v.len()))
        })?;
        Ok(Self(arr))
    }
}

impl Serialize for AgentPubkey {
    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
        ser.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for AgentPubkey {
    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
        let s = String::deserialize(de)?;
        s.parse().map_err(serde::de::Error::custom)
    }
}

// ---------------------------------------------------------------------------
// ChannelId / MessageId — 16-byte opaque identifiers
// ---------------------------------------------------------------------------

macro_rules! opaque_id {
    ($name:ident, $err_variant:ident, $ctx:literal) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
        pub struct $name([u8; 16]);

        impl $name {
            pub fn from_bytes(bytes: [u8; 16]) -> Self {
                Self(bytes)
            }

            pub fn as_bytes(&self) -> &[u8; 16] {
                &self.0
            }

            /// Generate a fresh random identifier.
            pub fn generate() -> Self {
                use rand::RngCore as _;
                let mut bytes = [0u8; 16];
                rand::thread_rng().fill_bytes(&mut bytes);
                Self(bytes)
            }
        }

        impl fmt::Display for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str(&URL_SAFE_NO_PAD.encode(self.0))
            }
        }

        impl FromStr for $name {
            type Err = CoreError;
            fn from_str(s: &str) -> Result<Self, Self::Err> {
                let bytes = URL_SAFE_NO_PAD.decode(s)?;
                let arr: [u8; 16] = bytes.try_into().map_err(|v: Vec<u8>| {
                    CoreError::$err_variant(format!(
                        concat!($ctx, ": expected 16 bytes, got {}"),
                        v.len()
                    ))
                })?;
                Ok(Self(arr))
            }
        }

        impl Serialize for $name {
            fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
                ser.serialize_str(&self.to_string())
            }
        }

        impl<'de> Deserialize<'de> for $name {
            fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
                let s = String::deserialize(de)?;
                s.parse().map_err(serde::de::Error::custom)
            }
        }
    };
}

opaque_id!(ChannelId, InvalidChannelId, "channel_id");
opaque_id!(MessageId, InvalidMessageId, "message_id");
opaque_id!(BlobId, InvalidBlobId, "blob_id");

// ---------------------------------------------------------------------------
// Seq
// ---------------------------------------------------------------------------

/// Monotonic per-channel message sequence number. Starts at 1, dense (no gaps).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Seq(pub u64);

impl fmt::Display for Seq {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        self.0.fmt(f)
    }
}

// ---------------------------------------------------------------------------
// Nonce — 16 random bytes, base64url-no-pad on the wire
// ---------------------------------------------------------------------------

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Nonce([u8; 16]);

impl Nonce {
    pub fn from_bytes(bytes: [u8; 16]) -> Self {
        Self(bytes)
    }

    pub fn as_bytes(&self) -> &[u8; 16] {
        &self.0
    }

    pub fn generate() -> Self {
        use rand::RngCore as _;
        let mut bytes = [0u8; 16];
        rand::thread_rng().fill_bytes(&mut bytes);
        Self(bytes)
    }
}

impl fmt::Display for Nonce {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&URL_SAFE_NO_PAD.encode(self.0))
    }
}

impl FromStr for Nonce {
    type Err = CoreError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let bytes = URL_SAFE_NO_PAD.decode(s)?;
        let arr: [u8; 16] = bytes.try_into().map_err(|v: Vec<u8>| {
            CoreError::InvalidNonce(format!("expected 16 bytes, got {}", v.len()))
        })?;
        Ok(Self(arr))
    }
}

impl Serialize for Nonce {
    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
        ser.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for Nonce {
    fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
        let s = String::deserialize(de)?;
        s.parse().map_err(serde::de::Error::custom)
    }
}

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

    #[test]
    fn network_id_validates() {
        assert!(NetworkId::new("parley-mainnet").is_ok());
        assert!(NetworkId::new("a").is_ok());
        assert!(NetworkId::new("").is_err());
        assert!(NetworkId::new("-leading").is_err());
        assert!(NetworkId::new("trailing-").is_err());
        assert!(NetworkId::new("UPPER").is_err());
        assert!(NetworkId::new("under_score").is_err());
        assert!(NetworkId::new("x".repeat(65)).is_err());
    }

    #[test]
    fn channel_id_roundtrip() {
        let id = ChannelId::generate();
        let s = id.to_string();
        assert_eq!(s.len(), 22);
        let parsed: ChannelId = s.parse().unwrap();
        assert_eq!(id, parsed);
    }

    #[test]
    fn agent_pubkey_roundtrip() {
        let pk = AgentPubkey::from_bytes([7u8; 32]);
        let s = pk.to_string();
        assert_eq!(s.len(), 43);
        let parsed: AgentPubkey = s.parse().unwrap();
        assert_eq!(pk, parsed);
    }
}