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