syntheca 0.3.0

Content-addressable storage on top of apotheca. Bytes go in, BLAKE3 hash comes out; the underlying cella's compare-and-swap pinax namespace is surfaced as a pass-through.
Documentation
// BLAKE3 hash type. SPEC §1.1, §1.2.
//
// 32 octets, encoded on the wire as 64 lowercase hexadecimal digits.
// Uppercase or mixed-case hex is rejected on input; equality is over
// the underlying octets.

use std::fmt;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Hash([u8; 32]);

impl Hash {
    /// Compute `blake3(bytes)`.
    pub fn of(bytes: &[u8]) -> Self {
        Hash(blake3::hash(bytes).into())
    }

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

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

    pub fn to_hex(&self) -> String {
        hex::encode(self.0)
    }

    /// Parse 64 lowercase hex digits. Uppercase or mixed-case is rejected.
    pub fn from_hex(s: &str) -> Result<Self, HashParseError> {
        if s.len() != 64 {
            return Err(HashParseError::WrongLength);
        }
        if !s.bytes().all(is_lower_hex) {
            return Err(HashParseError::InvalidChar);
        }
        let mut buf = [0u8; 32];
        hex::decode_to_slice(s, &mut buf).map_err(|_| HashParseError::InvalidChar)?;
        Ok(Hash(buf))
    }
}

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashParseError {
    WrongLength,
    InvalidChar,
}

impl fmt::Display for HashParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            HashParseError::WrongLength => f.write_str("hash must be 64 hex digits"),
            HashParseError::InvalidChar => {
                f.write_str("hash contains invalid character (must be lowercase hex)")
            }
        }
    }
}

impl std::error::Error for HashParseError {}

fn is_lower_hex(b: u8) -> bool {
    matches!(b, b'0'..=b'9' | b'a'..=b'f')
}

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

    #[test]
    fn of_known_value() {
        // BLAKE3 of empty input.
        let h = Hash::of(b"");
        assert_eq!(
            h.to_hex(),
            "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
        );
    }

    #[test]
    fn round_trip_hex() {
        let h = Hash::of(b"hello world");
        let s = h.to_hex();
        assert_eq!(s.len(), 64);
        let parsed = Hash::from_hex(&s).unwrap();
        assert_eq!(h, parsed);
    }

    #[test]
    fn rejects_uppercase() {
        let h = Hash::of(b"x");
        let upper = h.to_hex().to_uppercase();
        assert_eq!(Hash::from_hex(&upper), Err(HashParseError::InvalidChar));
    }

    #[test]
    fn rejects_wrong_length() {
        assert_eq!(Hash::from_hex(""), Err(HashParseError::WrongLength));
        assert_eq!(Hash::from_hex("abcd"), Err(HashParseError::WrongLength));
        let too_long = "a".repeat(65);
        assert_eq!(Hash::from_hex(&too_long), Err(HashParseError::WrongLength));
    }

    #[test]
    fn rejects_non_hex() {
        let bad = "g".repeat(64);
        assert_eq!(Hash::from_hex(&bad), Err(HashParseError::InvalidChar));
    }

    #[test]
    fn display_is_hex() {
        let h = Hash::of(b"abc");
        assert_eq!(format!("{h}"), h.to_hex());
    }
}