Skip to main content

barnabas_core/
records.rs

1//! A record-batch reader that does not build a record per record.
2//!
3//! # Why this exists
4//!
5//! `kafka_protocol`'s decoder produces a `Vec<Record>`, and each `Record`
6//! carries producer id, producer epoch, sequence, partition leader epoch,
7//! timestamp type and two flags — **every one of which is a property of the
8//! batch, copied into each of its records** — plus an `IndexMap` for headers.
9//!
10//! Measured on one batch of a thousand 128-byte records (`BARNABAS_CELLS=decode`
11//! in the bench):
12//!
13//! | | ns/record |
14//! |---|---:|
15//! | walking the records and keeping nothing | 3.0 |
16//! | keeping a 24-byte record | 5.5 |
17//! | keeping `Bytes` slices | 27.5 |
18//! | `kafka_protocol` → `Vec<Record>` | 70 |
19//!
20//! Parsing is 4% of it. The rest is materialisation, which is what this module
21//! avoids: batch-level facts are stored once, and a record is an offset, a
22//! timestamp and two ranges into the batch buffer.
23//!
24//! # What it does not do
25//!
26//! Falls back — returns `None` — only for a batch that is not magic 2. All four
27//! compression codecs and record headers are handled: compressed records are
28//! decompressed into a buffer the batch then owns, and headers are recorded as
29//! a byte range and parsed on demand.
30
31use bytes::Bytes;
32
33use crate::{Error, Result};
34
35/// Batch header layout (magic 2), by byte offset from the start of the batch.
36mod field {
37    pub const BASE_OFFSET: usize = 0;
38    pub const LENGTH: usize = 8;
39    pub const MAGIC: usize = 16;
40    pub const CRC: usize = 17;
41    pub const ATTRIBUTES: usize = 21;
42    pub const BASE_TIMESTAMP: usize = 27;
43    pub const PRODUCER_ID: usize = 43;
44    pub const RECORD_COUNT: usize = 57;
45    /// Everything above, and where the records begin.
46    pub const HEADER_LEN: usize = 61;
47    /// The CRC covers from just after itself to the end of the batch.
48    pub const CRC_FROM: usize = 21;
49}
50
51/// One record: where it is, not what it contains.
52#[derive(Debug, Clone, Copy)]
53pub struct LeanRecord {
54    pub offset: i64,
55    pub timestamp: i64,
56    key: (u32, u32),
57    value: (u32, u32),
58    /// The header block: where it is, how many, and *not* what it contains.
59    ///
60    /// Headers are variable in number, so holding them inline would put a
61    /// `Vec` in every record and undo the point of this type. Holding the
62    /// region and its count costs eight bytes and nothing at all unless a
63    /// caller asks for them.
64    headers: (u32, u32),
65    header_count: u32,
66}
67
68/// One batch, with the facts that belong to the batch held once.
69#[derive(Debug, Clone)]
70pub struct LeanBatch {
71    /// Retained so a record's key and value can be sliced from it on demand.
72    buffer: Bytes,
73    pub base_offset: i64,
74    pub producer_id: i64,
75    pub transactional: bool,
76    /// A control batch carries markers, not caller data. Batch-level in the
77    /// format, which is why it is stored here and not per record.
78    pub control: bool,
79    pub records: Vec<LeanRecord>,
80}
81
82impl LeanBatch {
83    /// This record's key, as a slice of the batch buffer.
84    #[must_use]
85    pub fn key(&self, record: &LeanRecord) -> Option<Bytes> {
86        self.slice(record.key)
87    }
88
89    /// This record's value, as a slice of the batch buffer.
90    ///
91    /// **The `Bytes` is built here rather than at decode time.** Every slice of
92    /// one buffer increments the same atomic refcount, so materialising two per
93    /// record up front is two million read-modify-writes on one cache line for
94    /// a million records — self-contended, and measurably as expensive as
95    /// copying the bytes outright. A caller that skips a record never pays for
96    /// it.
97    #[must_use]
98    pub fn value(&self, record: &LeanRecord) -> Option<Bytes> {
99        self.slice(record.value)
100    }
101
102    /// `u32::MAX` marks absent, which is how the format distinguishes a null
103    /// key from an empty one.
104    fn slice(&self, (at, len): (u32, u32)) -> Option<Bytes> {
105        if at == u32::MAX {
106            return None;
107        }
108        let at = at as usize;
109        Some(self.buffer.slice(at..at + len as usize))
110    }
111
112    /// This record's headers, parsed on demand.
113    ///
114    /// Returns an empty vector when there are none, which is the common case
115    /// and costs nothing — the region was never touched at decode time.
116    ///
117    /// # Errors
118    /// [`Error::Codec`] if the header block is malformed.
119    pub fn headers(&self, record: &LeanRecord) -> Result<Vec<(Bytes, Option<Bytes>)>> {
120        if record.header_count == 0 {
121            return Ok(Vec::new());
122        }
123        let at = record.headers.0 as usize;
124        let end = at + record.headers.1 as usize;
125        let block = self
126            .buffer
127            .get(at..end)
128            .ok_or_else(|| Error::Codec("header block".to_owned()))?;
129
130        let mut out = Vec::with_capacity(record.header_count as usize);
131        let mut pos = 0usize;
132        for _ in 0..record.header_count {
133            let key_len = varint(block, &mut pos)
134                .ok_or_else(|| Error::Codec("header key length".to_owned()))?;
135            let key_at = at + pos;
136            let key_len = key_len.max(0) as usize;
137            pos += key_len;
138
139            let value_len = varint(block, &mut pos)
140                .ok_or_else(|| Error::Codec("header value length".to_owned()))?;
141            let value = if value_len >= 0 {
142                let value_at = at + pos;
143                pos += value_len as usize;
144                Some(self.buffer.slice(value_at..value_at + value_len as usize))
145            } else {
146                None
147            };
148            out.push((self.buffer.slice(key_at..key_at + key_len), value));
149        }
150        Ok(out)
151    }
152
153    /// The control-marker type, for a control batch. 0 is abort.
154    ///
155    /// Reads the marker's key, which is where the type lives.
156    #[must_use]
157    pub fn control_type(&self, record: &LeanRecord) -> Option<i16> {
158        let key = self.slice(record.key)?;
159        if key.len() < 4 {
160            return None;
161        }
162        Some(i16::from_be_bytes([key[2], key[3]]))
163    }
164}
165
166/// Zigzag LEB128, the integer encoding inside a record batch.
167///
168/// Returns `None` rather than panicking on a truncated or over-long encoding:
169/// this parses bytes off a socket, and a malformed batch must not be able to
170/// index out of bounds or spin.
171#[inline]
172fn varint(buf: &[u8], pos: &mut usize) -> Option<i64> {
173    let mut raw: u64 = 0;
174    let mut shift = 0;
175    loop {
176        if shift > 63 {
177            return None;
178        }
179        let byte = *buf.get(*pos)?;
180        *pos += 1;
181        raw |= u64::from(byte & 0x7f) << shift;
182        if byte & 0x80 == 0 {
183            break;
184        }
185        shift += 7;
186    }
187    Some(((raw >> 1) as i64) ^ -((raw & 1) as i64))
188}
189
190fn i16_at(buf: &[u8], at: usize) -> Option<i16> {
191    Some(i16::from_be_bytes(buf.get(at..at + 2)?.try_into().ok()?))
192}
193
194fn i32_at(buf: &[u8], at: usize) -> Option<i32> {
195    Some(i32::from_be_bytes(buf.get(at..at + 4)?.try_into().ok()?))
196}
197
198fn i64_at(buf: &[u8], at: usize) -> Option<i64> {
199    Some(i64::from_be_bytes(buf.get(at..at + 8)?.try_into().ok()?))
200}
201
202/// Decode every batch in `buffer`.
203///
204/// Returns `Ok(None)` when any batch is something this reader does not handle —
205/// compressed, not magic 2, or carrying record headers — so the caller can fall
206/// back to the full decoder. Returns `Err` only when the bytes are actually
207/// wrong, which is the same distinction the rest of this crate draws.
208///
209/// # Errors
210/// [`Error::Codec`] if a batch is truncated or fails its CRC.
211pub fn decode_lean(buffer: &Bytes) -> Result<Option<Vec<LeanBatch>>> {
212    let mut batches = Vec::new();
213    let mut at = 0usize;
214
215    while at < buffer.len() {
216        // A fetch response is cut off at `max_bytes`, so a trailing partial
217        // batch is normal and means "stop", not "corrupt".
218        let Some(length) = i32_at(buffer, at + field::LENGTH) else {
219            break;
220        };
221        let end = at + field::LENGTH + 4 + length.max(0) as usize;
222        if length <= 0 || end > buffer.len() {
223            break;
224        }
225        let batch = &buffer[at..end];
226        if batch.len() < field::HEADER_LEN {
227            break;
228        }
229
230        if batch[field::MAGIC] != 2 {
231            return Ok(None);
232        }
233        let attributes = i16_at(batch, field::ATTRIBUTES)
234            .ok_or_else(|| Error::Codec("batch attributes".to_owned()))?;
235
236        let expected =
237            i32_at(batch, field::CRC).ok_or_else(|| Error::Codec("batch crc".to_owned()))? as u32;
238        let actual = crc32c::crc32c(&batch[field::CRC_FROM..]);
239        if expected != actual {
240            return Err(Error::Codec(format!(
241                "record batch crc: expected {expected:#x}, got {actual:#x}"
242            )));
243        }
244
245        let base_offset = i64_at(batch, field::BASE_OFFSET)
246            .ok_or_else(|| Error::Codec("base offset".to_owned()))?;
247        let base_timestamp = i64_at(batch, field::BASE_TIMESTAMP)
248            .ok_or_else(|| Error::Codec("base timestamp".to_owned()))?;
249        let producer_id = i64_at(batch, field::PRODUCER_ID)
250            .ok_or_else(|| Error::Codec("producer id".to_owned()))?;
251        let count = i32_at(batch, field::RECORD_COUNT)
252            .ok_or_else(|| Error::Codec("record count".to_owned()))?
253            .max(0) as usize;
254
255        // **The records section, however it arrived.** Uncompressed, it is a
256        // slice of the response buffer and nothing is copied. Compressed, it is
257        // the decompressed bytes — one allocation per batch, which the codec
258        // requires and which `kafka_protocol` pays too. Everything below parses
259        // the same way either way, because ranges are relative to this and the
260        // batch holds it.
261        let body: Bytes = match attributes & 0x07 {
262            0 => buffer.slice(at + field::HEADER_LEN..end),
263            codec => decompress(codec, &batch[field::HEADER_LEN..])?,
264        };
265
266        let mut records = Vec::with_capacity(count);
267        let mut pos = 0usize;
268        for _ in 0..count {
269            let Some(len) = varint(&body, &mut pos) else {
270                return Err(Error::Codec("record length".to_owned()));
271            };
272            let record_end = pos + len.max(0) as usize;
273            if record_end > body.len() {
274                return Err(Error::Codec("record overruns its batch".to_owned()));
275            }
276
277            pos += 1; // per-record attributes, unused in the format
278            let timestamp_delta = varint(&body, &mut pos)
279                .ok_or_else(|| Error::Codec("timestamp delta".to_owned()))?;
280            let offset_delta =
281                varint(&body, &mut pos).ok_or_else(|| Error::Codec("offset delta".to_owned()))?;
282
283            let key_len =
284                varint(&body, &mut pos).ok_or_else(|| Error::Codec("key length".to_owned()))?;
285            let key = if key_len >= 0 {
286                let range = (pos as u32, key_len as u32);
287                pos += key_len as usize;
288                range
289            } else {
290                (u32::MAX, 0)
291            };
292
293            let value_len =
294                varint(&body, &mut pos).ok_or_else(|| Error::Codec("value length".to_owned()))?;
295            let value = if value_len >= 0 {
296                let range = (pos as u32, value_len as u32);
297                pos += value_len as usize;
298                range
299            } else {
300                (u32::MAX, 0)
301            };
302
303            // The header block is recorded, not parsed. See
304            // [`LeanBatch::headers`].
305            let header_count = varint(&body, &mut pos)
306                .ok_or_else(|| Error::Codec("header count".to_owned()))?
307                .max(0) as u32;
308            let headers = (pos as u32, record_end.saturating_sub(pos) as u32);
309
310            records.push(LeanRecord {
311                offset: base_offset + offset_delta,
312                timestamp: base_timestamp + timestamp_delta,
313                key,
314                value,
315                headers,
316                header_count,
317            });
318            pos = record_end;
319        }
320
321        batches.push(LeanBatch {
322            buffer: body,
323            base_offset,
324            producer_id,
325            transactional: attributes & 0x10 != 0,
326            control: attributes & 0x20 != 0,
327            records,
328        });
329        at = end;
330    }
331
332    Ok(Some(batches))
333}
334
335/// Kafka's snappy is **xerial-framed**, not raw: a 16-byte magic header, then
336/// `[u32 length][block]` repeated. Java's reader falls back to raw snappy when
337/// the header is absent, and so does this — some producers write it that way.
338const SNAPPY_MAGIC: &[u8; 16] = b"\x82SNAPPY\x00\x00\x00\x00\x01\x00\x00\x00\x01";
339
340fn snappy(compressed: &[u8]) -> Result<Vec<u8>> {
341    let raw = |bytes: &[u8]| {
342        snap::raw::Decoder::new()
343            .decompress_vec(bytes)
344            .map_err(|e| Error::Codec(format!("snappy: {e}")))
345    };
346
347    if compressed.len() < SNAPPY_MAGIC.len() || &compressed[..SNAPPY_MAGIC.len()] != SNAPPY_MAGIC {
348        return raw(compressed);
349    }
350
351    let mut out = Vec::new();
352    let mut at = SNAPPY_MAGIC.len();
353    while at < compressed.len() {
354        let len = compressed
355            .get(at..at + 4)
356            .and_then(|b| b.try_into().ok())
357            .map(u32::from_be_bytes)
358            .ok_or_else(|| Error::Codec("snappy block length".to_owned()))?
359            as usize;
360        at += 4;
361        let block = compressed
362            .get(at..at + len)
363            .ok_or_else(|| Error::Codec("snappy block overruns".to_owned()))?;
364        out.extend_from_slice(&raw(block)?);
365        at += len;
366    }
367    Ok(out)
368}
369
370/// Decompress a batch's records section.
371///
372/// The four codecs Kafka defines. Each is already in the dependency tree via
373/// `kafka_protocol`, so supporting them here adds no crates — only the code to
374/// call them.
375fn decompress(codec: i16, compressed: &[u8]) -> Result<Bytes> {
376    use std::io::Read;
377
378    let mut out = Vec::new();
379    match codec {
380        1 => {
381            flate2::read::GzDecoder::new(compressed)
382                .read_to_end(&mut out)
383                .map_err(|e| Error::Codec(format!("gzip: {e}")))?;
384        }
385        2 => out = snappy(compressed)?,
386        3 => {
387            lz4::Decoder::new(compressed)
388                .map_err(|e| Error::Codec(format!("lz4: {e}")))?
389                .read_to_end(&mut out)
390                .map_err(|e| Error::Codec(format!("lz4: {e}")))?;
391        }
392        4 => {
393            zstd::stream::copy_decode(compressed, &mut out)
394                .map_err(|e| Error::Codec(format!("zstd: {e}")))?;
395        }
396        other => return Err(Error::Codec(format!("unknown compression codec {other}"))),
397    }
398    Ok(Bytes::from(out))
399}
400
401/// Apply the READ_COMMITTED rules **per batch**.
402///
403/// The ordinary filter works record by record because `kafka_protocol` flattens
404/// batches away. Here `transactional`, `control` and `producer_id` are still
405/// where the format puts them — on the batch — so an aborted transaction is
406/// dropped a batch at a time instead of a record at a time.
407///
408/// Returns the batches to hand the caller and the offset the next fetch should
409/// start from. As with the record-wise filter, the position advances past
410/// records that were dropped, so a partition of nothing but aborted data still
411/// makes progress.
412#[must_use]
413pub fn filter_batches(
414    batches: Vec<LeanBatch>,
415    aborted: &[crate::consumer::AbortedTransaction],
416    last_stable_offset: i64,
417    isolation: crate::IsolationLevel,
418    fetch_offset: i64,
419) -> (Vec<LeanBatch>, i64) {
420    let read_committed = isolation == crate::IsolationLevel::ReadCommitted;
421
422    let mut sorted: Vec<crate::consumer::AbortedTransaction> = aborted.to_vec();
423    sorted.sort_by_key(|a| a.first_offset);
424    let mut pending = sorted.into_iter().peekable();
425    let mut aborted_producers: std::collections::HashSet<i64> = std::collections::HashSet::new();
426
427    let mut kept = Vec::with_capacity(batches.len());
428    let mut next_offset = fetch_offset;
429
430    for mut batch in batches {
431        let Some(first) = batch.records.first().map(|r| r.offset) else {
432            continue;
433        };
434        // Everything at or above the LSO is withheld, and so is everything
435        // after it — the broker sends them in order.
436        if read_committed && first >= last_stable_offset {
437            break;
438        }
439
440        while pending.peek().is_some_and(|a| a.first_offset <= first) {
441            let a = pending.next().expect("peeked");
442            aborted_producers.insert(a.producer_id);
443        }
444
445        let last = batch.records.last().map_or(first, |r| r.offset);
446        next_offset = last + 1;
447
448        if batch.control {
449            // The abort marker closes the range, so a later transaction from
450            // the same producer is judged on its own.
451            for record in &batch.records {
452                if batch.control_type(record) == Some(CONTROL_ABORT) {
453                    aborted_producers.remove(&batch.producer_id);
454                }
455            }
456            continue;
457        }
458
459        if read_committed && batch.transactional && aborted_producers.contains(&batch.producer_id) {
460            continue;
461        }
462
463        // A batch can begin before the requested offset, since the broker sends
464        // whole batches.
465        if first < fetch_offset {
466            batch.records.retain(|r| r.offset >= fetch_offset);
467        }
468        if read_committed {
469            batch.records.retain(|r| r.offset < last_stable_offset);
470        }
471        if !batch.records.is_empty() {
472            kept.push(batch);
473        }
474    }
475
476    (kept, next_offset)
477}
478
479/// An abort marker. Matches `barnabas_core::consumer`.
480const CONTROL_ABORT: i16 = 0;
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use kafka_protocol::records::{
486        Compression, Record, RecordBatchEncoder, RecordEncodeOptions, TimestampType,
487    };
488
489    fn encode(records: &[Record], compression: Compression) -> Bytes {
490        let mut buf = bytes::BytesMut::new();
491        RecordBatchEncoder::encode(
492            &mut buf,
493            records.iter(),
494            &RecordEncodeOptions {
495                version: 2,
496                compression,
497            },
498        )
499        .expect("encode");
500        buf.freeze()
501    }
502
503    fn record(offset: i64, key: Option<&[u8]>, value: Option<&[u8]>) -> Record {
504        Record {
505            transactional: false,
506            control: false,
507            partition_leader_epoch: 0,
508            producer_id: 7,
509            producer_epoch: 0,
510            timestamp_type: TimestampType::Creation,
511            offset,
512            sequence: offset as i32,
513            timestamp: 1_000 + offset,
514            key: key.map(Bytes::copy_from_slice),
515            value: value.map(Bytes::copy_from_slice),
516            headers: Default::default(),
517        }
518    }
519
520    /// The property that matters: the same bytes, read two ways, agree.
521    #[test]
522    fn agrees_with_the_reference_decoder() {
523        let records: Vec<Record> = (0..64)
524            .map(|i| {
525                record(
526                    i,
527                    Some(format!("k{i}").as_bytes()),
528                    Some(format!("value-{i}").as_bytes()),
529                )
530            })
531            .collect();
532        let encoded = encode(&records, Compression::None);
533
534        let reference = kafka_protocol::records::RecordBatchDecoder::decode(&mut encoded.clone())
535            .expect("reference decode")
536            .records;
537        let lean = decode_lean(&encoded)
538            .expect("lean decode")
539            .expect("handled");
540
541        let flat: Vec<_> = lean
542            .iter()
543            .flat_map(|batch| batch.records.iter().map(move |r| (batch, r)))
544            .collect();
545        assert_eq!(flat.len(), reference.len());
546
547        for ((batch, lean), reference) in flat.iter().zip(&reference) {
548            assert_eq!(lean.offset, reference.offset, "offset");
549            assert_eq!(lean.timestamp, reference.timestamp, "timestamp");
550            assert_eq!(batch.key(lean), reference.key, "key");
551            assert_eq!(batch.value(lean), reference.value, "value");
552            assert_eq!(batch.producer_id, reference.producer_id, "producer id");
553        }
554    }
555
556    #[test]
557    fn a_null_key_stays_null() {
558        let encoded = encode(&[record(0, None, Some(b"v"))], Compression::None);
559        let lean = decode_lean(&encoded).expect("decode").expect("handled");
560        let batch = &lean[0];
561        assert_eq!(batch.key(&batch.records[0]), None);
562        assert_eq!(
563            batch.value(&batch.records[0]),
564            Some(Bytes::from_static(b"v"))
565        );
566    }
567
568    /// Every codec round-trips, and against the reference decoder's output.
569    #[test]
570    fn every_compression_codec_round_trips() {
571        for compression in [
572            Compression::Gzip,
573            Compression::Snappy,
574            Compression::Lz4,
575            Compression::Zstd,
576        ] {
577            let records: Vec<Record> = (0..32)
578                .map(|i| {
579                    record(
580                        i,
581                        Some(format!("k{i}").as_bytes()),
582                        Some(format!("value-{i}").as_bytes()),
583                    )
584                })
585                .collect();
586            let encoded = encode(&records, compression);
587
588            let lean = decode_lean(&encoded)
589                .unwrap_or_else(|e| panic!("{compression:?}: {e}"))
590                .unwrap_or_else(|| panic!("{compression:?} was handed back"));
591            let flat: Vec<_> = lean
592                .iter()
593                .flat_map(|b| b.records.iter().map(move |r| (b, r)))
594                .collect();
595            assert_eq!(flat.len(), records.len(), "{compression:?}");
596            for ((batch, lean), reference) in flat.iter().zip(&records) {
597                assert_eq!(lean.offset, reference.offset, "{compression:?} offset");
598                assert_eq!(batch.value(lean), reference.value, "{compression:?} value");
599            }
600        }
601    }
602
603    /// Headers survive, and are read from the region rather than at decode time.
604    #[test]
605    fn headers_are_read_on_demand() {
606        let mut with_headers = record(0, Some(b"k"), Some(b"v"));
607        with_headers.headers.insert(
608            kafka_protocol::protocol::StrBytes::from_static_str("trace"),
609            Some(Bytes::from_static(b"abc")),
610        );
611        with_headers.headers.insert(
612            kafka_protocol::protocol::StrBytes::from_static_str("empty"),
613            None,
614        );
615        let encoded = encode(&[with_headers], Compression::None);
616
617        let lean = decode_lean(&encoded).expect("decode").expect("handled");
618        let batch = &lean[0];
619        let headers = batch.headers(&batch.records[0]).expect("headers");
620        assert_eq!(headers.len(), 2);
621        assert_eq!(headers[0].0, Bytes::from_static(b"trace"));
622        assert_eq!(headers[0].1, Some(Bytes::from_static(b"abc")));
623        assert_eq!(headers[1].0, Bytes::from_static(b"empty"));
624        assert_eq!(headers[1].1, None);
625    }
626
627    /// A record with no headers costs nothing and reports nothing.
628    #[test]
629    fn no_headers_is_empty() {
630        let encoded = encode(&[record(0, None, Some(b"v"))], Compression::None);
631        let lean = decode_lean(&encoded).expect("decode").expect("handled");
632        let batch = &lean[0];
633        assert!(batch
634            .headers(&batch.records[0])
635            .expect("headers")
636            .is_empty());
637    }
638
639    /// A response cut off at `max_bytes` ends mid-batch. That fragment is not
640    /// an error — the broker does it on purpose — and must simply be ignored.
641    #[test]
642    fn a_truncated_trailing_batch_is_ignored() {
643        let encoded = encode(&[record(0, None, Some(b"v"))], Compression::None);
644        let mut truncated = bytes::BytesMut::from(&encoded[..]);
645        truncated.extend_from_slice(&encoded[..encoded.len() / 2]);
646        let lean = decode_lean(&truncated.freeze())
647            .expect("decode")
648            .expect("handled");
649        assert_eq!(
650            lean.len(),
651            1,
652            "the whole batch is kept, the fragment is not"
653        );
654        assert_eq!(lean[0].records.len(), 1);
655    }
656
657    /// A corrupted body must be caught, not handed to the caller.
658    #[test]
659    fn a_bad_crc_is_an_error() {
660        let encoded = encode(&[record(0, None, Some(b"value"))], Compression::None);
661        let mut corrupt = bytes::BytesMut::from(&encoded[..]);
662        let last = corrupt.len() - 1;
663        corrupt[last] ^= 0xff;
664        assert!(decode_lean(&corrupt.freeze()).is_err());
665    }
666}