//! Archive header types: format version, commitment kind, and the commitment //! itself. //! //! STAR-lite v1 introduces a `kind` byte that selects which commitment covers //! the archive and which meta object accompanies it. v0 archives carry no kind //! byte; they are implicitly [`Kind::Commit`]. use star_repo::Cid; use crate::error::{FormatError, Result}; /// `*l\0`: ASCII "star", "**l**ite", version 0. pub const MAGIC_V0: [u8; 3] = [0x2A, 0x6C, 0x00]; /// `*l\x01`: ASCII "star", "**l**ite", version 1. pub const MAGIC_V1: [u8; 3] = [0x2A, 0x6C, 0x01]; /// CID prefix in atproto format: CIDv1, dag-cbor codec, sha-256 multihash, /// 32-byte digest length. pub const HEADER_CID_PREFIX: [u8; 4] = [0x01, 0x71, 0x12, 0x20]; /// Wire size of a kind `C` commitment: 4-byte prefix + 32 digest bytes. pub const HEADER_CID_LEN: usize = 36; /// Wire size of a kind `L` commitment: a bare sha-256 digest. pub const LTHASH_COMMITMENT_LEN: usize = 32; /// Maximum meta length for kind `C` (the v0 partial commit limit). pub const MAX_PARTIAL_COMMIT_LEN: u64 = 4096; /// Maximum meta length for kind `L`. pub const MAX_SPACE_META_LEN: u64 = star_repo::space::MAX_META_LEN; /// Which version of the format an archive is encoded in. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Version { /// No kind byte; commitment is always an atproto CID. V0, /// Carries a kind byte after the magic. V1, } impl Version { pub fn magic(self) -> [u8; 3] { match self { Version::V0 => MAGIC_V0, Version::V1 => MAGIC_V1, } } pub fn from_magic(magic: &[u8; 3]) -> Option { match *magic { MAGIC_V0 => Some(Version::V0), MAGIC_V1 => Some(Version::V1), _ => None, } } /// True if this version encodes a kind byte in its header. pub fn has_kind_byte(self) -> bool { matches!(self, Version::V1) } } /// Which commitment covers the archive, and therefore what its meta object is. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Kind { /// `C` — an atproto CID commitment with an optional partial commit object. Commit, /// `L` — an LtHash commitment with an optional space meta object. LtHash, } impl Kind { pub const COMMIT_BYTE: u8 = b'C'; pub const LTHASH_BYTE: u8 = b'L'; pub fn from_byte(byte: u8) -> Result { match byte { Self::COMMIT_BYTE => Ok(Kind::Commit), Self::LTHASH_BYTE => Ok(Kind::LtHash), other => Err(FormatError::UnknownKind(other).into()), } } pub fn to_byte(self) -> u8 { match self { Kind::Commit => Self::COMMIT_BYTE, Kind::LtHash => Self::LTHASH_BYTE, } } /// Wire length of this kind's commitment field. pub fn commitment_len(self) -> usize { match self { Kind::Commit => HEADER_CID_LEN, Kind::LtHash => LTHASH_COMMITMENT_LEN, } } /// Maximum encoded length of this kind's meta object. pub fn max_meta_len(self) -> u64 { match self { Kind::Commit => MAX_PARTIAL_COMMIT_LEN, Kind::LtHash => MAX_SPACE_META_LEN, } } /// The version an archive of this kind is canonically emitted as. /// /// Kind `C` archives round-trip as v0: v1 offers them nothing, and the /// spec asks writers not to emit them as v1. pub fn canonical_version(self) -> Version { match self { Kind::Commit => Version::V0, Kind::LtHash => Version::V1, } } } /// The value in an archive header that the whole body must reproduce. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Commitment { /// Kind `C`: an atproto CID (CIDv1 / dag-cbor / sha-256). Cid(Cid), /// Kind `L`: `sha256` of the archive's LtHash state. LtHash([u8; LTHASH_COMMITMENT_LEN]), } impl Commitment { pub fn kind(&self) -> Kind { match self { Commitment::Cid(_) => Kind::Commit, Commitment::LtHash(_) => Kind::LtHash, } } pub fn as_cid(&self) -> Option { match self { Commitment::Cid(cid) => Some(*cid), Commitment::LtHash(_) => None, } } pub fn as_lthash(&self) -> Option<[u8; LTHASH_COMMITMENT_LEN]> { match self { Commitment::LtHash(d) => Some(*d), Commitment::Cid(_) => None, } } /// Parse a commitment from its wire bytes, which must be exactly /// [`Kind::commitment_len`] long. pub fn from_bytes(kind: Kind, bytes: &[u8]) -> Result { if bytes.len() != kind.commitment_len() { return Err(FormatError::UnexpectedEof.into()); } match kind { Kind::Commit => { if bytes[..HEADER_CID_PREFIX.len()] != HEADER_CID_PREFIX { return Err(FormatError::InvalidHeaderCid.into()); } let cid = Cid::try_from(bytes).map_err(|_| FormatError::InvalidHeaderCid)?; Ok(Commitment::Cid(cid)) } Kind::LtHash => { let mut digest = [0u8; LTHASH_COMMITMENT_LEN]; digest.copy_from_slice(bytes); Ok(Commitment::LtHash(digest)) } } } /// Serialize to the bytes that appear in a header. pub fn to_bytes(&self) -> Result> { match self { Commitment::Cid(cid) => { let bytes = cid.to_bytes(); if bytes.len() != HEADER_CID_LEN || bytes[..HEADER_CID_PREFIX.len()] != HEADER_CID_PREFIX { return Err(FormatError::InvalidHeaderCid.into()); } Ok(bytes) } Commitment::LtHash(d) => Ok(d.to_vec()), } } } impl std::fmt::Display for Commitment { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Commitment::Cid(cid) => write!(f, "{cid}"), Commitment::LtHash(d) => { for b in d { write!(f, "{b:02x}")?; } Ok(()) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn kind_bytes_are_ascii_letters() { assert_eq!(Kind::Commit.to_byte(), 0x43); assert_eq!(Kind::LtHash.to_byte(), 0x4C); assert_eq!(Kind::from_byte(b'C').unwrap(), Kind::Commit); assert_eq!(Kind::from_byte(b'L').unwrap(), Kind::LtHash); } #[test] fn unknown_kind_is_rejected_with_the_offending_byte() { // 0 and 1 are the tempting wrong guesses; neither is a valid kind. for byte in [0u8, 1, b'c', b'l', 0xff] { assert!(matches!( Kind::from_byte(byte), Err(crate::Error::Format(FormatError::UnknownKind(b))) if b == byte )); } } #[test] fn magic_roundtrips() { assert_eq!(Version::from_magic(&MAGIC_V0), Some(Version::V0)); assert_eq!(Version::from_magic(&MAGIC_V1), Some(Version::V1)); assert_eq!(Version::from_magic(&[0x2A, 0x6C, 0x02]), None); assert!(!Version::V0.has_kind_byte()); assert!(Version::V1.has_kind_byte()); } #[test] fn lthash_commitment_roundtrips() { let digest = [7u8; 32]; let c = Commitment::LtHash(digest); let bytes = c.to_bytes().unwrap(); assert_eq!(bytes.len(), 32); assert_eq!(Commitment::from_bytes(Kind::LtHash, &bytes).unwrap(), c); assert_eq!(c.as_lthash(), Some(digest)); assert_eq!(c.as_cid(), None); } #[test] fn cid_commitment_rejects_wrong_prefix() { let mut bytes = [0u8; 36]; bytes[..4].copy_from_slice(&[0x01, 0x55, 0x12, 0x20]); // raw codec, not dag-cbor assert!(Commitment::from_bytes(Kind::Commit, &bytes).is_err()); } #[test] fn commitment_length_mismatch_is_rejected() { assert!(Commitment::from_bytes(Kind::LtHash, &[0u8; 31]).is_err()); assert!(Commitment::from_bytes(Kind::Commit, &[0u8; 32]).is_err()); } #[test] fn canonical_versions_follow_the_spec() { assert_eq!(Kind::Commit.canonical_version(), Version::V0); assert_eq!(Kind::LtHash.canonical_version(), Version::V1); } }