Skip to main content

cas_kit/
hash.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! A BLAKE3 content hash (32 bytes / 256 bits).
3//!
4//! This is a self-contained copy of the `suture_common::Hash` type so the
5//! crate has no dependency on the Suture workspace. The in-memory
6//! representation (`pub [u8; 32]`) and the lowercase-hex text form are
7//! identical to the Suture definition, making the two types trivially
8//! interchangeable:
9//!
10//! ```text
11//! suture_common::Hash(hash.0)          // kit -> suture
12//! cas_kit::Hash(suture_hash.0)         // suture -> kit
13//! ```
14
15use std::fmt;
16
17use blake3::Hash as Blake3Hash;
18
19/// Error returned when parsing a [`Hash`] from text.
20#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21pub enum HashError {
22    /// The input was not exactly 64 characters.
23    #[error("invalid hash length: expected 64 hex chars, got {0}")]
24    InvalidLength(usize),
25    /// The input contained non-hex or non-UTF-8 characters.
26    #[error("invalid hex in hash")]
27    InvalidHex,
28}
29
30/// A BLAKE3 content hash (32 bytes / 256 bits).
31///
32/// Used as the canonical identifier for blobs in the content-addressed
33/// store. BLAKE3 provides SIMD-accelerated hashing with a 2^128 collision
34/// resistance bound.
35#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
36pub struct Hash(
37    /// The raw 32-byte digest.
38    pub [u8; 32],
39);
40
41impl Hash {
42    /// Compute the BLAKE3 hash of arbitrary data.
43    #[must_use]
44    pub fn from_data(data: &[u8]) -> Self {
45        Self(*blake3::hash(data).as_bytes())
46    }
47
48    /// Parse a hash from a 64-character lowercase hex string.
49    pub fn from_hex(hex: &str) -> Result<Self, HashError> {
50        if hex.len() != 64 {
51            return Err(HashError::InvalidLength(hex.len()));
52        }
53        let mut bytes = [0u8; 32];
54        hex.as_bytes()
55            .chunks_exact(2)
56            .zip(bytes.iter_mut())
57            .try_for_each(|(chunk, byte)| {
58                *byte = u8::from_str_radix(
59                    std::str::from_utf8(chunk).map_err(|_| HashError::InvalidHex)?,
60                    16,
61                )
62                .map_err(|_| HashError::InvalidHex)?;
63                Ok::<_, HashError>(())
64            })?;
65        Ok(Self(bytes))
66    }
67
68    /// Convert to a 64-character lowercase hex string.
69    #[must_use]
70    pub fn to_hex(&self) -> String {
71        const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
72        let mut s = String::with_capacity(64);
73        for byte in &self.0 {
74            s.push(HEX_CHARS[usize::from(byte >> 4)] as char);
75            s.push(HEX_CHARS[usize::from(byte & 0x0f)] as char);
76        }
77        s
78    }
79
80    /// The zero hash (all zeros). Used as a sentinel value.
81    pub const ZERO: Self = Self([0u8; 32]);
82
83    /// The 2-hex-prefix bucket index (`0..=255`) of this hash in the blob
84    /// store layout: blobs live under `objects/<2-hex-prefix>/<rest>`,
85    /// where the 2-hex prefix is the lowercase-hex encoding of this byte
86    /// (the digest's first byte). See `BlobStore::blob_path`.
87    ///
88    /// This is a pure function of the digest; Kani-verified in
89    /// `tests/kani.rs` to agree with the hex text form and to be stable
90    /// under the `to_hex`/`from_hex` roundtrip.
91    #[must_use]
92    pub fn bucket(&self) -> u8 {
93        self.0[0]
94    }
95
96    /// Convert to a `blake3::Hash` value.
97    #[must_use]
98    pub fn as_blake3(&self) -> Blake3Hash {
99        Blake3Hash::from_bytes(self.0)
100    }
101}
102
103impl fmt::Debug for Hash {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "Hash({})", self.to_hex())
106    }
107}
108
109impl fmt::Display for Hash {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        // Display short form: first 12 hex chars.
112        let hex = self.to_hex();
113        write!(f, "{}…", &hex[..12])
114    }
115}
116
117impl From<Blake3Hash> for Hash {
118    fn from(h: Blake3Hash) -> Self {
119        Self(*h.as_bytes())
120    }
121}
122
123impl From<[u8; 32]> for Hash {
124    fn from(bytes: [u8; 32]) -> Self {
125        Self(bytes)
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    /// BLAKE3 of the empty string (published test vector).
134    const EMPTY_BLAKE3_HEX: &str =
135        "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
136
137    #[test]
138    fn hex_roundtrip() -> Result<(), HashError> {
139        let h = Hash::from_data(b"hello");
140        let parsed = Hash::from_hex(&h.to_hex())?;
141        assert_eq!(h, parsed);
142        Ok(())
143    }
144
145    #[test]
146    fn empty_string_vector() -> Result<(), HashError> {
147        assert_eq!(Hash::from_data(b"").to_hex(), EMPTY_BLAKE3_HEX);
148        assert_eq!(Hash::from_hex(EMPTY_BLAKE3_HEX)?, Hash::from_data(b""));
149        Ok(())
150    }
151
152    #[test]
153    fn rejects_bad_length() {
154        assert_eq!(Hash::from_hex("abc"), Err(HashError::InvalidLength(3)));
155        assert_eq!(
156            Hash::from_hex(&"a".repeat(63)),
157            Err(HashError::InvalidLength(63))
158        );
159    }
160
161    #[test]
162    fn rejects_bad_hex() {
163        let mut bad = "a".repeat(63);
164        bad.push('g');
165        assert_eq!(Hash::from_hex(&bad), Err(HashError::InvalidHex));
166    }
167
168    #[test]
169    fn display_is_short_form() -> Result<(), HashError> {
170        let h = Hash::from_hex(EMPTY_BLAKE3_HEX)?;
171        assert_eq!(format!("{h}"), "af1349b9f5f9…");
172        Ok(())
173    }
174
175    #[test]
176    fn debug_is_full_hex() -> Result<(), HashError> {
177        let h = Hash::from_hex(EMPTY_BLAKE3_HEX)?;
178        assert_eq!(format!("{h:?}"), format!("Hash({EMPTY_BLAKE3_HEX})"));
179        Ok(())
180    }
181
182    #[test]
183    fn zero_sentinel() {
184        assert_eq!(Hash::ZERO.0, [0u8; 32]);
185    }
186
187    #[test]
188    fn ordering_is_byte_lexicographic() {
189        let mut a = Hash::ZERO;
190        a.0[0] = 0x00;
191        let mut b = Hash::ZERO;
192        b.0[0] = 0x01;
193        assert!(a < b);
194    }
195}