Skip to main content

krabka_protocol/records/
owned.rs

1//! Owned `RecordBatch`, `Record`, and `RecordHeader` types.
2
3use bytes::{Buf, BufMut, Bytes, BytesMut};
4use krabka_compression::RecordDecompressionPolicy;
5use krabka_units::{ByteSize, convert::ByteSizeExt as _};
6use zerocopy::FromBytes as _;
7
8use crate::{
9    primitives::varint::{
10        get_varint, get_varlong, put_varint, put_varlong, varint_len, varlong_len,
11    },
12    records::{
13        RecordsError,
14        crc::{crc32c, crc32c_append},
15        header::{Attributes, HEADER_LEN},
16    },
17};
18
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub struct RecordHeader {
21    pub key: String,
22    pub value: Option<Bytes>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Default)]
26pub struct Record {
27    pub attributes: i8,
28    pub timestamp_delta: i64,
29    pub offset_delta: i32,
30    pub key: Option<Bytes>,
31    pub value: Option<Bytes>,
32    pub headers: Vec<RecordHeader>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct RecordBatch {
37    pub base_offset: i64,
38    pub partition_leader_epoch: i32,
39    pub attributes: Attributes,
40    pub last_offset_delta: i32,
41    pub base_timestamp: i64,
42    pub max_timestamp: i64,
43    pub producer_id: i64,
44    pub producer_epoch: i16,
45    pub base_sequence: i32,
46    pub records: Vec<Record>,
47}
48
49impl Default for RecordBatch {
50    fn default() -> Self {
51        Self {
52            base_offset: 0,
53            partition_leader_epoch: 0,
54            attributes: Attributes::default(),
55            last_offset_delta: 0,
56            base_timestamp: 0,
57            max_timestamp: 0,
58            producer_id: -1, // sentinel: non-idempotent
59            producer_epoch: -1,
60            base_sequence: -1,
61            records: Vec::new(),
62        }
63    }
64}
65
66impl Record {
67    /// Encode a single record into `buf` as a varlong length prefix and fields.
68    ///
69    /// # Errors
70    ///
71    /// Returns the underlying protocol error when input is truncated, contains an invalid length or tag, or cannot be encoded for the selected version.
72    pub fn encode<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
73        let body_len = self.body_len();
74        put_varlong(
75            buf,
76            i64::try_from(body_len)
77                .map_err(|_| RecordsError::RecordParse("record body length overflow".into()))?,
78        );
79        self.encode_body(buf)
80    }
81
82    /// Predicted total length of this record on the wire, length-prefix plus body.
83    ///
84    /// # Panics
85    ///
86    /// Panics if a value previously validated by the protocol type no longer satisfies its encoded-length or field-range invariant.
87    pub fn encoded_len(&self) -> usize {
88        let body = self.body_len();
89        let body_i64 = i64::try_from(body).expect("record body length must fit in i64");
90        varlong_len(body_i64) + body
91    }
92
93    /// Size of this record's body in bytes: what Kafka's `DefaultRecord` calls
94    /// `sizeOfBodyInBytes`, and what its varlong length prefix declares.
95    ///
96    /// The body is the attributes byte, the two deltas, and the key, the value
97    /// and the headers, each behind its own length prefix. A null key, value or
98    /// header value counts as the one-byte `-1` marker. The length prefix
99    /// itself is not counted: [`Record::encoded_len`] is the prefix and this.
100    #[must_use]
101    pub fn body_len(&self) -> usize {
102        body_len_of(
103            self.timestamp_delta,
104            self.offset_delta,
105            self.key.as_ref().map(Bytes::len),
106            self.value.as_ref().map(Bytes::len),
107            self.headers
108                .iter()
109                .map(|h| (h.key.len(), h.value.as_ref().map(Bytes::len))),
110        )
111    }
112
113    fn encode_body<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
114        // Metadata needs at most 21 bytes. Include short keys in the same write.
115        const SMALL_KEY_LEN: usize = 32;
116        const PREFIX_LEN: usize = 21 + SMALL_KEY_LEN;
117        let mut prefix = [0_u8; PREFIX_LEN];
118        let mut fields = prefix.as_mut_slice();
119        fields.put_i8(self.attributes);
120        put_varlong(&mut fields, self.timestamp_delta);
121        put_varint(&mut fields, self.offset_delta);
122        match &self.key {
123            None => put_varint(&mut fields, -1),
124            Some(k) => {
125                let Ok(len) = i32::try_from(k.len()) else {
126                    // Preserve the fields already written before an invalid key.
127                    let used = PREFIX_LEN - fields.len();
128                    buf.put_slice(&prefix[..used]);
129                    return Err(RecordsError::RecordParse(
130                        "record key length overflow".into(),
131                    ));
132                };
133                put_varint(&mut fields, len);
134            }
135        }
136        if let Some(key) = &self.key
137            && key.len() <= SMALL_KEY_LEN
138        {
139            fields.put_slice(key);
140        }
141        let used = PREFIX_LEN - fields.len();
142        buf.put_slice(&prefix[..used]);
143        if let Some(key) = &self.key
144            && key.len() > SMALL_KEY_LEN
145        {
146            buf.put_slice(key);
147        }
148        match &self.value {
149            None => put_varint(buf, -1),
150            Some(v) => {
151                put_varint(
152                    buf,
153                    i32::try_from(v.len()).map_err(|_| {
154                        RecordsError::RecordParse("record value length overflow".into())
155                    })?,
156                );
157                buf.put_slice(v);
158            }
159        }
160        put_varint(
161            buf,
162            i32::try_from(self.headers.len())
163                .map_err(|_| RecordsError::RecordParse("record header count overflow".into()))?,
164        );
165        for h in &self.headers {
166            let key_bytes = h.key.as_bytes();
167            put_varint(
168                buf,
169                i32::try_from(key_bytes.len())
170                    .map_err(|_| RecordsError::RecordParse("header key length overflow".into()))?,
171            );
172            buf.put_slice(key_bytes);
173            match &h.value {
174                None => put_varint(buf, -1),
175                Some(v) => {
176                    put_varint(
177                        buf,
178                        i32::try_from(v.len()).map_err(|_| {
179                            RecordsError::RecordParse("header value length overflow".into())
180                        })?,
181                    );
182                    buf.put_slice(v);
183                }
184            }
185        }
186        Ok(())
187    }
188
189    /// Decode a single record.
190    ///
191    /// `buf` must be positioned at the record's varlong length prefix.
192    ///
193    /// # Errors
194    ///
195    /// Returns the underlying protocol error when input is truncated, contains an invalid length or tag, or cannot be encoded for the selected version.
196    pub fn decode<B: Buf>(buf: &mut B) -> Result<Self, RecordsError> {
197        let body_len = get_varlong(buf)
198            .map_err(|e| RecordsError::RecordParse(format!("record length: {e}")))?;
199        let body_len = usize::try_from(body_len).map_err(|_| {
200            RecordsError::RecordParse(format!("record length negative or too large: {body_len}"))
201        })?;
202        if buf.remaining() < body_len {
203            return Err(RecordsError::BodyTooShort {
204                needed: body_len - buf.remaining(),
205            });
206        }
207        // Restrict to body_len bytes so a malformed inner field doesn't run
208        // past the record boundary.
209        let mut body = buf.take(body_len);
210        let r = Self::decode_body(&mut body)?;
211        // Trailing bytes inside the record's claimed length — protocol corruption.
212        if body.has_remaining() {
213            return Err(RecordsError::RecordParse(format!(
214                "trailing bytes inside record (left={})",
215                body.remaining()
216            )));
217        }
218        Ok(r)
219    }
220
221    fn decode_body<B: Buf>(buf: &mut B) -> Result<Self, RecordsError> {
222        if buf.remaining() == 0 {
223            return Err(RecordsError::RecordParse("record body empty".into()));
224        }
225        let attributes = buf.get_i8();
226        let timestamp_delta = get_varlong(buf)
227            .map_err(|e| RecordsError::RecordParse(format!("timestamp_delta: {e}")))?;
228        let offset_delta =
229            get_varint(buf).map_err(|e| RecordsError::RecordParse(format!("offset_delta: {e}")))?;
230
231        let key = decode_nullable_bytes(buf, "key")?;
232        let value = decode_nullable_bytes(buf, "value")?;
233
234        let header_count =
235            get_varint(buf).map_err(|e| RecordsError::RecordParse(format!("header_count: {e}")))?;
236        if header_count < 0 {
237            return Err(RecordsError::RecordParse(format!(
238                "negative header count {header_count}"
239            )));
240        }
241        let header_count_usize =
242            usize::try_from(header_count).expect("non-negative header count must fit in usize");
243        // Bound pre-allocation: a record header is at least 1 byte on the wire,
244        // so an honest `header_count` can never exceed the bytes left in the
245        // record body. Clamp the capacity hint to reject huge declared counts
246        // without affecting the loop (it stops at EOF anyway).
247        let mut headers = Vec::with_capacity(header_count_usize.min(buf.remaining()));
248        for i in 0..header_count {
249            headers.push(
250                decode_record_header(buf)
251                    .map_err(|e| RecordsError::RecordParse(format!("header[{i}]: {e}")))?,
252            );
253        }
254
255        Ok(Self {
256            attributes,
257            timestamp_delta,
258            offset_delta,
259            key,
260            value,
261            headers,
262        })
263    }
264}
265
266/// The body size of a record with these field lengths, shared by the owned and
267/// the borrowed `Record::body_len`. `key` and `value` are `None` for null, and
268/// `headers` yields each header's key length and optional value length.
269pub(crate) fn body_len_of(
270    timestamp_delta: i64,
271    offset_delta: i32,
272    key: Option<usize>,
273    value: Option<usize>,
274    headers: impl ExactSizeIterator<Item = (usize, Option<usize>)>,
275) -> usize {
276    let field = |len: Option<usize>| match len {
277        None => varint_len(-1),
278        Some(len) => varint_len(i32::try_from(len).unwrap_or(i32::MAX)) + len,
279    };
280    let header_count = varint_len(i32::try_from(headers.len()).unwrap_or(i32::MAX));
281    let headers: usize = headers
282        .map(|(key, value)| field(Some(key)) + field(value))
283        .sum();
284    1 // attributes (i8)
285        + varlong_len(timestamp_delta)
286        + varint_len(offset_delta)
287        + field(key)
288        + field(value)
289        + header_count
290        + headers
291}
292
293fn decode_nullable_bytes<B: Buf>(buf: &mut B, label: &str) -> Result<Option<Bytes>, RecordsError> {
294    let len =
295        get_varint(buf).map_err(|e| RecordsError::RecordParse(format!("{label} length: {e}")))?;
296    if len < 0 {
297        Ok(None)
298    } else {
299        let n = usize::try_from(len).expect("non-negative value length must fit in usize");
300        if buf.remaining() < n {
301            return Err(RecordsError::BodyTooShort {
302                needed: n - buf.remaining(),
303            });
304        }
305        let mut v = vec![0u8; n];
306        buf.copy_to_slice(&mut v);
307        Ok(Some(Bytes::from(v)))
308    }
309}
310
311fn decode_record_header<B: Buf>(buf: &mut B) -> Result<RecordHeader, String> {
312    let key_len = get_varint(buf).map_err(|e| format!("key length: {e}"))?;
313    if key_len < 0 {
314        return Err(format!("non-nullable key has negative length {key_len}"));
315    }
316    let n = usize::try_from(key_len).expect("non-negative key length must fit in usize");
317    if buf.remaining() < n {
318        return Err(format!("key truncated (need {} more)", n - buf.remaining()));
319    }
320    let mut kv = vec![0u8; n];
321    buf.copy_to_slice(&mut kv);
322    let key = String::from_utf8(kv).map_err(|e| format!("key utf-8: {e}"))?;
323
324    let value_len = get_varint(buf).map_err(|e| format!("value length: {e}"))?;
325    let value = if value_len < 0 {
326        None
327    } else {
328        let n = usize::try_from(value_len).expect("non-negative value length must fit in usize");
329        if buf.remaining() < n {
330            return Err(format!(
331                "value truncated (need {} more)",
332                n - buf.remaining()
333            ));
334        }
335        let mut vv = vec![0u8; n];
336        buf.copy_to_slice(&mut vv);
337        Some(Bytes::from(vv))
338    };
339
340    Ok(RecordHeader { key, value })
341}
342
343#[cfg(test)]
344mod record_tests {
345
346    use bytes::BytesMut;
347
348    use super::*;
349
350    fn fixture_minimal_record() -> Record {
351        Record {
352            attributes: 0,
353            timestamp_delta: 0,
354            offset_delta: 0,
355            key: None,
356            value: None,
357            headers: vec![],
358        }
359    }
360
361    fn fixture_keyed_record() -> Record {
362        Record {
363            attributes: 0,
364            timestamp_delta: 17,
365            offset_delta: 2,
366            key: Some(Bytes::from_static(b"the-key")),
367            value: Some(Bytes::from_static(b"hello kafka")),
368            headers: vec![
369                RecordHeader {
370                    key: "trace-id".to_string(),
371                    value: Some(Bytes::from_static(b"abc")),
372                },
373                RecordHeader {
374                    key: "null-val".to_string(),
375                    value: None,
376                },
377            ],
378        }
379    }
380
381    fn fixture_large_payload_record() -> Record {
382        Record {
383            attributes: 0,
384            timestamp_delta: 1_000_000,
385            offset_delta: 999,
386            key: Some(Bytes::from(vec![b'k'; 128])),
387            value: Some(Bytes::from(vec![b'v'; 4096])),
388            headers: vec![],
389        }
390    }
391
392    #[test]
393    fn record_prefix_golden_bytes_across_destination_splits() {
394        let mut cases = vec![
395            (Record::default(), vec![0x0c, 0, 0, 0, 1, 1, 0]),
396            (
397                Record {
398                    attributes: i8::MIN,
399                    timestamp_delta: i64::MIN,
400                    offset_delta: i32::MIN,
401                    key: Some(Bytes::from_static(b"k")),
402                    value: Some(Bytes::from_static(b"v")),
403                    headers: vec![],
404                },
405                vec![
406                    0x2a, 0x80, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01, 0xff,
407                    0xff, 0xff, 0xff, 0x0f, 0x02, b'k', 0x02, b'v', 0,
408                ],
409            ),
410            (
411                Record {
412                    offset_delta: 129,
413                    key: Some(Bytes::from_static(b"k")),
414                    value: Some(Bytes::from_static(b"v")),
415                    ..Default::default()
416                },
417                vec![0x12, 0, 0, 0x82, 0x02, 0x02, b'k', 0x02, b'v', 0],
418            ),
419        ];
420        // Null and empty keys differ; 32/33 cover the short-key boundary,
421        // and 63/64 cross a zigzag length-prefix boundary.
422        for (len, length_prefix, key_prefix) in [
423            (0, vec![12], vec![0]),
424            (32, vec![76], vec![64]),
425            (33, vec![78], vec![66]),
426            (63, vec![138, 1], vec![126]),
427            (64, vec![142, 1], vec![128, 1]),
428        ] {
429            let mut expected = length_prefix;
430            expected.extend_from_slice(&[0, 0, 0]);
431            expected.extend(key_prefix);
432            expected.extend(std::iter::repeat_n(b'k', len));
433            expected.extend_from_slice(&[1, 0]);
434            cases.push((
435                Record {
436                    key: Some(Bytes::from(vec![b'k'; len])),
437                    ..Default::default()
438                },
439                expected,
440            ));
441        }
442        for (record, expected) in cases {
443            for split in 0..=expected.len() {
444                let mut encoded = vec![0xcd; expected.len()];
445                let (first, second) = encoded.split_at_mut(split);
446                let mut destination = first.chain_mut(second);
447                record.encode(&mut destination).unwrap();
448                assert2::assert!(destination.remaining_mut() == 0);
449                assert2::assert!(encoded == expected);
450                let mut cursor = encoded.as_slice();
451                assert2::assert!(Record::decode(&mut cursor).unwrap() == record);
452                assert2::assert!(cursor.is_empty());
453            }
454        }
455    }
456
457    #[test]
458    fn record_roundtrip_cases() {
459        type TestCase1<'a> = (&'a str, fn() -> Record);
460        let cases: [TestCase1<'_>; 3] = [
461            ("minimal", fixture_minimal_record),
462            ("keyed with headers", fixture_keyed_record),
463            ("large payload", fixture_large_payload_record),
464        ];
465        for (_case, fixture) in cases {
466            let record = fixture();
467            let mut buf = BytesMut::new();
468            record.encode(&mut buf).unwrap();
469            assert2::assert!(buf.len() == record.encoded_len());
470
471            let mut cur: &[u8] = &buf[..];
472            let decoded = Record::decode(&mut cur).unwrap();
473            assert2::assert!((decoded, cur.is_empty()) == (record, true));
474        }
475    }
476
477    /// A record with zero deltas, a null key, no headers and a `value_len`-byte
478    /// value has a body of `1 + 1 + 1 + 1 + prefix + value_len + 1` bytes: the
479    /// attributes byte, two one-byte deltas, the one-byte `-1` key, the value
480    /// behind its own zigzag-varint length prefix, and a one-byte header count.
481    /// The record's length prefix is a zigzag varlong too, so it grows with the
482    /// body: one byte up to 63, two bytes from 64 to 8191, three from 8192. The
483    /// value's length prefix grows the same way, one byte up to 63, two from 64
484    /// to 8191 and three from 8192.
485    #[test]
486    fn body_len_is_the_size_the_length_prefix_declares() {
487        for (value_len, body, prefix) in [
488            (0, 6, 1),
489            (57, 63, 1),
490            (58, 64, 2),
491            (63, 69, 2),
492            (64, 71, 2),
493            (8_184, 8_191, 2),
494            (8_185, 8_192, 3),
495            (8_192, 8_200, 3),
496            (70_000, 70_008, 3),
497        ] {
498            let record = Record {
499                value: Some(Bytes::from(vec![7_u8; value_len])),
500                ..Default::default()
501            };
502            let mut buf = BytesMut::new();
503            record.encode(&mut buf).unwrap();
504
505            let mut cur: &[u8] = &buf[..];
506            let declared = get_varlong(&mut cur).unwrap();
507            let case = format!("value_len={value_len}");
508            assert2::check!(record.body_len() == body, "{case}");
509            assert2::check!(record.encoded_len() == prefix + body, "{case}");
510            assert2::check!(buf.len() == prefix + body, "{case}");
511            assert2::check!(declared == i64::try_from(body).unwrap(), "{case}");
512            assert2::check!(buf.len() - cur.len() == prefix, "{case}");
513        }
514    }
515
516    /// Every field counts toward the body, and a null key, value or header
517    /// value counts as its one-byte `-1` marker. The sizes are worked out by
518    /// hand from the fields, and match what `encode` declares.
519    #[test]
520    fn body_len_counts_every_field() {
521        type Fixture<'a> = (&'a str, fn() -> Record, usize);
522        let cases: [Fixture<'_>; 3] = [
523            // attributes, three one-byte deltas and markers, header count
524            ("minimal", fixture_minimal_record, 6),
525            // 1 attributes + 1 timestamp delta + 1 offset delta
526            // + (1 + 7) key + (1 + 11) value + 1 header count
527            // + (1 + 8) + (1 + 3) first header + (1 + 8) + 1 second header
528            ("keyed with headers", fixture_keyed_record, 47),
529            // 1 attributes + 3 timestamp delta + 2 offset delta
530            // + (2 + 128) key + (2 + 4096) value + 1 header count
531            ("large payload", fixture_large_payload_record, 4_235),
532        ];
533        for (case, fixture, body) in cases {
534            let record = fixture();
535            let mut buf = BytesMut::new();
536            record.encode(&mut buf).unwrap();
537            let declared = get_varlong(&mut &buf[..]).unwrap();
538            assert2::check!(record.body_len() == body, "{case}");
539            assert2::check!(declared == i64::try_from(body).unwrap(), "{case}");
540        }
541    }
542
543    #[test]
544    fn decode_rejects_negative_header_count() {
545        let mut buf = BytesMut::new();
546        // body: attributes(1) + timestamp_delta(1) + offset_delta(1) + key=-1(1)
547        //       + value=-1(1) + headers=-1(1) = 6 bytes body
548        put_varlong(&mut buf, 6); // body length 6 bytes
549        buf.put_i8(0); // attributes
550        put_varlong(&mut buf, 0); // timestamp_delta = 0  (1 byte)
551        put_varint(&mut buf, 0); // offset_delta = 0     (1 byte)
552        put_varint(&mut buf, -1); // key len               (1 byte)
553        put_varint(&mut buf, -1); // value len             (1 byte)
554        put_varint(&mut buf, -1); // negative header count (1 byte)
555
556        let mut cur: &[u8] = &buf[..];
557        match Record::decode(&mut cur) {
558            Err(RecordsError::RecordParse(msg)) => {
559                assert2::assert!(msg.contains("negative header count"));
560            }
561            other => panic!("expected RecordParse, got {other:?}"),
562        }
563    }
564
565    #[test]
566    fn decode_huge_header_count_does_not_overallocate() {
567        // A record that declares ~1 billion headers but supplies none. The
568        // capacity hint must be clamped to the (tiny) body remaining, and the
569        // decode must fail cleanly on EOF rather than attempting a multi-GB
570        // allocation.
571        let mut inner = BytesMut::new();
572        inner.put_i8(0); // attributes
573        put_varlong(&mut inner, 0); // timestamp_delta
574        put_varint(&mut inner, 0); // offset_delta
575        put_varint(&mut inner, -1); // key len = null
576        put_varint(&mut inner, -1); // value len = null
577        put_varint(&mut inner, 1_000_000_000); // absurd header count
578
579        let mut buf = BytesMut::new();
580        put_varlong(&mut buf, i64::try_from(inner.len()).unwrap());
581        buf.extend_from_slice(&inner);
582
583        let mut cur: &[u8] = &buf[..];
584        // Must return an Err (EOF reading the first header), not OOM.
585        assert2::assert!(Record::decode(&mut cur).is_err());
586    }
587}
588
589impl RecordBatch {
590    /// KIP-534 delete horizon, if the delete-horizon attribute bit is set.
591    ///
592    /// `base_timestamp` carries the horizon. There is no separate wire field.
593    #[must_use]
594    pub fn delete_horizon_ms(&self) -> Option<i64> {
595        self.attributes
596            .has_delete_horizon()
597            .then_some(self.base_timestamp)
598    }
599
600    /// Stamp the delete horizon onto this batch.
601    ///
602    /// This method sets bit 6 and moves the horizon into `base_timestamp`. It
603    /// then rewrites every record's `timestamp_delta`, so the reconstructed
604    /// absolute timestamps `base_timestamp + delta` do not change.
605    #[must_use]
606    pub fn with_delete_horizon(mut self, horizon_ms: i64) -> Self {
607        let old_base = self.base_timestamp;
608        for r in &mut self.records {
609            // Reconstruct the original absolute timestamp, then re-base it onto
610            // the new `base_timestamp`. Deltas are `i64`, so absolute timestamps
611            // round-trip exactly across re-basing; `saturating_*` only guards
612            // pathological inputs from panicking.
613            let abs = old_base.saturating_add(r.timestamp_delta);
614            r.timestamp_delta = abs.saturating_sub(horizon_ms);
615        }
616        self.base_timestamp = horizon_ms;
617        self.attributes = self.attributes.with_delete_horizon(true);
618        self
619    }
620
621    /// Decode a complete v2 record batch from `buf`.
622    ///
623    /// This method reads from the start of the header.
624    ///
625    /// # Errors
626    ///
627    /// Returns the underlying protocol error when input is truncated, contains an invalid length or tag, or cannot be encoded for the selected version.
628    ///
629    /// # Panics
630    ///
631    /// Panics if a value previously validated by the protocol type no longer satisfies its encoded-length or field-range invariant.
632    pub fn decode<B: Buf>(buf: &mut B) -> Result<Self, RecordsError> {
633        Self::decode_with_policy(buf, RecordDecompressionPolicy::default())
634    }
635
636    /// Decode a complete v2 record batch with explicit decompression limits.
637    ///
638    /// # Errors
639    ///
640    /// Returns the underlying records error for malformed, truncated, corrupt,
641    /// or over-limit input.
642    ///
643    /// # Panics
644    ///
645    /// Panics if a value previously validated by the protocol type no longer
646    /// satisfies its field-range invariant.
647    pub fn decode_with_policy<B: Buf>(
648        buf: &mut B,
649        policy: RecordDecompressionPolicy,
650    ) -> Result<Self, RecordsError> {
651        // batch_length field semantics: bytes after itself.
652        // Header tail = partition_leader_epoch(4) + magic(1) + crc(4) +
653        //   attributes(2) + last_offset_delta(4) + base_timestamp(8) +
654        //   max_timestamp(8) + producer_id(8) + producer_epoch(2) +
655        //   base_sequence(4) + records_count(4) = 49 bytes.
656        const HEADER_TAIL_LEN: i32 = 49;
657
658        // Need the full header before doing anything.
659        if buf.remaining() < HEADER_LEN {
660            return Err(RecordsError::HeaderTooShort {
661                needed: HEADER_LEN - buf.remaining(),
662            });
663        }
664        // Copy out the header to a stack buffer so we can use zerocopy.
665        let mut hdr_bytes = [0u8; HEADER_LEN];
666        buf.copy_to_slice(&mut hdr_bytes);
667
668        let hdr = crate::records::header::RecordBatchHeader::ref_from_bytes(&hdr_bytes[..])
669            .map_err(|_| RecordsError::ZerocopyFailure)?;
670
671        if hdr.magic != 2 {
672            return Err(RecordsError::UnsupportedMagic { found: hdr.magic });
673        }
674
675        // body_len = batch_length - HEADER_TAIL_LEN
676        let body_len = i32::checked_sub(hdr.batch_length.get(), HEADER_TAIL_LEN)
677            .and_then(|n| usize::try_from(n).ok())
678            .ok_or_else(|| {
679                RecordsError::RecordParse("negative or oversized batch_length".into())
680            })?;
681
682        if buf.remaining() < body_len {
683            return Err(RecordsError::BodyTooShort {
684                needed: body_len - buf.remaining(),
685            });
686        }
687
688        // CRC is computed over: header bytes 21..HEADER_LEN (attributes through
689        // records_count), then the body bytes.
690        let expected_crc = hdr.crc.get();
691        let verify_crc = |body: &[u8]| {
692            let computed = crc32c_append(crc32c(&hdr_bytes[21..HEADER_LEN]), body);
693            if computed == expected_crc {
694                Ok(())
695            } else {
696                Err(RecordsError::CrcMismatch {
697                    expected: expected_crc,
698                    computed,
699                })
700            }
701        };
702
703        let attributes = Attributes(hdr.attributes.get());
704        let codec = attributes.compression();
705
706        let decompress = |body: &[u8]| -> Result<Bytes, RecordsError> {
707            verify_crc(body)?;
708            Ok(krabka_compression::decompress(
709                codec,
710                body,
711                policy.output_limit(ByteSize::from_bytes(body.len() as u64)),
712            )?)
713        };
714
715        // A compressed body can be read directly from contiguous input. The
716        // decoded records own the decompressed output, so they retain no input
717        // buffer. Consume the body even on CRC/decompression errors, as the
718        // original staged read did. Fragmented input still needs staging;
719        // uncompressed records keep their own body instead of a larger frame.
720        let body_for_records = if codec != krabka_compression::CompressionType::None
721            && buf.chunk().len() >= body_len
722        {
723            let decoded = decompress(&buf.chunk()[..body_len]);
724            buf.advance(body_len);
725            decoded?
726        } else {
727            let mut body = vec![0u8; body_len];
728            buf.copy_to_slice(&mut body);
729            if codec == krabka_compression::CompressionType::None {
730                verify_crc(&body)?;
731                Bytes::from(body)
732            } else {
733                decompress(&body)?
734            }
735        };
736
737        // Parse records.
738        let count = hdr.records_count.get();
739        if count < 0 {
740            return Err(RecordsError::RecordParse(format!(
741                "negative records_count {count}"
742            )));
743        }
744        let mut body_cur: &[u8] = &body_for_records[..];
745        // Bound pre-allocation: each record is at least 1 byte in the
746        // decompressed body, so an honest `records_count` can never exceed the
747        // body length. Clamp the capacity hint to reject huge declared counts.
748        let record_capacity = usize::try_from(count)
749            .expect("non-negative record count must fit in usize")
750            .min(body_for_records.len());
751        let mut records = Vec::with_capacity(record_capacity);
752        for i in 0..count {
753            // Parse the record into borrowed slices, then materialise its
754            // key / value / header-values as zero-copy `Bytes` views into
755            // `body_for_records`. The whole batch shares that single backing
756            // allocation instead of copying every field into its own `Bytes`,
757            // which removes ~2 heap allocations + memcpys per record on the
758            // common (header-free) path.
759            let r = crate::records::borrowed::parse_one_record(&mut body_cur)
760                .map_err(|e| RecordsError::RecordParse(format!("record[{i}]: {e}")))?;
761            records.push(Record {
762                attributes: r.attributes,
763                timestamp_delta: r.timestamp_delta,
764                offset_delta: r.offset_delta,
765                key: r.key.map(|s| body_for_records.slice_ref(s)),
766                value: r.value.map(|s| body_for_records.slice_ref(s)),
767                headers: r
768                    .headers
769                    .into_iter()
770                    .map(|h| RecordHeader {
771                        key: h.key.to_string(),
772                        value: h.value.map(|s| body_for_records.slice_ref(s)),
773                    })
774                    .collect(),
775            });
776        }
777        if !body_cur.is_empty() {
778            return Err(RecordsError::RecordParse(format!(
779                "trailing bytes after records (left={})",
780                body_cur.len()
781            )));
782        }
783
784        Ok(Self {
785            base_offset: hdr.base_offset.get(),
786            partition_leader_epoch: hdr.partition_leader_epoch.get(),
787            attributes,
788            last_offset_delta: hdr.last_offset_delta.get(),
789            base_timestamp: hdr.base_timestamp.get(),
790            max_timestamp: hdr.max_timestamp.get(),
791            producer_id: hdr.producer_id.get(),
792            producer_epoch: hdr.producer_epoch.get(),
793            base_sequence: hdr.base_sequence.get(),
794            records,
795        })
796    }
797
798    /// Encode this batch into `buf`.
799    ///
800    /// # Errors
801    ///
802    /// Returns the underlying protocol error when input is truncated, contains an invalid length or tag, or cannot be encoded for the selected version.
803    pub fn encode<B: BufMut>(&self, buf: &mut B) -> Result<(), RecordsError> {
804        self.encode_with_compression_level(buf, None)
805    }
806
807    /// Encode this batch into an owned wire buffer.
808    ///
809    /// Uncompressed records are written directly behind the header, avoiding
810    /// a temporary body buffer and its copy into the output.
811    ///
812    /// # Errors
813    ///
814    /// Returns the same encoding errors as [`Self::encode`].
815    pub fn encode_to_bytes(&self) -> Result<Bytes, RecordsError> {
816        let mut wire = BytesMut::with_capacity(self.encoded_len());
817        if self.attributes.compression() == krabka_compression::CompressionType::None {
818            wire.resize(HEADER_LEN, 0);
819            for record in &self.records {
820                record.encode(&mut wire)?;
821            }
822            let (mut header, body) = wire.split_at_mut(HEADER_LEN);
823            self.encode_header(&mut header, body)?;
824        } else {
825            self.encode(&mut wire)?;
826        }
827        Ok(wire.freeze())
828    }
829
830    /// Encode this batch into `buf`, and compress the records at `level`.
831    ///
832    /// `None` uses the default level of the codec, as [`Self::encode`] does.
833    /// A producer passes its `compression.<codec>.level` setting (KIP-390).
834    /// See [`krabka_compression::compress_with_level`] for how each codec
835    /// applies the level.
836    ///
837    /// # Errors
838    ///
839    /// Returns [`RecordsError::Compression`] when `level` is not a level that
840    /// Kafka accepts for the codec of the batch attributes, including any
841    /// level for a batch with no compression. Returns the other errors of
842    /// [`Self::encode`].
843    pub fn encode_with_compression_level<B: BufMut>(
844        &self,
845        buf: &mut B,
846        level: Option<i32>,
847    ) -> Result<(), RecordsError> {
848        // 1. Encode records into a temporary buffer.
849        let mut raw_body =
850            BytesMut::with_capacity(self.records.iter().map(Record::encoded_len).sum());
851        for r in &self.records {
852            r.encode(&mut raw_body)?;
853        }
854        let raw_body = raw_body.freeze();
855
856        // 2. Compress if needed.
857        let codec = self.attributes.compression();
858        let body: Bytes = match level {
859            Some(level) => krabka_compression::compress_with_level(codec, &raw_body, level)?,
860            None if codec == krabka_compression::CompressionType::None => raw_body,
861            None => krabka_compression::compress(codec, &raw_body)?,
862        };
863
864        self.encode_header(buf, &body)?;
865        buf.put_slice(&body);
866        Ok(())
867    }
868
869    fn encode_header<B: BufMut>(&self, buf: &mut B, body: &[u8]) -> Result<(), RecordsError> {
870        const HEADER_TAIL_LEN: i32 = 49;
871
872        // batch_length = HEADER_TAIL_LEN + body_len
873        let batch_length = HEADER_TAIL_LEN
874            + i32::try_from(body.len())
875                .map_err(|_| RecordsError::RecordParse("body length exceeds i32".into()))?;
876
877        // CRC-covered header portion (attributes through records_count = 40 bytes).
878        let mut covered_head = [0_u8; 40];
879        let mut covered = covered_head.as_mut_slice();
880        covered.put_i16(self.attributes.0);
881        covered.put_i32(self.last_offset_delta);
882        covered.put_i64(self.base_timestamp);
883        covered.put_i64(self.max_timestamp);
884        covered.put_i64(self.producer_id);
885        covered.put_i16(self.producer_epoch);
886        covered.put_i32(self.base_sequence);
887        covered.put_i32(
888            i32::try_from(self.records.len())
889                .map_err(|_| RecordsError::RecordParse("records_count exceeds i32".into()))?,
890        );
891        // Compute CRC over covered_head then body.
892        let mut crc = crc32c(&covered_head);
893        crc = crc32c_append(crc, body);
894
895        // Emit the full header.
896        buf.put_i64(self.base_offset);
897        buf.put_i32(batch_length);
898        buf.put_i32(self.partition_leader_epoch);
899        buf.put_i8(2); // magic v2
900        buf.put_u32(crc);
901        buf.put_slice(&covered_head);
902        Ok(())
903    }
904
905    /// Predicted total bytes that `encode` will write, for an uncompressed batch.
906    ///
907    /// For a compressed batch, the actual size will differ.
908    pub fn encoded_len(&self) -> usize {
909        let body: usize = self.records.iter().map(Record::encoded_len).sum();
910        HEADER_LEN + body
911    }
912}
913
914#[cfg(test)]
915mod batch_tests {
916    use assert2::check;
917    use krabka_compression::{CompressionError, CompressionType, RecordDecompressionPolicy};
918    use krabka_units::{bytes, fraction};
919
920    use super::*;
921
922    fn fixture_empty_batch() -> RecordBatch {
923        RecordBatch::default()
924    }
925
926    fn fixture_single_record_batch() -> RecordBatch {
927        RecordBatch {
928            records: vec![Record {
929                key: Some(Bytes::from_static(b"k1")),
930                value: Some(Bytes::from_static(b"v1")),
931                ..Default::default()
932            }],
933            ..RecordBatch::default()
934        }
935    }
936
937    fn fixture_multi_record_batch() -> RecordBatch {
938        RecordBatch {
939            base_offset: 42,
940            partition_leader_epoch: 5,
941            last_offset_delta: 2,
942            base_timestamp: 1_700_000_000,
943            max_timestamp: 1_700_000_500,
944            producer_id: 100,
945            producer_epoch: 3,
946            base_sequence: 7,
947            records: vec![
948                Record {
949                    offset_delta: 0,
950                    timestamp_delta: 0,
951                    key: Some(Bytes::from_static(b"a")),
952                    value: Some(Bytes::from_static(b"1")),
953                    ..Default::default()
954                },
955                Record {
956                    offset_delta: 1,
957                    timestamp_delta: 100,
958                    key: Some(Bytes::from_static(b"b")),
959                    value: Some(Bytes::from_static(b"2")),
960                    ..Default::default()
961                },
962                Record {
963                    offset_delta: 2,
964                    timestamp_delta: 500,
965                    key: None,
966                    value: Some(Bytes::from_static(b"3")),
967                    headers: vec![RecordHeader {
968                        key: "h".to_string(),
969                        value: Some(Bytes::from_static(b"hv")),
970                    }],
971                    ..Default::default()
972                },
973            ],
974            ..RecordBatch::default()
975        }
976    }
977
978    #[test]
979    fn uncompressed_roundtrip_cases() {
980        type TestCase2 = (&'static str, fn() -> RecordBatch);
981        let cases: [TestCase2; 3] = [
982            ("empty", fixture_empty_batch),
983            ("single", fixture_single_record_batch),
984            ("multiple", fixture_multi_record_batch),
985        ];
986        for (_case, fixture) in cases {
987            let mut batch = fixture();
988            batch.attributes = batch.attributes.with_compression(CompressionType::None);
989            let mut buf = BytesMut::new();
990            batch.encode(&mut buf).unwrap();
991            assert2::assert!(buf.len() == batch.encoded_len());
992            let mut cur: &[u8] = &buf[..];
993            let decoded = RecordBatch::decode(&mut cur).unwrap();
994            assert2::assert!((decoded, cur.is_empty()) == (batch, true));
995        }
996    }
997
998    /// The level reaches the codec. A gzip batch at level 1 and at level 9
999    /// has different bytes, both decode to the batch, and the default level
1000    /// gives the bytes of `encode`. A level that Kafka rejects fails the
1001    /// encode.
1002    #[test]
1003    fn encode_with_compression_level_compresses_at_the_level() {
1004        let value: Vec<u8> = (0..16 * 1024u32)
1005            .map(|i| u8::try_from((i * 7 + i / 13) % 251).unwrap_or(0))
1006            .collect();
1007        let mut batch = RecordBatch {
1008            records: vec![Record {
1009                value: Some(Bytes::from(value)),
1010                ..Default::default()
1011            }],
1012            ..RecordBatch::default()
1013        };
1014        batch.attributes = batch.attributes.with_compression(CompressionType::Gzip);
1015        let encoded = |level: Option<i32>| {
1016            let mut buf = BytesMut::new();
1017            batch
1018                .encode_with_compression_level(&mut buf, level)
1019                .map(|()| buf.freeze())
1020        };
1021        let default = encoded(None).unwrap();
1022        let fastest = encoded(Some(1)).unwrap();
1023        let best = encoded(Some(9)).unwrap();
1024        let decoded =
1025            [&fastest, &best].map(|bytes| RecordBatch::decode(&mut &bytes[..]).unwrap() == batch);
1026
1027        assert2::assert!(
1028            (
1029                encoded(Some(-1)).unwrap() == default,
1030                fastest != best,
1031                decoded,
1032            ) == (true, true, [true, true])
1033        );
1034        assert2::assert!(matches!(
1035            encoded(Some(10)),
1036            Err(RecordsError::Compression(CompressionError::InvalidLevel {
1037                level: 10,
1038                ..
1039            }))
1040        ));
1041    }
1042
1043    #[test]
1044    fn rejects_pre_v2_magic() {
1045        let mut buf = BytesMut::new();
1046        buf.put_i64(0); // base_offset
1047        buf.put_i32(49); // batch_length
1048        buf.put_i32(0); // partition_leader_epoch
1049        buf.put_i8(1); // magic = 1 (v1, deprecated)
1050        buf.put_u32(0); // crc (irrelevant; we reject on magic first)
1051        for _ in 21..HEADER_LEN {
1052            buf.put_u8(0);
1053        }
1054        let mut cur: &[u8] = &buf[..];
1055        assert2::assert!(matches!(
1056            RecordBatch::decode(&mut cur),
1057            Err(RecordsError::UnsupportedMagic { found: 1 })
1058        ));
1059    }
1060
1061    #[test]
1062    fn rejects_bad_crc() {
1063        let b = fixture_single_record_batch();
1064        let mut buf = BytesMut::new();
1065        b.encode(&mut buf).unwrap();
1066        // Corrupt the CRC bytes (offsets 17..21).
1067        buf[17] ^= 0xFF;
1068        let mut cur: &[u8] = &buf[..];
1069        assert2::assert!(matches!(
1070            RecordBatch::decode(&mut cur),
1071            Err(RecordsError::CrcMismatch { .. })
1072        ));
1073    }
1074
1075    #[test]
1076    fn compressed_roundtrip_cases() {
1077        for (_case, codec) in [
1078            ("gzip", CompressionType::Gzip),
1079            ("snappy", CompressionType::Snappy),
1080            ("lz4", CompressionType::Lz4),
1081            ("zstd", CompressionType::Zstd),
1082        ] {
1083            let mut batch = fixture_multi_record_batch();
1084            batch.attributes = batch.attributes.with_compression(codec);
1085            let mut buf = BytesMut::new();
1086            batch.encode(&mut buf).unwrap();
1087            let mut cur: &[u8] = &buf[..];
1088            let decoded = RecordBatch::decode(&mut cur).unwrap();
1089            assert2::assert!((decoded, cur.is_empty()) == (batch, true));
1090        }
1091    }
1092
1093    #[test]
1094    fn decompression_policy_limits_owned_decode() {
1095        let mut batch = fixture_single_record_batch();
1096        batch.records[0].value = Some(Bytes::from(vec![b'x'; 4096]));
1097        batch.attributes = batch.attributes.with_compression(CompressionType::Lz4);
1098        let mut wire = BytesMut::new();
1099        batch.encode(&mut wire).unwrap();
1100
1101        let mut default_cur = &wire[..];
1102        RecordBatch::decode(&mut default_cur).unwrap();
1103
1104        let policy = RecordDecompressionPolicy::new(fraction(1.0), bytes(1), bytes(32)).unwrap();
1105        let mut limited_cur = &wire[..];
1106        assert2::assert!(matches!(
1107            RecordBatch::decode_with_policy(&mut limited_cur, policy),
1108            Err(RecordsError::Compression(CompressionError::TooLarge {
1109                limit: 32
1110            }))
1111        ));
1112    }
1113
1114    #[test]
1115    fn with_delete_horizon_stamps_and_preserves_record_timestamps() {
1116        // base 1000, two records at deltas [0, 5] → absolutes [1000, 1005].
1117        let b = RecordBatch {
1118            base_timestamp: 1000,
1119            records: vec![
1120                Record {
1121                    timestamp_delta: 0,
1122                    ..Default::default()
1123                },
1124                Record {
1125                    timestamp_delta: 5,
1126                    ..Default::default()
1127                },
1128            ],
1129            ..RecordBatch::default()
1130        };
1131
1132        let stamped = b.with_delete_horizon(9999);
1133
1134        check!(stamped.attributes.has_delete_horizon());
1135        check!(stamped.base_timestamp == 9999);
1136        check!(stamped.delete_horizon_ms() == Some(9999));
1137
1138        // Reconstructed absolutes (base + delta) must equal the ORIGINALS.
1139        let absolutes: Vec<i64> = stamped
1140            .records
1141            .iter()
1142            .map(|r| stamped.base_timestamp + r.timestamp_delta)
1143            .collect();
1144        assert2::assert!(absolutes == vec![1000, 1005]);
1145    }
1146
1147    #[test]
1148    fn delete_horizon_round_trips_through_encode_decode() {
1149        // base 1000, two keyed records at deltas [0, 5] → absolutes [1000, 1005].
1150        let b = RecordBatch {
1151            base_timestamp: 1000,
1152            last_offset_delta: 1,
1153            records: vec![
1154                Record {
1155                    timestamp_delta: 0,
1156                    offset_delta: 0,
1157                    key: Some(Bytes::from_static(b"k1")),
1158                    value: Some(Bytes::from_static(b"v1")),
1159                    ..Default::default()
1160                },
1161                Record {
1162                    timestamp_delta: 5,
1163                    offset_delta: 1,
1164                    key: Some(Bytes::from_static(b"k2")),
1165                    value: Some(Bytes::from_static(b"v2")),
1166                    ..Default::default()
1167                },
1168            ],
1169            ..RecordBatch::default()
1170        }
1171        .with_delete_horizon(9999);
1172
1173        let mut buf = BytesMut::new();
1174        b.encode(&mut buf).unwrap();
1175
1176        let mut cur: &[u8] = &buf[..];
1177        let decoded = RecordBatch::decode(&mut cur).unwrap();
1178        assert2::assert!(cur.is_empty());
1179
1180        assert2::assert!(decoded.delete_horizon_ms() == Some(9999));
1181
1182        let absolutes: Vec<i64> = decoded
1183            .records
1184            .iter()
1185            .map(|r| decoded.base_timestamp + r.timestamp_delta)
1186            .collect();
1187        assert2::assert!(absolutes == vec![1000, 1005]);
1188    }
1189
1190    #[test]
1191    fn decode_huge_records_count_does_not_overallocate() {
1192        // Encode an empty uncompressed batch, then overwrite records_count with
1193        // an absurd value (~1 billion) and fix up the CRC. The capacity hint
1194        // must be clamped to the (empty) decompressed body, and decode must
1195        // fail cleanly on EOF rather than attempting a multi-GB allocation.
1196        let mut b = fixture_empty_batch();
1197        b.attributes = b.attributes.with_compression(CompressionType::None);
1198        let mut buf = BytesMut::new();
1199        b.encode(&mut buf).unwrap();
1200
1201        // records_count is the last 4 bytes of the fixed header.
1202        let rc_off = HEADER_LEN - 4;
1203        buf[rc_off..HEADER_LEN].copy_from_slice(&1_000_000_000i32.to_be_bytes());
1204
1205        // Recompute CRC over header[21..HEADER_LEN] + body (body is empty here).
1206        let body = &buf[HEADER_LEN..];
1207        let mut computed = crc32c(&buf[21..HEADER_LEN]);
1208        computed = crc32c_append(computed, body);
1209        buf[17..21].copy_from_slice(&computed.to_be_bytes());
1210
1211        let mut cur: &[u8] = &buf[..];
1212        // Must return an Err (EOF reading the first record), not OOM.
1213        assert2::assert!(RecordBatch::decode(&mut cur).is_err());
1214    }
1215}
1216
1217impl crate::Encode for RecordBatch {
1218    fn encode<B: BufMut>(&self, buf: &mut B, _version: i16) -> Result<(), crate::ProtocolError> {
1219        RecordBatch::encode(self, buf).map_err(Into::into)
1220    }
1221
1222    fn encoded_len(&self, _version: i16) -> usize {
1223        RecordBatch::encoded_len(self)
1224    }
1225}
1226
1227impl crate::Decode<'_> for RecordBatch {
1228    fn decode<B: Buf>(buf: &mut B, _version: i16) -> Result<Self, crate::ProtocolError> {
1229        RecordBatch::decode(buf).map_err(Into::into)
1230    }
1231}