jacquardSnapshot

← snapshot

8255 bytes
//! Content digests.
//!
//! BLAKE3 is used throughout. It is tree-structured and SIMD-accelerated, so
//! it saturates memory bandwidth on the blob-hashing path, and it is fast on
//! the small inputs that dominate tree and snapshot encoding.

use core::fmt::{self, Write as _};
use core::str::FromStr;

use serde::de::Visitor;
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::error::CoreError;

/// Length of a digest in bytes.
pub const DIGEST_LEN: usize = 32;

/// Length of a digest rendered as lowercase hex.
pub const HEX_LEN: usize = DIGEST_LEN * 2;

const HEX_LUT: &[u8; 16] = b"0123456789abcdef";

/// A 32-byte BLAKE3 digest, stored inline.
///
/// `Digest` is [`Copy`] and totally ordered by lexicographic byte order, so
/// sorted slices of digests can be binary-searched without hashing into a map.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
#[repr(transparent)]
pub struct Digest([u8; DIGEST_LEN]);

impl Digest {
    /// The all-zero digest.
    pub const ZERO: Self = Self([0u8; DIGEST_LEN]);

    /// Hashes `bytes` with BLAKE3.
    #[must_use]
    pub fn of(bytes: &[u8]) -> Self {
        Self(*blake3::hash(bytes).as_bytes())
    }

    /// Wraps raw bytes that are already a digest.
    #[must_use]
    pub const fn from_bytes(bytes: [u8; DIGEST_LEN]) -> Self {
        Self(bytes)
    }

    /// Borrows the raw bytes.
    #[must_use]
    pub const fn as_bytes(&self) -> &[u8; DIGEST_LEN] {
        &self.0
    }

    /// Renders the digest as lowercase hex without allocating.
    #[must_use]
    pub const fn to_hex(self) -> HexDigest {
        let mut out = [0u8; HEX_LEN];
        let mut i = 0;
        while i < DIGEST_LEN {
            let b = self.0[i];
            out[i * 2] = HEX_LUT[(b >> 4) as usize];
            out[i * 2 + 1] = HEX_LUT[(b & 0x0f) as usize];
            i += 1;
        }
        HexDigest(out)
    }

    /// Returns the first `n` hex characters, for log lines where a full digest
    /// is noise. Never use a truncated digest for a comparison.
    #[must_use]
    pub fn short(self, n: usize) -> String {
        let hex = self.to_hex();
        hex.as_str().chars().take(n.min(HEX_LEN)).collect()
    }
}

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

impl fmt::Debug for Digest {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("Digest(")?;
        f.write_str(self.to_hex().as_str())?;
        f.write_char(')')
    }
}

impl FromStr for Digest {
    type Err = CoreError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let bytes = s.as_bytes();
        if bytes.len() != HEX_LEN {
            return Err(CoreError::MalformedDigest { len: bytes.len() });
        }
        let mut out = [0u8; DIGEST_LEN];
        for (i, slot) in out.iter_mut().enumerate() {
            let hi = decode_nibble(bytes[i * 2])?;
            let lo = decode_nibble(bytes[i * 2 + 1])?;
            *slot = (hi << 4) | lo;
        }
        Ok(Self(out))
    }
}

const fn decode_nibble(c: u8) -> Result<u8, CoreError> {
    match c {
        b'0'..=b'9' => Ok(c - b'0'),
        b'a'..=b'f' => Ok(c - b'a' + 10),
        b'A'..=b'F' => Ok(c - b'A' + 10),
        _ => Err(CoreError::MalformedDigestChar),
    }
}

impl Serialize for Digest {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        if s.is_human_readable() {
            s.serialize_str(self.to_hex().as_str())
        } else {
            s.serialize_bytes(&self.0)
        }
    }
}

