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 rewrite-stable logical 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 (hc-XXXXXXXXXX...).
129    pub fn to_string_full(&self) -> String {
130        format!(
131            "hc-{}",
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 hc- prefix).
143    pub fn parse(s: &str) -> Result<Self, ChangeIdParseError> {
144        let s = s.strip_prefix("hc-").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
161/// Content-addressed identity of an immutable state core.
162#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, Default)]
163pub struct StateId([u8; 32]);
164
165impl StateId {
166    pub fn from_bytes(bytes: [u8; 32]) -> Self {
167        Self(bytes)
168    }
169
170    pub fn from_content_hash(hash: ContentHash) -> Self {
171        Self(*hash.as_bytes())
172    }
173
174    pub fn try_from_slice(bytes: &[u8]) -> Result<Self, StateIdParseError> {
175        if bytes.len() != 32 {
176            return Err(StateIdParseError::InvalidLength);
177        }
178        let mut value = [0; 32];
179        value.copy_from_slice(bytes);
180        Ok(Self(value))
181    }
182
183    pub fn as_bytes(&self) -> &[u8; 32] {
184        &self.0
185    }
186
187    pub fn as_content_hash(&self) -> ContentHash {
188        ContentHash::from_bytes(self.0)
189    }
190
191    pub fn to_string_full(&self) -> String {
192        format!(
193            "hs-{}",
194            base32::encode(base32::Alphabet::Crockford, &self.0).to_lowercase()
195        )
196    }
197
198    pub fn short(&self) -> String {
199        let full = self.to_string_full();
200        full[..18.min(full.len())].to_string()
201    }
202
203    pub fn parse(s: &str) -> Result<Self, StateIdParseError> {
204        let encoded = s.strip_prefix("hs-").unwrap_or(s);
205        let bytes = base32::decode(base32::Alphabet::Crockford, &encoded.to_uppercase())
206            .ok_or(StateIdParseError::InvalidBase32)?;
207        Self::try_from_slice(&bytes)
208    }
209
210    pub fn is_zero(&self) -> bool {
211        self.0 == [0; 32]
212    }
213}
214
215impl fmt::Debug for StateId {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        write!(f, "StateId({})", self.short())
218    }
219}
220
221impl fmt::Display for StateId {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        f.write_str(&self.short())
224    }
225}
226
227#[derive(Debug, Clone, thiserror::Error)]
228pub enum StateIdParseError {
229    #[error("invalid base32 encoding")]
230    InvalidBase32,
231    #[error("invalid length (expected 32 bytes)")]
232    InvalidLength,
233}
234
235impl fmt::Debug for ChangeId {
236    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237        write!(f, "ChangeId({})", self.short())
238    }
239}
240
241impl fmt::Display for ChangeId {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        write!(f, "{}", self.short())
244    }
245}
246
247/// Error parsing a ChangeId.
248#[derive(Debug, Clone, thiserror::Error)]
249pub enum ChangeIdParseError {
250    #[error("invalid base32 encoding")]
251    InvalidBase32,
252    #[error("invalid length (expected 16 bytes)")]
253    InvalidLength,
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_content_hash_compute() {
262        let hash = ContentHash::compute(b"hello world");
263        assert_eq!(hash.to_hex().len(), 64);
264
265        let hash2 = ContentHash::compute(b"hello world");
266        assert_eq!(hash, hash2);
267
268        let hash3 = ContentHash::compute(b"hello world!");
269        assert_ne!(hash, hash3);
270    }
271
272    #[test]
273    fn test_content_hash_typed() {
274        let hash1 = ContentHash::compute_typed("blob", b"hello");
275        let hash2 = ContentHash::compute_typed("tree", b"hello");
276        assert_ne!(hash1, hash2);
277    }
278
279    #[test]
280    fn test_content_hash_hex_roundtrip() {
281        let hash = ContentHash::compute(b"test");
282        let hex = hash.to_hex();
283        let parsed = ContentHash::from_hex(&hex).unwrap();
284        assert_eq!(hash, parsed);
285    }
286
287    #[test]
288    fn test_change_id_generate() {
289        let id1 = ChangeId::generate();
290        let id2 = ChangeId::generate();
291        assert_ne!(id1, id2);
292        assert!(!id1.is_zero());
293    }
294
295    #[test]
296    fn test_change_id_roundtrip() {
297        let id = ChangeId::generate();
298        let s = id.to_string_full();
299        assert!(s.starts_with("hc-"));
300        let parsed = ChangeId::parse(&s).unwrap();
301        assert_eq!(id, parsed);
302    }
303
304    #[test]
305    fn test_change_id_short() {
306        let id = ChangeId::generate();
307        let short = id.short();
308        assert!(short.starts_with("hc-"));
309        assert!(short.len() <= 15);
310    }
311
312    #[test]
313    fn test_state_id_roundtrip() {
314        let id = StateId::from_content_hash(ContentHash::compute(b"state"));
315        let encoded = id.to_string_full();
316
317        assert!(encoded.starts_with("hs-"));
318        assert_eq!(StateId::parse(&encoded).unwrap(), id);
319        assert_eq!(StateId::try_from_slice(id.as_bytes()).unwrap(), id);
320    }
321}