Skip to main content

objects/object/
hash.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Content hashing and change identifiers.
3
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8/// A BLAKE3 content hash (32 bytes / 256 bits).
9///
10/// Used for content-addressing blobs, trees, and states.
11/// The hash changes when content changes.
12#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
13pub struct ContentHash([u8; 32]);
14
15impl ContentHash {
16    /// Create a ContentHash from raw bytes.
17    pub fn from_bytes(bytes: [u8; 32]) -> Self {
18        Self(bytes)
19    }
20
21    /// Compute the hash of the given content.
22    pub fn compute(content: &[u8]) -> Self {
23        Self(blake3::hash(content).into())
24    }
25
26    /// Compute hash with a type prefix (e.g., "blob", "tree", "state").
27    pub fn compute_typed(type_prefix: &str, content: &[u8]) -> Self {
28        let mut hasher = Self::typed_hasher(type_prefix, content.len() as u64);
29        hasher.update(content);
30        Self(hasher.finalize().into())
31    }
32
33    /// Create a typed hasher pre-seeded with the prefix and length.
34    pub fn typed_hasher(type_prefix: &str, content_len: u64) -> blake3::Hasher {
35        let mut hasher = blake3::Hasher::new();
36        hasher.update(type_prefix.as_bytes());
37        hasher.update(&content_len.to_le_bytes());
38        hasher.update(&[0]);
39        hasher
40    }
41
42    /// Compute hash with a known content length using incremental updates.
43    pub fn compute_typed_with_len(
44        type_prefix: &str,
45        content_len: u64,
46        update: impl FnOnce(&mut blake3::Hasher),
47    ) -> Self {
48        let mut hasher = Self::typed_hasher(type_prefix, content_len);
49        update(&mut hasher);
50        Self(hasher.finalize().into())
51    }
52
53    /// Get the raw bytes.
54    pub fn as_bytes(&self) -> &[u8; 32] {
55        &self.0
56    }
57
58    /// Convert to hexadecimal string.
59    pub fn to_hex(&self) -> String {
60        hex::encode(self.0)
61    }
62
63    /// Parse from hexadecimal string.
64    pub fn from_hex(s: &str) -> Result<Self, hex::FromHexError> {
65        let bytes = hex::decode(s)?;
66        if bytes.len() != 32 {
67            return Err(hex::FromHexError::InvalidStringLength);
68        }
69        let mut arr = [0u8; 32];
70        arr.copy_from_slice(&bytes);
71        Ok(Self(arr))
72    }
73
74    /// Get a short prefix for display (default 8 chars).
75    pub fn short(&self) -> String {
76        self.to_hex()[..8].to_string()
77    }
78
79    /// Check if a hex prefix matches this hash.
80    pub fn matches_prefix(&self, prefix: &str) -> bool {
81        self.to_hex().starts_with(prefix)
82    }
83}
84
85impl fmt::Debug for ContentHash {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "ContentHash({})", self.short())
88    }
89}
90
91impl fmt::Display for ContentHash {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "{}", self.to_hex())
94    }
95}
96
97/// A stable change identifier (16 bytes / 128 bits).
98#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
99pub struct ChangeId([u8; 16]);
100
101impl ChangeId {
102    /// Generate a new random ChangeId.
103    pub fn generate() -> Self {
104        Self(rand::random())
105    }
106
107    /// Create from raw bytes.
108    pub fn from_bytes(bytes: [u8; 16]) -> Self {
109        Self(bytes)
110    }
111
112    /// Decode from a 16-byte slice. Used at the proto/wire boundary where
113    /// ChangeIds arrive as `bytes` fields (`Vec<u8>`).
114    pub fn try_from_slice(bytes: &[u8]) -> Result<Self, ChangeIdParseError> {
115        if bytes.len() != 16 {
116            return Err(ChangeIdParseError::InvalidLength);
117        }
118        let mut arr = [0u8; 16];
119        arr.copy_from_slice(bytes);
120        Ok(Self(arr))
121    }
122
123    /// Get the raw bytes.
124    pub fn as_bytes(&self) -> &[u8; 16] {
125        &self.0
126    }
127
128    /// Convert to display string (hd-XXXXXXXXXX...).
129    pub fn to_string_full(&self) -> String {
130        format!(
131            "hd-{}",
132            base32::encode(base32::Alphabet::Crockford, &self.0).to_lowercase()
133        )
134    }
135
136    /// Short form for display (first 12 chars after prefix).
137    pub fn short(&self) -> String {
138        let full = self.to_string_full();
139        full[..15.min(full.len())].to_string()
140    }
141
142    /// Parse from string (with or without hd- prefix).
143    pub fn parse(s: &str) -> Result<Self, ChangeIdParseError> {
144        let s = s.strip_prefix("hd-").unwrap_or(s);
145        let bytes = base32::decode(base32::Alphabet::Crockford, &s.to_uppercase())
146            .ok_or(ChangeIdParseError::InvalidBase32)?;
147        if bytes.len() != 16 {
148            return Err(ChangeIdParseError::InvalidLength);
149        }
150        let mut arr = [0u8; 16];
151        arr.copy_from_slice(&bytes);
152        Ok(Self(arr))
153    }
154
155    /// Check if this ChangeId is all zeros (uninitialized).
156    pub fn is_zero(&self) -> bool {
157        self.0 == [0u8; 16]
158    }
159}
160
161impl fmt::Debug for ChangeId {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        write!(f, "ChangeId({})", self.short())
164    }
165}
166
167impl fmt::Display for ChangeId {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        write!(f, "{}", self.short())
170    }
171}
172
173/// Error parsing a ChangeId.
174#[derive(Debug, Clone, thiserror::Error)]
175pub enum ChangeIdParseError {
176    #[error("invalid base32 encoding")]
177    InvalidBase32,
178    #[error("invalid length (expected 16 bytes)")]
179    InvalidLength,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_content_hash_compute() {
188        let hash = ContentHash::compute(b"hello world");
189        assert_eq!(hash.to_hex().len(), 64);
190
191        let hash2 = ContentHash::compute(b"hello world");
192        assert_eq!(hash, hash2);
193
194        let hash3 = ContentHash::compute(b"hello world!");
195        assert_ne!(hash, hash3);
196    }
197
198    #[test]
199    fn test_content_hash_typed() {
200        let hash1 = ContentHash::compute_typed("blob", b"hello");
201        let hash2 = ContentHash::compute_typed("tree", b"hello");
202        assert_ne!(hash1, hash2);
203    }
204
205    #[test]
206    fn test_content_hash_hex_roundtrip() {
207        let hash = ContentHash::compute(b"test");
208        let hex = hash.to_hex();
209        let parsed = ContentHash::from_hex(&hex).unwrap();
210        assert_eq!(hash, parsed);
211    }
212
213    #[test]
214    fn test_change_id_generate() {
215        let id1 = ChangeId::generate();
216        let id2 = ChangeId::generate();
217        assert_ne!(id1, id2);
218        assert!(!id1.is_zero());
219    }
220
221    #[test]
222    fn test_change_id_roundtrip() {
223        let id = ChangeId::generate();
224        let s = id.to_string_full();
225        assert!(s.starts_with("hd-"));
226        let parsed = ChangeId::parse(&s).unwrap();
227        assert_eq!(id, parsed);
228    }
229
230    #[test]
231    fn test_change_id_short() {
232        let id = ChangeId::generate();
233        let short = id.short();
234        assert!(short.starts_with("hd-"));
235        assert!(short.len() <= 15);
236    }
237}