Skip to main content

crabka_protocol/records/
payload.rs

1//! `RecordsPayload`: the wire-field type for any Kafka message whose schema
2//! declares a `records` field (`Fetch`, `Produce`, `FetchSnapshot`, `ShareFetch`).
3//!
4//! Kafka's records-field is "opaque bytes" at the protocol layer; the
5//! contents may be a v2 `RecordBatch` (current) or a v0/v1 `MessageSet`
6//! (legacy, used by old clients on down-conversion). The discriminator
7//! is the **magic byte at offset 16** of the first batch, which is at
8//! the same offset for both formats by coincidence of layout (v2:
9//! `base_offset+batch_length+leader_epoch+magic`; legacy: `offset+
10//! message_size+crc+magic`).
11//!
12//! Eagerly parsing the v2 form keeps the existing broker code paths
13//! unchanged: where they used `RecordBatch::encoded_len` and similar,
14//! they now call the equivalent method on `RecordsPayload`. Legacy
15//! payloads are kept as raw [`Bytes`] and round-tripped verbatim — the
16//! [`crabka-records-legacy`](../../records_legacy/index.html) crate
17//! provides the codec when an old client actually appears on the wire.
18
19use bytes::{Buf, BufMut, Bytes};
20
21use crate::records::{
22    RecordsError, borrowed::RecordBatch as RecordBatchBorrowed, owned::RecordBatch,
23};
24
25/// Owned form of a records-field payload.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum RecordsPayload {
28    /// Zero or more parsed v2 batches (the records field is a *sequence*).
29    V2(Vec<RecordBatch>),
30    /// Verbatim, already-wire-format v2 bytes (one or more batches),
31    /// forwarded without parsing. Produced by the fetch pass-through path.
32    Raw(Bytes),
33    /// Opaque pre-v2 bytes (v0/v1 `MessageSet`). Decode with
34    /// `crabka_records_legacy::decode_message_set`.
35    Legacy(Bytes),
36    /// Zero-copy fetch (Increments D + E): the records run lives in segment
37    /// `.log` files and is `sendfile(2)`d straight to a plaintext socket —
38    /// never materialized in userspace. One [`FileRegion`] per contributing
39    /// segment. `encode_to` falls back to `pread` + `put_slice` (used on TLS /
40    /// non-sendfile platforms and for `encoded_len` agreement). Gated on the
41    /// SENDFILE alias (Linux + Apple + FreeBSD/DragonFly) because it only exists
42    /// to feed the `sendfile` drainer; on Windows the fallback `pread` path runs.
43    #[cfg(any(
44        target_os = "linux",
45        target_os = "macos",
46        target_os = "ios",
47        target_os = "tvos",
48        target_os = "watchos",
49        target_os = "freebsd",
50        target_os = "dragonfly",
51    ))]
52    FileRegions(Vec<crate::records::FileRegion>),
53}
54
55impl RecordsPayload {
56    /// Construct from raw records-field bytes. When the bytes look like v2,
57    /// decode *every* batch in the field; otherwise keep as opaque legacy.
58    pub fn from_bytes(bytes: Bytes) -> Result<Self, RecordsError> {
59        if looks_like_v2(&bytes) {
60            let mut cur: &[u8] = &bytes;
61            let mut batches = Vec::new();
62            while !cur.is_empty() {
63                batches.push(RecordBatch::decode(&mut cur)?);
64            }
65            Ok(Self::V2(batches))
66        } else {
67            Ok(Self::Legacy(bytes))
68        }
69    }
70
71    /// Wire size of the records-field bytes (no outer length prefix).
72    #[must_use]
73    pub fn payload_len(&self) -> usize {
74        match self {
75            Self::V2(batches) => batches.iter().map(RecordBatch::encoded_len).sum(),
76            Self::Raw(b) | Self::Legacy(b) => b.len(),
77            #[cfg(any(
78                target_os = "linux",
79                target_os = "macos",
80                target_os = "ios",
81                target_os = "tvos",
82                target_os = "watchos",
83                target_os = "freebsd",
84                target_os = "dragonfly",
85            ))]
86            Self::FileRegions(regions) => regions.iter().map(|r| r.len).sum(),
87        }
88    }
89
90    /// Write the payload bytes into `buf` (caller owns the outer framing).
91    ///
92    /// For `FileRegions` this is the **fallback** path (TLS / non-Linux /
93    /// `encoded_len` agreement): it `pread`s each region out of the segment
94    /// file and copies it into `buf`. The zero-copy `sendfile` path in the
95    /// broker never calls this — it consumes the `FileRegion`s directly.
96    pub fn encode_to<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
97        match self {
98            Self::V2(batches) => {
99                for b in batches {
100                    b.encode(buf)?;
101                }
102                Ok(())
103            }
104            Self::Raw(b) | Self::Legacy(b) => {
105                buf.put_slice(b);
106                Ok(())
107            }
108            #[cfg(any(
109                target_os = "linux",
110                target_os = "macos",
111                target_os = "ios",
112                target_os = "tvos",
113                target_os = "watchos",
114                target_os = "freebsd",
115                target_os = "dragonfly",
116            ))]
117            Self::FileRegions(regions) => {
118                use std::os::unix::fs::FileExt;
119                let mut scratch = vec![0u8; 0];
120                for region in regions {
121                    scratch.resize(region.len, 0);
122                    let mut filled = 0usize;
123                    let mut offset = region.offset;
124                    while filled < region.len {
125                        match region.file.read_at(&mut scratch[filled..], offset) {
126                            Ok(0) => {
127                                return Err(RecordsError::RecordParse(
128                                    "FileRegion read hit EOF before len bytes".into(),
129                                ));
130                            }
131                            Ok(n) => {
132                                filled += n;
133                                offset += n as u64;
134                            }
135                            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
136                            Err(e) => {
137                                return Err(RecordsError::RecordParse(format!(
138                                    "FileRegion read error: {e}"
139                                )));
140                            }
141                        }
142                    }
143                    buf.put_slice(&scratch);
144                }
145                Ok(())
146            }
147        }
148    }
149
150    /// Borrow the parsed v2 batches, if this is a parsed `V2` payload.
151    /// Returns `None` for `Raw` (intentionally unparsed), `Legacy`, and
152    /// `FileRegions` (deliberately never materialized).
153    #[must_use]
154    pub fn as_v2(&self) -> Option<&[RecordBatch]> {
155        match self {
156            Self::V2(batches) => Some(batches),
157            #[cfg(any(
158                target_os = "linux",
159                target_os = "macos",
160                target_os = "ios",
161                target_os = "tvos",
162                target_os = "watchos",
163                target_os = "freebsd",
164                target_os = "dragonfly",
165            ))]
166            Self::FileRegions(_) => None,
167            Self::Raw(_) | Self::Legacy(_) => None,
168        }
169    }
170
171    /// Borrow as raw legacy bytes, if that's what this payload is.
172    #[must_use]
173    pub fn as_legacy(&self) -> Option<&Bytes> {
174        match self {
175            Self::Legacy(b) => Some(b),
176            #[cfg(any(
177                target_os = "linux",
178                target_os = "macos",
179                target_os = "ios",
180                target_os = "tvos",
181                target_os = "watchos",
182                target_os = "freebsd",
183                target_os = "dragonfly",
184            ))]
185            Self::FileRegions(_) => None,
186            Self::V2(_) | Self::Raw(_) => None,
187        }
188    }
189
190    /// Decode a **response-side** records field, tolerating a truncated
191    /// trailing batch. Kafka returns a partial final `RecordBatch` when a
192    /// partition's fetch byte budget is hit mid-batch; the JVM consumer stops
193    /// at the first incomplete batch and re-fetches it from the next offset.
194    /// We mirror that: decode every complete batch, and on the first
195    /// `HeaderTooShort` / `BodyTooShort` stop and drop the remainder. A
196    /// *corrupt* complete batch (bad CRC/magic/content) still errors — leniency
197    /// forgives truncation only. Strict [`from_bytes`](Self::from_bytes) is
198    /// retained for Produce-request validation.
199    ///
200    /// Only `HeaderTooShort`/`BodyTooShort` are treated as truncation; a genuinely
201    /// invalid `batch_length` (`RecordParse`) is corruption and still errors —
202    /// legitimate Kafka truncation always preserves a valid `batch_length` prefix,
203    /// so it can only manifest as the too-short variants.
204    pub fn from_fetch_bytes(bytes: Bytes) -> Result<Self, RecordsError> {
205        if !looks_like_v2(&bytes) {
206            return Ok(Self::Legacy(bytes));
207        }
208        let mut cur: &[u8] = &bytes;
209        let mut batches = Vec::new();
210        while !cur.is_empty() {
211            match RecordBatch::decode(&mut cur) {
212                Ok(rb) => batches.push(rb),
213                Err(RecordsError::HeaderTooShort { .. } | RecordsError::BodyTooShort { .. }) => {
214                    break;
215                }
216                Err(e) => return Err(e),
217            }
218        }
219        Ok(Self::V2(batches))
220    }
221
222    /// `Decode`-shaped lenient entry point the generated codec calls for
223    /// records fields in **response** messages. Consumes the whole sliced
224    /// field buffer (the caller has already framed it) and parses leniently
225    /// via [`from_fetch_bytes`](Self::from_fetch_bytes).
226    pub fn decode_lenient<B: Buf>(
227        buf: &mut B,
228        _version: i16,
229    ) -> Result<Self, crate::ProtocolError> {
230        let bytes = buf.copy_to_bytes(buf.remaining());
231        Self::from_fetch_bytes(bytes).map_err(Into::into)
232    }
233}
234
235impl From<RecordBatch> for RecordsPayload {
236    fn from(rb: RecordBatch) -> Self {
237        Self::V2(vec![rb])
238    }
239}
240
241impl From<Vec<RecordBatch>> for RecordsPayload {
242    fn from(v: Vec<RecordBatch>) -> Self {
243        Self::V2(v)
244    }
245}
246
247impl Default for RecordsPayload {
248    fn default() -> Self {
249        Self::V2(Vec::new())
250    }
251}
252
253impl crate::Encode for RecordsPayload {
254    fn encode<B: BufMut>(&self, buf: &mut B, _version: i16) -> Result<(), crate::ProtocolError> {
255        self.encode_to(buf).map_err(Into::into)
256    }
257
258    fn encoded_len(&self, _version: i16) -> usize {
259        self.payload_len()
260    }
261}
262
263impl crate::Decode<'_> for RecordsPayload {
264    fn decode<B: Buf>(buf: &mut B, _version: i16) -> Result<Self, crate::ProtocolError> {
265        // The caller (generated codec) has already sliced the buffer to
266        // exactly the records-field bytes via `get_(nullable_)bytes_owned`,
267        // so we consume everything.
268        let bytes = buf.copy_to_bytes(buf.remaining());
269        Self::from_bytes(bytes).map_err(Into::into)
270    }
271}
272
273/// Borrowed form: zero-copy view into the input buffer.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub enum RecordsPayloadBorrowed<'a> {
276    V2(Vec<RecordBatchBorrowed<'a>>),
277    Legacy(&'a [u8]),
278}
279
280impl<'a> RecordsPayloadBorrowed<'a> {
281    pub fn from_slice(bytes: &'a [u8]) -> Result<Self, RecordsError> {
282        if looks_like_v2(bytes) {
283            let mut cur: &'a [u8] = bytes;
284            let mut batches = Vec::new();
285            while !cur.is_empty() {
286                let rb = <RecordBatchBorrowed<'a> as crate::DecodeBorrow<'a>>::decode_borrow(
287                    &mut cur, 0,
288                )
289                .map_err(|e| RecordsError::RecordParse(format!("borrowed v2 decode: {e}")))?;
290                batches.push(rb);
291            }
292            Ok(Self::V2(batches))
293        } else {
294            Ok(Self::Legacy(bytes))
295        }
296    }
297
298    #[must_use]
299    pub fn payload_len(&self) -> usize {
300        match self {
301            Self::V2(batches) => batches
302                .iter()
303                .map(|rb| crate::Encode::encoded_len(rb, 0))
304                .sum(),
305            Self::Legacy(b) => b.len(),
306        }
307    }
308
309    pub fn encode_to<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
310        match self {
311            Self::V2(batches) => {
312                for rb in batches {
313                    crate::Encode::encode(rb, buf, 0).map_err(|e| {
314                        RecordsError::RecordParse(format!("borrowed v2 encode: {e}"))
315                    })?;
316                }
317                Ok(())
318            }
319            Self::Legacy(b) => {
320                buf.put_slice(b);
321                Ok(())
322            }
323        }
324    }
325
326    /// Convert to the owned flavor, performing any necessary buffer copies.
327    pub fn to_owned(&self) -> Result<RecordsPayload, RecordsError> {
328        match self {
329            Self::V2(batches) => {
330                let mut owned = Vec::with_capacity(batches.len());
331                for rb in batches {
332                    owned.push(rb.to_owned()?);
333                }
334                Ok(RecordsPayload::V2(owned))
335            }
336            Self::Legacy(b) => Ok(RecordsPayload::Legacy(Bytes::copy_from_slice(b))),
337        }
338    }
339}
340
341impl Default for RecordsPayloadBorrowed<'_> {
342    fn default() -> Self {
343        Self::V2(Vec::new())
344    }
345}
346
347impl crate::Encode for RecordsPayloadBorrowed<'_> {
348    fn encode<B: BufMut>(&self, buf: &mut B, _version: i16) -> Result<(), crate::ProtocolError> {
349        self.encode_to(buf).map_err(Into::into)
350    }
351
352    fn encoded_len(&self, _version: i16) -> usize {
353        self.payload_len()
354    }
355}
356
357impl<'de> crate::DecodeBorrow<'de> for RecordsPayloadBorrowed<'de> {
358    fn decode_borrow(buf: &mut &'de [u8], _version: i16) -> Result<Self, crate::ProtocolError> {
359        // Caller has sliced the buffer to exactly the records-field bytes;
360        // consume everything by swapping in an empty tail.
361        let bytes = std::mem::take(buf);
362        Self::from_slice(bytes).map_err(Into::into)
363    }
364}
365
366/// True when `bytes` look like a v2 record batch (magic byte 2 at the
367/// well-known offset). v0 and v1 legacy `MessageSets` carry magic 0 or 1
368/// at the same offset, so this check distinguishes the two.
369///
370/// The threshold is `MAGIC_OFFSET + 1 = 17`, not the full `HEADER_LEN`:
371/// a truncated v2 batch still needs to land in the V2 arm so the
372/// downstream `RecordBatch::decode` can surface a precise error,
373/// rather than be silently misclassified as legacy.
374#[inline]
375fn looks_like_v2(bytes: &[u8]) -> bool {
376    // The magic byte sits at `base_offset(8) + batch_length(4) +
377    // partition_leader_epoch(4) = 16` in v2. Legacy MessageSets place
378    // the first message's magic byte at `offset(8) + message_size(4) +
379    // crc(4) = 16` — same index, different meaning.
380    const MAGIC_OFFSET: usize = 16;
381    bytes.len() > MAGIC_OFFSET && bytes[MAGIC_OFFSET] == 2
382}
383
384#[cfg(test)]
385mod tests {
386    use assert2::assert;
387    use bytes::BytesMut;
388
389    use super::*;
390    use crate::records::{Record, RecordBatch};
391
392    fn sample_v2() -> RecordBatch {
393        RecordBatch {
394            base_offset: 42,
395            records: vec![Record {
396                key: Some(Bytes::from_static(b"k")),
397                value: Some(Bytes::from_static(b"v")),
398                ..Default::default()
399            }],
400            ..RecordBatch::default()
401        }
402    }
403
404    #[test]
405    fn from_bytes_dispatches_v2() {
406        let rb = sample_v2();
407        let mut buf = BytesMut::new();
408        rb.encode(&mut buf).unwrap();
409        let p = RecordsPayload::from_bytes(buf.freeze()).unwrap();
410        match p {
411            RecordsPayload::V2(batches) => assert!(batches == vec![rb]),
412            _ => panic!("expected V2"),
413        }
414    }
415
416    #[test]
417    fn from_bytes_parses_all_batches() {
418        // Two v2 batches concatenated must both decode.
419        let mut b0 = sample_v2();
420        b0.base_offset = 0;
421        let mut b1 = sample_v2();
422        b1.base_offset = 1;
423        let mut buf = BytesMut::new();
424        b0.encode(&mut buf).unwrap();
425        b1.encode(&mut buf).unwrap();
426        let p = RecordsPayload::from_bytes(buf.freeze()).unwrap();
427        let batches = p.as_v2().expect("v2");
428        assert!(batches == &[b0, b1][..]);
429    }
430
431    #[test]
432    fn raw_passthrough_roundtrips() {
433        let mut b = sample_v2();
434        b.base_offset = 7;
435        let mut wire = BytesMut::new();
436        b.encode(&mut wire).unwrap();
437        let wire = wire.freeze();
438        let p = RecordsPayload::Raw(wire.clone());
439        assert!(p.payload_len() == wire.len());
440        let mut out = BytesMut::new();
441        p.encode_to(&mut out).unwrap();
442        assert!(&out[..] == &wire[..]); // verbatim
443        assert!(p.as_v2().is_none()); // Raw is unparsed
444    }
445
446    #[test]
447    fn from_bytes_dispatches_legacy() {
448        // Build a fake legacy MessageSet entry: offset(8) + size(4) + crc(4) +
449        // magic(1)=1 + … . Just need byte 16 to be 1.
450        let mut buf = vec![0u8; 17];
451        buf[16] = 1;
452        let p = RecordsPayload::from_bytes(Bytes::from(buf.clone())).unwrap();
453        match p {
454            RecordsPayload::Legacy(b) => assert!(&b[..] == &buf[..]),
455            _ => panic!("expected Legacy"),
456        }
457    }
458
459    #[test]
460    fn roundtrip_v2() {
461        let p: RecordsPayload = sample_v2().into();
462        let mut buf = BytesMut::new();
463        p.encode_to(&mut buf).unwrap();
464        let back = RecordsPayload::from_bytes(buf.freeze()).unwrap();
465        assert!(p == back);
466        assert!(p.payload_len() == back.payload_len());
467    }
468
469    #[test]
470    fn encode_decode_via_traits() {
471        let p: RecordsPayload = sample_v2().into();
472        let mut buf = BytesMut::new();
473        <RecordsPayload as crate::Encode>::encode(&p, &mut buf, 0).unwrap();
474        let mut cur: &[u8] = &buf;
475        let back = <RecordsPayload as crate::Decode>::decode(&mut cur, 0).unwrap();
476        assert!(p == back);
477    }
478
479    #[test]
480    fn borrowed_dispatches() {
481        let rb = sample_v2();
482        let mut buf = BytesMut::new();
483        rb.encode(&mut buf).unwrap();
484        let frozen = buf.freeze();
485        let p = RecordsPayloadBorrowed::from_slice(&frozen).unwrap();
486        assert!(matches!(p, RecordsPayloadBorrowed::V2(_)));
487        let owned = p.to_owned().unwrap();
488        match owned {
489            RecordsPayload::V2(batches) => assert!(batches[0].base_offset == 42),
490            _ => panic!("expected V2"),
491        }
492    }
493
494    #[test]
495    fn from_record_batch() {
496        let rb = sample_v2();
497        let p: RecordsPayload = rb.clone().into();
498        assert!(p.as_v2() == Some(&[rb][..]));
499        assert!(p.as_legacy().is_none());
500    }
501
502    fn legacy_bytes() -> Bytes {
503        let mut buf = vec![0u8; 24];
504        buf[16] = 1;
505        for (i, b) in (b'a'..=b'h').enumerate() {
506            buf[17 + i % 7] = b;
507        }
508        Bytes::from(buf)
509    }
510
511    #[test]
512    fn legacy_payload_len_and_encode_owned() {
513        let bytes = legacy_bytes();
514        let p = RecordsPayload::from_bytes(bytes.clone()).unwrap();
515        assert!(p == RecordsPayload::Legacy(bytes.clone()));
516        assert!(p.payload_len() == bytes.len());
517
518        let mut out = BytesMut::new();
519        p.encode_to(&mut out).unwrap();
520        assert!(&out[..] == &bytes[..]);
521    }
522
523    #[test]
524    fn legacy_roundtrip_via_traits() {
525        let bytes = legacy_bytes();
526        let p = RecordsPayload::from_bytes(bytes.clone()).unwrap();
527        let mut buf = BytesMut::new();
528        <RecordsPayload as crate::Encode>::encode(&p, &mut buf, 0).unwrap();
529        assert!(<RecordsPayload as crate::Encode>::encoded_len(&p, 0) == bytes.len());
530        let mut cur: &[u8] = &buf;
531        let back = <RecordsPayload as crate::Decode>::decode(&mut cur, 0).unwrap();
532        assert!(matches!(back, RecordsPayload::Legacy(_)));
533        assert!(back.as_legacy().unwrap() == &bytes);
534    }
535
536    #[test]
537    fn owned_default_is_empty_v2() {
538        let p = RecordsPayload::default();
539        assert!(matches!(p, RecordsPayload::V2(ref v) if v.is_empty()));
540    }
541
542    #[test]
543    fn looks_like_v2_rejects_short_buffer() {
544        // Too short to peek the magic byte at offset 16; must fall through to Legacy.
545        let short = Bytes::from_static(&[0u8; 10]);
546        let p = RecordsPayload::from_bytes(short.clone()).unwrap();
547        assert!(p.as_legacy() == Some(&short));
548    }
549
550    #[test]
551    fn borrowed_legacy_roundtrip() {
552        let bytes = legacy_bytes();
553        let p = RecordsPayloadBorrowed::from_slice(&bytes).unwrap();
554        assert!(matches!(p, RecordsPayloadBorrowed::Legacy(_)));
555        assert!(p.payload_len() == bytes.len());
556
557        let mut out = BytesMut::new();
558        p.encode_to(&mut out).unwrap();
559        assert!(&out[..] == &bytes[..]);
560
561        let owned = p.to_owned().unwrap();
562        match owned {
563            RecordsPayload::Legacy(b) => assert!(&b[..] == &bytes[..]),
564            _ => panic!("expected Legacy"),
565        }
566    }
567
568    #[test]
569    fn borrowed_v2_payload_len_and_encode() {
570        let rb = sample_v2();
571        let mut buf = BytesMut::new();
572        rb.encode(&mut buf).unwrap();
573        let frozen = buf.freeze();
574        let p = RecordsPayloadBorrowed::from_slice(&frozen).unwrap();
575        assert!(p.payload_len() == frozen.len());
576
577        let mut out = BytesMut::new();
578        p.encode_to(&mut out).unwrap();
579        assert!(&out[..] == &frozen[..]);
580    }
581
582    #[test]
583    fn borrowed_encode_decode_via_traits() {
584        let rb = sample_v2();
585        let mut buf = BytesMut::new();
586        rb.encode(&mut buf).unwrap();
587        let frozen = buf.freeze();
588
589        let p = RecordsPayloadBorrowed::from_slice(&frozen).unwrap();
590        let mut out = BytesMut::new();
591        <RecordsPayloadBorrowed as crate::Encode>::encode(&p, &mut out, 0).unwrap();
592        assert!(<RecordsPayloadBorrowed as crate::Encode>::encoded_len(&p, 0) == frozen.len());
593
594        let mut cur: &[u8] = &out;
595        let back =
596            <RecordsPayloadBorrowed as crate::DecodeBorrow>::decode_borrow(&mut cur, 0).unwrap();
597        assert!(matches!(back, RecordsPayloadBorrowed::V2(_)));
598    }
599
600    #[test]
601    fn borrowed_default_is_empty_v2() {
602        let p = RecordsPayloadBorrowed::default();
603        assert!(matches!(p, RecordsPayloadBorrowed::V2(ref v) if v.is_empty()));
604    }
605
606    #[test]
607    fn from_fetch_bytes_drops_incomplete_trailing_batch() {
608        // Two complete batches followed by a truncated third (only a few
609        // bytes of its header). Kafka sends this when the partition byte
610        // budget cuts the final batch; the consumer must keep the two
611        // complete batches and drop the fragment.
612        let mut b0 = sample_v2();
613        b0.base_offset = 0;
614        let mut b1 = sample_v2();
615        b1.base_offset = 1;
616        let mut buf = BytesMut::new();
617        b0.encode(&mut buf).unwrap();
618        b1.encode(&mut buf).unwrap();
619        buf.extend_from_slice(&[0u8; 7]); // partial trailing batch header
620        let p = RecordsPayload::from_fetch_bytes(buf.freeze()).unwrap();
621        let batches = p.as_v2().expect("v2");
622        assert!(batches == &[b0, b1][..]);
623    }
624
625    #[test]
626    fn from_fetch_bytes_still_errors_on_corrupt_batch() {
627        // A complete-looking batch whose CRC is wrong must still error, even
628        // leniently — leniency only forgives truncation, not corruption.
629        let rb = sample_v2();
630        let mut buf = BytesMut::new();
631        rb.encode(&mut buf).unwrap();
632        let mut bytes = buf.to_vec();
633        // Corrupt a body byte after the header (HEADER_LEN = 61) to break CRC.
634        bytes[61] ^= 0xFF;
635        let err = RecordsPayload::from_fetch_bytes(Bytes::from(bytes)).unwrap_err();
636        assert!(matches!(err, RecordsError::CrcMismatch { .. }));
637    }
638
639    #[test]
640    fn from_fetch_bytes_legacy_passes_through() {
641        let bytes = legacy_bytes();
642        let p = RecordsPayload::from_fetch_bytes(bytes.clone()).unwrap();
643        assert!(p.as_legacy() == Some(&bytes));
644    }
645
646    #[test]
647    fn from_fetch_bytes_empty_is_empty_v2() {
648        // Empty bytes do not carry a magic byte, so looks_like_v2 returns false
649        // and from_fetch_bytes yields an empty Legacy payload (no panic, no error).
650        let p = RecordsPayload::from_fetch_bytes(Bytes::new()).unwrap();
651        assert!(matches!(p, RecordsPayload::Legacy(ref b) if b.is_empty()));
652    }
653}