impl<'de> Deserialize<'de> for Digest {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        struct V;
        impl Visitor<'_> for V {
            type Value = Digest;

            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                f.write_str("a 64-character hex digest or 32 raw bytes")
            }

            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Digest, E> {
                Digest::from_str(v).map_err(E::custom)
            }

            fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Digest, E> {
                let arr: [u8; DIGEST_LEN] = v
                    .try_into()
                    .map_err(|_| E::invalid_length(v.len(), &self))?;
                Ok(Digest::from_bytes(arr))
            }
        }
        if d.is_human_readable() {
            d.deserialize_str(V)
        } else {
            d.deserialize_bytes(V)
        }
    }
}

/// A stack-allocated hex rendering of a [`Digest`].
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct HexDigest([u8; HEX_LEN]);

impl HexDigest {
    /// Borrows the rendering as a string slice.
    ///
    /// The buffer is written only from a fixed ASCII table, so the conversion
    /// cannot fail; the fallback exists solely to keep this panic-free.
    #[must_use]
    pub fn as_str(&self) -> &str {
        core::str::from_utf8(&self.0).unwrap_or("<non-utf8 digest>")
    }
}

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

impl fmt::Debug for HexDigest {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Incremental hasher for building digests over structured, multi-part input.
///
/// Fields are length-prefixed before being absorbed. Without that, the
/// records `("ab", "c")` and `("a", "bc")` would produce the same digest,
/// which would let two different objects share a content address.
#[derive(Debug, Clone)]
pub struct DigestHasher(blake3::Hasher);

impl DigestHasher {
    /// Starts a new hasher.
    #[must_use]
    pub fn new() -> Self {
        Self(blake3::Hasher::new())
    }

    /// Absorbs a single domain-separation byte, unprefixed.
    ///
    /// Feed exactly one tag byte first (see [`crate::oid::ObjectTag`]) so
    /// objects of different kinds can never collide.
    pub fn tag(&mut self, tag: u8) -> &mut Self {
        self.0.update(&[tag]);
        self
    }

    /// Absorbs a length-prefixed field.
    pub fn field(&mut self, bytes: &[u8]) -> &mut Self {
        let len = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
        self.0.update(&len.to_le_bytes());
        self.0.update(bytes);
        self
    }

    /// Absorbs a `u64` field.
    pub fn u64(&mut self, v: u64) -> &mut Self {
        self.0.update(&v.to_le_bytes());
        self
    }

    /// Absorbs an `i64` field.
    pub fn i64(&mut self, v: i64) -> &mut Self {
        self.0.update(&v.to_le_bytes());
        self
    }

    /// Absorbs another digest.
    pub fn digest(&mut self, d: Digest) -> &mut Self {
        self.0.update(d.as_bytes());
        self
    }

    /// Finalises into a [`Digest`].
    #[must_use]
    pub fn finish(&self) -> Digest {
        Digest(*self.0.finalize().as_bytes())
    }
}

impl Default for DigestHasher {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "test assertions read better with unwrap"
)]
mod tests {
    use super::*;

    #[test]
    fn digest_is_inline_and_copy() {
        assert_eq!(size_of::<Digest>(), DIGEST_LEN);
    }

    #[test]
    fn hex_roundtrips() {
        let d = Digest::of(b"jacquard");
        let s = d.to_hex();
        assert_eq!(s.as_str().len(), HEX_LEN);
        assert_eq!(Digest::from_str(s.as_str()).unwrap(), d);
    }

    #[test]
    fn rejects_malformed_hex() {
        assert!(Digest::from_str("abc").is_err());
        assert!(Digest::from_str(&"z".repeat(HEX_LEN)).is_err());
    }

    #[test]
    fn length_prefixing_prevents_field_confusion() {
        let a = DigestHasher::new().field(b"ab").field(b"c").finish();
        let b = DigestHasher::new().field(b"a").field(b"bc").finish();
        assert_ne!(a, b, "unprefixed concatenation would collide here");
    }

    #[test]
    fn tag_byte_separates_domains() {
        let a = DigestHasher::new().tag(1).field(b"x").finish();
        let b = DigestHasher::new().tag(2).field(b"x").finish();
        assert_ne!(a, b, "same fields under different tags must not collide");
    }
}