Skip to main content

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}