Skip to main content

weir_core/
envelope.rs

1//! The weir wire frame: [`Header`] (the 16-byte fixed header) and [`Envelope`]
2//! (header + payload), with the `encode`/`decode` codecs that own CRC
3//! computation and the DoS-resistant validation order.
4
5use crate::{
6    durability::Durability,
7    error::DecodeError,
8    payload::Payload,
9    version::{MAX_PAYLOAD_HARD_CAP, WIRE_VERSION},
10};
11
12/// Magic bytes at offset 0. Presence at a fixed offset allows stream resync by scanning for b"WEIR".
13const MAGIC: [u8; 4] = *b"WEIR";
14
15/// Wire layout (16 bytes; multi-byte fields are little-endian):
16///
17/// ```text
18/// [0..4]   magic         b"WEIR"
19/// [4]      version       u8        — WIRE_VERSION
20/// [5]      message_type  u8        — MessageType repr
21/// [6]      durability    u8        — Durability repr
22/// [7]      flags         u8        — reserved; zero on write
23/// [8..12]  payload_len   u32 LE
24/// [12..16] header_crc32  u32 LE    — CRC32 of bytes [0..12]
25/// ```
26pub const HEADER_LEN: usize = 16;
27
28/// Number of header bytes covered by the CRC. Everything except the CRC field itself.
29const HEADER_CRC_COVERAGE: usize = 12;
30
31/// Minimum valid frame: header + zero-length payload + payload CRC.
32pub const MIN_FRAME_LEN: usize = HEADER_LEN + 4;
33
34/// Wire message types. Values are fixed; changing them requires a WIRE_VERSION bump.
35///
36/// `#[non_exhaustive]`, since 4.0. It was left exhaustive on the reasoning
37/// recorded in `nack.rs` — that changing this enum "requires a `WIRE_VERSION`
38/// bump (a major event)" — and 3.0 disproved that by adding `PushTracked` and
39/// `AckTracked` with `WIRE_VERSION` unchanged, because message-type bytes
40/// `0x08`-`0xFF` are reserved for additive growth. Every such addition was
41/// therefore forcing a major release of the published Rust API for a change the
42/// wire did not consider breaking at all. Closing that costs one break, once.
43///
44/// Downstream matches need a wildcard arm from here on. That is the point: a new
45/// message type is additive on the wire and should be additive in the API too.
46#[repr(u8)]
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48#[non_exhaustive]
49pub enum MessageType {
50    /// Producer → daemon: a record to durably buffer.
51    Push = 0x01,
52    /// Daemon → producer: the pushed record was accepted at its durability tier.
53    Ack = 0x02,
54    /// Daemon → producer: the frame or record was rejected (see [`NackReason`](crate::NackReason)).
55    Nack = 0x03,
56    /// Producer → daemon: liveness probe (zero-length payload).
57    HealthCheck = 0x04,
58    /// Daemon → producer: reply to a [`HealthCheck`](MessageType::HealthCheck).
59    HealthCheckResponse = 0x05,
60    /// Producer → daemon: a record to durably buffer, **and** tell me where it
61    /// landed. Identical to [`Push`](MessageType::Push) in every other respect —
62    /// same durability tiers, same caps, same Nack reasons — but answered with
63    /// [`AckTracked`](MessageType::AckTracked) instead of a bare
64    /// [`Ack`](MessageType::Ack).
65    ///
66    /// Additive by construction: a producer built against wire v1 never emits
67    /// this byte, so it never receives the longer reply, and a daemon that
68    /// predates the type rejects it with the already-specified
69    /// [`UnknownMessage`](crate::NackReason::UnknownMessage) — the permanent,
70    /// connection-closing error for exactly this version skew.
71    PushTracked = 0x06,
72    /// Daemon → producer: the pushed record was accepted, and here is its
73    /// [`RecordCoordinate`](crate::RecordCoordinate) — the only weir response
74    /// whose payload exceeds two bytes, and only ever sent in reply to a
75    /// [`PushTracked`](MessageType::PushTracked).
76    AckTracked = 0x07,
77    /// Producer → daemon: N records in one frame, answered once with
78    /// [`AckBatch`](MessageType::AckBatch).
79    ///
80    /// Additive the same way [`PushTracked`](MessageType::PushTracked) was: a
81    /// producer built against an earlier wire never emits this byte, so it never
82    /// receives the reply, and a daemon that predates it answers
83    /// [`UnknownMessage`](crate::NackReason::UnknownMessage) — a permanent,
84    /// connection-closing error that names the version skew rather than
85    /// misparsing.
86    PushBatch = 0x08,
87    /// Daemon → producer: one reply for a whole [`PushBatch`](MessageType::PushBatch),
88    /// carrying a positional bitmap of per-record outcomes.
89    ///
90    /// **Bit `i` is LSB-first** within each byte and means record `i` is durable
91    /// at the requested tier; a clear bit means *not durable as of this reply —
92    /// retry, and expect it may nonetheless have been written*. See
93    /// [`crate::batch`] for why a clear bit needs no reason byte.
94    AckBatch = 0x09,
95}
96
97/// Error returned when a byte does not map to a known MessageType.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct UnknownMessageType(pub u8);
100
101impl std::fmt::Display for UnknownMessageType {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        write!(f, "unknown message type byte: {:#04x}", self.0)
104    }
105}
106
107impl std::error::Error for UnknownMessageType {}
108
109impl TryFrom<u8> for MessageType {
110    type Error = UnknownMessageType;
111
112    fn try_from(value: u8) -> Result<Self, Self::Error> {
113        match value {
114            0x01 => Ok(MessageType::Push),
115            0x02 => Ok(MessageType::Ack),
116            0x03 => Ok(MessageType::Nack),
117            0x04 => Ok(MessageType::HealthCheck),
118            0x05 => Ok(MessageType::HealthCheckResponse),
119            0x06 => Ok(MessageType::PushTracked),
120            0x07 => Ok(MessageType::AckTracked),
121            0x08 => Ok(MessageType::PushBatch),
122            0x09 => Ok(MessageType::AckBatch),
123            v => Err(UnknownMessageType(v)),
124        }
125    }
126}
127
128impl From<MessageType> for u8 {
129    /// The wire byte for this message type. Inverse of [`MessageType::try_from`].
130    fn from(m: MessageType) -> u8 {
131        m as u8
132    }
133}
134
135/// Decoded wire header. `magic` and `header_crc32` are not stored: magic is always
136/// `b"WEIR"` after a successful decode (storing it invites confusion about validity),
137/// and CRC is computed on encode rather than held as state.
138///
139/// Fields are private and read-only (accessors below): a header is only ever
140/// produced by [`Header::new`] (which pins `version` to [`WIRE_VERSION`]) or by
141/// [`Header::decode`] (which validates every field). This makes it impossible
142/// for a caller to desync `payload_len` from the real payload or set `version`
143/// off `WIRE_VERSION` after construction.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct Header {
146    version: u8,
147    message_type: MessageType,
148    durability: Durability,
149    /// Currently reserved; must be zero on write. Preserved verbatim through encode/decode.
150    flags: u8,
151    payload_len: u32,
152}
153
154impl Header {
155    /// Constructs a header for outbound frames. Version is always `WIRE_VERSION`.
156    ///
157    /// `payload_len` is NOT a parameter: it is set to 0 here and becomes
158    /// authoritative only when the header is wrapped in an [`Envelope`], which
159    /// derives it from the actual payload (see [`Envelope::new`]). This makes a
160    /// header whose declared length disagrees with its payload unrepresentable —
161    /// a bare `Header::encode` always declares `payload_len = 0`, so it is only
162    /// valid for the genuinely-empty-payload frames (Ack / HealthCheck) that use
163    /// it (F50).
164    ///
165    /// In wire v1 `flags` MUST be 0. Any other value encodes a frame that
166    /// [`Header::decode`] rejects with [`DecodeError::ReservedFlagsSet`] (and the
167    /// daemon Nacks then closes the connection); the parameter is kept non-zero-
168    /// capable only so the codec can construct reject-path test frames.
169    #[must_use]
170    pub fn new(message_type: MessageType, durability: Durability, flags: u8) -> Self {
171        Self {
172            version: WIRE_VERSION,
173            message_type,
174            durability,
175            flags,
176            payload_len: 0,
177        }
178    }
179
180    /// Wire protocol version. Always [`WIRE_VERSION`] for a header built by
181    /// [`Header::new`]; a decoded header only exists if its version matched.
182    pub fn version(&self) -> u8 {
183        self.version
184    }
185
186    /// The frame's [`MessageType`].
187    pub fn message_type(&self) -> MessageType {
188        self.message_type
189    }
190
191    /// The frame's [`Durability`] tier.
192    pub fn durability(&self) -> Durability {
193        self.durability
194    }
195
196    /// Reserved flags byte (zero in v1; preserved verbatim through the codec).
197    pub fn flags(&self) -> u8 {
198        self.flags
199    }
200
201    /// Declared payload length in bytes. For an [`Envelope`] this always equals
202    /// the actual payload length (see [`Envelope::new`]); on a bare decoded
203    /// header it is the wire-declared length used to read the payload.
204    pub fn payload_len(&self) -> u32 {
205        self.payload_len
206    }
207
208    /// Serialises the header to exactly `HEADER_LEN` bytes. CRC is computed here;
209    /// the caller does not need to know the CRC coverage range.
210    #[must_use]
211    pub fn encode(&self) -> [u8; HEADER_LEN] {
212        let mut buf = [0u8; HEADER_LEN];
213        buf[0..4].copy_from_slice(&MAGIC);
214        buf[4] = self.version;
215        buf[5] = u8::from(self.message_type);
216        buf[6] = u8::from(self.durability);
217        buf[7] = self.flags;
218        buf[8..12].copy_from_slice(&self.payload_len.to_le_bytes());
219        let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
220        buf[12..16].copy_from_slice(&crc.to_le_bytes());
221        buf
222    }
223
224    /// Decodes and validates a 16-byte header buffer.
225    ///
226    /// Validation order is deliberate:
227    /// 1. Magic — cheapest check; eliminates non-weir traffic before any further work.
228    /// 2. Version — before CRC, so a version-mismatched frame from a v2 client receives
229    ///    `VersionMismatch` (with the daemon's version in the Nack payload) rather than
230    ///    a confusing `HeaderCrcMismatch` if the frame layout shifted between versions.
231    /// 3. Header CRC — validates the remaining bytes are uncorrupted before parsing them.
232    /// 4. Typed field parsing — only after all integrity checks pass.
233    pub fn decode(buf: &[u8; HEADER_LEN]) -> Result<Self, DecodeError> {
234        if buf[0..4] != MAGIC {
235            return Err(DecodeError::BadMagic);
236        }
237
238        let version = buf[4];
239        if version != WIRE_VERSION {
240            return Err(DecodeError::VersionMismatch {
241                supported: WIRE_VERSION,
242                received: version,
243            });
244        }
245
246        let expected_crc = u32::from_le_bytes(buf[12..16].try_into().unwrap());
247        let computed_crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
248        if expected_crc != computed_crc {
249            return Err(DecodeError::HeaderCrcMismatch {
250                expected: expected_crc,
251                computed: computed_crc,
252            });
253        }
254
255        let message_type =
256            MessageType::try_from(buf[5]).map_err(|e| DecodeError::UnknownMessageType(e.0))?;
257        let durability =
258            Durability::try_from(buf[6]).map_err(|e| DecodeError::UnknownDurability(e.0))?;
259        // Reserved flags byte MUST be zero in wire v1. Reject a nonzero value
260        // rather than preserving-and-ignoring it: silently dropping a flag that a
261        // future version gives meaning to would let a producer believe a semantic
262        // flag took effect when this daemon ignored it. Adding flags later is
263        // therefore an explicit, version-gated change (F52).
264        let flags = buf[7];
265        if flags != 0 {
266            return Err(DecodeError::ReservedFlagsSet { flags });
267        }
268        let payload_len = u32::from_le_bytes(buf[8..12].try_into().unwrap());
269
270        Ok(Self {
271            version,
272            message_type,
273            durability,
274            flags,
275            payload_len,
276        })
277    }
278}
279
280/// A complete weir wire frame: validated header + payload bytes.
281///
282/// Fields are private and read-only ([`header`](Envelope::header) /
283/// [`payload`](Envelope::payload)). [`Envelope::new`] makes the payload
284/// authoritative for `header.payload_len`, so the declared length and the
285/// actual payload can never disagree.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Envelope {
288    header: Header,
289    payload: Payload,
290}
291
292impl Envelope {
293    /// Builds a frame from a header and payload. The header's `payload_len` is
294    /// overwritten with the payload's actual length, so the two cannot desync
295    /// — the payload is the single source of truth for the length on the wire.
296    #[must_use]
297    pub fn new(header: Header, payload: impl Into<Payload>) -> Self {
298        let payload = payload.into();
299        // Saturate rather than `as u32`-truncate: a >= 4 GiB payload would
300        // otherwise wrap to a small length and desync the wire frame — the exact
301        // failure this "cannot desync" constructor exists to prevent (F49). Such
302        // a payload is far past MAX_PAYLOAD_HARD_CAP (16 MiB) and is rejected on
303        // decode; saturating to u32::MAX keeps it rejected instead of wrapping.
304        let payload_len = u32::try_from(payload.len()).unwrap_or(u32::MAX);
305        let header = Header {
306            payload_len,
307            ..header
308        };
309        Self { header, payload }
310    }
311
312    /// The frame's validated [`Header`].
313    pub fn header(&self) -> Header {
314        self.header
315    }
316
317    /// The frame's payload bytes.
318    pub fn payload(&self) -> &Payload {
319        &self.payload
320    }
321
322    /// Serialises the full frame: header bytes + payload bytes + payload CRC32 (LE).
323    #[must_use]
324    pub fn encode(&self) -> Vec<u8> {
325        let mut out = Vec::with_capacity(HEADER_LEN + self.payload.len() + 4);
326        out.extend_from_slice(&self.header.encode());
327        out.extend_from_slice(&self.payload);
328        let payload_crc = crc32fast::hash(&self.payload);
329        out.extend_from_slice(&payload_crc.to_le_bytes());
330        out
331    }
332
333    /// Decodes and validates a complete frame from a byte slice.
334    ///
335    /// Validation order defends against pre-allocation DoS:
336    /// 1. Buffer length >= HEADER_LEN — check before any parsing.
337    /// 2. Header decode (magic → version → CRC) — before touching payload.
338    /// 3. `payload_len <= MAX_PAYLOAD_HARD_CAP` — rejection before any heap allocation.
339    /// 4. Buffer length == HEADER_LEN + payload_len + 4 — the buffer must be
340    ///    *exactly* one frame: shorter is `TruncatedFrame`, longer is `TrailingBytes`
341    ///    (the caller owns framing; the codec never silently discards a remainder — G18).
342    /// 5. Payload CRC — validate after allocation, before returning data to caller.
343    ///
344    /// The socket layer applies an additional cap (`min(config.max_payload_bytes, MAX_PAYLOAD_HARD_CAP)`)
345    /// before reaching this function. This function applies only the hard cap as a safety floor.
346    pub fn decode(buf: &[u8]) -> Result<Self, DecodeError> {
347        if buf.len() < HEADER_LEN {
348            return Err(DecodeError::TruncatedFrame);
349        }
350
351        let header_bytes: &[u8; HEADER_LEN] = buf[..HEADER_LEN]
352            .try_into()
353            .expect("slice is exactly HEADER_LEN bytes, checked above");
354        let header = Header::decode(header_bytes)?;
355
356        let payload_len = header.payload_len as usize;
357        if payload_len > MAX_PAYLOAD_HARD_CAP {
358            return Err(DecodeError::PayloadTooLarge {
359                len: payload_len,
360                cap: MAX_PAYLOAD_HARD_CAP,
361            });
362        }
363
364        // payload_len <= MAX_PAYLOAD_HARD_CAP (16 MiB), so HEADER_LEN + payload_len + 4
365        // cannot overflow usize on any supported platform (min usize on 32-bit: 4 GiB).
366        let frame_len = HEADER_LEN + payload_len + 4;
367        if buf.len() < frame_len {
368            return Err(DecodeError::TruncatedFrame);
369        }
370        // The buffer must be EXACTLY one frame. A longer buffer is rejected rather
371        // than decoding the first frame and discarding the rest: this crate is the
372        // executable wire reference, and silently dropping trailing bytes would let
373        // a desynced or concatenated stream lose records without error. The caller
374        // owns framing — weir's own socket/client paths read exactly frame_len and
375        // never reach this arm (G18).
376        if buf.len() > frame_len {
377            return Err(DecodeError::TrailingBytes {
378                extra: buf.len() - frame_len,
379            });
380        }
381
382        let payload = Payload::copy_from_slice(&buf[HEADER_LEN..HEADER_LEN + payload_len]);
383        let expected_crc =
384            u32::from_le_bytes(buf[HEADER_LEN + payload_len..frame_len].try_into().unwrap());
385        let computed_crc = crc32fast::hash(&payload);
386        if expected_crc != computed_crc {
387            return Err(DecodeError::PayloadCrcMismatch {
388                expected: expected_crc,
389                computed: computed_crc,
390            });
391        }
392
393        Ok(Self { header, payload })
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::version::WIRE_VERSION;
401
402    fn push_header() -> Header {
403        Header::new(MessageType::Push, Durability::Durable, 0)
404    }
405
406    // ── Header ──────────────────────────────────────────────────────────────
407
408    #[test]
409    fn header_encode_decode_round_trip() {
410        let h = push_header();
411        let encoded = h.encode();
412        let decoded = Header::decode(&encoded).unwrap();
413        assert_eq!(h, decoded);
414    }
415
416    #[test]
417    fn header_encode_is_16_bytes() {
418        let encoded = push_header().encode();
419        assert_eq!(encoded.len(), HEADER_LEN);
420    }
421
422    #[test]
423    fn header_decode_rejects_bad_magic() {
424        let mut buf = push_header().encode();
425        buf[0] = 0x00;
426        assert_eq!(Header::decode(&buf), Err(DecodeError::BadMagic));
427    }
428
429    #[test]
430    fn header_decode_rejects_future_version() {
431        let mut buf = push_header().encode();
432        buf[4] = WIRE_VERSION + 1;
433        // Must recalculate CRC so we test the version path, not the CRC path.
434        let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
435        buf[12..16].copy_from_slice(&crc.to_le_bytes());
436        assert_eq!(
437            Header::decode(&buf),
438            Err(DecodeError::VersionMismatch {
439                supported: WIRE_VERSION,
440                received: WIRE_VERSION + 1,
441            })
442        );
443    }
444
445    #[test]
446    fn header_decode_rejects_older_version() {
447        let mut buf = push_header().encode();
448        buf[4] = WIRE_VERSION - 1;
449        let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
450        buf[12..16].copy_from_slice(&crc.to_le_bytes());
451        assert_eq!(
452            Header::decode(&buf),
453            Err(DecodeError::VersionMismatch {
454                supported: WIRE_VERSION,
455                received: WIRE_VERSION - 1,
456            })
457        );
458    }
459
460    /// Version is checked before CRC. A frame with a wrong version but valid CRC must
461    /// return VersionMismatch, not HeaderCrcMismatch.
462    #[test]
463    fn header_decode_version_checked_before_crc() {
464        let mut buf = push_header().encode();
465        // Set version to a future value and recompute a valid CRC for that version.
466        buf[4] = WIRE_VERSION + 1;
467        let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
468        buf[12..16].copy_from_slice(&crc.to_le_bytes());
469        // CRC is now valid for this (future-versioned) header.
470        match Header::decode(&buf) {
471            Err(DecodeError::VersionMismatch { .. }) => {}
472            other => panic!("expected VersionMismatch, got {other:?}"),
473        }
474    }
475
476    #[test]
477    fn header_decode_rejects_corrupted_crc() {
478        let mut buf = push_header().encode();
479        buf[12] ^= 0xff;
480        assert!(matches!(
481            Header::decode(&buf),
482            Err(DecodeError::HeaderCrcMismatch { .. })
483        ));
484    }
485
486    #[test]
487    fn header_decode_rejects_unknown_message_type() {
488        let mut buf = push_header().encode();
489        buf[5] = 0xff;
490        let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
491        buf[12..16].copy_from_slice(&crc.to_le_bytes());
492        assert_eq!(
493            Header::decode(&buf),
494            Err(DecodeError::UnknownMessageType(0xff))
495        );
496    }
497
498    #[test]
499    fn header_decode_rejects_unknown_durability() {
500        let mut buf = push_header().encode();
501        buf[6] = 0xff;
502        let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
503        buf[12..16].copy_from_slice(&crc.to_le_bytes());
504        assert_eq!(
505            Header::decode(&buf),
506            Err(DecodeError::UnknownDurability(0xff))
507        );
508    }
509
510    /// In wire v1 the reserved flags byte must be zero. A header that sets any
511    /// flag bit (with an otherwise-valid CRC over that byte) is rejected with
512    /// `ReservedFlagsSet` rather than silently accepted (F52).
513    #[test]
514    fn header_decode_rejects_nonzero_flags() {
515        let buf = Header::new(MessageType::Push, Durability::Durable, 0x01).encode();
516        assert_eq!(
517            Header::decode(&buf),
518            Err(DecodeError::ReservedFlagsSet { flags: 0x01 })
519        );
520        // The full byte is reported verbatim, not just "nonzero".
521        let buf = Header::new(MessageType::Push, Durability::Durable, 0xff).encode();
522        assert_eq!(
523            Header::decode(&buf),
524            Err(DecodeError::ReservedFlagsSet { flags: 0xff })
525        );
526    }
527
528    /// The reserved-flags check fires only after magic/version/CRC/type/durability
529    /// all pass: a frame that is *both* nonzero-flagged and unknown-durability
530    /// surfaces the durability error first (the decode order is load-bearing for
531    /// the daemon's Nack mapping).
532    #[test]
533    fn header_decode_flags_checked_after_durability() {
534        let mut buf = Header::new(MessageType::Push, Durability::Durable, 0x01).encode();
535        buf[6] = 0xff; // unknown durability
536        let crc = crc32fast::hash(&buf[..HEADER_CRC_COVERAGE]);
537        buf[12..16].copy_from_slice(&crc.to_le_bytes());
538        assert_eq!(
539            Header::decode(&buf),
540            Err(DecodeError::UnknownDurability(0xff))
541        );
542    }
543
544    // ── MessageType ──────────────────────────────────────────────────────────
545
546    #[test]
547    fn message_type_try_from_accepts_all_known_values() {
548        assert_eq!(MessageType::try_from(0x01).unwrap(), MessageType::Push);
549        assert_eq!(MessageType::try_from(0x02).unwrap(), MessageType::Ack);
550        assert_eq!(MessageType::try_from(0x03).unwrap(), MessageType::Nack);
551        assert_eq!(
552            MessageType::try_from(0x04).unwrap(),
553            MessageType::HealthCheck
554        );
555        assert_eq!(
556            MessageType::try_from(0x05).unwrap(),
557            MessageType::HealthCheckResponse
558        );
559        assert_eq!(
560            MessageType::try_from(0x06).unwrap(),
561            MessageType::PushTracked
562        );
563        assert_eq!(
564            MessageType::try_from(0x07).unwrap(),
565            MessageType::AckTracked
566        );
567    }
568
569    #[test]
570    fn message_type_try_from_rejects_unknown() {
571        assert!(MessageType::try_from(0x00).is_err());
572        // 0x08/0x09 became PushBatch/AckBatch in 4.0; 0x0A is the first
573        // unassigned byte now. This assertion moves each time the additive
574        // space is used, which is the point — it pins where the boundary is.
575        assert!(MessageType::try_from(0x0A).is_err());
576        assert!(MessageType::try_from(0xff).is_err());
577    }
578
579    /// The byte range `0x08`-`0xFF` is what makes a new message type additive
580    /// within wire v1, so the assigned set must stay a contiguous prefix with no
581    /// accidental gaps or overlaps.
582    #[test]
583    fn the_assigned_message_type_bytes_are_contiguous_from_one() {
584        let assigned: Vec<u8> = (0u8..=u8::MAX)
585            .filter(|b| MessageType::try_from(*b).is_ok())
586            .collect();
587        assert_eq!(
588            assigned,
589            (0x01u8..=0x09).collect::<Vec<u8>>(),
590            "assigned message-type bytes should be 0x01..=0x09 with no holes"
591        );
592        // 0xFF must stay unassigned: the frozen vector
593        // `reject_unknown_message_type` pins it to UnknownMessageType, and
594        // changing a frozen vector is what the freeze forbids.
595        assert!(MessageType::try_from(0xFF).is_err());
596    }
597
598    #[test]
599    fn message_type_from_u8_round_trips() {
600        // From<MessageType> for u8 is the inverse of TryFrom<u8>, matching the
601        // symmetric pair Durability and NackReason already expose (P3-F3).
602        for mt in [
603            MessageType::Push,
604            MessageType::Ack,
605            MessageType::Nack,
606            MessageType::HealthCheck,
607            MessageType::HealthCheckResponse,
608            MessageType::PushTracked,
609            MessageType::AckTracked,
610            MessageType::PushBatch,
611            MessageType::AckBatch,
612        ] {
613            assert_eq!(MessageType::try_from(u8::from(mt)).unwrap(), mt);
614        }
615    }
616
617    #[test]
618    fn message_type_repr_values_match_wire() {
619        assert_eq!(MessageType::Push as u8, 0x01);
620        assert_eq!(MessageType::Ack as u8, 0x02);
621        assert_eq!(MessageType::Nack as u8, 0x03);
622        assert_eq!(MessageType::HealthCheck as u8, 0x04);
623        assert_eq!(MessageType::HealthCheckResponse as u8, 0x05);
624        assert_eq!(MessageType::PushTracked as u8, 0x06);
625        assert_eq!(MessageType::AckTracked as u8, 0x07);
626    }
627
628    // ── Envelope ─────────────────────────────────────────────────────────────
629
630    fn round_trip(msg_type: MessageType) {
631        let payload = b"hello weir".to_vec();
632        let header = Header::new(msg_type, Durability::Durable, 0);
633        let env = Envelope::new(header, payload);
634        let encoded = env.encode();
635        let decoded = Envelope::decode(&encoded).unwrap();
636        assert_eq!(env, decoded);
637    }
638
639    #[test]
640    fn envelope_round_trip_push() {
641        round_trip(MessageType::Push);
642    }
643
644    #[test]
645    fn envelope_round_trip_ack() {
646        round_trip(MessageType::Ack);
647    }
648
649    #[test]
650    fn envelope_round_trip_nack() {
651        round_trip(MessageType::Nack);
652    }
653
654    #[test]
655    fn envelope_round_trip_health_check() {
656        round_trip(MessageType::HealthCheck);
657    }
658
659    #[test]
660    fn envelope_round_trip_health_check_response() {
661        round_trip(MessageType::HealthCheckResponse);
662    }
663
664    #[test]
665    fn envelope_round_trip_push_tracked() {
666        round_trip(MessageType::PushTracked);
667    }
668
669    #[test]
670    fn envelope_round_trip_ack_tracked() {
671        round_trip(MessageType::AckTracked);
672    }
673
674    #[test]
675    fn envelope_round_trip_empty_payload() {
676        let header = push_header();
677        let env = Envelope::new(header, vec![]);
678        let encoded = env.encode();
679        let decoded = Envelope::decode(&encoded).unwrap();
680        assert_eq!(env, decoded);
681    }
682
683    /// F49: Envelope::new makes the payload authoritative for the header's
684    /// payload_len. The >4 GiB saturation arm is intentionally untestable without
685    /// a multi-GiB allocation, but pinning the equality across the realistic
686    /// in-cap range proves no `as`-truncation snuck in (a declared len that
687    /// disagreed with the payload would be a desync the crown invariant relies on
688    /// never happening).
689    #[test]
690    fn envelope_new_payload_len_equals_payload_length() {
691        for len in [0usize, 1, 7, 256, 65_536, 1_000_000] {
692            let env = Envelope::new(push_header(), vec![0xAB; len]);
693            assert_eq!(
694                env.header().payload_len() as usize,
695                len,
696                "payload_len must equal the actual payload length for len={len}"
697            );
698        }
699    }
700
701    #[test]
702    fn envelope_decode_rejects_truncated_header() {
703        let env = Envelope::new(push_header(), b"data".to_vec());
704        let encoded = env.encode();
705        assert_eq!(
706            Envelope::decode(&encoded[..HEADER_LEN - 1]),
707            Err(DecodeError::TruncatedFrame)
708        );
709    }
710
711    #[test]
712    fn envelope_decode_rejects_truncated_payload() {
713        let payload = b"hello".to_vec();
714        let env = Envelope::new(push_header(), payload);
715        let encoded = env.encode();
716        // Strip the last byte of the payload+CRC section.
717        assert_eq!(
718            Envelope::decode(&encoded[..encoded.len() - 1]),
719            Err(DecodeError::TruncatedFrame)
720        );
721    }
722
723    #[test]
724    fn envelope_decode_rejects_header_only() {
725        let env = Envelope::new(push_header(), b"data".to_vec());
726        let encoded = env.encode();
727        assert_eq!(
728            Envelope::decode(&encoded[..HEADER_LEN]),
729            Err(DecodeError::TruncatedFrame)
730        );
731    }
732
733    #[test]
734    fn envelope_decode_rejects_corrupted_payload_crc() {
735        let payload = b"hello".to_vec();
736        let env = Envelope::new(push_header(), payload);
737        let mut encoded = env.encode();
738        // Flip a bit in the trailing CRC.
739        let last = encoded.len() - 1;
740        encoded[last] ^= 0xff;
741        assert!(matches!(
742            Envelope::decode(&encoded),
743            Err(DecodeError::PayloadCrcMismatch { .. })
744        ));
745    }
746
747    #[test]
748    fn envelope_decode_rejects_trailing_bytes() {
749        // A buffer longer than one frame is rejected (G18) rather than decoding
750        // the first frame and silently discarding the rest — the codec never loses
751        // a remainder; the caller owns framing.
752        let env = Envelope::new(push_header(), b"hello".to_vec());
753        let mut encoded = env.encode();
754        encoded.extend_from_slice(b"garbage"); // 7 extra bytes
755        assert_eq!(
756            Envelope::decode(&encoded),
757            Err(DecodeError::TrailingBytes { extra: 7 })
758        );
759    }
760
761    #[test]
762    fn envelope_decode_accepts_exact_frame() {
763        // The boundary case: a buffer that is exactly one frame decodes cleanly.
764        let env = Envelope::new(push_header(), b"hello".to_vec());
765        let encoded = env.encode();
766        assert_eq!(Envelope::decode(&encoded).unwrap(), env);
767    }
768
769    #[test]
770    fn envelope_decode_trailing_bytes_checked_after_payload_too_large() {
771        // A 16-byte buffer declaring an over-cap payload returns PayloadTooLarge,
772        // not TrailingBytes: the cap check (step 3) precedes the frame-length check
773        // (step 4), so an over-cap declared length is rejected before allocation
774        // even though the buffer is "too short" for the declared frame.
775        let oversized_len = (MAX_PAYLOAD_HARD_CAP + 1) as u32;
776        let mut header_bytes = push_header().encode();
777        header_bytes[8..12].copy_from_slice(&oversized_len.to_le_bytes());
778        let crc = crc32fast::hash(&header_bytes[..HEADER_CRC_COVERAGE]);
779        header_bytes[12..16].copy_from_slice(&crc.to_le_bytes());
780        assert_eq!(
781            Envelope::decode(&header_bytes),
782            Err(DecodeError::PayloadTooLarge {
783                len: MAX_PAYLOAD_HARD_CAP + 1,
784                cap: MAX_PAYLOAD_HARD_CAP,
785            })
786        );
787    }
788
789    #[test]
790    fn envelope_decode_rejects_oversized_payload_len() {
791        // The cap check fires immediately after Header::decode, before the frame-length
792        // check, so even a 16-byte buffer returns PayloadTooLarge (not TruncatedFrame).
793        // This ordering is deliberate: it prevents any heap allocation attempt.
794        //
795        // Header::new can no longer declare an oversized length (F50), so patch the
796        // payload_len field + recompute the header CRC directly to put the over-cap
797        // declared length on the wire.
798        let oversized_len = (MAX_PAYLOAD_HARD_CAP + 1) as u32;
799        let mut header_bytes = Header::new(MessageType::Push, Durability::Durable, 0).encode();
800        header_bytes[8..12].copy_from_slice(&oversized_len.to_le_bytes());
801        let crc = crc32fast::hash(&header_bytes[..HEADER_CRC_COVERAGE]);
802        header_bytes[12..16].copy_from_slice(&crc.to_le_bytes());
803        assert_eq!(
804            Envelope::decode(&header_bytes),
805            Err(DecodeError::PayloadTooLarge {
806                len: MAX_PAYLOAD_HARD_CAP + 1,
807                cap: MAX_PAYLOAD_HARD_CAP,
808            })
809        );
810    }
811}