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}