Skip to main content

crabka_protocol/records/
owned.rs

1//! Owned `RecordBatch`, `Record`, and `RecordHeader` types.
2
3use bytes::{Buf, BufMut, Bytes, BytesMut};
4use zerocopy::FromBytes as _;
5
6use crate::primitives::varint::{
7    get_varint, get_varlong, put_varint, put_varlong, varint_len, varlong_len,
8};
9use crate::records::RecordsError;
10use crate::records::crc::{crc32c, crc32c_append};
11use crate::records::header::{Attributes, HEADER_LEN};
12
13#[derive(Debug, Clone, PartialEq, Eq, Default)]
14pub struct RecordHeader {
15    pub key: String,
16    pub value: Option<Bytes>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct Record {
21    pub attributes: i8,
22    pub timestamp_delta: i64,
23    pub offset_delta: i32,
24    pub key: Option<Bytes>,
25    pub value: Option<Bytes>,
26    pub headers: Vec<RecordHeader>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RecordBatch {
31    pub base_offset: i64,
32    pub partition_leader_epoch: i32,
33    pub attributes: Attributes,
34    pub last_offset_delta: i32,
35    pub base_timestamp: i64,
36    pub max_timestamp: i64,
37    pub producer_id: i64,
38    pub producer_epoch: i16,
39    pub base_sequence: i32,
40    pub records: Vec<Record>,
41}
42
43impl Default for RecordBatch {
44    fn default() -> Self {
45        Self {
46            base_offset: 0,
47            partition_leader_epoch: 0,
48            attributes: Attributes::default(),
49            last_offset_delta: 0,
50            base_timestamp: 0,
51            max_timestamp: 0,
52            producer_id: -1, // sentinel: non-idempotent
53            producer_epoch: -1,
54            base_sequence: -1,
55            records: Vec::new(),
56        }
57    }
58}
59
60impl Record {
61    /// Encode a single record (varlong length prefix + fields) into `buf`.
62    pub fn encode<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
63        let body_len = self.body_len();
64        put_varlong(
65            buf,
66            i64::try_from(body_len)
67                .map_err(|_| RecordsError::RecordParse("record body length overflow".into()))?,
68        );
69        self.encode_body(buf)
70    }
71
72    /// Predicted total length of this record on the wire (length-prefix + body).
73    pub fn encoded_len(&self) -> usize {
74        let body = self.body_len();
75        #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
76        let body_i64 = body as i64;
77        varlong_len(body_i64) + body
78    }
79
80    fn body_len(&self) -> usize {
81        let mut n = 1; // attributes (i8)
82        n += varlong_len(self.timestamp_delta);
83        n += varint_len(self.offset_delta);
84        n += match &self.key {
85            None => varint_len(-1),
86            Some(k) => varint_len(i32::try_from(k.len()).unwrap_or(i32::MAX)) + k.len(),
87        };
88        n += match &self.value {
89            None => varint_len(-1),
90            Some(v) => varint_len(i32::try_from(v.len()).unwrap_or(i32::MAX)) + v.len(),
91        };
92        n += varint_len(i32::try_from(self.headers.len()).unwrap_or(i32::MAX));
93        for h in &self.headers {
94            let key_bytes = h.key.as_bytes();
95            n += varint_len(i32::try_from(key_bytes.len()).unwrap_or(i32::MAX)) + key_bytes.len();
96            n += match &h.value {
97                None => varint_len(-1),
98                Some(v) => varint_len(i32::try_from(v.len()).unwrap_or(i32::MAX)) + v.len(),
99            };
100        }
101        n
102    }
103
104    fn encode_body<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
105        buf.put_i8(self.attributes);
106        put_varlong(buf, self.timestamp_delta);
107        put_varint(buf, self.offset_delta);
108        match &self.key {
109            None => put_varint(buf, -1),
110            Some(k) => {
111                put_varint(
112                    buf,
113                    i32::try_from(k.len()).map_err(|_| {
114                        RecordsError::RecordParse("record key length overflow".into())
115                    })?,
116                );
117                buf.put_slice(k);
118            }
119        }
120        match &self.value {
121            None => put_varint(buf, -1),
122            Some(v) => {
123                put_varint(
124                    buf,
125                    i32::try_from(v.len()).map_err(|_| {
126                        RecordsError::RecordParse("record value length overflow".into())
127                    })?,
128                );
129                buf.put_slice(v);
130            }
131        }
132        put_varint(
133            buf,
134            i32::try_from(self.headers.len())
135                .map_err(|_| RecordsError::RecordParse("record header count overflow".into()))?,
136        );
137        for h in &self.headers {
138            let key_bytes = h.key.as_bytes();
139            put_varint(
140                buf,
141                i32::try_from(key_bytes.len())
142                    .map_err(|_| RecordsError::RecordParse("header key length overflow".into()))?,
143            );
144            buf.put_slice(key_bytes);
145            match &h.value {
146                None => put_varint(buf, -1),
147                Some(v) => {
148                    put_varint(
149                        buf,
150                        i32::try_from(v.len()).map_err(|_| {
151                            RecordsError::RecordParse("header value length overflow".into())
152                        })?,
153                    );
154                    buf.put_slice(v);
155                }
156            }
157        }
158        Ok(())
159    }
160
161    /// Decode a single record. `buf` must be positioned at the record's
162    /// varlong length prefix.
163    pub fn decode<B: Buf>(buf: &mut B) -> Result<Self, RecordsError> {
164        let body_len = get_varlong(buf)
165            .map_err(|e| RecordsError::RecordParse(format!("record length: {e}")))?;
166        let body_len = usize::try_from(body_len).map_err(|_| {
167            RecordsError::RecordParse(format!("record length negative or too large: {body_len}"))
168        })?;
169        if buf.remaining() < body_len {
170            return Err(RecordsError::BodyTooShort {
171                needed: body_len - buf.remaining(),
172            });
173        }
174        // Restrict to body_len bytes so a malformed inner field doesn't run
175        // past the record boundary.
176        let mut body = buf.take(body_len);
177        let r = Self::decode_body(&mut body)?;
178        // Trailing bytes inside the record's claimed length — protocol corruption.
179        if body.has_remaining() {
180            return Err(RecordsError::RecordParse(format!(
181                "trailing bytes inside record (left={})",
182                body.remaining()
183            )));
184        }
185        Ok(r)
186    }
187
188    fn decode_body<B: Buf>(buf: &mut B) -> Result<Self, RecordsError> {
189        if buf.remaining() == 0 {
190            return Err(RecordsError::RecordParse("record body empty".into()));
191        }
192        let attributes = buf.get_i8();
193        let timestamp_delta = get_varlong(buf)
194            .map_err(|e| RecordsError::RecordParse(format!("timestamp_delta: {e}")))?;
195        let offset_delta =
196            get_varint(buf).map_err(|e| RecordsError::RecordParse(format!("offset_delta: {e}")))?;
197
198        let key = decode_nullable_bytes(buf, "key")?;
199        let value = decode_nullable_bytes(buf, "value")?;
200
201        let header_count =
202            get_varint(buf).map_err(|e| RecordsError::RecordParse(format!("header_count: {e}")))?;
203        if header_count < 0 {
204            return Err(RecordsError::RecordParse(format!(
205                "negative header count {header_count}"
206            )));
207        }
208        #[allow(clippy::cast_sign_loss)] // checked < 0 above
209        let header_count_usize = header_count as usize;
210        // Bound pre-allocation: a record header is at least 1 byte on the wire,
211        // so an honest `header_count` can never exceed the bytes left in the
212        // record body. Clamp the capacity hint to reject huge declared counts
213        // without affecting the loop (it stops at EOF anyway).
214        let mut headers = Vec::with_capacity(header_count_usize.min(buf.remaining()));
215        for i in 0..header_count {
216            headers.push(
217                decode_record_header(buf)
218                    .map_err(|e| RecordsError::RecordParse(format!("header[{i}]: {e}")))?,
219            );
220        }
221
222        Ok(Self {
223            attributes,
224            timestamp_delta,
225            offset_delta,
226            key,
227            value,
228            headers,
229        })
230    }
231}
232
233fn decode_nullable_bytes<B: Buf>(buf: &mut B, label: &str) -> Result<Option<Bytes>, RecordsError> {
234    let len =
235        get_varint(buf).map_err(|e| RecordsError::RecordParse(format!("{label} length: {e}")))?;
236    if len < 0 {
237        Ok(None)
238    } else {
239        #[allow(clippy::cast_sign_loss)] // checked < 0 above
240        let n = len as usize;
241        if buf.remaining() < n {
242            return Err(RecordsError::BodyTooShort {
243                needed: n - buf.remaining(),
244            });
245        }
246        let mut v = vec![0u8; n];
247        buf.copy_to_slice(&mut v);
248        Ok(Some(Bytes::from(v)))
249    }
250}
251
252fn decode_record_header<B: Buf>(buf: &mut B) -> Result<RecordHeader, String> {
253    let key_len = get_varint(buf).map_err(|e| format!("key length: {e}"))?;
254    if key_len < 0 {
255        return Err(format!("non-nullable key has negative length {key_len}"));
256    }
257    #[allow(clippy::cast_sign_loss)] // checked < 0 above
258    let n = key_len as usize;
259    if buf.remaining() < n {
260        return Err(format!("key truncated (need {} more)", n - buf.remaining()));
261    }
262    let mut kv = vec![0u8; n];
263    buf.copy_to_slice(&mut kv);
264    let key = String::from_utf8(kv).map_err(|e| format!("key utf-8: {e}"))?;
265
266    let value_len = get_varint(buf).map_err(|e| format!("value length: {e}"))?;
267    let value = if value_len < 0 {
268        None
269    } else {
270        #[allow(clippy::cast_sign_loss)] // checked < 0 above
271        let n = value_len as usize;
272        if buf.remaining() < n {
273            return Err(format!(
274                "value truncated (need {} more)",
275                n - buf.remaining()
276            ));
277        }
278        let mut vv = vec![0u8; n];
279        buf.copy_to_slice(&mut vv);
280        Some(Bytes::from(vv))
281    };
282
283    Ok(RecordHeader { key, value })
284}
285
286#[cfg(test)]
287mod record_tests {
288    use super::*;
289    use assert2::assert;
290    use bytes::BytesMut;
291
292    fn fixture_minimal_record() -> Record {
293        Record {
294            attributes: 0,
295            timestamp_delta: 0,
296            offset_delta: 0,
297            key: None,
298            value: None,
299            headers: vec![],
300        }
301    }
302
303    fn fixture_keyed_record() -> Record {
304        Record {
305            attributes: 0,
306            timestamp_delta: 17,
307            offset_delta: 2,
308            key: Some(Bytes::from_static(b"the-key")),
309            value: Some(Bytes::from_static(b"hello kafka")),
310            headers: vec![
311                RecordHeader {
312                    key: "trace-id".to_string(),
313                    value: Some(Bytes::from_static(b"abc")),
314                },
315                RecordHeader {
316                    key: "null-val".to_string(),
317                    value: None,
318                },
319            ],
320        }
321    }
322
323    fn fixture_large_payload_record() -> Record {
324        Record {
325            attributes: 0,
326            timestamp_delta: 1_000_000,
327            offset_delta: 999,
328            key: Some(Bytes::from(vec![b'k'; 128])),
329            value: Some(Bytes::from(vec![b'v'; 4096])),
330            headers: vec![],
331        }
332    }
333
334    macro_rules! roundtrip {
335        ($name:ident, $fixture:ident) => {
336            #[test]
337            fn $name() {
338                let r = $fixture();
339                let mut buf = BytesMut::new();
340                r.encode(&mut buf).unwrap();
341                assert!(buf.len() == r.encoded_len(), "predicted len mismatch");
342
343                let mut cur: &[u8] = &buf[..];
344                let decoded = Record::decode(&mut cur).unwrap();
345                assert!(decoded == r);
346                assert!(cur.is_empty(), "trailing bytes after decode");
347            }
348        };
349    }
350
351    roundtrip!(minimal, fixture_minimal_record);
352    roundtrip!(keyed_with_headers, fixture_keyed_record);
353    roundtrip!(large_payload, fixture_large_payload_record);
354
355    #[test]
356    fn decode_rejects_negative_header_count() {
357        let mut buf = BytesMut::new();
358        // body: attributes(1) + timestamp_delta(1) + offset_delta(1) + key=-1(1)
359        //       + value=-1(1) + headers=-1(1) = 6 bytes body
360        put_varlong(&mut buf, 6); // body length 6 bytes
361        buf.put_i8(0); // attributes
362        put_varlong(&mut buf, 0); // timestamp_delta = 0  (1 byte)
363        put_varint(&mut buf, 0); // offset_delta = 0     (1 byte)
364        put_varint(&mut buf, -1); // key len               (1 byte)
365        put_varint(&mut buf, -1); // value len             (1 byte)
366        put_varint(&mut buf, -1); // negative header count (1 byte)
367
368        let mut cur: &[u8] = &buf[..];
369        match Record::decode(&mut cur) {
370            Err(RecordsError::RecordParse(msg)) => {
371                assert!(msg.contains("negative header count"), "got: {msg}");
372            }
373            other => panic!("expected RecordParse, got {other:?}"),
374        }
375    }
376
377    #[test]
378    fn decode_huge_header_count_does_not_overallocate() {
379        // A record that declares ~1 billion headers but supplies none. The
380        // capacity hint must be clamped to the (tiny) body remaining, and the
381        // decode must fail cleanly on EOF rather than attempting a multi-GB
382        // allocation.
383        let mut inner = BytesMut::new();
384        inner.put_i8(0); // attributes
385        put_varlong(&mut inner, 0); // timestamp_delta
386        put_varint(&mut inner, 0); // offset_delta
387        put_varint(&mut inner, -1); // key len = null
388        put_varint(&mut inner, -1); // value len = null
389        put_varint(&mut inner, 1_000_000_000); // absurd header count
390
391        let mut buf = BytesMut::new();
392        put_varlong(&mut buf, i64::try_from(inner.len()).unwrap());
393        buf.extend_from_slice(&inner);
394
395        let mut cur: &[u8] = &buf[..];
396        // Must return an Err (EOF reading the first header), not OOM.
397        assert!(Record::decode(&mut cur).is_err());
398    }
399}
400
401impl RecordBatch {
402    /// KIP-534 delete horizon, if the delete-horizon attribute bit is set.
403    /// `base_timestamp` is repurposed to carry it (no separate wire field).
404    #[must_use]
405    pub fn delete_horizon_ms(&self) -> Option<i64> {
406        self.attributes
407            .has_delete_horizon()
408            .then_some(self.base_timestamp)
409    }
410
411    /// Stamp the delete horizon: set bit 6, move the horizon into
412    /// `base_timestamp`, and rewrite every record's `timestamp_delta` so
413    /// reconstructed absolute timestamps (`base_timestamp + delta`) are
414    /// unchanged.
415    #[must_use]
416    pub fn with_delete_horizon(mut self, horizon_ms: i64) -> Self {
417        let old_base = self.base_timestamp;
418        for r in &mut self.records {
419            // Reconstruct the original absolute timestamp, then re-base it onto
420            // the new `base_timestamp`. Deltas are `i64`, so absolute timestamps
421            // round-trip exactly across re-basing; `saturating_*` only guards
422            // pathological inputs from panicking.
423            let abs = old_base.saturating_add(r.timestamp_delta);
424            r.timestamp_delta = abs.saturating_sub(horizon_ms);
425        }
426        self.base_timestamp = horizon_ms;
427        self.attributes = self.attributes.with_delete_horizon(true);
428        self
429    }
430
431    /// Decode a complete v2 record batch from `buf`. Reads from the start of
432    /// the header.
433    pub fn decode<B: Buf>(buf: &mut B) -> Result<Self, RecordsError> {
434        // batch_length field semantics: bytes after itself.
435        // Header tail = partition_leader_epoch(4) + magic(1) + crc(4) +
436        //   attributes(2) + last_offset_delta(4) + base_timestamp(8) +
437        //   max_timestamp(8) + producer_id(8) + producer_epoch(2) +
438        //   base_sequence(4) + records_count(4) = 49 bytes.
439        const HEADER_TAIL_LEN: i32 = 49;
440
441        // Need the full header before doing anything.
442        if buf.remaining() < HEADER_LEN {
443            return Err(RecordsError::HeaderTooShort {
444                needed: HEADER_LEN - buf.remaining(),
445            });
446        }
447        // Copy out the header to a stack buffer so we can use zerocopy.
448        let mut hdr_bytes = [0u8; HEADER_LEN];
449        buf.copy_to_slice(&mut hdr_bytes);
450
451        let hdr = crate::records::header::RecordBatchHeader::ref_from_bytes(&hdr_bytes[..])
452            .map_err(|_| RecordsError::ZerocopyFailure)?;
453
454        if hdr.magic != 2 {
455            return Err(RecordsError::UnsupportedMagic { found: hdr.magic });
456        }
457
458        // body_len = batch_length - HEADER_TAIL_LEN
459        let body_len = i32::checked_sub(hdr.batch_length.get(), HEADER_TAIL_LEN)
460            .and_then(|n| usize::try_from(n).ok())
461            .ok_or_else(|| {
462                RecordsError::RecordParse("negative or oversized batch_length".into())
463            })?;
464
465        if buf.remaining() < body_len {
466            return Err(RecordsError::BodyTooShort {
467                needed: body_len - buf.remaining(),
468            });
469        }
470
471        // Read the (possibly compressed) body.
472        let mut body = vec![0u8; body_len];
473        buf.copy_to_slice(&mut body);
474
475        // CRC is computed over: header bytes 21..HEADER_LEN (attributes through
476        // records_count), then the body bytes.
477        let expected_crc = hdr.crc.get();
478        let mut computed = crc32c(&hdr_bytes[21..HEADER_LEN]);
479        computed = crc32c_append(computed, &body);
480        if computed != expected_crc {
481            return Err(RecordsError::CrcMismatch {
482                expected: expected_crc,
483                computed,
484            });
485        }
486
487        let attributes = Attributes(hdr.attributes.get());
488        let codec = attributes.compression();
489
490        // Decompress body if needed.
491        let body_for_records: Bytes = if codec == crabka_compression::CompressionType::None {
492            Bytes::from(body)
493        } else {
494            // Bound decompressed output: generous vs. legit ratios, but finite.
495            // A small compressed batch must not be able to expand to gigabytes
496            // and OOM the broker (decompression bomb).
497            const DECOMPRESS_MIN_CAP: usize = 16 * 1024 * 1024; // 16 MiB floor (small inputs)
498            const DECOMPRESS_MAX_RATIO: usize = 100; // ≤100x the compressed size
499            const DECOMPRESS_ABSOLUTE_CEILING: usize = 1024 * 1024 * 1024; // 1 GiB hard ceiling
500            let max_output = body
501                .len()
502                .saturating_mul(DECOMPRESS_MAX_RATIO)
503                .clamp(DECOMPRESS_MIN_CAP, DECOMPRESS_ABSOLUTE_CEILING);
504            crabka_compression::decompress(codec, &body, max_output)?
505        };
506
507        // Parse records.
508        let count = hdr.records_count.get();
509        if count < 0 {
510            return Err(RecordsError::RecordParse(format!(
511                "negative records_count {count}"
512            )));
513        }
514        let mut body_cur: &[u8] = &body_for_records[..];
515        // Bound pre-allocation: each record is at least 1 byte in the
516        // decompressed body, so an honest `records_count` can never exceed the
517        // body length. Clamp the capacity hint to reject huge declared counts.
518        #[allow(clippy::cast_sign_loss)] // checked < 0 above
519        let mut records = Vec::with_capacity((count as usize).min(body_for_records.len()));
520        for i in 0..count {
521            records.push(
522                Record::decode(&mut body_cur)
523                    .map_err(|e| RecordsError::RecordParse(format!("record[{i}]: {e}")))?,
524            );
525        }
526        if !body_cur.is_empty() {
527            return Err(RecordsError::RecordParse(format!(
528                "trailing bytes after records (left={})",
529                body_cur.len()
530            )));
531        }
532
533        Ok(Self {
534            base_offset: hdr.base_offset.get(),
535            partition_leader_epoch: hdr.partition_leader_epoch.get(),
536            attributes,
537            last_offset_delta: hdr.last_offset_delta.get(),
538            base_timestamp: hdr.base_timestamp.get(),
539            max_timestamp: hdr.max_timestamp.get(),
540            producer_id: hdr.producer_id.get(),
541            producer_epoch: hdr.producer_epoch.get(),
542            base_sequence: hdr.base_sequence.get(),
543            records,
544        })
545    }
546
547    /// Encode this batch into `buf`.
548    pub fn encode<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
549        const HEADER_TAIL_LEN: i32 = 49;
550
551        // 1. Encode records into a temporary buffer.
552        let mut raw_body =
553            BytesMut::with_capacity(self.records.iter().map(Record::encoded_len).sum());
554        for r in &self.records {
555            r.encode(&mut raw_body)?;
556        }
557        let raw_body = raw_body.freeze();
558
559        // 2. Compress if needed.
560        let codec = self.attributes.compression();
561        let body: Bytes = if codec == crabka_compression::CompressionType::None {
562            raw_body
563        } else {
564            crabka_compression::compress(codec, &raw_body)?
565        };
566
567        // 3. batch_length = HEADER_TAIL_LEN + body_len
568        let batch_length = HEADER_TAIL_LEN
569            + i32::try_from(body.len())
570                .map_err(|_| RecordsError::RecordParse("body length exceeds i32".into()))?;
571
572        // 4. Build the CRC-covered header portion (attributes through records_count = 40 bytes).
573        let mut covered = BytesMut::with_capacity(40);
574        covered.put_i16(self.attributes.0);
575        covered.put_i32(self.last_offset_delta);
576        covered.put_i64(self.base_timestamp);
577        covered.put_i64(self.max_timestamp);
578        covered.put_i64(self.producer_id);
579        covered.put_i16(self.producer_epoch);
580        covered.put_i32(self.base_sequence);
581        covered.put_i32(
582            i32::try_from(self.records.len())
583                .map_err(|_| RecordsError::RecordParse("records_count exceeds i32".into()))?,
584        );
585        let covered_head = covered.freeze();
586
587        // 5. Compute CRC over covered_head then body.
588        let mut crc = crc32c(&covered_head);
589        crc = crc32c_append(crc, &body);
590
591        // 6. Emit the full header then body.
592        buf.put_i64(self.base_offset);
593        buf.put_i32(batch_length);
594        buf.put_i32(self.partition_leader_epoch);
595        buf.put_i8(2); // magic v2
596        buf.put_u32(crc);
597        buf.put_slice(&covered_head);
598        buf.put_slice(&body);
599        Ok(())
600    }
601
602    /// Predicted total bytes that `encode` will write (uncompressed; for
603    /// compressed batches the actual size will differ).
604    pub fn encoded_len(&self) -> usize {
605        let body: usize = self.records.iter().map(Record::encoded_len).sum();
606        HEADER_LEN + body
607    }
608}
609
610#[cfg(test)]
611mod batch_tests {
612    use super::*;
613    use assert2::assert;
614    use crabka_compression::CompressionType;
615
616    fn fixture_empty_batch() -> RecordBatch {
617        RecordBatch::default()
618    }
619
620    fn fixture_single_record_batch() -> RecordBatch {
621        RecordBatch {
622            records: vec![Record {
623                key: Some(Bytes::from_static(b"k1")),
624                value: Some(Bytes::from_static(b"v1")),
625                ..Default::default()
626            }],
627            ..RecordBatch::default()
628        }
629    }
630
631    fn fixture_multi_record_batch() -> RecordBatch {
632        RecordBatch {
633            base_offset: 42,
634            partition_leader_epoch: 5,
635            last_offset_delta: 2,
636            base_timestamp: 1_700_000_000,
637            max_timestamp: 1_700_000_500,
638            producer_id: 100,
639            producer_epoch: 3,
640            base_sequence: 7,
641            records: vec![
642                Record {
643                    offset_delta: 0,
644                    timestamp_delta: 0,
645                    key: Some(Bytes::from_static(b"a")),
646                    value: Some(Bytes::from_static(b"1")),
647                    ..Default::default()
648                },
649                Record {
650                    offset_delta: 1,
651                    timestamp_delta: 100,
652                    key: Some(Bytes::from_static(b"b")),
653                    value: Some(Bytes::from_static(b"2")),
654                    ..Default::default()
655                },
656                Record {
657                    offset_delta: 2,
658                    timestamp_delta: 500,
659                    key: None,
660                    value: Some(Bytes::from_static(b"3")),
661                    headers: vec![RecordHeader {
662                        key: "h".to_string(),
663                        value: Some(Bytes::from_static(b"hv")),
664                    }],
665                    ..Default::default()
666                },
667            ],
668            ..RecordBatch::default()
669        }
670    }
671
672    macro_rules! roundtrip_uncompressed {
673        ($name:ident, $fixture:ident) => {
674            #[test]
675            fn $name() {
676                let mut b = $fixture();
677                b.attributes = b.attributes.with_compression(CompressionType::None);
678
679                let mut buf = BytesMut::new();
680                b.encode(&mut buf).unwrap();
681                assert!(buf.len() == b.encoded_len());
682
683                let mut cur: &[u8] = &buf[..];
684                let decoded = RecordBatch::decode(&mut cur).unwrap();
685                assert!(decoded == b);
686                assert!(cur.is_empty());
687            }
688        };
689    }
690
691    roundtrip_uncompressed!(uncompressed_empty, fixture_empty_batch);
692    roundtrip_uncompressed!(uncompressed_single, fixture_single_record_batch);
693    roundtrip_uncompressed!(uncompressed_multi, fixture_multi_record_batch);
694
695    #[test]
696    fn rejects_pre_v2_magic() {
697        let mut buf = BytesMut::new();
698        buf.put_i64(0); // base_offset
699        buf.put_i32(49); // batch_length
700        buf.put_i32(0); // partition_leader_epoch
701        buf.put_i8(1); // magic = 1 (v1, deprecated)
702        buf.put_u32(0); // crc (irrelevant; we reject on magic first)
703        for _ in 21..HEADER_LEN {
704            buf.put_u8(0);
705        }
706        let mut cur: &[u8] = &buf[..];
707        assert!(matches!(
708            RecordBatch::decode(&mut cur),
709            Err(RecordsError::UnsupportedMagic { found: 1 })
710        ));
711    }
712
713    #[test]
714    fn rejects_bad_crc() {
715        let b = fixture_single_record_batch();
716        let mut buf = BytesMut::new();
717        b.encode(&mut buf).unwrap();
718        // Corrupt the CRC bytes (offsets 17..21).
719        buf[17] ^= 0xFF;
720        let mut cur: &[u8] = &buf[..];
721        assert!(matches!(
722            RecordBatch::decode(&mut cur),
723            Err(RecordsError::CrcMismatch { .. })
724        ));
725    }
726
727    macro_rules! roundtrip_compressed {
728        ($name:ident, $codec:expr) => {
729            #[test]
730            fn $name() {
731                let mut b = fixture_multi_record_batch();
732                b.attributes = b.attributes.with_compression($codec);
733
734                let mut buf = BytesMut::new();
735                b.encode(&mut buf).unwrap();
736                let mut cur: &[u8] = &buf[..];
737                let decoded = RecordBatch::decode(&mut cur).unwrap();
738                assert!(decoded == b);
739                assert!(cur.is_empty());
740            }
741        };
742    }
743
744    roundtrip_compressed!(compressed_gzip, CompressionType::Gzip);
745    roundtrip_compressed!(compressed_snappy, CompressionType::Snappy);
746    roundtrip_compressed!(compressed_lz4, CompressionType::Lz4);
747    roundtrip_compressed!(compressed_zstd, CompressionType::Zstd);
748
749    #[test]
750    fn with_delete_horizon_stamps_and_preserves_record_timestamps() {
751        // base 1000, two records at deltas [0, 5] → absolutes [1000, 1005].
752        let b = RecordBatch {
753            base_timestamp: 1000,
754            records: vec![
755                Record {
756                    timestamp_delta: 0,
757                    ..Default::default()
758                },
759                Record {
760                    timestamp_delta: 5,
761                    ..Default::default()
762                },
763            ],
764            ..RecordBatch::default()
765        };
766
767        let stamped = b.with_delete_horizon(9999);
768
769        assert!(stamped.attributes.has_delete_horizon());
770        assert!(stamped.base_timestamp == 9999);
771        assert!(stamped.delete_horizon_ms() == Some(9999));
772
773        // Reconstructed absolutes (base + delta) must equal the ORIGINALS.
774        let absolutes: Vec<i64> = stamped
775            .records
776            .iter()
777            .map(|r| stamped.base_timestamp + r.timestamp_delta)
778            .collect();
779        assert!(absolutes == vec![1000, 1005]);
780    }
781
782    #[test]
783    fn delete_horizon_round_trips_through_encode_decode() {
784        // base 1000, two keyed records at deltas [0, 5] → absolutes [1000, 1005].
785        let b = RecordBatch {
786            base_timestamp: 1000,
787            last_offset_delta: 1,
788            records: vec![
789                Record {
790                    timestamp_delta: 0,
791                    offset_delta: 0,
792                    key: Some(Bytes::from_static(b"k1")),
793                    value: Some(Bytes::from_static(b"v1")),
794                    ..Default::default()
795                },
796                Record {
797                    timestamp_delta: 5,
798                    offset_delta: 1,
799                    key: Some(Bytes::from_static(b"k2")),
800                    value: Some(Bytes::from_static(b"v2")),
801                    ..Default::default()
802                },
803            ],
804            ..RecordBatch::default()
805        }
806        .with_delete_horizon(9999);
807
808        let mut buf = BytesMut::new();
809        b.encode(&mut buf).unwrap();
810
811        let mut cur: &[u8] = &buf[..];
812        let decoded = RecordBatch::decode(&mut cur).unwrap();
813        assert!(cur.is_empty());
814
815        assert!(decoded.delete_horizon_ms() == Some(9999));
816
817        let absolutes: Vec<i64> = decoded
818            .records
819            .iter()
820            .map(|r| decoded.base_timestamp + r.timestamp_delta)
821            .collect();
822        assert!(absolutes == vec![1000, 1005]);
823    }
824
825    #[test]
826    fn decode_huge_records_count_does_not_overallocate() {
827        // Encode an empty uncompressed batch, then overwrite records_count with
828        // an absurd value (~1 billion) and fix up the CRC. The capacity hint
829        // must be clamped to the (empty) decompressed body, and decode must
830        // fail cleanly on EOF rather than attempting a multi-GB allocation.
831        let mut b = fixture_empty_batch();
832        b.attributes = b.attributes.with_compression(CompressionType::None);
833        let mut buf = BytesMut::new();
834        b.encode(&mut buf).unwrap();
835
836        // records_count is the last 4 bytes of the fixed header.
837        let rc_off = HEADER_LEN - 4;
838        buf[rc_off..HEADER_LEN].copy_from_slice(&1_000_000_000i32.to_be_bytes());
839
840        // Recompute CRC over header[21..HEADER_LEN] + body (body is empty here).
841        let body = &buf[HEADER_LEN..];
842        let mut computed = crc32c(&buf[21..HEADER_LEN]);
843        computed = crc32c_append(computed, body);
844        buf[17..21].copy_from_slice(&computed.to_be_bytes());
845
846        let mut cur: &[u8] = &buf[..];
847        // Must return an Err (EOF reading the first record), not OOM.
848        assert!(RecordBatch::decode(&mut cur).is_err());
849    }
850}
851
852impl crate::Encode for RecordBatch {
853    fn encode<B: BufMut>(&self, buf: &mut B, _version: i16) -> Result<(), crate::ProtocolError> {
854        RecordBatch::encode(self, buf).map_err(Into::into)
855    }
856
857    fn encoded_len(&self, _version: i16) -> usize {
858        RecordBatch::encoded_len(self)
859    }
860}
861
862impl crate::Decode<'_> for RecordBatch {
863    fn decode<B: Buf>(buf: &mut B, _version: i16) -> Result<Self, crate::ProtocolError> {
864        RecordBatch::decode(buf).map_err(Into::into)
865    }
866}