Skip to main content

ferro_lumberjack/
frame.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure-data frame codec — no I/O, no async runtime.
3//!
4//! Encoders are free functions that allocate on demand. The
5//! [`FrameDecoder`] is a streaming state machine that accepts arbitrary
6//! byte slices and yields fully decoded [`Frame`] values once enough
7//! bytes are available. Useful from any runtime, sync or async, and
8//! used by the optional [`crate::client`] under the hood.
9//!
10//! ## Frame layout (Lumberjack v2)
11//!
12//! ```text
13//! Window:     [ '2' ][ 'W' ][ count : u32 BE ]                                (6 bytes)
14//! JSON:       [ '2' ][ 'J' ][ seq : u32 BE ][ len : u32 BE ][ payload ]       (10 + len)
15//! Compressed: [ '2' ][ 'C' ][ len : u32 BE ][ zlib(...) ]                     (6 + len)
16//! Ack:        [ '2' ][ 'A' ][ seq : u32 BE ]                                  (6 bytes)
17//! ```
18//!
19//! Frames in a "window" are sent as a Window frame followed by `count`
20//! data frames (typically `J` frames; `D` legacy KV frames are decoded
21//! as [`Frame::Unknown`]). When the receiver has processed all data
22//! frames in the window it returns a single Ack frame whose `seq` is
23//! the highest data-frame `seq` it processed.
24
25use std::io::{Read, Write};
26
27use crate::{DEFAULT_MAX_FRAME_PAYLOAD, FrameError, PROTOCOL_VERSION};
28
29/// Wire byte for the Window frame type (`b'W'`).
30pub const FRAME_TYPE_WINDOW: u8 = b'W';
31/// Wire byte for the JSON data-frame type (`b'J'`).
32pub const FRAME_TYPE_JSON: u8 = b'J';
33/// Wire byte for the Compressed frame type (`b'C'`).
34pub const FRAME_TYPE_COMPRESSED: u8 = b'C';
35/// Wire byte for the ACK frame type (`b'A'`).
36pub const FRAME_TYPE_ACK: u8 = b'A';
37/// Wire byte for the legacy data-frame type (`b'D'`).
38///
39/// Modern Beats agents use [`FRAME_TYPE_JSON`] exclusively. The decoder
40/// surfaces `D` frames as [`Frame::Unknown`] so a server-side consumer
41/// can choose to decode them.
42pub const FRAME_TYPE_DATA_LEGACY: u8 = b'D';
43
44/// Identifies a Lumberjack v2 frame on the wire.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum FrameType {
47    /// Window — declares the number of data frames the sender will emit
48    /// before expecting an ACK.
49    Window,
50    /// JSON-encoded event with a monotonic sequence number.
51    Json,
52    /// Compressed batch — payload is zlib-encoded inner frames.
53    Compressed,
54    /// ACK from receiver — `seq` is the highest sequence successfully
55    /// processed.
56    Ack,
57}
58
59impl FrameType {
60    /// Wire byte that identifies this frame type.
61    #[must_use]
62    pub const fn wire_byte(self) -> u8 {
63        match self {
64            Self::Window => FRAME_TYPE_WINDOW,
65            Self::Json => FRAME_TYPE_JSON,
66            Self::Compressed => FRAME_TYPE_COMPRESSED,
67            Self::Ack => FRAME_TYPE_ACK,
68        }
69    }
70}
71
72/// A fully decoded Lumberjack v2 frame.
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum Frame {
76    /// Window frame — `count` is the number of data frames the sender
77    /// promises to emit before expecting an ACK.
78    Window {
79        /// Number of data frames in the window.
80        count: u32,
81    },
82    /// JSON event frame.
83    Json {
84        /// Monotonic sequence number assigned by the sender.
85        seq: u32,
86        /// JSON payload bytes (UTF-8 — but the codec does not validate
87        /// this; it is the caller's job).
88        payload: Vec<u8>,
89    },
90    /// Compressed batch — the wrapped bytes are the *decompressed*
91    /// inner frames (the codec hides the zlib boundary). To consume the
92    /// inner frames, feed `decompressed` into a fresh [`FrameDecoder`]:
93    ///
94    /// ```
95    /// # use ferro_lumberjack::frame::{FrameDecoder, Frame};
96    /// # fn handle(outer: &mut FrameDecoder) -> Result<(), ferro_lumberjack::FrameError> {
97    /// while let Some(frame) = outer.next_frame()? {
98    ///     if let Frame::Compressed { decompressed } = frame {
99    ///         let mut inner = FrameDecoder::new();
100    ///         inner.feed(&decompressed);
101    ///         while let Some(inner_frame) = inner.next_frame()? {
102    ///             // …
103    ///             let _ = inner_frame;
104    ///         }
105    ///     }
106    /// }
107    /// # Ok(()) }
108    /// ```
109    Compressed {
110        /// Decompressed inner bytes (one or more concatenated frames).
111        decompressed: Vec<u8>,
112    },
113    /// ACK frame.
114    Ack {
115        /// Highest sequence number the receiver has processed.
116        seq: u32,
117    },
118    /// A frame the codec recognised as version-2 but whose type byte is
119    /// not in the enumerated set above (currently `D` legacy KV frames
120    /// and any future additions). The raw bytes — including the 2-byte
121    /// header — are surfaced for forward-compatibility.
122    Unknown {
123        /// Wire type byte (e.g. `b'D'`).
124        frame_type: u8,
125        /// Full raw bytes of the frame, header included.
126        raw: Vec<u8>,
127    },
128}
129
130// ---------------------------------------------------------------------------
131// Encoders
132// ---------------------------------------------------------------------------
133
134/// Encode a Window frame: `2 W <count: u32 BE>` (6 bytes total).
135#[must_use]
136pub fn encode_window(count: u32) -> [u8; 6] {
137    let mut out = [0u8; 6];
138    out[0] = PROTOCOL_VERSION;
139    out[1] = FRAME_TYPE_WINDOW;
140    out[2..6].copy_from_slice(&count.to_be_bytes());
141    out
142}
143
144/// Encode an ACK frame: `2 A <seq: u32 BE>` (6 bytes total).
145#[must_use]
146pub fn encode_ack(seq: u32) -> [u8; 6] {
147    let mut out = [0u8; 6];
148    out[0] = PROTOCOL_VERSION;
149    out[1] = FRAME_TYPE_ACK;
150    out[2..6].copy_from_slice(&seq.to_be_bytes());
151    out
152}
153
154/// Encode a JSON frame: `2 J <seq: u32 BE> <len: u32 BE> <payload>`.
155///
156/// `payload` is written verbatim — no UTF-8 validation, no JSON
157/// validation. Both are the caller's responsibility.
158#[must_use]
159pub fn encode_json_frame(seq: u32, payload: &[u8]) -> Vec<u8> {
160    let mut out = Vec::with_capacity(10 + payload.len());
161    out.push(PROTOCOL_VERSION);
162    out.push(FRAME_TYPE_JSON);
163    out.extend_from_slice(&seq.to_be_bytes());
164    // Length field is u32 BE — see `FrameError::PayloadTooLarge` in the
165    // decoder. We do not refuse to encode large payloads here; that is a
166    // policy decision for the caller.
167    let len = u32::try_from(payload.len()).unwrap_or(u32::MAX);
168    out.extend_from_slice(&len.to_be_bytes());
169    out.extend_from_slice(payload);
170    out
171}
172
173/// Encode a Compressed frame containing the supplied (already-encoded)
174/// inner frame bytes.
175///
176/// `level` is passed straight to [`flate2::Compression::new`]; valid
177/// values are `0..=9`. Higher levels compress more at the cost of CPU.
178///
179/// Returns the wire bytes of the `C` frame, including the 6-byte header.
180pub fn encode_compressed(level: u32, inner_frames: &[u8]) -> Result<Vec<u8>, FrameError> {
181    use flate2::Compression;
182    use flate2::write::ZlibEncoder;
183
184    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::new(level));
185    encoder
186        .write_all(inner_frames)
187        .map_err(|e| FrameError::Compression(e.to_string()))?;
188    let compressed = encoder
189        .finish()
190        .map_err(|e| FrameError::Compression(e.to_string()))?;
191
192    let len = u32::try_from(compressed.len()).map_err(|_| FrameError::PayloadTooLarge {
193        requested: compressed.len(),
194        limit: u32::MAX as usize,
195    })?;
196
197    let mut out = Vec::with_capacity(6 + compressed.len());
198    out.push(PROTOCOL_VERSION);
199    out.push(FRAME_TYPE_COMPRESSED);
200    out.extend_from_slice(&len.to_be_bytes());
201    out.extend_from_slice(&compressed);
202    Ok(out)
203}
204
205// ---------------------------------------------------------------------------
206// Streaming decoder
207// ---------------------------------------------------------------------------
208
209/// Streaming Lumberjack v2 frame decoder.
210///
211/// Accepts arbitrary slices of bytes via [`FrameDecoder::feed`] and emits
212/// fully decoded [`Frame`] values via [`FrameDecoder::next_frame`]. The
213/// internal buffer grows on demand and is compacted when consumed bytes
214/// pass a threshold so long-lived decoders do not accumulate unbounded
215/// dead capacity.
216///
217/// The decoder is **not** zero-copy: each `Frame` variant owns its
218/// payload bytes. This is deliberate — yielded frames are typically
219/// JSON-decoded immediately and the original bytes are dropped.
220///
221/// ### Resource bounding
222///
223/// Both raw payload lengths (`J`, `C` frames) **and** decompressed
224/// inner sizes (`C` frame contents) are capped by
225/// [`FrameDecoder::with_max_frame_payload`]. The default
226/// is [`crate::DEFAULT_MAX_FRAME_PAYLOAD`] (64 MiB). This protects
227/// servers reading from untrusted peers from naive resource-exhaustion
228/// attacks (huge declared lengths, zlib bombs).
229#[derive(Debug)]
230pub struct FrameDecoder {
231    buf: Vec<u8>,
232    /// Current read position in `buf`. Bytes before this index have
233    /// already been consumed and will be reclaimed on the next compaction.
234    read_pos: usize,
235    /// Maximum bytes a single frame's payload (or, for `C` frames, the
236    /// decompressed inner content) may occupy.
237    max_frame_payload: usize,
238}
239
240impl Default for FrameDecoder {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246impl FrameDecoder {
247    /// Create a decoder with the default 64 MiB per-frame size cap.
248    #[must_use]
249    pub const fn new() -> Self {
250        Self::with_max_frame_payload(DEFAULT_MAX_FRAME_PAYLOAD)
251    }
252
253    /// Create a decoder with a custom per-frame size cap.
254    ///
255    /// Applies to both raw payload lengths and decompressed inner
256    /// content of `C` frames. Set to `usize::MAX` for "no cap" (not
257    /// recommended on inputs from untrusted peers).
258    #[must_use]
259    pub const fn with_max_frame_payload(max_frame_payload: usize) -> Self {
260        Self {
261            buf: Vec::new(),
262            read_pos: 0,
263            max_frame_payload,
264        }
265    }
266
267    /// Append bytes to the internal buffer. The bytes will be parsed on
268    /// subsequent calls to [`Self::next_frame`].
269    pub fn feed(&mut self, bytes: &[u8]) {
270        // Compact only when consumed bytes are at least 1/2 of the buffer
271        // so we amortise the memmove cost over many feeds.
272        if self.read_pos > 0 && self.read_pos >= self.buf.len() / 2 {
273            self.buf.drain(..self.read_pos);
274            self.read_pos = 0;
275        }
276        self.buf.extend_from_slice(bytes);
277    }
278
279    /// How many fed bytes remain unconsumed in the internal buffer.
280    #[must_use]
281    pub const fn pending(&self) -> usize {
282        self.buf.len() - self.read_pos
283    }
284
285    /// Try to decode one frame from the buffered bytes.
286    ///
287    /// Returns:
288    ///
289    /// - `Ok(Some(frame))` — a complete frame is available.
290    /// - `Ok(None)` — not enough bytes yet; feed more.
291    /// - `Err(_)` — malformed input; the buffer is left untouched at
292    ///   the offending position so the caller may inspect it. To
293    ///   continue parsing, callers typically tear down the connection.
294    pub fn next_frame(&mut self) -> Result<Option<Frame>, FrameError> {
295        let avail = self.pending();
296        if avail < 2 {
297            return Ok(None);
298        }
299        let header = &self.buf[self.read_pos..self.read_pos + 2];
300        if header[0] != PROTOCOL_VERSION {
301            return Err(FrameError::UnsupportedVersion(header[0]));
302        }
303        let frame_type = header[1];
304        match frame_type {
305            FRAME_TYPE_WINDOW => Ok(self.try_decode_window()),
306            FRAME_TYPE_ACK => Ok(self.try_decode_ack()),
307            FRAME_TYPE_JSON => self.try_decode_json(),
308            FRAME_TYPE_COMPRESSED => self.try_decode_compressed(),
309            FRAME_TYPE_DATA_LEGACY => self.try_decode_unknown_with_seq_count(b'D'),
310            other => Err(FrameError::UnknownFrameType(other)),
311        }
312    }
313
314    /// Reborrow `&self.buf[read_pos..read_pos + n]` as a `[u8; M]`.
315    fn read_at<const M: usize>(&self, offset: usize) -> Option<[u8; M]> {
316        let start = self.read_pos + offset;
317        if self.buf.len() < start + M {
318            return None;
319        }
320        let mut out = [0u8; M];
321        out.copy_from_slice(&self.buf[start..start + M]);
322        Some(out)
323    }
324
325    fn try_decode_window(&mut self) -> Option<Frame> {
326        // Layout: 2 W <u32 count> = 6 bytes.
327        if self.pending() < 6 {
328            return None;
329        }
330        let count = u32::from_be_bytes(
331            self.read_at::<4>(2)
332                .expect("just verified ≥ 6 bytes pending"),
333        );
334        self.read_pos += 6;
335        Some(Frame::Window { count })
336    }
337
338    fn try_decode_ack(&mut self) -> Option<Frame> {
339        // Layout: 2 A <u32 seq> = 6 bytes.
340        if self.pending() < 6 {
341            return None;
342        }
343        let seq = u32::from_be_bytes(
344            self.read_at::<4>(2)
345                .expect("just verified ≥ 6 bytes pending"),
346        );
347        self.read_pos += 6;
348        Some(Frame::Ack { seq })
349    }
350
351    fn try_decode_json(&mut self) -> Result<Option<Frame>, FrameError> {
352        // Layout: 2 J <u32 seq> <u32 len> <payload> = 10 + len bytes.
353        if self.pending() < 10 {
354            return Ok(None);
355        }
356        let seq = u32::from_be_bytes(self.read_at::<4>(2).expect("≥ 10 pending"));
357        let len_raw = u32::from_be_bytes(self.read_at::<4>(6).expect("≥ 10 pending"));
358        let len = len_raw as usize;
359        if len > self.max_frame_payload {
360            return Err(FrameError::PayloadTooLarge {
361                requested: len,
362                limit: self.max_frame_payload,
363            });
364        }
365        if self.pending() < 10 + len {
366            return Ok(None);
367        }
368        let start = self.read_pos + 10;
369        let payload = self.buf[start..start + len].to_vec();
370        self.read_pos += 10 + len;
371        Ok(Some(Frame::Json { seq, payload }))
372    }
373
374    fn try_decode_compressed(&mut self) -> Result<Option<Frame>, FrameError> {
375        // Layout: 2 C <u32 len> <zlib bytes> = 6 + len bytes.
376        if self.pending() < 6 {
377            return Ok(None);
378        }
379        let len_raw = u32::from_be_bytes(self.read_at::<4>(2).expect("≥ 6 pending"));
380        let len = len_raw as usize;
381        if len > self.max_frame_payload {
382            return Err(FrameError::PayloadTooLarge {
383                requested: len,
384                limit: self.max_frame_payload,
385            });
386        }
387        if self.pending() < 6 + len {
388            return Ok(None);
389        }
390        let start = self.read_pos + 6;
391        let compressed = &self.buf[start..start + len];
392        let decompressed = decompress_capped(compressed, self.max_frame_payload)?;
393        self.read_pos += 6 + len;
394        Ok(Some(Frame::Compressed { decompressed }))
395    }
396
397    /// Decode a known-but-not-handled frame type whose first 8 bytes
398    /// after the header are `<seq: u32 BE> <count: u32 BE>` — i.e. the
399    /// legacy `D` frame. Each KV pair is then `<key_len: u32 BE> <key>
400    /// <value_len: u32 BE> <value>`. We don't decode the pairs; we just
401    /// scan past them so subsequent frames can be parsed, and surface the
402    /// raw bytes as [`Frame::Unknown`].
403    fn try_decode_unknown_with_seq_count(
404        &mut self,
405        type_byte: u8,
406    ) -> Result<Option<Frame>, FrameError> {
407        if self.pending() < 10 {
408            return Ok(None);
409        }
410        let pair_count = u32::from_be_bytes(self.read_at::<4>(6).expect("≥ 10 pending")) as usize;
411
412        // Walk pair-by-pair without copying, computing total length.
413        // 10 bytes header + 2 * (4-byte length prefix + content).
414        let mut cursor = 10;
415        for _ in 0..pair_count {
416            // Need 4 bytes for key_len.
417            if self.pending() < cursor + 4 {
418                return Ok(None);
419            }
420            let key_len = u32::from_be_bytes(
421                self.read_at::<4>(cursor)
422                    .expect("just bounded by pending check"),
423            ) as usize;
424            if key_len > self.max_frame_payload {
425                return Err(FrameError::PayloadTooLarge {
426                    requested: key_len,
427                    limit: self.max_frame_payload,
428                });
429            }
430            cursor += 4 + key_len;
431
432            if self.pending() < cursor + 4 {
433                return Ok(None);
434            }
435            let val_len = u32::from_be_bytes(
436                self.read_at::<4>(cursor)
437                    .expect("just bounded by pending check"),
438            ) as usize;
439            if val_len > self.max_frame_payload {
440                return Err(FrameError::PayloadTooLarge {
441                    requested: val_len,
442                    limit: self.max_frame_payload,
443                });
444            }
445            cursor += 4 + val_len;
446        }
447
448        if self.pending() < cursor {
449            return Ok(None);
450        }
451        let raw = self.buf[self.read_pos..self.read_pos + cursor].to_vec();
452        self.read_pos += cursor;
453        Ok(Some(Frame::Unknown {
454            frame_type: type_byte,
455            raw,
456        }))
457    }
458}
459
460/// Decompress zlib bytes into a fresh `Vec<u8>`, stopping if the output
461/// would exceed `limit`. Returns `FrameError::DecompressedTooLarge` if
462/// the stream would have produced more than `limit` bytes — i.e. zlib
463/// bomb defence.
464fn decompress_capped(compressed: &[u8], limit: usize) -> Result<Vec<u8>, FrameError> {
465    use flate2::read::ZlibDecoder;
466
467    // Read up to `limit + 1` bytes so we can distinguish "exactly limit"
468    // from "more than limit".
469    let mut out = Vec::new();
470    let take_limit = u64::try_from(limit).unwrap_or(u64::MAX);
471    let take_plus_one = take_limit.saturating_add(1);
472
473    let decoder = ZlibDecoder::new(compressed);
474    let mut bounded = decoder.take(take_plus_one);
475    bounded
476        .read_to_end(&mut out)
477        .map_err(|e| FrameError::Decompression(e.to_string()))?;
478
479    if out.len() > limit {
480        return Err(FrameError::DecompressedTooLarge { limit });
481    }
482
483    Ok(out)
484}
485
486// ---------------------------------------------------------------------------
487// Tests
488// ---------------------------------------------------------------------------
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use proptest::prelude::*;
494
495    #[test]
496    fn encode_window_layout() {
497        let bytes = encode_window(42);
498        assert_eq!(bytes[0], b'2');
499        assert_eq!(bytes[1], b'W');
500        assert_eq!(
501            u32::from_be_bytes([bytes[2], bytes[3], bytes[4], bytes[5]]),
502            42
503        );
504    }
505
506    #[test]
507    fn encode_ack_layout() {
508        let bytes = encode_ack(7);
509        assert_eq!(bytes[0], b'2');
510        assert_eq!(bytes[1], b'A');
511        assert_eq!(
512            u32::from_be_bytes([bytes[2], bytes[3], bytes[4], bytes[5]]),
513            7
514        );
515    }
516
517    #[test]
518    fn encode_json_frame_layout() {
519        let bytes = encode_json_frame(13, b"hello");
520        assert_eq!(&bytes[..2], b"2J");
521        assert_eq!(
522            u32::from_be_bytes([bytes[2], bytes[3], bytes[4], bytes[5]]),
523            13
524        );
525        assert_eq!(
526            u32::from_be_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]),
527            5
528        );
529        assert_eq!(&bytes[10..], b"hello");
530    }
531
532    #[test]
533    fn decode_window_round_trip() {
534        let mut d = FrameDecoder::new();
535        d.feed(&encode_window(123));
536        let f = d.next_frame().unwrap().unwrap();
537        assert_eq!(f, Frame::Window { count: 123 });
538        assert!(d.next_frame().unwrap().is_none());
539    }
540
541    #[test]
542    fn decode_ack_round_trip() {
543        let mut d = FrameDecoder::new();
544        d.feed(&encode_ack(987_654));
545        assert_eq!(d.next_frame().unwrap(), Some(Frame::Ack { seq: 987_654 }));
546    }
547
548    #[test]
549    fn decode_json_round_trip() {
550        let mut d = FrameDecoder::new();
551        d.feed(&encode_json_frame(1, br#"{"k":"v"}"#));
552        let f = d.next_frame().unwrap().unwrap();
553        let Frame::Json { seq, payload } = f else {
554            panic!("expected Json")
555        };
556        assert_eq!(seq, 1);
557        assert_eq!(payload, br#"{"k":"v"}"#);
558    }
559
560    #[test]
561    fn decode_handles_concatenated_frames() {
562        let mut d = FrameDecoder::new();
563        let mut feed = Vec::new();
564        feed.extend_from_slice(&encode_window(2));
565        feed.extend_from_slice(&encode_json_frame(1, b"a"));
566        feed.extend_from_slice(&encode_json_frame(2, b"bb"));
567        feed.extend_from_slice(&encode_ack(2));
568        d.feed(&feed);
569
570        assert_eq!(d.next_frame().unwrap(), Some(Frame::Window { count: 2 }));
571        let Some(Frame::Json { seq: 1, payload }) = d.next_frame().unwrap() else {
572            panic!()
573        };
574        assert_eq!(payload, b"a");
575        let Some(Frame::Json { seq: 2, payload }) = d.next_frame().unwrap() else {
576            panic!()
577        };
578        assert_eq!(payload, b"bb");
579        assert_eq!(d.next_frame().unwrap(), Some(Frame::Ack { seq: 2 }));
580        assert!(d.next_frame().unwrap().is_none());
581    }
582
583    #[test]
584    fn decode_handles_byte_at_a_time_feeds() {
585        // Pathological feed pattern: one byte at a time.
586        let mut d = FrameDecoder::new();
587        let frame = encode_json_frame(5, b"abcdefgh");
588        for byte in &frame {
589            assert!(d.next_frame().unwrap().is_none());
590            d.feed(std::slice::from_ref(byte));
591        }
592        let Frame::Json { seq, payload } = d.next_frame().unwrap().unwrap() else {
593            panic!()
594        };
595        assert_eq!(seq, 5);
596        assert_eq!(payload, b"abcdefgh");
597    }
598
599    #[test]
600    fn decode_compressed_round_trip() {
601        let inner = [
602            encode_json_frame(1, b"hello").as_slice(),
603            encode_json_frame(2, b"world").as_slice(),
604        ]
605        .concat();
606        let outer = encode_compressed(6, &inner).unwrap();
607
608        let mut d = FrameDecoder::new();
609        d.feed(&outer);
610        let Frame::Compressed { decompressed } = d.next_frame().unwrap().unwrap() else {
611            panic!()
612        };
613        assert_eq!(decompressed, inner);
614    }
615
616    #[test]
617    fn decode_rejects_bad_version() {
618        let mut d = FrameDecoder::new();
619        d.feed(&[b'1', b'W', 0, 0, 0, 1]);
620        assert!(matches!(
621            d.next_frame(),
622            Err(FrameError::UnsupportedVersion(b'1'))
623        ));
624    }
625
626    #[test]
627    fn decode_rejects_unknown_frame_type() {
628        let mut d = FrameDecoder::new();
629        d.feed(&[b'2', b'Z', 0, 0, 0, 1]);
630        assert!(matches!(
631            d.next_frame(),
632            Err(FrameError::UnknownFrameType(b'Z'))
633        ));
634    }
635
636    #[test]
637    fn decode_caps_oversize_json_payload() {
638        let mut d = FrameDecoder::with_max_frame_payload(16);
639        // Declares a 100-byte payload but our limit is 16.
640        let mut buf = vec![b'2', b'J', 0, 0, 0, 1];
641        buf.extend_from_slice(&100u32.to_be_bytes());
642        d.feed(&buf);
643        assert!(matches!(
644            d.next_frame(),
645            Err(FrameError::PayloadTooLarge { .. })
646        ));
647    }
648
649    #[test]
650    fn decode_caps_zlib_bomb() {
651        // 1 MiB of zeros compresses to ~1 KiB. If we cap at 64 KiB we
652        // should reject decompression. Use a tiny limit to confirm.
653        let original = vec![0u8; 1024 * 64];
654        let frame = encode_compressed(9, &original).unwrap();
655        let mut d = FrameDecoder::with_max_frame_payload(1024); // 1 KiB cap
656        d.feed(&frame);
657        match d.next_frame() {
658            // ok — the compressed form may itself exceed 1 KiB, or the
659            // decompressed cap may have triggered. Either is the intended
660            // defence.
661            Err(FrameError::DecompressedTooLarge { .. } | FrameError::PayloadTooLarge { .. }) => {}
662            other => panic!("expected size-related error, got {other:?}"),
663        }
664    }
665
666    #[test]
667    fn legacy_d_frame_is_decoded_as_unknown_and_advances() {
668        // Manually build a 'D' frame with one KV pair: key="foo", value="bar"
669        let mut frame = Vec::new();
670        frame.push(b'2');
671        frame.push(b'D');
672        frame.extend_from_slice(&5u32.to_be_bytes()); // seq
673        frame.extend_from_slice(&1u32.to_be_bytes()); // pair_count
674        // pair: key
675        frame.extend_from_slice(&3u32.to_be_bytes());
676        frame.extend_from_slice(b"foo");
677        // pair: value
678        frame.extend_from_slice(&3u32.to_be_bytes());
679        frame.extend_from_slice(b"bar");
680
681        // Append a known-good ack so we can confirm the cursor advanced.
682        frame.extend_from_slice(&encode_ack(5));
683
684        let mut d = FrameDecoder::new();
685        d.feed(&frame);
686        let f = d.next_frame().unwrap().unwrap();
687        let Frame::Unknown { frame_type, raw } = f else {
688            panic!()
689        };
690        assert_eq!(frame_type, b'D');
691        assert_eq!(&raw[..2], b"2D");
692        // Next frame should be the ack.
693        assert_eq!(d.next_frame().unwrap(), Some(Frame::Ack { seq: 5 }));
694    }
695
696    #[test]
697    fn decoder_compacts_after_consuming_half() {
698        let mut d = FrameDecoder::new();
699        for _ in 0..32 {
700            d.feed(&encode_ack(1));
701            let _ = d.next_frame().unwrap();
702        }
703        // After many ack consumptions, internal buffer must not grow
704        // unboundedly.
705        assert!(d.buf.capacity() < 1024, "buf cap = {}", d.buf.capacity());
706    }
707
708    // -----------------------------------------------------------------
709    // Mutation-hardening: exact wire-byte assertions (frame.rs:63).
710    // -----------------------------------------------------------------
711
712    #[test]
713    fn frame_type_wire_bytes_are_exact() {
714        // Pins each FrameType's wire byte. Kills `wire_byte -> 0` and
715        // `wire_byte -> 1` constant-return mutants: a constant return
716        // would collapse all four to the same value and/or change them
717        // off their documented ASCII letters.
718        assert_eq!(FrameType::Window.wire_byte(), b'W');
719        assert_eq!(FrameType::Json.wire_byte(), b'J');
720        assert_eq!(FrameType::Compressed.wire_byte(), b'C');
721        assert_eq!(FrameType::Ack.wire_byte(), b'A');
722
723        // None of them is 0 or 1 (the mutant constants) and all four are
724        // pairwise distinct.
725        for ft in [
726            FrameType::Window,
727            FrameType::Json,
728            FrameType::Compressed,
729            FrameType::Ack,
730        ] {
731            assert_ne!(ft.wire_byte(), 0);
732            assert_ne!(ft.wire_byte(), 1);
733        }
734        let bytes = [
735            FrameType::Window.wire_byte(),
736            FrameType::Json.wire_byte(),
737            FrameType::Compressed.wire_byte(),
738            FrameType::Ack.wire_byte(),
739        ];
740        let mut sorted = bytes.to_vec();
741        sorted.sort_unstable();
742        sorted.dedup();
743        assert_eq!(sorted.len(), 4, "wire bytes must be pairwise distinct");
744
745        // And they line up with the module-level constants and the
746        // encoders' emitted header bytes.
747        assert_eq!(FrameType::Window.wire_byte(), FRAME_TYPE_WINDOW);
748        assert_eq!(FrameType::Json.wire_byte(), FRAME_TYPE_JSON);
749        assert_eq!(FrameType::Compressed.wire_byte(), FRAME_TYPE_COMPRESSED);
750        assert_eq!(FrameType::Ack.wire_byte(), FRAME_TYPE_ACK);
751        assert_eq!(encode_window(0)[1], FrameType::Window.wire_byte());
752        assert_eq!(encode_ack(0)[1], FrameType::Ack.wire_byte());
753        assert_eq!(encode_json_frame(0, b"")[1], FrameType::Json.wire_byte());
754    }
755
756    // -----------------------------------------------------------------
757    // Mutation-hardening: feed() compaction predicate (frame.rs:272).
758    //
759    // `feed` compacts iff `read_pos > 0 && read_pos >= buf.len()/2`.
760    // These tests pin each operand by constructing exact (read_pos,
761    // buf.len()) states and asserting whether compaction fired —
762    // observable via the in-module `buf`/`read_pos` fields.
763    // -----------------------------------------------------------------
764
765    /// Drive a decoder to a known `(read_pos, buf.len())` by feeding
766    /// `total` filler bytes then consuming `consumed` of them via
767    /// 6-byte ack frames. Returns the decoder positioned with
768    /// `read_pos == consumed*6` and `buf.len() == total*6` (no compaction
769    /// triggered yet because we feed once up front).
770    fn decoder_at(acks_total: usize, acks_consumed: usize) -> FrameDecoder {
771        let mut d = FrameDecoder::new();
772        let mut feed = Vec::new();
773        for _ in 0..acks_total {
774            feed.extend_from_slice(&encode_ack(7));
775        }
776        d.feed(&feed);
777        for _ in 0..acks_consumed {
778            let _ = d.next_frame().unwrap();
779        }
780        d
781    }
782
783    #[test]
784    fn feed_compacts_when_read_pos_at_least_half() {
785        // read_pos == buf.len()/2 exactly: compaction MUST fire (`>=`).
786        // 4 acks fed (buf.len()=24), 2 consumed (read_pos=12). 12 >= 12.
787        let mut d = decoder_at(4, 2);
788        assert_eq!(d.read_pos, 12);
789        assert_eq!(d.buf.len(), 24);
790        d.feed(&[]); // empty append still runs the compaction predicate
791        assert_eq!(d.read_pos, 0, "compaction must reset read_pos at the boundary");
792        assert_eq!(d.buf.len(), 12, "drained the 12 consumed bytes");
793        // Remaining bytes still decode correctly.
794        assert_eq!(d.next_frame().unwrap(), Some(Frame::Ack { seq: 7 }));
795    }
796
797    #[test]
798    fn feed_does_not_compact_when_read_pos_below_half() {
799        // read_pos just BELOW buf.len()/2: compaction must NOT fire.
800        // 6 acks fed (buf.len()=36, half=18), 2 consumed (read_pos=12).
801        let mut d = decoder_at(6, 2);
802        assert_eq!(d.read_pos, 12);
803        assert_eq!(d.buf.len(), 36);
804        d.feed(&[]);
805        assert_eq!(d.read_pos, 12, "below half → no compaction");
806        assert_eq!(d.buf.len(), 36, "buffer untouched below threshold");
807    }
808
809    #[test]
810    fn feed_does_not_compact_when_read_pos_zero() {
811        // read_pos == 0 guard: even though 0 >= buf.len()/2 is false here,
812        // pin the `read_pos > 0` operand. Fresh decoder, nothing consumed.
813        let mut d = FrameDecoder::new();
814        d.feed(&encode_ack(7));
815        assert_eq!(d.read_pos, 0);
816        let len_before = d.buf.len();
817        d.feed(&[]);
818        assert_eq!(d.read_pos, 0);
819        assert_eq!(d.buf.len(), len_before);
820    }
821
822    #[test]
823    fn feed_compacts_strictly_above_half() {
824        // read_pos strictly GREATER than buf.len()/2: distinguishes the
825        // mutant `> ` / `==` forms of the boundary that flips `>=` to
826        // other operators. 4 acks (buf=24, half=12), 3 consumed (rp=18).
827        let mut d = decoder_at(4, 3);
828        assert_eq!(d.read_pos, 18);
829        assert_eq!(d.buf.len(), 24);
830        d.feed(&[]);
831        assert_eq!(d.read_pos, 0, "well above half must compact");
832        assert_eq!(d.buf.len(), 6);
833        assert_eq!(d.next_frame().unwrap(), Some(Frame::Ack { seq: 7 }));
834    }
835
836    #[test]
837    fn feed_half_uses_integer_division_not_mod_or_mul() {
838        // Pins the `/` in `buf.len() / 2`. With buf.len()=24, `/2`=12,
839        // `%2`=0, `*2`=48. read_pos=12:
840        //   - real (`/2`=12): 12 >= 12 → compact.
841        //   - `%2`=0:         12 >= 0  → would also compact (not
842        //                     distinguishable here) — handled below.
843        //   - `*2`=48:        12 >= 48 → would NOT compact → read_pos
844        //                     stays 12. So `*` is killed here.
845        let mut d = decoder_at(4, 2);
846        assert_eq!((d.read_pos, d.buf.len()), (12, 24));
847        d.feed(&[]);
848        assert_eq!(d.read_pos, 0, "`* ` mutant would skip compaction");
849
850        // Kill the `%` mutant: choose a state where `/2` says "don't
851        // compact" but `%2` says "compact". buf.len()=36 → /2=18, %2=0.
852        // read_pos=6: real 6>=18 false (no compact); `%`-mutant 6>=0 true
853        // (would compact). So observing NO compaction kills `%`.
854        let mut d2 = decoder_at(6, 1);
855        assert_eq!((d2.read_pos, d2.buf.len()), (6, 36));
856        d2.feed(&[]);
857        assert_eq!(d2.read_pos, 6, "`%` mutant would wrongly compact here");
858        assert_eq!(d2.buf.len(), 36);
859    }
860
861    #[test]
862    fn feed_and_predicate_requires_both_operands() {
863        // `&&` → `||`: with `||`, compaction fires if EITHER `read_pos>0`
864        // OR `read_pos>=len/2`. State: read_pos=6, buf.len()=36 (half=18).
865        // real: 6>0 && 6>=18 → false (no compact).
866        // `||`-mutant: 6>0 || 6>=18 → true (would compact). Observing NO
867        // compaction kills `||`.
868        let mut d = decoder_at(6, 1);
869        assert_eq!((d.read_pos, d.buf.len()), (6, 36));
870        d.feed(&[]);
871        assert_eq!(d.read_pos, 6, "`||` mutant would compact with rp>0 alone");
872    }
873
874    // -----------------------------------------------------------------
875    // Mutation-hardening: per-decoder "need more bytes" boundaries.
876    // For each `pending() < N` guard, feed N-1 / N bytes and assert
877    // None vs Some(frame). This pins the comparison operator and the N.
878    // -----------------------------------------------------------------
879
880    #[test]
881    fn window_needs_exactly_six_bytes() {
882        // frame.rs:296 `pending() < 6`.
883        let full = encode_window(0xDEAD_BEEF);
884        // 5 bytes → not enough.
885        let mut d = FrameDecoder::new();
886        d.feed(&full[..5]);
887        assert_eq!(d.next_frame().unwrap(), None, "5 bytes < 6 → None");
888        // 6th byte arrives → decodes.
889        d.feed(&full[5..6]);
890        assert_eq!(
891            d.next_frame().unwrap(),
892            Some(Frame::Window { count: 0xDEAD_BEEF }),
893            "exactly 6 bytes → Some",
894        );
895    }
896
897    #[test]
898    fn ack_needs_exactly_six_bytes() {
899        // frame.rs:340 `pending() < 6`.
900        let full = encode_ack(0x0102_0304);
901        let mut d = FrameDecoder::new();
902        d.feed(&full[..5]);
903        assert_eq!(d.next_frame().unwrap(), None, "5 bytes → None");
904        d.feed(&full[5..6]);
905        assert_eq!(
906            d.next_frame().unwrap(),
907            Some(Frame::Ack { seq: 0x0102_0304 }),
908        );
909    }
910
911    #[test]
912    fn json_needs_ten_header_bytes_then_payload() {
913        // frame.rs (try_decode_json header `pending() < 10`).
914        let full = encode_json_frame(9, b"ABCD"); // 10 + 4 = 14 bytes
915        let mut d = FrameDecoder::new();
916        d.feed(&full[..9]);
917        assert_eq!(d.next_frame().unwrap(), None, "9 header bytes → None");
918        d.feed(&full[9..10]);
919        // Header complete but payload not yet.
920        assert_eq!(d.next_frame().unwrap(), None, "header only, no payload → None");
921        d.feed(&full[10..]);
922        let Some(Frame::Json { seq, payload }) = d.next_frame().unwrap() else {
923            panic!("expected Json")
924        };
925        assert_eq!(seq, 9);
926        assert_eq!(payload, b"ABCD");
927    }
928
929    #[test]
930    fn json_payload_cap_is_strictly_greater_than() {
931        // frame.rs:359 `len > max_frame_payload`. Boundary trio at the cap.
932        // len == cap must be ACCEPTED; len == cap+1 must be REJECTED.
933        let cap = 8;
934        // Exactly cap: accepted (round-trips).
935        let payload = vec![b'z'; cap];
936        let frame = encode_json_frame(1, &payload);
937        let mut d = FrameDecoder::with_max_frame_payload(cap);
938        d.feed(&frame);
939        let Some(Frame::Json { payload: got, .. }) = d.next_frame().unwrap() else {
940            panic!("len == cap must be accepted (`>=` mutant would reject)")
941        };
942        assert_eq!(got.len(), cap);
943
944        // cap + 1: rejected.
945        let payload2 = vec![b'z'; cap + 1];
946        let frame2 = encode_json_frame(1, &payload2);
947        let mut d2 = FrameDecoder::with_max_frame_payload(cap);
948        d2.feed(&frame2);
949        assert!(
950            matches!(d2.next_frame(), Err(FrameError::PayloadTooLarge { requested, limit })
951                if requested == cap + 1 && limit == cap),
952            "len == cap+1 must be rejected",
953        );
954    }
955
956    #[test]
957    fn compressed_needs_six_header_bytes() {
958        // frame.rs:376 `pending() < 6` and 387 `pending() < 6 + len`.
959        let inner = encode_json_frame(1, b"hi");
960        let frame = encode_compressed(6, &inner).unwrap();
961        let mut d = FrameDecoder::new();
962        d.feed(&frame[..5]);
963        assert_eq!(d.next_frame().unwrap(), None, "5 header bytes → None");
964        d.feed(&frame[5..6]);
965        // Header (len) known but body bytes not all present yet.
966        assert_eq!(d.next_frame().unwrap(), None, "header only → None");
967        d.feed(&frame[6..frame.len() - 1]);
968        assert_eq!(d.next_frame().unwrap(), None, "body short by 1 → None");
969        d.feed(&frame[frame.len() - 1..]);
970        let Some(Frame::Compressed { decompressed }) = d.next_frame().unwrap() else {
971            panic!("complete body → Some")
972        };
973        assert_eq!(decompressed, inner);
974    }
975
976    #[test]
977    fn compressed_payload_cap_is_strictly_greater_than() {
978        // frame.rs:381 `len > max_frame_payload`. The declared compressed
979        // length is compared against the cap before decompression. Build
980        // a C frame whose *compressed* length is exactly N, then cap at
981        // N (accept path) and N-1 (reject path).
982        let inner = encode_json_frame(1, b"hello world payload");
983        let frame = encode_compressed(0, &inner).unwrap(); // level 0: stored
984        let clen = u32::from_be_bytes([frame[2], frame[3], frame[4], frame[5]]) as usize;
985
986        // cap == compressed len → accepted (the *decompressed* size also
987        // fits because inner is small).
988        let mut d = FrameDecoder::with_max_frame_payload(clen.max(inner.len()));
989        d.feed(&frame);
990        assert!(
991            matches!(d.next_frame(), Ok(Some(Frame::Compressed { .. }))),
992            "declared len == cap must pass the `>` gate",
993        );
994
995        // cap == compressed len - 1 → rejected at the length gate.
996        let mut d2 = FrameDecoder::with_max_frame_payload(clen - 1);
997        d2.feed(&frame);
998        assert!(
999            matches!(d2.next_frame(), Err(FrameError::PayloadTooLarge { requested, limit })
1000                if requested == clen && limit == clen - 1),
1001            "declared len > cap must be rejected",
1002        );
1003    }
1004
1005    #[test]
1006    fn compressed_read_pos_advances_by_six_plus_len() {
1007        // frame.rs:393 `self.read_pos += 6 + len`. A trailing ack must be
1008        // decodable immediately after, proving the cursor advanced by
1009        // exactly 6+len (not 6*len via `*` mutant, nor 6 via dropped +len).
1010        let inner = encode_json_frame(1, b"payload-bytes");
1011        let mut wire = encode_compressed(0, &inner).unwrap();
1012        let clen = wire.len() - 6;
1013        wire.extend_from_slice(&encode_ack(55));
1014        let mut d = FrameDecoder::with_max_frame_payload(64 * 1024);
1015        d.feed(&wire);
1016        assert!(matches!(
1017            d.next_frame().unwrap(),
1018            Some(Frame::Compressed { .. })
1019        ));
1020        // If read_pos advanced by anything other than 6+clen, the next
1021        // frame would be garbage or None.
1022        assert_eq!(
1023            d.next_frame().unwrap(),
1024            Some(Frame::Ack { seq: 55 }),
1025            "cursor must advance by exactly 6 + len (={})",
1026            6 + clen,
1027        );
1028        assert_eq!(d.next_frame().unwrap(), None);
1029    }
1030
1031    #[test]
1032    fn legacy_d_frame_header_boundary_is_ten() {
1033        // frame.rs:407 `pending() < 10`.
1034        let mut frame = Vec::new();
1035        frame.push(b'2');
1036        frame.push(b'D');
1037        frame.extend_from_slice(&5u32.to_be_bytes()); // seq
1038        frame.extend_from_slice(&0u32.to_be_bytes()); // pair_count = 0
1039        // 10-byte D frame with zero pairs.
1040        let mut d = FrameDecoder::new();
1041        d.feed(&frame[..9]);
1042        assert_eq!(d.next_frame().unwrap(), None, "9 header bytes → None");
1043        d.feed(&frame[9..10]);
1044        let Some(Frame::Unknown { frame_type, raw }) = d.next_frame().unwrap() else {
1045            panic!("10-byte zero-pair D frame → Unknown")
1046        };
1047        assert_eq!(frame_type, b'D');
1048        assert_eq!(raw.len(), 10);
1049    }
1050
1051    #[test]
1052    fn legacy_d_frame_pair_length_boundaries() {
1053        // frame.rs:417/424/432/439/448 — per-pair key_len / val_len need
1054        // checks and the `cursor + 4` boundary. Build a D frame with one
1055        // KV pair (key="ab", value="cde") and feed it in cursor-precise
1056        // increments, asserting None until the very last byte arrives.
1057        let mut frame = Vec::new();
1058        frame.push(b'2');
1059        frame.push(b'D');
1060        frame.extend_from_slice(&5u32.to_be_bytes()); // seq
1061        frame.extend_from_slice(&1u32.to_be_bytes()); // pair_count = 1
1062        frame.extend_from_slice(&2u32.to_be_bytes()); // key_len
1063        frame.extend_from_slice(b"ab");
1064        frame.extend_from_slice(&3u32.to_be_bytes()); // val_len
1065        frame.extend_from_slice(b"cde");
1066        let total = frame.len(); // 10 + (4+2) + (4+3) = 23
1067
1068        // Feed every prefix except the last byte → always None.
1069        for cut in 1..total {
1070            let mut d = FrameDecoder::new();
1071            d.feed(&frame[..cut]);
1072            assert_eq!(
1073                d.next_frame().unwrap(),
1074                None,
1075                "{cut} of {total} bytes must be incomplete",
1076            );
1077        }
1078        // Full frame → decodes, raw length == cursor (== total).
1079        let mut d = FrameDecoder::new();
1080        d.feed(&frame);
1081        let Some(Frame::Unknown { raw, .. }) = d.next_frame().unwrap() else {
1082            panic!("full D frame → Unknown")
1083        };
1084        assert_eq!(raw.len(), total, "cursor must equal 10 + 4+key + 4+val");
1085    }
1086
1087    #[test]
1088    fn legacy_d_frame_key_and_val_len_caps() {
1089        // frame.rs:424/439 `key_len > cap` / `val_len > cap`. Boundary at
1090        // the cap: a pair whose key_len equals cap+1 must reject.
1091        let cap: u32 = 4;
1092        let cap_usize = cap as usize;
1093        let mut frame = Vec::new();
1094        frame.push(b'2');
1095        frame.push(b'D');
1096        frame.extend_from_slice(&1u32.to_be_bytes()); // seq
1097        frame.extend_from_slice(&1u32.to_be_bytes()); // pair_count
1098        frame.extend_from_slice(&(cap + 1).to_be_bytes()); // key_len = cap+1
1099        let mut d = FrameDecoder::with_max_frame_payload(cap_usize);
1100        d.feed(&frame);
1101        assert!(
1102            matches!(d.next_frame(), Err(FrameError::PayloadTooLarge { requested, limit })
1103                if requested == cap_usize + 1 && limit == cap_usize),
1104            "key_len > cap must reject",
1105        );
1106
1107        // val_len > cap rejects too (key fits, value oversize).
1108        let mut frame2 = Vec::new();
1109        frame2.push(b'2');
1110        frame2.push(b'D');
1111        frame2.extend_from_slice(&1u32.to_be_bytes());
1112        frame2.extend_from_slice(&1u32.to_be_bytes());
1113        frame2.extend_from_slice(&1u32.to_be_bytes()); // key_len = 1
1114        frame2.push(b'k');
1115        frame2.extend_from_slice(&(cap + 1).to_be_bytes()); // val_len = cap+1
1116        let mut d2 = FrameDecoder::with_max_frame_payload(cap_usize);
1117        d2.feed(&frame2);
1118        assert!(
1119            matches!(d2.next_frame(), Err(FrameError::PayloadTooLarge { requested, limit })
1120                if requested == cap_usize + 1 && limit == cap_usize),
1121            "val_len > cap must reject",
1122        );
1123    }
1124
1125    #[test]
1126    fn decompress_cap_is_inclusive_at_limit() {
1127        // frame.rs:479 `out.len() > limit`. Decompressed output of
1128        // exactly `limit` bytes must be ACCEPTED; `limit - 1` must be
1129        // REJECTED as DecompressedTooLarge. Use a 32-byte all-zero inner
1130        // so the *compressed* form is tiny and the compressed-length gate
1131        // never fires — only the decompressed-size gate is under test.
1132        let inner = vec![0u8; 32]; // zeros compress to well under 32 bytes
1133        let frame = encode_compressed(9, &inner).unwrap();
1134        let clen = frame.len() - 6;
1135        assert!(clen <= 31, "compressed zeros must stay tiny (got {clen})");
1136
1137        // limit == decompressed size (32): accepted (`>=` mutant rejects).
1138        let mut d_ok = FrameDecoder::with_max_frame_payload(32);
1139        d_ok.feed(&frame);
1140        let Some(Frame::Compressed { decompressed }) = d_ok.next_frame().unwrap() else {
1141            panic!("decompressed len == limit must be accepted")
1142        };
1143        assert_eq!(decompressed.len(), 32);
1144
1145        // limit == 31: the same 32-byte output exceeds it → rejected.
1146        let mut d_bad = FrameDecoder::with_max_frame_payload(31);
1147        d_bad.feed(&frame);
1148        assert!(
1149            matches!(
1150                d_bad.next_frame(),
1151                Err(FrameError::DecompressedTooLarge { limit: 31 })
1152            ),
1153            "decompressed len > limit must reject",
1154        );
1155    }
1156
1157    #[test]
1158    fn next_frame_min_header_boundary_is_strictly_less_than_two() {
1159        // frame.rs:296 `avail < 2` (the "need a 2-byte header" guard).
1160        // The existing `*_needs_exactly_six_bytes` tests feed 5→6 bytes and
1161        // never exercise the `avail == 2` boundary, so `<` vs `<=` is not
1162        // distinguished there. Feed EXACTLY two bytes that form a complete,
1163        // header-decidable input: `[PROTOCOL_VERSION, <unknown type>]`.
1164        //
1165        //   real (`avail < 2`): 2 < 2 is false → reads the header → the
1166        //       unknown frame type surfaces as `Err(UnknownFrameType)`.
1167        //   `<=` mutant:        2 <= 2 is true  → returns `Ok(None)`.
1168        //
1169        // Asserting the Err at avail == 2 kills the `<=` mutant (and the
1170        // `==`/`>` siblings, which would mis-handle the 2-byte case too).
1171        let mut d = FrameDecoder::new();
1172        d.feed(&[PROTOCOL_VERSION, b'Z']);
1173        assert_eq!(d.pending(), 2, "exactly two bytes pending");
1174        assert!(
1175            matches!(d.next_frame(), Err(FrameError::UnknownFrameType(b'Z'))),
1176            "avail == 2 must read the header (not be treated as `need more`)",
1177        );
1178
1179        // And a bad-version 2-byte input is likewise decided at avail == 2.
1180        let mut d2 = FrameDecoder::new();
1181        d2.feed(b"1W");
1182        assert_eq!(d2.pending(), 2);
1183        assert!(
1184            matches!(d2.next_frame(), Err(FrameError::UnsupportedVersion(b'1'))),
1185            "avail == 2 must reach the version check",
1186        );
1187
1188        // One byte really IS too few → None (pins the `2` and the `<`).
1189        let mut d3 = FrameDecoder::new();
1190        d3.feed(&[PROTOCOL_VERSION]);
1191        assert_eq!(d3.next_frame().unwrap(), None, "1 byte < 2 → None");
1192    }
1193
1194    #[test]
1195    fn compressed_header_boundary_is_strictly_less_than_six() {
1196        // frame.rs:376 `pending() < 6`. The existing
1197        // `compressed_needs_six_header_bytes` test feeds a frame whose
1198        // declared `len` is > 0, so at `pending() == 6` the *body* check
1199        // (`pending() < 6 + len`) returns None regardless of the `< 6`
1200        // operator — leaving `<` vs `<=` indistinguishable. Hand-craft a
1201        // C frame with declared `len == 0` so the header alone (6 bytes) is
1202        // a *complete* frame:
1203        //
1204        //   real (`pending() < 6`): 6 < 6 false → proceeds; the empty
1205        //       compressed body decodes to an empty Vec, yielding
1206        //       `Ok(Some(Frame::Compressed { decompressed: [] }))`.
1207        //   `<=` mutant:            6 <= 6 true → returns `Ok(None)`.
1208        //
1209        // Some-vs-None at pending == 6 kills the `<=` mutant.
1210        let mut d = FrameDecoder::new();
1211        d.feed(&[PROTOCOL_VERSION, FRAME_TYPE_COMPRESSED, 0, 0, 0, 0]);
1212        assert_eq!(d.pending(), 6, "exactly the 6-byte C header, len = 0");
1213        let Some(Frame::Compressed { decompressed }) = d.next_frame().unwrap() else {
1214            panic!(
1215                "pending == 6 must enter the body path (empty body → empty frame), \
1216                 not be treated as `need more bytes` (the `<=` mutant returns None)"
1217            )
1218        };
1219        assert!(decompressed.is_empty(), "len == 0 → empty decompressed body");
1220
1221        // 5 bytes truly is short → None (pins the `6` and the `<`).
1222        let mut d2 = FrameDecoder::new();
1223        d2.feed(&[PROTOCOL_VERSION, FRAME_TYPE_COMPRESSED, 0, 0, 0]);
1224        assert_eq!(d2.next_frame().unwrap(), None, "5 < 6 → None");
1225    }
1226
1227    #[test]
1228    fn legacy_d_frame_key_and_val_len_caps_accept_at_exactly_cap() {
1229        // frame.rs:424 `key_len > cap` and 439 `val_len > cap`. The existing
1230        // `*_caps` test only checks `cap + 1` (reject). The `>` vs `>=`
1231        // mutants flip behaviour at the EXACT boundary `len == cap`, which
1232        // must be ACCEPTED by the real `>` but REJECTED by the `>=` mutant.
1233        // Build a complete D frame whose key_len and val_len both equal the
1234        // cap and assert it decodes to a `Frame::Unknown`.
1235        let cap: u32 = 3;
1236        let cap_usize = cap as usize;
1237        let mut frame = Vec::new();
1238        frame.push(PROTOCOL_VERSION);
1239        frame.push(FRAME_TYPE_DATA_LEGACY);
1240        frame.extend_from_slice(&7u32.to_be_bytes()); // seq
1241        frame.extend_from_slice(&1u32.to_be_bytes()); // pair_count = 1
1242        frame.extend_from_slice(&cap.to_be_bytes()); // key_len == cap (3)
1243        frame.extend_from_slice(b"abc"); // 3-byte key
1244        frame.extend_from_slice(&cap.to_be_bytes()); // val_len == cap (3)
1245        frame.extend_from_slice(b"xyz"); // 3-byte value
1246
1247        let mut d = FrameDecoder::with_max_frame_payload(cap_usize);
1248        d.feed(&frame);
1249        let Some(Frame::Unknown { frame_type, raw }) = d.next_frame().unwrap() else {
1250            panic!(
1251                "key_len == cap && val_len == cap must be ACCEPTED \
1252                 (the `>=` mutants on 424/439 would reject as PayloadTooLarge)"
1253            )
1254        };
1255        assert_eq!(frame_type, b'D');
1256        // raw == 10 header + (4 + 3) key + (4 + 3) val = 24 bytes.
1257        assert_eq!(raw.len(), 24);
1258
1259        // Sanity: cap - 1 (so len == cap is now strictly over) DOES reject,
1260        // confirming the gate is live and the accept above was meaningful.
1261        let mut d2 = FrameDecoder::with_max_frame_payload(cap_usize - 1);
1262        d2.feed(&frame);
1263        assert!(
1264            matches!(d2.next_frame(), Err(FrameError::PayloadTooLarge { requested, limit })
1265                if requested == cap_usize && limit == cap_usize - 1),
1266            "key_len (== cap) now strictly exceeds the lowered cap → reject",
1267        );
1268    }
1269
1270    proptest! {
1271        #[test]
1272        fn prop_json_frame_round_trip(seq: u32, payload: Vec<u8>) {
1273            let bytes = encode_json_frame(seq, &payload);
1274            let mut d = FrameDecoder::new();
1275            d.feed(&bytes);
1276            let frame = d.next_frame().unwrap().unwrap();
1277            let Frame::Json { seq: got_seq, payload: got_payload } = frame else {
1278                panic!("expected Json")
1279            };
1280            prop_assert_eq!(got_seq, seq);
1281            prop_assert_eq!(got_payload, payload);
1282            prop_assert!(d.next_frame().unwrap().is_none());
1283        }
1284
1285        #[test]
1286        fn prop_window_round_trip(count: u32) {
1287            let bytes = encode_window(count);
1288            let mut d = FrameDecoder::new();
1289            d.feed(&bytes);
1290            prop_assert_eq!(d.next_frame().unwrap(), Some(Frame::Window { count }));
1291        }
1292
1293        #[test]
1294        fn prop_ack_round_trip(seq: u32) {
1295            let bytes = encode_ack(seq);
1296            let mut d = FrameDecoder::new();
1297            d.feed(&bytes);
1298            prop_assert_eq!(d.next_frame().unwrap(), Some(Frame::Ack { seq }));
1299        }
1300
1301        #[test]
1302        fn prop_compressed_round_trip(payloads in proptest::collection::vec(any::<Vec<u8>>(), 1..16)) {
1303            let mut inner = Vec::new();
1304            for (i, p) in payloads.iter().enumerate() {
1305                let seq = u32::try_from(i + 1).unwrap_or(u32::MAX);
1306                inner.extend_from_slice(&encode_json_frame(seq, p));
1307            }
1308            let outer = encode_compressed(3, &inner).unwrap();
1309            let mut d = FrameDecoder::new();
1310            d.feed(&outer);
1311            let Some(Frame::Compressed { decompressed }) = d.next_frame().unwrap() else {
1312                panic!()
1313            };
1314            prop_assert_eq!(decompressed, inner);
1315        }
1316
1317        /// Decoder must never panic on arbitrary fed bytes.
1318        #[test]
1319        fn prop_decoder_does_not_panic(bytes in proptest::collection::vec(any::<u8>(), 0..4096)) {
1320            let mut d = FrameDecoder::with_max_frame_payload(8 * 1024);
1321            d.feed(&bytes);
1322            // Drain until decoder returns None or an error — never panic.
1323            for _ in 0..1024 {
1324                match d.next_frame() {
1325                    Ok(Some(_)) => {}
1326                    Ok(None) | Err(_) => break,
1327                }
1328            }
1329        }
1330    }
1331}