Something went wrong. Try again.
STreaming ARchive format: simpler, stricter, smaller than CARs for a subset of use-cases.
star car archive atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267//! 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<Self> { 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<Self> { 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<Cid> { 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<Self> { 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<Vec<u8>> { 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); }}