← 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");
}
}