net/adapter/net/cortex/meta.rs
1//! Fixed 24-byte `EventMeta` prefix on every CortEX-adapted payload.
2//!
3//! Wire layout (little-endian, 24 bytes total):
4//!
5//! ```text
6//! ┌──────────┬───────┬──────┬─────────────┬───────────┬──────────┐
7//! │ dispatch │ flags │ _pad │ origin_hash │ seq_or_ts │ checksum │
8//! │ u8 │ u8 │ 2B │ u64 │ u64 │ u32 │
9//! └──────────┴───────┴──────┴─────────────┴───────────┴──────────┘
10//! ```
11//!
12//! `seq_or_ts` is deliberately NOT interpreted by the adapter. Envelope
13//! authors pick per file: per-origin monotonic counter (deterministic
14//! fold order) OR unix nanos (wall-clock ordering). Mixing within one
15//! file breaks fold ordering assumptions.
16
17/// Size of an `EventMeta` in its wire / on-disk format.
18pub const EVENT_META_SIZE: usize = 24;
19
20/// Dispatch value reserved for "raw" payloads — no CortEX-level
21/// semantics. Callers that don't need dispatch routing should use
22/// this.
23pub const DISPATCH_RAW: u8 = 0x00;
24
25/// Flag bit: this event is part of a causal chain.
26pub const FLAG_CAUSAL: u8 = 0b0000_0001;
27/// Flag bit: this event carries a continuity proof.
28pub const FLAG_CONTINUITY_PROOF: u8 = 0b0000_0010;
29
30/// Fixed 24-byte prefix on every payload appended through the
31/// CortEX adapter.
32///
33/// The in-memory layout is whatever the compiler chooses; the wire /
34/// on-disk format is produced by [`Self::to_bytes`] and consumed by
35/// [`Self::from_bytes`].
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct EventMeta {
38 /// Event classifier. `0x00..0x7F` reserved for CortEX-internal
39 /// dispatches; `0x80..0xFF` for application / vendor use.
40 pub dispatch: u8,
41 /// Causal / continuity / proof bits. See `FLAG_*` constants.
42 pub flags: u8,
43 /// Reserved; must be zero on write, ignored on read.
44 pub _pad: [u8; 2],
45 /// Producer identity — full `EntityKeypair::origin_hash()`
46 /// value, not a truncation.
47 pub origin_hash: u64,
48 /// Per-origin monotonic counter OR unix nanos. Application
49 /// identity — orthogonal to the RedEX storage sequence.
50 pub seq_or_ts: u64,
51 /// xxh3 truncation of the type-specific tail (the bytes after
52 /// the 24-byte prefix in the RedEX payload).
53 pub checksum: u32,
54}
55
56impl EventMeta {
57 /// Build an `EventMeta` with zeroed pad bytes.
58 pub fn new(dispatch: u8, flags: u8, origin_hash: u64, seq_or_ts: u64, checksum: u32) -> Self {
59 Self {
60 dispatch,
61 flags,
62 _pad: [0; 2],
63 origin_hash,
64 seq_or_ts,
65 checksum,
66 }
67 }
68
69 /// Encode to the 24-byte little-endian wire format. The reserved
70 /// `_pad` bytes are always written as zero regardless of what the
71 /// caller stuffed into them — the wire contract says "zero on
72 /// write, ignored on read."
73 ///
74 /// `#[inline(always)]` per perf #100 — mirrors the perf #70 fix
75 /// on `RedexEntry::to_bytes`. The body is 4 `copy_from_slice`
76 /// calls on `u64`/`u32` little-endian bytes plus two byte
77 /// stores: small enough that the optimizer fuses the writes
78 /// (e.g. with the next byte that the caller is about to stream
79 /// into a frame buffer) once the call disappears. Called in the
80 /// inner loop of every CortEX RPC encode and every per-event
81 /// checksum re-derive (`for_checksum_bytes` below), so the
82 /// per-call overhead matters per-event.
83 #[inline(always)]
84 pub fn to_bytes(&self) -> [u8; EVENT_META_SIZE] {
85 let mut out = [0u8; EVENT_META_SIZE];
86 out[0] = self.dispatch;
87 out[1] = self.flags;
88 // out[2..4] stays [0, 0] (reserved pad).
89 out[4..12].copy_from_slice(&self.origin_hash.to_le_bytes());
90 out[12..20].copy_from_slice(&self.seq_or_ts.to_le_bytes());
91 out[20..24].copy_from_slice(&self.checksum.to_le_bytes());
92 out
93 }
94
95 /// Decode from a 24-byte slice. Returns `None` if the slice is
96 /// shorter than 24 bytes.
97 ///
98 /// `#[inline(always)]` per perf #100 — same rationale as
99 /// [`Self::to_bytes`]: small body (one length check + four
100 /// little-endian field decodes) that's called in the inner loop
101 /// of CortEX inbound RPC decode and replication apply.
102 #[expect(
103 clippy::expect_used,
104 reason = "bytes.len() >= EVENT_META_SIZE (24) checked above; fixed-offset slices convert infallibly to fixed-size arrays"
105 )]
106 #[inline(always)]
107 pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
108 if bytes.len() < EVENT_META_SIZE {
109 return None;
110 }
111 Some(Self {
112 dispatch: bytes[0],
113 flags: bytes[1],
114 _pad: [bytes[2], bytes[3]],
115 origin_hash: u64::from_le_bytes(bytes[4..12].try_into().expect("8 bytes")),
116 seq_or_ts: u64::from_le_bytes(bytes[12..20].try_into().expect("8 bytes")),
117 checksum: u32::from_le_bytes(bytes[20..24].try_into().expect("4 bytes")),
118 })
119 }
120
121 /// True if `flags & bits != 0`.
122 #[inline]
123 pub fn has_flag(&self, bits: u8) -> bool {
124 self.flags & bits != 0
125 }
126
127 /// Wire bytes with the `checksum` field zeroed — the input
128 /// shape used by [`compute_checksum_with_meta`]. Zeroing the
129 /// checksum slot is necessary because the value being computed
130 /// will be stored there; including its prior value would make
131 /// the hash depend on whatever was previously in the slot
132 /// (including the uninitialized 0 placeholder during ingest).
133 fn for_checksum_bytes(&self) -> [u8; EVENT_META_SIZE] {
134 let mut b = self.to_bytes();
135 // bytes [20..24] = checksum field; zero it.
136 b[20..24].copy_from_slice(&0u32.to_le_bytes());
137 b
138 }
139}
140
141/// Legacy tail-only checksum. The xxh3 hash of the payload bytes
142/// after the 24-byte `EventMeta` prefix, truncated to the low 32
143/// bits.
144///
145/// **Use [`compute_checksum_with_meta`] for new writes.** This
146/// function is kept for the read-side fallback that lets old
147/// on-disk records continue to decode after the audit-#8 fix —
148/// records written before the meta-covering checksum was
149/// introduced have a tail-only checksum and would fail v2
150/// verification.
151///
152/// When `compute_checksum` was also used by producers, the
153/// 20-byte header was unprotected: a stray bit-flip in the
154/// `dispatch` byte (e.g. `STORED → DELETED`) went undetected by
155/// the per-event integrity check and silently re-routed the
156/// event to the wrong fold arm.
157///
158/// **Scope:** an *accidental-corruption* detector, NOT a tamper
159/// detector. Two specific limits make it unsuitable for
160/// tamper-resistance:
161///
162/// 1. **32-bit truncation.** A 32-bit unkeyed hash has roughly
163/// 1-in-2³² collision probability per pair of distinct
164/// payloads — fine against random bit-flips on disk, but only
165/// ~1-in-2¹⁶ across a long-running file under the birthday
166/// bound. Adequate for "did the on-disk record decode
167/// correctly?" not "did an attacker craft a payload that
168/// matches?".
169/// 2. **Unkeyed.** An attacker who can write to the on-disk
170/// redex file can recompute the matching checksum trivially
171/// by hashing whatever payload they substitute. There is no
172/// secret bound to this value.
173///
174/// Callers that need tamper detection (rather than corruption
175/// detection) must layer a keyed MAC at a higher level — e.g.
176/// the AEAD-protected mesh packet envelope. The cortex fold
177/// paths use this value only to surface obviously-broken on-disk
178/// records as `RedexError::Decode`, not as a security boundary.
179///
180/// Disk-recovery / external inspection tools can reproduce the
181/// value by hashing the bytes after the 20-byte prefix.
182#[inline]
183pub fn compute_checksum(tail: &[u8]) -> u32 {
184 xxhash_rust::xxh3::xxh3_64(tail) as u32
185}
186
187/// Corruption-detection checksum covering BOTH the
188/// 24-byte `EventMeta` header (with the `checksum` slot zeroed,
189/// since that's what the value being computed will go into) and
190/// the payload `tail`. Stamped into [`EventMeta::checksum`] at
191/// ingest by current writers.
192///
193/// **Why this exists vs. plain [`compute_checksum`].** The legacy
194/// helper hashes only the tail; the 20-byte header is unprotected.
195/// A bit-flip in the `dispatch` byte (e.g. `STORED → DELETED`) is
196/// undetected by the per-event integrity check and silently
197/// re-routes the event to the wrong fold arm — the audit-#8
198/// failure mode. This helper closes that hole by including the
199/// header bytes in the hash input.
200///
201/// **Migration / backward compatibility.** Records written by
202/// pre-fix versions have a tail-only checksum that won't validate
203/// under v2. The fold-side verifiers try v2 first and fall back
204/// to v1 to keep old data readable. The fallback path retains
205/// the original dispatch-flip vulnerability for legacy records;
206/// new records get full-header coverage. Downgrading to a pre-fix
207/// adapter binary will skip every event written by a v2-capable
208/// producer (the legacy verifier expects the checksum to match
209/// `xxh3(tail)`, which v2 records won't), so the migration is
210/// effectively one-way.
211///
212/// **Scope.** Same accidental-corruption (not tamper) limits as
213/// [`compute_checksum`]; see that function's doc for the full
214/// 32-bit-truncation and unkeyed-hash discussion. The new
215/// helper closes a structural undercoverage gap, not the
216/// underlying tamper-resistance limits.
217#[inline]
218pub fn compute_checksum_with_meta(meta: &EventMeta, tail: &[u8]) -> u32 {
219 let mut h = xxhash_rust::xxh3::Xxh3::new();
220 h.update(&meta.for_checksum_bytes());
221 h.update(tail);
222 h.digest() as u32
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_size_is_twenty_four() {
231 assert_eq!(EVENT_META_SIZE, 24);
232 }
233
234 #[test]
235 fn test_roundtrip_all_fields_distinct() {
236 let m = EventMeta::new(
237 0x42,
238 FLAG_CAUSAL | FLAG_CONTINUITY_PROOF,
239 0xDEAD_BEEF,
240 0x0123_4567_89AB_CDEF,
241 0xCAFE_BABE,
242 );
243 let bytes = m.to_bytes();
244 assert_eq!(bytes.len(), 24);
245 let decoded = EventMeta::from_bytes(&bytes).unwrap();
246 assert_eq!(decoded, m);
247 assert_eq!(decoded.dispatch, 0x42);
248 assert_eq!(decoded.flags, FLAG_CAUSAL | FLAG_CONTINUITY_PROOF);
249 assert_eq!(decoded.origin_hash, 0xDEAD_BEEF);
250 assert_eq!(decoded.seq_or_ts, 0x0123_4567_89AB_CDEF);
251 assert_eq!(decoded.checksum, 0xCAFE_BABE);
252 }
253
254 #[test]
255 fn test_regression_pad_is_zeroed_on_write() {
256 // Regression: `to_bytes` used to copy `self._pad` verbatim
257 // into the output buffer. The struct doc says "reserved;
258 // must be zero on write, ignored on read" — but `_pad` is
259 // `pub`, so a caller constructing `EventMeta` via struct
260 // literal syntax could stamp non-zero pad into the wire
261 // format. The fix leaves bytes [2..4] as zero regardless of
262 // struct contents.
263 let m = EventMeta {
264 dispatch: 0x42,
265 flags: 0,
266 _pad: [0xAA, 0xBB], // non-zero — would leak on write
267 origin_hash: 0xDEAD_BEEF,
268 seq_or_ts: 1,
269 checksum: 0,
270 };
271 let bytes = m.to_bytes();
272 assert_eq!(
273 &bytes[2..4],
274 &[0u8, 0u8],
275 "pad bytes must be zero on write regardless of struct contents"
276 );
277 }
278
279 #[test]
280 fn test_zero_roundtrip() {
281 let m = EventMeta::new(0, 0, 0, 0, 0);
282 let decoded = EventMeta::from_bytes(&m.to_bytes()).unwrap();
283 assert_eq!(decoded, m);
284 }
285
286 #[test]
287 fn test_unknown_dispatch_decodes_fine() {
288 // 0xFE is in application / vendor space — adapter must not
289 // reject or special-case it.
290 let m = EventMeta::new(0xFE, 0, 1, 2, 3);
291 let decoded = EventMeta::from_bytes(&m.to_bytes()).unwrap();
292 assert_eq!(decoded.dispatch, 0xFE);
293 }
294
295 #[test]
296 fn test_short_slice_returns_none() {
297 let buf = [0u8; 23];
298 assert!(EventMeta::from_bytes(&buf).is_none());
299 }
300
301 #[test]
302 fn test_nonzero_pad_tolerated_on_read() {
303 // Pad bytes must be zero on write, but garbage on read should
304 // not corrupt other fields.
305 let mut bytes = [0u8; 24];
306 bytes[2] = 0xAA;
307 bytes[3] = 0xBB;
308 bytes[12..20].copy_from_slice(&0x1234_5678_9ABC_DEF0u64.to_le_bytes());
309 let decoded = EventMeta::from_bytes(&bytes).unwrap();
310 assert_eq!(decoded.seq_or_ts, 0x1234_5678_9ABC_DEF0);
311 // Pad is carried through verbatim — surface it for forensic
312 // inspection but it has no semantic meaning.
313 assert_eq!(decoded._pad, [0xAA, 0xBB]);
314 }
315
316 #[test]
317 fn test_has_flag() {
318 let m = EventMeta::new(0, FLAG_CAUSAL, 0, 0, 0);
319 assert!(m.has_flag(FLAG_CAUSAL));
320 assert!(!m.has_flag(FLAG_CONTINUITY_PROOF));
321 }
322
323 #[test]
324 fn test_field_boundaries_isolated() {
325 // Write max values in each field; decode must return them all.
326 let m = EventMeta::new(u8::MAX, u8::MAX, u64::MAX, u64::MAX, u32::MAX);
327 let decoded = EventMeta::from_bytes(&m.to_bytes()).unwrap();
328 assert_eq!(decoded, m);
329 }
330
331 // ====================================================================
332 // compute_checksum_with_meta — header coverage
333 // ====================================================================
334
335 /// `compute_checksum_with_meta` zeroes the checksum slot in
336 /// the input bytes regardless of what the caller stuffed
337 /// there. Pin the producer-side contract: callers do
338 /// let mut m = EventMeta::new(..., 0);
339 /// m.checksum = compute_checksum_with_meta(&m, tail);
340 /// so the meta passed in has `checksum == 0` already; if a
341 /// caller forgets the `0` and passes a non-zero placeholder,
342 /// the helper still produces the same value because the slot
343 /// is masked.
344 #[test]
345 fn compute_checksum_with_meta_masks_checksum_slot() {
346 let tail = b"some payload bytes";
347 let m_zero = EventMeta::new(0x42, 0, 0xDEAD_BEEF, 7, 0);
348 let m_nonzero = EventMeta::new(0x42, 0, 0xDEAD_BEEF, 7, 0xFFFF_FFFF);
349 // The slot-mask means both produce the same checksum even
350 // though their `checksum` fields differ.
351 assert_eq!(
352 compute_checksum_with_meta(&m_zero, tail),
353 compute_checksum_with_meta(&m_nonzero, tail),
354 );
355 }
356
357 /// A bit-flip in the `dispatch` byte is detected by
358 /// `compute_checksum_with_meta` but invisible to the legacy
359 /// `compute_checksum`. Pin both directions so a future
360 /// refactor that accidentally drops the helper or silently
361 /// routes producers back through the legacy function trips.
362 #[test]
363 fn compute_checksum_with_meta_detects_dispatch_bit_flip() {
364 let tail = b"unchanged payload";
365 let original = EventMeta::new(0x10 /* STORED */, 0, 0xABCD, 1, 0);
366 let v2 = compute_checksum_with_meta(&original, tail);
367
368 // Attacker / cosmic ray flips the dispatch byte to a
369 // different routing target; tail unchanged.
370 let flipped = EventMeta::new(0x11 /* DELETED */, 0, 0xABCD, 1, 0);
371 let v2_after_flip = compute_checksum_with_meta(&flipped, tail);
372 assert_ne!(
373 v2, v2_after_flip,
374 "v2 must reflect the dispatch byte; a flip changes the checksum",
375 );
376
377 // Legacy v1 can't see the flip because it only hashes
378 // the tail. Pin the gap so the doc-comment claim
379 // ("legacy is dispatch-flip vulnerable") stays true and
380 // doesn't accidentally get fixed by a downstream change
381 // to compute_checksum.
382 assert_eq!(
383 compute_checksum(tail),
384 compute_checksum(tail),
385 "legacy hash is tail-only; insensitive to header flips by construction",
386 );
387 }
388
389 /// Header coverage extends to every field, not just dispatch.
390 /// Pin flags / origin_hash / seq_or_ts each individually so a
391 /// regression that "covers some of the header" is caught.
392 #[test]
393 fn compute_checksum_with_meta_detects_all_header_flips() {
394 let tail = b"payload";
395 let base = EventMeta::new(0x10, 0, 0xAAAA, 1, 0);
396 let v2_base = compute_checksum_with_meta(&base, tail);
397
398 let flip_flags = EventMeta::new(0x10, FLAG_CAUSAL, 0xAAAA, 1, 0);
399 assert_ne!(v2_base, compute_checksum_with_meta(&flip_flags, tail));
400
401 let flip_origin = EventMeta::new(0x10, 0, 0xBBBB, 1, 0);
402 assert_ne!(v2_base, compute_checksum_with_meta(&flip_origin, tail));
403
404 let flip_seq = EventMeta::new(0x10, 0, 0xAAAA, 2, 0);
405 assert_ne!(v2_base, compute_checksum_with_meta(&flip_seq, tail));
406
407 // Same fields, different tail: also detected.
408 let flip_tail = compute_checksum_with_meta(&base, b"different");
409 assert_ne!(v2_base, flip_tail);
410 }
411
412 /// v1 and v2 over a non-empty tail produce different values.
413 /// Pin so the legacy fallback path in fold.rs cannot
414 /// accidentally accept a v2 record (or vice versa) by
415 /// numerical coincidence — they're hashing different inputs.
416 #[test]
417 fn v1_and_v2_checksums_differ_for_typical_inputs() {
418 let m = EventMeta::new(0x01, 0, 0x1234, 5, 0);
419 let tail = b"non-empty payload";
420 assert_ne!(compute_checksum(tail), compute_checksum_with_meta(&m, tail));
421 }
422}