tatara_process/three_pillar.rs
1//! Three-pillar BLAKE3 composition — the ONE substrate owner for the
2//! typed hashing chain every `tatara-process/v1alpha1` attestation +
3//! receipt-envelope consumer walks.
4//!
5//! # Why it exists
6//!
7//! Two peer consumers in this crate walked the SAME domain-tagged
8//! BLAKE3 chain pre-lift, each with its own private `DOMAIN_TAG`
9//! constant, its own 4-argument `compose_*` fn, AND its own
10//! `constant_time_eq` byte-comparator:
11//!
12//! * [`crate::attestation::ProcessAttestation::compose`] + `verify` —
13//! the on-chain attestation writer. Composes a new
14//! `attestation.composed_root` from the four pillars + a chained
15//! `previous_root`, and verifies a persisted attestation matches
16//! its own claim.
17//! * [`crate::receipt::ReceiptEnvelope::build`] + `verify_root` — the
18//! fleet-wide receipt-envelope writer + reader. Composes a new
19//! `envelope.composed_root` from the four pillars + the operator's
20//! expected `previous_root`, and verifies a wire-parsed envelope
21//! matches its own claim.
22//!
23//! Both consumers restated the identical BLAKE3 chain byte-for-byte
24//! (`DOMAIN_TAG` | `artifact` | `\n` | `control?` | `\n` | `intent` |
25//! `\n` | `previous?`), the identical `hex::encode(h.finalize().
26//! as_bytes())` cast, AND the identical eight-line
27//! `constant_time_eq` byte-comparator. The receipt-side even documented
28//! the duplication in a pre-lift comment ("Same composition as
29//! `ProcessAttestation::composed_hex` — kept local so
30//! `tatara_process::receipt::compose_root(...)` is a single line in
31//! downstream code without re-importing the attestation module").
32//!
33//! Silent divergence between the two chains would break receipt
34//! verification with **no compile-time signal** — the reconciler's
35//! `ConditionKind::ClosedLoopAuth` evaluator would false-negative
36//! every closed-loop probe receipt against a Process attestation
37//! whose composed_root uses the drifted rule. Silent divergence on
38//! `DOMAIN_TAG` (a version bump on ONE side, or a typo on either)
39//! would silently invalidate every persisted receipt against the
40//! attestation chain that reads it back. Silent divergence on the
41//! `constant_time_eq` bit-mask fold (a `!=` typo, an early `return
42//! true` on empty inputs, a short-circuit `&&`) would open a
43//! timing-side-channel corner AT ONE consumer without touching the
44//! peer's proof.
45//!
46//! # What the lift owns
47//!
48//! One typed owner per shape:
49//!
50//! * [`DOMAIN_TAG`] — the `tatara-process/v1alpha1\n` prefix bytes.
51//! The prefix "tatara-process" is the *crate name*, not the K8s
52//! API group (which is `tatara.pleme.io`) — the receipt schema
53//! version + the attestation domain-separation tag are keyed off
54//! the crate that owns the wire type, deliberately independent of
55//! how kube-rs projects the CRD group. A pin below binds the const
56//! to `format!("tatara-process/{}\n", crate::VERSION)` so a future
57//! CRD-version bump lands at the substrate owner AND the domain
58//! tag together, not at the tag alone (silent invalidation of
59//! every persisted composed_root) or at the version alone (silent
60//! attestation of a stale tag past a wire-format break).
61//! * [`compose_root`] — the 4-pillar BLAKE3 → hex projection.
62//! * [`constant_time_eq`] — the length-checked, bit-mask-folded
63//! byte-comparator. Peer to the `subtle` crate's `ConstantTimeEq`
64//! trait but pure Rust, no dep.
65//!
66//! # Why it compounds
67//!
68//! A future normalization at the substrate owner reaches BOTH
69//! consumers (attestation + receipt) mechanically — no per-site
70//! edit at either callsite:
71//!
72//! * A CRD-version bump (`v1alpha1` → `v1beta1` → `v1`) lands as ONE
73//! `DOMAIN_TAG` byte-string edit at the substrate owner; both
74//! consumers pick it up at the same commit or neither does.
75//! * A domain-tag structural change (a length-prefix, a version-
76//! independent stable tag, a per-pillar sub-tag) lands at ONE
77//! composer body.
78//! * A move to a subtler constant-time comparator (a `subtle`-crate
79//! dep, an intrinsics-backed comparator on nightly, an
80//! architecture-conditional short-circuit ban) lands at ONE
81//! comparator body.
82//!
83//! # Not a `constant_time_eq` crate substitution
84//!
85//! The workspace's Cargo.lock already carries the `constant_time_eq`
86//! crate as a transitive dep of the BLAKE3 backend, but pulling it in
87//! as a direct dep here would add a compile-time-tunable direct dep
88//! for a comparator whose body is literally eight lines and whose
89//! typed contract this module already owns. Kept pure Rust; a future
90//! swap onto `subtle::ConstantTimeEq` or an intrinsics-backed
91//! comparator lands at [`constant_time_eq`] below without changing
92//! any caller.
93
94use blake3::Hasher;
95use serde::Serialize;
96
97/// The domain-separation tag every three-pillar composition rides.
98///
99/// The prefix `tatara-process` is the *crate name* that owns the
100/// wire type, deliberately independent of the CRD's K8s API group
101/// (`tatara.pleme.io`). The version suffix binds to
102/// [`crate::VERSION`] via the pin at
103/// [`tests::domain_tag_matches_crate_name_and_version_bytes`] so a
104/// future CRD-version bump either lands at both or fails-loudly at
105/// the pin.
106pub const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
107
108/// Compose the three-pillar BLAKE3 → hex composed_root from the four
109/// pillars. `control` and `previous` are `Option<&str>` because the
110/// receipt-envelope + attestation surfaces both treat an absent
111/// slot as "no control step" / "no chain predecessor", encoded on
112/// the wire as either an empty string (the receipt-envelope
113/// `control_hash: ""` posture) or an absent slot (the attestation
114/// `previous_root: None` posture). The composer normalizes both onto
115/// the same "empty-bytes chunk between the `\n` separators" wire
116/// shape — matching every pre-lift consumer byte-for-byte.
117///
118/// A byte-identity pin at [`tests::compose_root_matches_pre_lift_
119/// hand_authored_chain`] fixes the composition against the
120/// hand-authored chain both pre-lift consumers walked, so a
121/// regression at the composer's body (a reordered pillar, a swapped
122/// separator, a missing `hex::encode`) surfaces at ONE substrate
123/// pin rather than as silent invalidation of every downstream
124/// composed_root read.
125#[must_use]
126pub fn compose_root(
127 artifact: &str,
128 control: Option<&str>,
129 intent: &str,
130 previous: Option<&str>,
131) -> String {
132 let mut h = Hasher::new();
133 h.update(DOMAIN_TAG);
134 h.update(artifact.as_bytes());
135 h.update(b"\n");
136 h.update(control.unwrap_or("").as_bytes());
137 h.update(b"\n");
138 h.update(intent.as_bytes());
139 h.update(b"\n");
140 h.update(previous.unwrap_or("").as_bytes());
141 // Terminal `hex::encode(<hash>.as_bytes())` step rides through
142 // the substrate primitive [`crate::hash::hex_blake3_hash`] — the
143 // ONE owner of the streaming-digest hex encoding. Pre-lift this
144 // site restated `hex::encode(h.finalize().as_bytes())` inline,
145 // sibling to the same 1-link chain hand-authored at
146 // `tatara-reconciler::phase_machine::handle_running` (the per-ref
147 // artifact-hash fold on the ATTEST step) past the ★★ PRIME-
148 // DIRECTIVE ≥ 2 duplication threshold; post-lift both consumers
149 // route through ONE substrate function, and a future re-encoding
150 // reaches both mechanically.
151 crate::hash::hex_blake3_hash(&h.finalize())
152}
153
154/// Canonical serialize-to-bytes projection for an attestation-pillar
155/// input.
156///
157/// Owns the pre-lift `serde_json::to_vec(v).unwrap_or_default()`
158/// shape every producer of a pillar-shaped byte buffer restated by
159/// hand pre-lift — SIX workspace-wide sites past the ★★ PRIME-
160/// DIRECTIVE ≥ 2 duplication trigger:
161///
162/// * [`crate::intent::IntentVariant::canonical_bytes`] — SIX arms
163/// inside the enum-dispatch method, each restating the fallback
164/// shape on a different inner variant reference. Post-lift each
165/// arm names the payload once and delegates through this ONE
166/// primitive.
167/// * [`crate::identity::content_hash`] — the 128-bit content-
168/// addressable BLAKE3 identity input every `Process` walks; the
169/// base32-encoding downstream is untouched, only the shared
170/// pillar-bytes input rides through the substrate owner.
171/// * `tatara-reconciler::render::render_flux` /
172/// `render_aplicacao` / `render_nix` — the three workload-emitting
173/// render helpers whose `intent_bytes` return value feeds the
174/// ATTEST-phase intent-pillar hash.
175/// * `tatara-reconciler::render::render` (Guest arm) — Guest
176/// intents (HVF / VZ / WASM) are owned by tatara-hospedeiro and
177/// emit no K8s resources, but their intent bytes still feed the
178/// three-pillar attestation chain.
179/// * `tatara-reconciler::phase_machine::compute_intent_hash` — the
180/// stable-hash-of-intent projection on the reconcile-tick side,
181/// feeding `hex_blake3` directly.
182///
183/// # `unwrap_or_default()` — why the empty-bytes fallback is
184/// load-bearing
185///
186/// `serde_json::to_vec` returns `Err` only when the input contains
187/// a non-serializable shape (a map with non-string keys, a value
188/// too deep for the recursion limit) — none of which the typed
189/// intent / spec inputs at any current callsite can produce. The
190/// `unwrap_or_default()` fallback is a defensive guard that
191/// composes empty bytes onto the pillar hash rather than panicking
192/// the reconciler; a regression that swapped it for `.expect(...)`
193/// would turn a serde-error corner into a controller-crash corner
194/// (silently — no test panics if the corner never triggers). ONE
195/// substrate owner concentrates the policy so a future upgrade (a
196/// serde-error trace event before returning empty, a size-cap
197/// guard against pathological payloads, a canonical-JSON
198/// serializer for stable byte ordering across serde versions) lands
199/// at this ONE function and every pillar-bytes consumer inherits
200/// the upgrade mechanically.
201///
202/// # `#[must_use]`
203///
204/// Every consumer either feeds the returned bytes into a BLAKE3
205/// hash (intent pillar, artifact pillar, content-hash identity)
206/// or stores them into a `RenderOutput.intent_bytes` slot. Dropping
207/// the return means the payload was serialized for no observable
208/// reason.
209///
210/// # Theory anchor
211///
212/// THEORY.md §VI.1 (generation over composition — the
213/// `serde_json::to_vec(v).unwrap_or_default()` shape recurred at
214/// SIX hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
215/// duplication threshold, and lifts to ONE substrate owner here).
216/// THEORY.md §II.1 invariant 5 (composition preserves proofs —
217/// the byte-identity pin
218/// [`tests::pillar_bytes_matches_pre_lift_serde_json_to_vec_shape_bytewise`]
219/// binds the primitive byte-identically to the pre-lift spelling
220/// so a regression at the substrate owner surfaces at ONE pin
221/// rather than as silent pillar-bytes drift across every
222/// downstream three-pillar consumer).
223#[must_use]
224pub fn pillar_bytes<T: Serialize + ?Sized>(v: &T) -> Vec<u8> {
225 serde_json::to_vec(v).unwrap_or_default()
226}
227
228/// Canonical `serde_json` bytes for a pillar-input — the strict,
229/// error-propagating peer of [`pillar_bytes`] that routes the payload
230/// through `serde_json::Value` before emitting bytes.
231///
232/// Two workspace-local `canonical_json` helpers walked the SAME 2-link
233/// `serde_json::to_value(v)? → serde_json::to_vec(&v)` chain past the
234/// ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, each with its own
235/// per-file private helper concentrating the round-trip:
236///
237/// * `tatara-process::hostname::canonical_json` — the private helper
238/// feeding [`crate::hostname::ephemeral_id_from_spec`], which hashes
239/// the canonical bytes of a `ProcessSpec` to derive the content-
240/// addressable `ephemeral_id` slot every per-instance FQDN routes
241/// through.
242/// * `tatara-export-worker::canonical_json` — the private helper
243/// feeding `compose_export_receipt`, which hashes the canonical
244/// bytes of both an `ExportSpec` (intent pillar) AND an
245/// `ExportOutcome` (control pillar) into a `tatara-receipt/v1`
246/// envelope that chains into the Process attestation tree.
247///
248/// Both helpers' bodies were byte-identical
249/// (`let v = serde_json::to_value(value)?; serde_json::to_vec(&v)`),
250/// differing only in return-error type (`serde_json::Error` on the
251/// hostname peer; `anyhow::Result` on the worker peer, achieved via
252/// `?` sugar). Post-lift both consumers name the payload ONCE and
253/// route through this ONE substrate primitive; the concrete
254/// `serde_json::Error` return type composes into `anyhow::Error` via
255/// `?` at the worker callsite and into `HostnameError::InvalidLabel`
256/// via `.map_err(...)` at the hostname callsite.
257///
258/// # Canonicalization semantics (the load-bearing difference from
259/// [`pillar_bytes`])
260///
261/// [`pillar_bytes`] calls `serde_json::to_vec` directly. On a `HashMap
262/// <String, V>` (or any serializer walking arbitrary iteration order),
263/// that yields the HashMap's non-deterministic key order — silently
264/// different bytes across runs on the SAME input. `canonical_bytes`
265/// interposes `serde_json::to_value` so the intermediate
266/// `Value::Object` — which is [`serde_json::Map`], itself a
267/// `BTreeMap<String, Value>` by default (this workspace does NOT
268/// enable `serde_json/preserve_order`; verified via the absence of
269/// `indexmap` under `serde_json` in `Cargo.lock`) — sorts keys
270/// alphabetically before the final `to_vec` emits them. This is the
271/// property both hostname + worker helpers relied on for
272/// hash-stability: identical spec / outcome payloads must produce
273/// identical canonical bytes across every reconcile / worker run.
274///
275/// Struct fields ALSO get sorted alphabetically through
276/// [`canonical_bytes`] — the intermediate `Value::Object` uses the
277/// same BTreeMap-backed [`serde_json::Map`], and the serde-json
278/// serializer for structs walks fields through the map surface (each
279/// field-name → `serialize_map_entry`), so the BTreeMap absorbs
280/// declaration order and re-emits alphabetically. This is a stronger
281/// canonicalization than [`pillar_bytes`] performs — the direct
282/// [`serde_json::to_vec`] emits struct fields in DECLARATION order.
283/// A pin at
284/// [`tests::canonical_bytes_sorts_struct_fields_alphabetically`]
285/// binds the sort behavior for structs, and the divergence pin
286/// [`tests::canonical_bytes_diverges_from_pillar_bytes_on_non_alphabetical_field_order`]
287/// binds the byte-shape difference from [`pillar_bytes`] on the
288/// non-alphabetical-declaration corner so the split between the two
289/// pillar-bytes primitives stays visible at fail-before-pass-after
290/// granularity. A `#[derive(Serialize)] struct` whose declaration
291/// order happens to coincide with alphabetical order (the common
292/// case for structs with `a`, `b`, `c` fields) will still produce the
293/// SAME bytes through both primitives — the coherence corner is
294/// pinned at
295/// [`tests::canonical_bytes_agrees_with_pillar_bytes_on_alphabetical_shapes`].
296///
297/// # Error surface
298///
299/// Returns `Result<Vec<u8>, serde_json::Error>` — the concrete
300/// serde error type both pre-lift helpers threaded upward. Callers
301/// convert to their target error kind at the callsite:
302///
303/// * `tatara-export-worker` composes into `anyhow::Result` through the
304/// `?` operator's `impl From<serde_json::Error> for anyhow::Error`
305/// sugar — one character of glue at the callsite instead of a
306/// dedicated `.map_err` wrap.
307/// * `tatara-process::hostname` composes into `Result<_, HostnameError>`
308/// through `.map_err(|_| HostnameError::InvalidLabel { .. })` — the
309/// substrate-primitive's typed error is projected onto the
310/// invalid-spec corner of the hostname's typed error surface. The
311/// underlying `serde_json` diagnostic is discarded deliberately at
312/// the pre-lift callsite (its wording is not operator-actionable at
313/// the FQDN emit boundary), and the substrate primitive preserves
314/// that discard choice.
315///
316/// # `#[must_use]`
317///
318/// Every consumer feeds the returned bytes into a BLAKE3 hash — the
319/// intent / control pillar on the receipt-envelope compose side, the
320/// content-hash prefix on the ephemeral-id compose side. Dropping the
321/// return silently reduces the pillar to empty bytes, which is never
322/// the intended semantic (the `?` propagation in every consumer would
323/// mask the drop with a compiler warning that this attribute
324/// surfaces).
325///
326/// # Theory anchor
327///
328/// THEORY.md §VI.1 (generation over composition — the 2-link
329/// `serde_json::to_value → to_vec` chain recurred at two hand-authored
330/// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is
331/// lifted to ONE substrate owner here). THEORY.md §II.1 invariant 5
332/// (composition preserves proofs — the byte-identity pin
333/// [`tests::canonical_bytes_matches_pre_lift_to_value_to_vec_chain_bytewise`]
334/// binds the primitive byte-identically to both hand-authored
335/// spellings, AND the key-canonicalization pin
336/// [`tests::canonical_bytes_sorts_hashmap_keys_alphabetically`]
337/// binds the load-bearing sort property that both pre-lift consumers
338/// depended on for hash stability).
339#[must_use = "an unused pillar-bytes result silently drops the payload; hash the result or thread it via `?`"]
340pub fn canonical_bytes<T: Serialize + ?Sized>(v: &T) -> Result<Vec<u8>, serde_json::Error> {
341 let value = serde_json::to_value(v)?;
342 serde_json::to_vec(&value)
343}
344
345/// Length-checked, bit-mask-folded constant-time byte comparator.
346///
347/// Returns `true` iff `a` and `b` are equal in length AND in every
348/// byte. On unequal lengths short-circuits `false` without touching
349/// the payload — matches every pre-lift comparator byte-for-byte
350/// (the length short-circuit at both attestation.rs + receipt.rs
351/// pre-lift is a load-bearing "different lengths CAN NEVER be
352/// equal" fast path, not a leak). On equal lengths folds a bit-mask
353/// across the full payload before deciding, so a per-byte timing
354/// leak does not surface at ONE consumer without touching the peer.
355#[must_use]
356pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
357 if a.len() != b.len() {
358 return false;
359 }
360 let mut acc: u8 = 0;
361 for (x, y) in a.iter().zip(b.iter()) {
362 acc |= x ^ y;
363 }
364 acc == 0
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 // ── DOMAIN_TAG shape pins ─────────────────────────────────────
372
373 #[test]
374 fn domain_tag_matches_crate_name_and_version_bytes() {
375 // Binds the substrate `DOMAIN_TAG` const to the crate-name
376 // prefix "tatara-process" + the workspace-wide
377 // `crate::VERSION` spelling. A future CRD-version bump that
378 // lands at ONE side (say, `VERSION` becomes `v1beta1` but
379 // `DOMAIN_TAG` stays `v1alpha1`) fails loudly HERE rather
380 // than as silent invalidation of every persisted
381 // composed_root on the wire.
382 //
383 // Note the prefix is the CRATE name, not the K8s API GROUP
384 // (`tatara.pleme.io`) — the receipt schema version + the
385 // attestation domain-separation tag are keyed off the crate
386 // that owns the wire type, deliberately independent of how
387 // kube-rs projects the CRD group.
388 let expected = format!("tatara-process/{}\n", crate::VERSION);
389 assert_eq!(DOMAIN_TAG, expected.as_bytes());
390 }
391
392 #[test]
393 fn domain_tag_ends_with_newline_separator() {
394 // The pre-lift chain relied on `DOMAIN_TAG`'s trailing `\n`
395 // to double as the first field separator (no explicit `h.
396 // update(b"\n")` between the tag and the artifact chunk).
397 // A regression that dropped the trailing newline would
398 // silently produce a different composed_root for every
399 // downstream receipt, so bind the shape here.
400 assert_eq!(DOMAIN_TAG.last(), Some(&b'\n'));
401 }
402
403 // ── compose_root byte-identity pins ───────────────────────────
404
405 /// The hand-authored chain both pre-lift consumers walked —
406 /// `attestation::composed_hex` and `receipt::compose_root` had
407 /// identical bodies to this. The substrate `compose_root` MUST
408 /// match this byte-for-byte for every input on every consumer.
409 fn hand_authored_chain(
410 artifact: &str,
411 control: Option<&str>,
412 intent: &str,
413 previous: Option<&str>,
414 ) -> String {
415 let mut h = Hasher::new();
416 h.update(DOMAIN_TAG);
417 h.update(artifact.as_bytes());
418 h.update(b"\n");
419 h.update(control.unwrap_or("").as_bytes());
420 h.update(b"\n");
421 h.update(intent.as_bytes());
422 h.update(b"\n");
423 h.update(previous.unwrap_or("").as_bytes());
424 hex::encode(h.finalize().as_bytes())
425 }
426
427 #[test]
428 fn compose_root_matches_pre_lift_hand_authored_chain() {
429 // Sweeps every corner of the (control, previous) Option pair
430 // — both consumers' pre-lift chains treated `None` as
431 // empty-bytes, so the substrate composer MUST too.
432 let cases: &[(&str, Option<&str>, &str, Option<&str>)] = &[
433 ("aaaa", None, "iiii", None),
434 ("aaaa", Some("cccc"), "iiii", None),
435 ("aaaa", None, "iiii", Some("pppp")),
436 ("aaaa", Some("cccc"), "iiii", Some("pppp")),
437 ("", None, "", None),
438 ("", Some(""), "", Some("")),
439 ];
440 for (artifact, control, intent, previous) in cases {
441 assert_eq!(
442 compose_root(artifact, *control, intent, *previous),
443 hand_authored_chain(artifact, *control, intent, *previous),
444 "compose_root drifted from pre-lift hand-authored chain \
445 for inputs (artifact={artifact:?}, control={control:?}, \
446 intent={intent:?}, previous={previous:?})",
447 );
448 }
449 }
450
451 #[test]
452 fn compose_root_treats_empty_control_and_none_control_identically() {
453 // Load-bearing invariant the receipt-envelope + attestation
454 // consumers both rely on: an absent `control_hash` slot
455 // (attestation's `Option<String>::None`) and an empty-string
456 // `control_hash` slot (the receipt-envelope wire posture
457 // where the writer stamps `""` for "no control step") MUST
458 // compose to the SAME composed_root. Otherwise a receipt
459 // written with `""` would false-negative against an
460 // attestation chained with `None` even on identical pillars.
461 let with_none = compose_root("art", None, "int", None);
462 let with_empty = compose_root("art", Some(""), "int", Some(""));
463 assert_eq!(with_none, with_empty);
464 }
465
466 #[test]
467 fn compose_root_is_deterministic_across_calls() {
468 // BLAKE3 is deterministic; the composer is pure. Pin it so
469 // a future refactor that accidentally seeds a nonce or
470 // reads a clock fails-loudly HERE.
471 let a = compose_root("art", Some("ctl"), "int", Some("prev"));
472 let b = compose_root("art", Some("ctl"), "int", Some("prev"));
473 assert_eq!(a, b);
474 }
475
476 #[test]
477 fn compose_root_differs_across_every_pillar() {
478 // Each of the four pillars is load-bearing — a swap between
479 // any two MUST produce a distinct composed_root, else the
480 // domain-separation between pillars collapsed.
481 let base = compose_root("aaaa", Some("cccc"), "iiii", Some("pppp"));
482 assert_ne!(
483 base,
484 compose_root("BBBB", Some("cccc"), "iiii", Some("pppp")),
485 "artifact pillar swap failed to alter composed_root"
486 );
487 assert_ne!(
488 base,
489 compose_root("aaaa", Some("CCCC"), "iiii", Some("pppp")),
490 "control pillar swap failed to alter composed_root"
491 );
492 assert_ne!(
493 base,
494 compose_root("aaaa", Some("cccc"), "IIII", Some("pppp")),
495 "intent pillar swap failed to alter composed_root"
496 );
497 assert_ne!(
498 base,
499 compose_root("aaaa", Some("cccc"), "iiii", Some("PPPP")),
500 "previous pillar swap failed to alter composed_root"
501 );
502 }
503
504 #[test]
505 fn compose_root_output_is_lowercase_hex_of_blake3_length() {
506 // BLAKE3 produces 32-byte digests; hex-encoded → 64 lowercase
507 // characters. Pin the output shape so a downstream reader's
508 // width assumption (a 26-char base32 slot in the wire form,
509 // for instance) surfaces here rather than as a wire-parse
510 // failure.
511 let out = compose_root("a", None, "i", None);
512 assert_eq!(out.len(), 64);
513 assert!(out.chars().all(|c| c.is_ascii_hexdigit()));
514 assert!(out.chars().all(|c| !c.is_ascii_uppercase()));
515 }
516
517 // ── pillar_bytes byte-identity + corner pins ──────────────────
518
519 #[test]
520 fn pillar_bytes_matches_pre_lift_serde_json_to_vec_shape_bytewise() {
521 // Byte-identical parity with the pre-lift
522 // `serde_json::to_vec(v).unwrap_or_default()` spelling every
523 // three-pillar producer restated at its own body. Swept across
524 // representative pillar-input shapes (unit, primitive, struct,
525 // vec, map, nested) so a substrate-side canonicalization or
526 // reordering the pre-lift chain does NOT apply would surface
527 // HERE rather than as silent pillar-bytes drift at every
528 // downstream three-pillar consumer.
529 use serde::Serialize;
530 #[derive(Serialize)]
531 struct Inner {
532 a: u32,
533 b: String,
534 }
535 assert_eq!(
536 pillar_bytes(&()),
537 serde_json::to_vec(&()).unwrap_or_default(),
538 );
539 assert_eq!(
540 pillar_bytes(&42u64),
541 serde_json::to_vec(&42u64).unwrap_or_default(),
542 );
543 assert_eq!(
544 pillar_bytes(&"hello".to_string()),
545 serde_json::to_vec(&"hello".to_string()).unwrap_or_default(),
546 );
547 let inner = Inner {
548 a: 7,
549 b: "x".into(),
550 };
551 assert_eq!(
552 pillar_bytes(&inner),
553 serde_json::to_vec(&inner).unwrap_or_default(),
554 );
555 let v: Vec<u32> = vec![1, 2, 3];
556 assert_eq!(pillar_bytes(&v), serde_json::to_vec(&v).unwrap_or_default());
557 let mut map = std::collections::BTreeMap::new();
558 map.insert("k".to_string(), 1u32);
559 map.insert("j".to_string(), 2u32);
560 assert_eq!(
561 pillar_bytes(&map),
562 serde_json::to_vec(&map).unwrap_or_default(),
563 );
564 }
565
566 #[test]
567 fn pillar_bytes_is_deterministic_across_calls() {
568 // serde_json is deterministic on a stable input; the primitive
569 // is pure. A regression that accidentally seeded a nonce, read
570 // a clock, or salted the encoding would fail loudly HERE rather
571 // than as silent composed_root drift at every downstream
572 // consumer.
573 #[derive(serde::Serialize)]
574 struct S {
575 a: u32,
576 }
577 let s = S { a: 1 };
578 assert_eq!(pillar_bytes(&s), pillar_bytes(&s));
579 }
580
581 #[test]
582 fn pillar_bytes_of_unit_produces_null_json() {
583 // The empty-bytes fallback is triggered by serde errors, NOT
584 // by an empty input — `pillar_bytes(&())` is `b"null"`, not
585 // `[]`. Pin the corner so a regression that mis-conflated
586 // "empty pillar" with "serde failure" would fail HERE rather
587 // than as silent pillar-input drift at any downstream reader
588 // that treated the two corners identically.
589 assert_eq!(pillar_bytes(&()), b"null");
590 }
591
592 #[test]
593 fn pillar_bytes_accepts_borrowed_and_owned_serializable_inputs() {
594 // Both borrowed (`&String`) and owned-via-borrow (`&<T:
595 // Serialize>` where the caller already owns the payload)
596 // ride through the same `T: Serialize + ?Sized` bound
597 // without a per-callsite `.to_owned()` / `.clone()` wrap.
598 // The `?Sized` relaxation is required so `pillar_bytes(&"x")`
599 // (a `&str`, unsized) type-checks the same as
600 // `pillar_bytes(&owned_string)`.
601 let owned: String = "hello".into();
602 assert_eq!(pillar_bytes(&owned), b"\"hello\"");
603 assert_eq!(pillar_bytes("hello"), b"\"hello\"");
604 assert_eq!(pillar_bytes(&owned), pillar_bytes("hello"));
605 }
606
607 // ── canonical_bytes byte-identity + corner pins ───────────────
608
609 #[test]
610 fn canonical_bytes_matches_pre_lift_to_value_to_vec_chain_bytewise() {
611 // Byte-identical parity with the pre-lift 2-link
612 // `serde_json::to_value(v)? → serde_json::to_vec(&v)` spelling
613 // both `tatara-process::hostname::canonical_json` +
614 // `tatara-export-worker::canonical_json` restated at their own
615 // bodies. Sweeps unit / primitive / struct / vec / map / nested
616 // shapes so a substrate-side reordering (a canonicalization
617 // that swept struct fields, a serde-version change to Value's
618 // internal Map backing) surfaces HERE rather than as silent
619 // canonical-bytes drift at every downstream consumer.
620 use serde::Serialize;
621 #[derive(Serialize)]
622 struct Inner {
623 a: u32,
624 b: String,
625 }
626 let pre_lift =
627 |v: &serde_json::Value| -> Result<Vec<u8>, serde_json::Error> { serde_json::to_vec(v) };
628 for value in [
629 serde_json::to_value(()).unwrap(),
630 serde_json::to_value(42u64).unwrap(),
631 serde_json::to_value("hello".to_string()).unwrap(),
632 serde_json::to_value(Inner {
633 a: 7,
634 b: "x".into(),
635 })
636 .unwrap(),
637 serde_json::to_value(vec![1u32, 2, 3]).unwrap(),
638 ] {
639 let via_primitive = canonical_bytes(&value).unwrap();
640 let via_pre_lift = pre_lift(&value).unwrap();
641 assert_eq!(via_primitive, via_pre_lift);
642 }
643 }
644
645 #[test]
646 fn canonical_bytes_sorts_hashmap_keys_alphabetically() {
647 // The load-bearing canonicalization property: keys of a
648 // `HashMap<String, V>` (whose iteration order is
649 // unspecified across serde-json versions and per-run randomized
650 // for BuildHasherDefault) come out ALPHABETICALLY sorted
651 // through this primitive. The `serde_json::Value::Object`
652 // intermediate uses `serde_json::Map` = `BTreeMap<String,
653 // Value>` in this workspace (no `preserve_order` feature —
654 // confirmed by the absence of `indexmap` under `serde_json` in
655 // `Cargo.lock`), so the `to_value` round-trip normalizes the
656 // key emission order before the final `to_vec`. A regression
657 // that dropped the Value round-trip (or that flipped the
658 // workspace to `preserve_order`) would fail-loudly HERE
659 // rather than as silent per-run hash drift at every
660 // ephemeral-id / export-receipt consumer.
661 use std::collections::HashMap;
662 let mut map: HashMap<String, u32> = HashMap::new();
663 map.insert("z".to_string(), 1);
664 map.insert("m".to_string(), 2);
665 map.insert("a".to_string(), 3);
666 let bytes = canonical_bytes(&map).unwrap();
667 assert_eq!(bytes, br#"{"a":3,"m":2,"z":1}"#);
668 }
669
670 #[test]
671 fn canonical_bytes_is_deterministic_across_hashmap_insertion_orders() {
672 // Two HashMaps with the SAME keys+values but populated in
673 // opposite insertion orders MUST project onto identical
674 // canonical bytes. This is the direct consumer-side contract
675 // both pre-lift `canonical_json` helpers depended on for hash
676 // stability (identical spec → identical ephemeral_id;
677 // identical outcome → identical control_hash). A regression
678 // that lost the sort — say, a switch to `IndexMap` under
679 // `preserve_order` — would surface as silent per-run drift at
680 // every downstream BLAKE3 consumer; the pin binds the
681 // insertion-order invariant HERE.
682 use std::collections::HashMap;
683 let mut ascending: HashMap<String, u32> = HashMap::new();
684 ascending.insert("a".to_string(), 3);
685 ascending.insert("m".to_string(), 2);
686 ascending.insert("z".to_string(), 1);
687 let mut descending: HashMap<String, u32> = HashMap::new();
688 descending.insert("z".to_string(), 1);
689 descending.insert("m".to_string(), 2);
690 descending.insert("a".to_string(), 3);
691 assert_eq!(
692 canonical_bytes(&ascending).unwrap(),
693 canonical_bytes(&descending).unwrap()
694 );
695 }
696
697 #[test]
698 fn canonical_bytes_agrees_with_pillar_bytes_on_alphabetical_shapes() {
699 // Coherence with the sibling primitive `pillar_bytes` on every
700 // shape whose emission is already alphabetical (a struct whose
701 // declaration order coincides with alphabetical order, an
702 // already-sorted BTreeMap, non-map primitives). These are the
703 // pillar-input shapes both primitives serialize identically. A
704 // regression at either owner that drifted the shared corner —
705 // a switch to some non-alphabetical struct-field sort at
706 // `canonical_bytes`, a swap of `to_vec` for a canonicalizing
707 // encoder at `pillar_bytes` — would fail-loudly at THIS pin
708 // rather than as silent drift between the two workspace-wide
709 // pillar-bytes primitives on the shared corner.
710 use serde::Serialize;
711 #[derive(Serialize)]
712 struct AlphaOrdered {
713 a: u32,
714 b: String,
715 }
716 let s = AlphaOrdered {
717 a: 7,
718 b: "x".into(),
719 };
720 assert_eq!(canonical_bytes(&s).unwrap(), pillar_bytes(&s));
721 assert_eq!(canonical_bytes(&()).unwrap(), pillar_bytes(&()));
722 assert_eq!(canonical_bytes(&42u64).unwrap(), pillar_bytes(&42u64));
723 let v: Vec<u32> = vec![1, 2, 3];
724 assert_eq!(canonical_bytes(&v).unwrap(), pillar_bytes(&v));
725 let mut btree = std::collections::BTreeMap::new();
726 btree.insert("k".to_string(), 1u32);
727 btree.insert("j".to_string(), 2u32);
728 assert_eq!(canonical_bytes(&btree).unwrap(), pillar_bytes(&btree));
729 }
730
731 #[test]
732 fn canonical_bytes_diverges_from_pillar_bytes_on_non_alphabetical_field_order() {
733 // Byte-shape divergence pin: on a struct whose declaration
734 // order is NOT alphabetical, the two primitives produce
735 // different bytes. `pillar_bytes` emits DECLARATION order (the
736 // direct `serde_json::to_vec` behavior); `canonical_bytes`
737 // emits ALPHABETICAL order (the Value round-trip through the
738 // BTreeMap-backed `serde_json::Map`). This split is
739 // load-bearing — a caller choosing `canonical_bytes` over
740 // `pillar_bytes` is asking for the canonicalizing sort, and a
741 // regression that silently merged the two primitives at the
742 // struct corner would invalidate every persisted receipt whose
743 // pillar-input has a non-alphabetical field order. The pin
744 // binds the split HERE so the two primitives evolve as an
745 // explicitly-partitioned pair on the (canonicalize? y/n) axis.
746 use serde::Serialize;
747 #[derive(Serialize)]
748 struct DescOrdered {
749 z_first: u32,
750 a_last: u32,
751 }
752 let s = DescOrdered {
753 z_first: 1,
754 a_last: 2,
755 };
756 // pillar_bytes preserves declaration order:
757 assert_eq!(pillar_bytes(&s), br#"{"z_first":1,"a_last":2}"#);
758 // canonical_bytes sorts alphabetically:
759 assert_eq!(canonical_bytes(&s).unwrap(), br#"{"a_last":2,"z_first":1}"#);
760 // The two must diverge on this corner:
761 assert_ne!(canonical_bytes(&s).unwrap(), pillar_bytes(&s));
762 }
763
764 #[test]
765 fn canonical_bytes_of_unit_produces_null_json() {
766 // The unit input projects to `b"null"` — matching the sibling
767 // `pillar_bytes(&())` corner. `canonical_bytes` succeeds on
768 // this input (serde emits `null` for `()`), so a regression
769 // that mis-conflated "empty pillar" with "serde failure" at
770 // the strict-Result peer would fail HERE rather than as silent
771 // pillar-input drift.
772 assert_eq!(canonical_bytes(&()).unwrap(), b"null");
773 }
774
775 #[test]
776 fn canonical_bytes_accepts_borrowed_and_owned_serializable_inputs() {
777 // The `T: Serialize + ?Sized` bound admits both borrowed
778 // (`&String`, `&Vec<u8>`) and unsized-via-borrow (`&str`)
779 // inputs without a per-callsite `.to_owned()` / `.clone()`
780 // wrap — matching the sibling `pillar_bytes` bound.
781 let owned: String = "hello".into();
782 assert_eq!(canonical_bytes(&owned).unwrap(), b"\"hello\"");
783 assert_eq!(canonical_bytes("hello").unwrap(), b"\"hello\"");
784 assert_eq!(
785 canonical_bytes(&owned).unwrap(),
786 canonical_bytes("hello").unwrap()
787 );
788 }
789
790 #[test]
791 fn canonical_bytes_sorts_struct_fields_alphabetically() {
792 // Struct fields are emitted in ALPHABETICAL order through
793 // `canonical_bytes` — the `to_value` intermediate `Value::Object`
794 // is `serde_json::Map = BTreeMap<String, Value>`, so serde's
795 // struct→map serializer inserts each field-name and the BTreeMap
796 // re-emits them alphabetically regardless of declaration order.
797 // This is the stronger-canonicalization behavior every downstream
798 // receipt / ephemeral-id consumer implicitly relied on for
799 // cross-run hash stability (a struct with a HashMap-typed field
800 // OR a struct whose declaration order changes across a
801 // refactor would still produce the same canonical bytes). A
802 // regression that dropped the Value round-trip would surface
803 // as declaration-order output HERE, and would silently
804 // invalidate every persisted receipt whose pillar-input is a
805 // struct with a non-alphabetical field order.
806 use serde::Serialize;
807 #[derive(Serialize)]
808 struct Ordered {
809 z_first: u32,
810 a_last: u32,
811 }
812 let s = Ordered {
813 z_first: 1,
814 a_last: 2,
815 };
816 assert_eq!(canonical_bytes(&s).unwrap(), br#"{"a_last":2,"z_first":1}"#);
817 }
818
819 // ── constant_time_eq byte-identity + corner pins ──────────────
820
821 fn hand_authored_ct_eq(a: &[u8], b: &[u8]) -> bool {
822 if a.len() != b.len() {
823 return false;
824 }
825 let mut acc: u8 = 0;
826 for (x, y) in a.iter().zip(b.iter()) {
827 acc |= x ^ y;
828 }
829 acc == 0
830 }
831
832 #[test]
833 fn constant_time_eq_matches_pre_lift_hand_authored_body() {
834 // Sweeps both length axes AND both equality axes so the
835 // substrate comparator matches both pre-lift bodies byte-
836 // for-byte on every corner.
837 let cases: &[(&[u8], &[u8])] = &[
838 (b"", b""),
839 (b"", b"a"),
840 (b"a", b""),
841 (b"a", b"a"),
842 (b"a", b"b"),
843 (b"abcd", b"abcd"),
844 (b"abcd", b"abce"),
845 (b"abcd", b"abc"),
846 (b"abc", b"abcd"),
847 (b"\x00\x00\x00", b"\x00\x00\x00"),
848 (b"\xff\xff\xff", b"\xff\xff\xff"),
849 (b"\xff\xff\xff", b"\xff\xff\x00"),
850 ];
851 for (a, b) in cases {
852 assert_eq!(
853 constant_time_eq(a, b),
854 hand_authored_ct_eq(a, b),
855 "constant_time_eq drifted from pre-lift hand-authored \
856 body for inputs (a={a:?}, b={b:?})",
857 );
858 }
859 }
860
861 #[test]
862 fn constant_time_eq_short_circuits_on_length_mismatch() {
863 // The pre-lift length short-circuit at both consumers is a
864 // load-bearing "different lengths CAN NEVER be equal" fast
865 // path, not a leak. Pin the corner explicitly.
866 assert!(!constant_time_eq(b"", b"a"));
867 assert!(!constant_time_eq(b"abc", b"abcd"));
868 assert!(!constant_time_eq(b"abcd", b"abc"));
869 }
870
871 #[test]
872 fn constant_time_eq_returns_true_only_on_full_byte_equality() {
873 assert!(constant_time_eq(b"", b""));
874 assert!(constant_time_eq(b"abc", b"abc"));
875 assert!(!constant_time_eq(b"abc", b"abd"));
876 // Distinct only at the final byte — verifies the fold
877 // reaches the end rather than short-circuiting on the
878 // first mismatch.
879 assert!(!constant_time_eq(b"abcdef", b"abcdeg"));
880 // Distinct only at the first byte — verifies the fold
881 // does NOT short-circuit on the first byte (the "constant"
882 // in "constant time" — full payload gets folded before
883 // deciding).
884 assert!(!constant_time_eq(b"Abcdef", b"abcdef"));
885 }
886}