tatara_process/hash.rs
1//! Substrate primitive for BLAKE3 hex digests.
2//!
3//! Two peer entries — [`hex_blake3`] for the in-memory-buffer shape
4//! (`hex::encode(blake3::hash(bytes).as_bytes())`) every three-pillar
5//! attestation producer that fed BLAKE3 a single buffer restated by
6//! hand pre-lift, and [`hex_blake3_hash`] for the streaming-digest
7//! shape (`hex::encode(hash.as_bytes())` where `hash =
8//! blake3::Hasher::finalize()`) every consumer that folded per-item
9//! updates into a `Hasher` before finalizing walked. Both peers ride
10//! through ONE hex-encoding step at [`hex_blake3_hash`] so a future
11//! swap onto `blake3::Hash::to_hex().to_string()` (or a different
12//! encoding — base32, base64url, uppercase hex for a downstream tool)
13//! lands at ONE substrate function and every downstream three-pillar
14//! consumer inherits the upgrade mechanically.
15//!
16//! Return type is `String` for wire-shape stability with the pre-lift
17//! consumers — `ReceiptEnvelope.{intent,artifact,control}_hash` are
18//! typed as `String`, so a `Cow`/`&str` return would force allocation
19//! at every call site regardless.
20//!
21//! # Which peer to call
22//!
23//! - Have `&[u8]` in hand → [`hex_blake3`]. Internally it composes
24//! [`hex_blake3_hash`] over `blake3::hash(bytes)`.
25//! - Have a `blake3::Hasher` you already folded per-item updates into
26//! → `hex_blake3_hash(&h.finalize())`. Skips the one-shot round-trip
27//! through `&[u8]` that would force the caller to materialize the
28//! full input buffer just to re-hash it.
29
30/// Compute the lowercase 64-char BLAKE3 hex digest of `bytes`.
31///
32/// # Invariants
33///
34/// - **Length:** the returned string is always exactly 64 chars
35/// (BLAKE3's 32-byte digest encoded as lowercase hex).
36/// - **Charset:** every char is one of `[0-9a-f]` (lowercase).
37/// - **Determinism:** byte-identical output across runs for the same
38/// input; matches both the `hex::encode(blake3::hash(x).as_bytes())`
39/// and `blake3::hash(x).to_hex().to_string()` pre-lift spellings.
40///
41/// # `#[must_use]`
42///
43/// Every consumer either stores the returned hex into a receipt
44/// pillar (`intent_hash`, `artifact_hash`, `control_hash`) or feeds
45/// it into a DNS slot / stable name. Dropping the return means the
46/// hash was computed for no observable reason — the attribute
47/// surfaces that as a warning at every call site.
48///
49/// Delegates the terminal `hex::encode(...as_bytes())` step to the
50/// sibling [`hex_blake3_hash`] primitive so the encoding rule lives at
51/// ONE substrate owner; a future re-encoding (base32, base64url,
52/// uppercase, `blake3::Hash::to_hex()`) reaches BOTH the one-shot and
53/// the streaming corner through ONE edit.
54#[must_use]
55pub fn hex_blake3(bytes: &[u8]) -> String {
56 hex_blake3_hash(&blake3::hash(bytes))
57}
58
59/// Streaming-digest peer of [`hex_blake3`] — the ONE substrate owner
60/// of the 1-link `hex::encode(hash.as_bytes())` encoding step every
61/// three-pillar producer that folded per-item updates into a
62/// `blake3::Hasher` walked pre-lift.
63///
64/// # Why it exists
65///
66/// The `Hasher::finalize() → hex::encode(<Hash>.as_bytes())` chain was
67/// hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
68/// duplication threshold, each carrying a `Hasher` it fed per-item
69/// updates into before finalizing:
70///
71/// * [`crate::three_pillar::compose_root`] — folds the domain tag +
72/// the four pillars (artifact, control, intent, previous) into a
73/// `blake3::Hasher`, then encodes the final hash. Pre-lift ended
74/// with `hex::encode(h.finalize().as_bytes())` inline; post-lift
75/// ends with `hex_blake3_hash(&h.finalize())`.
76/// * `tatara-reconciler::phase_machine::handle_running` — folds each
77/// observed FluxCD resource's `(apiVersion, kind, ns, name)`
78/// 4-slot identity into a `blake3::Hasher`, then encodes the final
79/// hash as the artifact-pillar input for the ATTEST step.
80///
81/// Pre-lift the one-shot corner ([`hex_blake3`]) and the streaming
82/// corner both restated `hex::encode(...as_bytes())` at their own
83/// bodies, leaving a two-place drift trap — a future swap onto
84/// `blake3::Hash::to_hex().to_string()` (a subtler pre-existing
85/// spelling that appears at `crate::hostname::short_hex_blake3`), an
86/// uppercase-hex flip for a downstream tool, or a base32-encoded
87/// variant would have to land at BOTH sites or silently break receipt
88/// verification at the corner that missed the update. Post-lift the
89/// terminal hex step lives at ONE substrate owner and the one-shot
90/// peer's body reduces to `hex_blake3_hash(&blake3::hash(bytes))`.
91///
92/// # Invariants
93///
94/// Same shape as [`hex_blake3`]: 64 chars of lowercase hex, every
95/// char in `[0-9a-f]`, byte-identical to the pre-lift `hex::encode(<
96/// hash>.as_bytes())` spelling.
97///
98/// # `#[must_use]`
99///
100/// Every consumer either stores the returned hex into a receipt
101/// pillar or a `composed_root` slot. Dropping the return means the
102/// caller finalized a `Hasher` for no observable reason — the
103/// attribute surfaces that as a warning at every call site.
104///
105/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
106/// `hex::encode(...as_bytes())` step recurred at two hand-authored
107/// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is
108/// lifted to ONE owner here). THEORY.md §II.1 invariant 5
109/// (composition preserves proofs — the pin
110/// [`tests::hex_blake3_hash_matches_pre_lift_hex_encode_spelling_bytewise`]
111/// binds the streaming corner byte-identically to the pre-lift
112/// spelling, and the cross-corner pin
113/// [`tests::hex_blake3_bytes_form_delegates_through_hex_blake3_hash`]
114/// binds the one-shot peer to `hex_blake3_hash` so a regression in
115/// either surfaces at ONE substrate pin rather than as silent
116/// composed_root drift across every downstream consumer).
117#[must_use]
118pub fn hex_blake3_hash(hash: &blake3::Hash) -> String {
119 hex::encode(hash.as_bytes())
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn hex_blake3_returns_64_char_lowercase_hex() {
128 let d = hex_blake3(b"hello");
129 assert_eq!(d.len(), 64, "BLAKE3 digest hex-encodes to 64 chars");
130 assert!(
131 d.chars()
132 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
133 "digest must be lowercase hex: {d}"
134 );
135 }
136
137 #[test]
138 fn hex_blake3_is_deterministic() {
139 assert_eq!(hex_blake3(b"hello"), hex_blake3(b"hello"));
140 assert_ne!(hex_blake3(b"hello"), hex_blake3(b"world"));
141 }
142
143 #[test]
144 fn hex_blake3_empty_input_matches_known_digest() {
145 // Known BLAKE3 digest of the empty input — a rename of the
146 // underlying algo (or an accidental salting) would land here
147 // rather than as silent receipt-root drift across every
148 // three-pillar consumer.
149 assert_eq!(
150 hex_blake3(b""),
151 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",
152 );
153 }
154
155 #[test]
156 fn hex_blake3_matches_pre_lift_hex_encode_spelling_bytewise() {
157 // Byte-identical parity with the `hex::encode(blake3::hash(x).as_bytes())`
158 // pre-lift spelling used at every reconciler + probe + worker
159 // consumer routed onto this primitive; guards against a
160 // substrate-side canonicalization the pre-lift chain does NOT
161 // apply.
162 for buf in [
163 b"" as &[u8],
164 b"x",
165 b"hello",
166 &[0u8; 256],
167 b"tatara-receipt/v1",
168 b"{\"kind\":\"tatara.export\"}",
169 ] {
170 assert_eq!(
171 hex_blake3(buf),
172 hex::encode(blake3::hash(buf).as_bytes()),
173 "pre-lift `hex::encode(...)` spelling drifted for buf.len()={}",
174 buf.len(),
175 );
176 }
177 }
178
179 #[test]
180 fn hex_blake3_matches_pre_lift_to_hex_spelling_bytewise() {
181 // Byte-identical parity with the alternate `blake3::hash(x).to_hex().to_string()`
182 // pre-lift spelling used at `tatara-process::hostname::short_hex_blake3`,
183 // `tatara-export-worker::hex_blake3`, and the `p2p::chunk::blake3_hash`
184 // helpers; catches a divergence between the two workspace
185 // spellings that would otherwise silently break stable-name /
186 // ephemeral-id / receipt-root parity at any consumer that
187 // still spelled it the other way.
188 for buf in [
189 b"" as &[u8],
190 b"x",
191 b"hello",
192 &[0xFFu8; 128],
193 b"pleme-dev/ephemeral-test-01",
194 ] {
195 assert_eq!(
196 hex_blake3(buf),
197 blake3::hash(buf).to_hex().to_string(),
198 "pre-lift `.to_hex().to_string()` spelling drifted for buf.len()={}",
199 buf.len(),
200 );
201 }
202 }
203
204 // ── hex_blake3_hash streaming-digest peer pins ─────────────────
205
206 #[test]
207 fn hex_blake3_hash_matches_pre_lift_hex_encode_spelling_bytewise() {
208 // Byte-identical parity with the `hex::encode(<hash>.as_bytes())`
209 // pre-lift spelling every three-pillar producer that
210 // finalized a `Hasher` walked (compose_root's internal chain
211 // + phase_machine's per-ref artifact-hash fold). Swept across
212 // representative Hasher inputs so a substrate-side re-encoding
213 // (a base32 flip, an uppercase-hex flip, a `to_hex().to_string()`
214 // spelling drift) surfaces HERE rather than as silent
215 // composed_root drift at every downstream consumer.
216 for buf in [
217 b"" as &[u8],
218 b"x",
219 b"hello",
220 &[0u8; 64],
221 &[0xFFu8; 128],
222 b"tatara-process/v1alpha1\n",
223 b"aaaa\ncccc\niiii\npppp",
224 ] {
225 let mut h = blake3::Hasher::new();
226 h.update(buf);
227 let hash = h.finalize();
228 assert_eq!(
229 hex_blake3_hash(&hash),
230 hex::encode(hash.as_bytes()),
231 "pre-lift `hex::encode(<hash>.as_bytes())` spelling drifted for buf.len()={}",
232 buf.len(),
233 );
234 }
235 }
236
237 #[test]
238 fn hex_blake3_hash_matches_pre_lift_to_hex_spelling_bytewise() {
239 // Cross-spelling coherence with the alternate
240 // `blake3::Hash::to_hex().to_string()` form used at
241 // `tatara-process::hostname::short_hex_blake3`; both
242 // spellings MUST produce byte-identical output so a future
243 // consumer routed onto `hex_blake3_hash` cannot silently
244 // diverge from a peer that still spells it the other way.
245 for buf in [b"" as &[u8], b"x", b"hello", &[0xFFu8; 128]] {
246 let mut h = blake3::Hasher::new();
247 h.update(buf);
248 let hash = h.finalize();
249 assert_eq!(
250 hex_blake3_hash(&hash),
251 hash.to_hex().to_string(),
252 "streaming `.to_hex().to_string()` spelling drifted for buf.len()={}",
253 buf.len(),
254 );
255 }
256 }
257
258 #[test]
259 fn hex_blake3_hash_output_is_lowercase_hex_of_blake3_length() {
260 // BLAKE3 produces 32-byte digests; hex-encoded → 64 lowercase
261 // characters. Pin the output shape so a downstream reader's
262 // width assumption (a 26-char base32 slot in the wire form,
263 // for instance) surfaces here rather than as a wire-parse
264 // failure downstream.
265 let mut h = blake3::Hasher::new();
266 h.update(b"pillar-input");
267 let out = hex_blake3_hash(&h.finalize());
268 assert_eq!(out.len(), 64);
269 assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
270 assert!(out.chars().all(|c| !c.is_ascii_uppercase()));
271 }
272
273 #[test]
274 fn hex_blake3_bytes_form_delegates_through_hex_blake3_hash() {
275 // Cross-primitive coherence — the one-shot [`hex_blake3`]
276 // peer MUST agree byte-for-byte with the streaming peer
277 // composed over `blake3::hash(bytes)`. A regression that
278 // specialized ONE peer (a different encoding, a
279 // canonicalization step) would surface HERE rather than as
280 // silent drift between the one-shot and streaming corners
281 // at every downstream consumer.
282 for buf in [
283 b"" as &[u8],
284 b"x",
285 b"hello",
286 &[0u8; 256],
287 b"tatara-receipt/v1",
288 b"{\"kind\":\"tatara.export\"}",
289 ] {
290 assert_eq!(
291 hex_blake3(buf),
292 hex_blake3_hash(&blake3::hash(buf)),
293 "one-shot `hex_blake3` drifted from streaming peer for buf.len()={}",
294 buf.len(),
295 );
296 }
297 }
298
299 #[test]
300 fn hex_blake3_hash_is_deterministic_across_calls() {
301 // BLAKE3 is deterministic; the encoder is pure. A regression
302 // that accidentally seeded a nonce, read a clock, or salted
303 // the encoding would fail loudly HERE rather than as silent
304 // composed_root drift at every downstream consumer.
305 let mut h = blake3::Hasher::new();
306 h.update(b"input");
307 let hash = h.finalize();
308 assert_eq!(hex_blake3_hash(&hash), hex_blake3_hash(&hash));
309 }
310}