Skip to main content

rskit_util/hash/
content.rs

1//! BLAKE3 content hashing with optional domain-separated framing.
2//!
3//! Provides one canonical content-hash implementation (BLAKE3) behind a small,
4//! allocation-light API so callers depend on the concept of a "content hash"
5//! rather than on a specific hashing crate. Digests are rendered as lowercase
6//! hexadecimal, giving a stable, comparable identity for cache keys, change
7//! detection, and deduplication.
8//!
9//! [`ContentHasher::update_framed`] applies unambiguous, length-prefixed domain
10//! separation (each of `label` and `value` is preceded by its length) so
11//! independently folded fields cannot collide for *arbitrary* byte inputs —
12//! e.g. `("ab", "c")` and `("a", "bc")` hash differently, and so do
13//! `("a:b", "c")` and `("a", "b:c")`. Prefer it whenever several independent
14//! fields are folded into one digest.
15//!
16//! # Example
17//!
18//! ```
19//! use rskit_util::hash::{ContentHasher, hash_hex};
20//!
21//! // One-shot digest of a byte slice.
22//! let digest = hash_hex(b"hello");
23//! assert_eq!(digest.len(), 64);
24//!
25//! // Incremental, domain-separated digest of several fields.
26//! let mut hasher = ContentHasher::new();
27//! hasher.update_framed(b"name", b"toven");
28//! hasher.update_framed(b"version", b"1");
29//! let framed = hasher.finalize_hex();
30//! assert_ne!(framed, digest);
31//! ```
32
33use blake3::Hasher;
34
35/// Incremental content hasher producing a stable lowercase-hex digest.
36///
37/// Backed by BLAKE3. Feed bytes with [`update`](Self::update) (raw) or
38/// [`update_framed`](Self::update_framed) (domain-separated), then read the
39/// digest with [`finalize_hex`](Self::finalize_hex). The hasher may be reused
40/// after finalizing; finalizing does not consume it.
41#[derive(Clone, Default)]
42pub struct ContentHasher {
43    inner: Hasher,
44}
45
46impl ContentHasher {
47    /// Create an empty hasher.
48    #[must_use]
49    pub fn new() -> Self {
50        Self {
51            inner: Hasher::new(),
52        }
53    }
54
55    /// Fold `bytes` into the digest verbatim, without framing.
56    ///
57    /// Returns `&mut Self` so updates can be chained.
58    pub fn update(&mut self, bytes: &[u8]) -> &mut Self {
59        self.inner.update(bytes);
60        self
61    }
62
63    /// Fold a labelled `value` into the digest with unambiguous framing.
64    ///
65    /// Each of `label` and `value` is folded length-prefixed (its length as a
66    /// little-endian `u64`, then its bytes), so field boundaries stay
67    /// unambiguous even when the inputs contain arbitrary bytes such as `:` or
68    /// `\0`. Independently folded fields therefore cannot alias one another.
69    /// Returns `&mut Self` so updates can be chained.
70    pub fn update_framed(&mut self, label: &[u8], value: &[u8]) -> &mut Self {
71        self.update_length_prefixed(label);
72        self.update_length_prefixed(value);
73        self
74    }
75
76    /// Fold `bytes` preceded by its length (little-endian `u64`).
77    fn update_length_prefixed(&mut self, bytes: &[u8]) -> &mut Self {
78        self.inner.update(&(bytes.len() as u64).to_le_bytes());
79        self.inner.update(bytes);
80        self
81    }
82
83    /// Render the current digest as a lowercase hexadecimal string.
84    ///
85    /// Does not consume the hasher: further updates may follow and produce a
86    /// new digest.
87    #[must_use]
88    pub fn finalize_hex(&self) -> String {
89        self.inner.finalize().to_hex().to_string()
90    }
91}
92
93/// Return the lowercase-hex content digest of a single byte slice.
94#[must_use]
95pub fn hash_hex(bytes: &[u8]) -> String {
96    let mut hasher = ContentHasher::new();
97    hasher.update(bytes);
98    hasher.finalize_hex()
99}
100
101#[cfg(test)]
102mod tests {
103    use super::{ContentHasher, hash_hex};
104
105    #[test]
106    fn hash_hex_is_stable_and_64_hex_chars() {
107        let first = hash_hex(b"toven");
108        let second = hash_hex(b"toven");
109        assert_eq!(first, second);
110        assert_eq!(first.len(), 64);
111        assert!(
112            first
113                .bytes()
114                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
115        );
116    }
117
118    #[test]
119    fn distinct_input_yields_distinct_digest() {
120        assert_ne!(hash_hex(b"a"), hash_hex(b"b"));
121    }
122
123    #[test]
124    fn finalize_does_not_consume_the_hasher() {
125        let mut hasher = ContentHasher::new();
126        hasher.update(b"a");
127        let after_a = hasher.finalize_hex();
128        hasher.update(b"b");
129        let after_ab = hasher.finalize_hex();
130        assert_ne!(after_a, after_ab);
131    }
132
133    #[test]
134    fn framing_prevents_field_boundary_collisions() {
135        let mut left = ContentHasher::new();
136        left.update_framed(b"ab", b"c");
137        let mut right = ContentHasher::new();
138        right.update_framed(b"a", b"bc");
139        assert_ne!(left.finalize_hex(), right.finalize_hex());
140    }
141
142    #[test]
143    fn framing_stays_unambiguous_for_arbitrary_bytes() {
144        // Values/labels containing the historical separator and terminator bytes
145        // must not let differently-split fields alias one another.
146        let mut left = ContentHasher::new();
147        left.update_framed(b"a:b", b"c\0d");
148        let mut right = ContentHasher::new();
149        right.update_framed(b"a", b"b:c\0d");
150        assert_ne!(left.finalize_hex(), right.finalize_hex());
151
152        let mut embedded = ContentHasher::new();
153        embedded.update_framed(b"name", b"value\0name\0other");
154        let mut split = ContentHasher::new();
155        split.update_framed(b"name", b"value");
156        split.update_framed(b"name", b"other");
157        assert_ne!(embedded.finalize_hex(), split.finalize_hex());
158    }
159
160    #[test]
161    fn raw_concatenation_would_collide_without_framing() {
162        // Demonstrates why framing matters: raw updates of split fields alias.
163        let mut left = ContentHasher::new();
164        left.update(b"ab").update(b"c");
165        let mut right = ContentHasher::new();
166        right.update(b"a").update(b"bc");
167        assert_eq!(left.finalize_hex(), right.finalize_hex());
168    }
169}