Skip to main content

krabka_compression/
lib.rs

1//! Kafka wire-protocol compression codecs.
2//!
3//! Kafka uses four codecs on the wire: gzip, snappy, lz4, and zstd. Each codec
4//! has specific framing conventions.
5//!
6//! - **gzip**: standard RFC-1952 gzip through `flate2`, which uses the
7//!   pure-Rust `miniz_oxide` backend.
8//! - **snappy**: xerial-snappy framing over `snap` raw blocks. Kafka does not
9//!   use the standard Google Snappy stream format. It uses the xerial framing:
10//!   an 8-byte magic header, two 4-byte version fields, then a sequence of
11//!   `u32-BE` length-prefixed raw snappy chunks.
12//! - **lz4**: LZ4 frame format with magic `0x04 22 4D 18`, independent blocks,
13//!   and 64 KiB block size. These are the defaults of
14//!   `KafkaLZ4BlockOutputStream`.
15//! - **zstd**: plain zstd at compression level 3, which is Kafka's default.
16//!
17//! Each codec is behind a Cargo feature: `gzip`, `snappy`, `lz4`, and `zstd`.
18//! All of them are enabled by default. If you disable a feature, the API stays
19//! the same, but the call returns
20//! `Err(`[`CompressionError::FeatureDisabled`]`)` at runtime.
21//!
22//! ## Compress and decompress a record payload
23//!
24//! ```rust
25//! use krabka_compression::{CompressionType, compress, decompress};
26//! use krabka_units::kibibytes;
27//!
28//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
29//! let compressed = compress(CompressionType::Lz4, b"order-created")?;
30//! let plain = decompress(CompressionType::Lz4, &compressed, kibibytes(1))?;
31//! assert_eq!(plain.as_ref(), b"order-created");
32//! # Ok(())
33//! # }
34//! ```
35
36mod codec_type;
37mod error;
38
39use bytes::Bytes;
40pub use codec_type::CompressionType;
41pub use error::CompressionError;
42use krabka_units::{
43    ByteSize, Ratio,
44    convert::{ByteSizeExt as _, RatioExt as _},
45    fraction, gibibytes, mebibytes,
46};
47use refined_type::rule::MinMaxU64;
48
49/// Fixed security ceiling for record decompression expansion.
50pub const RECORD_DECOMPRESSION_HARD_MAX_RATIO: Ratio = fraction(100.0);
51
52/// Fixed security ceiling for a decompressed record payload.
53pub const RECORD_DECOMPRESSION_HARD_MAX_OUTPUT: ByteSize = gibibytes(1);
54
55const RECORD_DECOMPRESSION_HARD_MAX_OUTPUT_BYTES: u64 = 1_073_741_824;
56type RefinedRecordDecompressionBytes = MinMaxU64<1, RECORD_DECOMPRESSION_HARD_MAX_OUTPUT_BYTES>;
57
58/// Validated limits for decompressing untrusted Kafka record payloads.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub struct RecordDecompressionPolicy {
61    max_ratio: Ratio,
62    output_floor: ByteSize,
63    output_ceiling: ByteSize,
64}
65
66impl RecordDecompressionPolicy {
67    /// Validate policy values against the fixed decompression security bounds.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error for a non-positive or non-finite ratio, fractional or
72    /// non-positive byte limits, an inverted range, or a weakened hard bound.
73    pub fn new(
74        max_ratio: Ratio,
75        output_floor: ByteSize,
76        output_ceiling: ByteSize,
77    ) -> Result<Self, String> {
78        let ratio = max_ratio.as_f64();
79        if !ratio.is_finite()
80            || ratio <= 0.0
81            || ratio > RECORD_DECOMPRESSION_HARD_MAX_RATIO.as_f64()
82        {
83            return Err(
84                "record decompression ratio must be finite and within 0 < ratio <= 100".into(),
85            );
86        }
87        let floor = validated_whole_bytes("record decompression output floor", output_floor)?;
88        let ceiling = validated_whole_bytes("record decompression output ceiling", output_ceiling)?;
89        if floor > ceiling {
90            return Err("record decompression output floor exceeds ceiling".into());
91        }
92        Ok(Self {
93            max_ratio,
94            output_floor,
95            output_ceiling,
96        })
97    }
98
99    /// Return the maximum decompression expansion ratio.
100    #[must_use]
101    pub fn max_ratio(self) -> Ratio {
102        self.max_ratio
103    }
104
105    /// Return the minimum decompression output budget.
106    #[must_use]
107    pub fn output_floor(self) -> ByteSize {
108        self.output_floor
109    }
110
111    /// Return the maximum decompression output budget.
112    #[must_use]
113    pub fn output_ceiling(self) -> ByteSize {
114        self.output_ceiling
115    }
116
117    /// Calculate the output budget for a compressed payload.
118    #[must_use]
119    pub fn output_limit(self, compressed: ByteSize) -> ByteSize {
120        ByteSize::from_bytes_f64(
121            (compressed.bytes_f64() * self.max_ratio.as_f64())
122                .max(self.output_floor.bytes_f64())
123                .min(self.output_ceiling.bytes_f64()),
124        )
125    }
126}
127
128impl Default for RecordDecompressionPolicy {
129    fn default() -> Self {
130        Self {
131            max_ratio: RECORD_DECOMPRESSION_HARD_MAX_RATIO,
132            output_floor: mebibytes(16),
133            output_ceiling: RECORD_DECOMPRESSION_HARD_MAX_OUTPUT,
134        }
135    }
136}
137
138fn validated_whole_bytes(name: &str, value: ByteSize) -> Result<u64, String> {
139    let raw = value.bytes_f64();
140    if !raw.is_finite() || raw.fract() != 0.0 {
141        return Err(format!("{name} must be a positive whole number of bytes"));
142    }
143    RefinedRecordDecompressionBytes::new(value.bytes_u64())
144        .map(refined_type::Refined::into_value)
145        .map_err(|error| format!("{name}: {error}"))
146}
147
148/// Compress `data` using the codec identified by `ct`.
149///
150/// For `CompressionType::None`, the function returns the input unchanged in a
151/// new `Bytes`. For the other codecs, it dispatches to the per-codec module. If
152/// the codec's Cargo feature is not enabled, it returns
153/// `Err(CompressionError::FeatureDisabled(_))`.
154/// # Errors
155/// Returns an error when input is malformed, compression or decompression fails, or runtime rate-limit state cannot be updated.
156pub fn compress(ct: CompressionType, data: &[u8]) -> Result<Bytes, CompressionError> {
157    match ct {
158        CompressionType::None => Ok(Bytes::copy_from_slice(data)),
159        CompressionType::Gzip => gzip_compress(data),
160        CompressionType::Snappy => snappy_compress(data),
161        CompressionType::Lz4 => lz4_compress(data),
162        CompressionType::Zstd => zstd_compress(data),
163    }
164}
165
166/// Compress `data` using the codec identified by `ct` at compression `level`
167/// (KIP-390).
168///
169/// The level must pass [`CompressionType::check_level`], which follows Kafka's
170/// `CompressionType.levelValidator`. `compress(ct, data)` gives the same bytes
171/// as this function with [`CompressionType::default_level`].
172///
173/// - gzip: the deflate level. -1 is the zlib default, which is level 6.
174/// - zstd: the zstd level.
175/// - lz4: Kafka's `Lz4BlockOutputStream` uses the fast compressor at the
176///   default level 9, and LZ4 HC for every other level in `1..=17`. This
177///   function matches that split.
178///
179/// # Errors
180///
181/// Returns [`CompressionError::InvalidLevel`] when the level is out of range,
182/// or when the codec (`none`, `snappy`) has no levels. Returns
183/// [`CompressionError::FeatureDisabled`] when the codec's Cargo feature is not
184/// enabled.
185pub fn compress_with_level(
186    ct: CompressionType,
187    data: &[u8],
188    level: i32,
189) -> Result<Bytes, CompressionError> {
190    ct.check_level(level)?;
191    match ct {
192        CompressionType::Gzip => gzip_compress_with_level(data, level),
193        CompressionType::Lz4 => lz4_compress_with_level(data, level),
194        CompressionType::Zstd => zstd_compress_with_level(data, level),
195        // `check_level` rejects the codecs without levels.
196        CompressionType::None | CompressionType::Snappy => compress(ct, data),
197    }
198}
199
200/// Decompress `data` using the codec identified by `ct`. See `compress`.
201///
202/// `max_output` bounds the size of the decompressed output. If decompression
203/// would produce more than `max_output` bytes, the function returns
204/// `Err(CompressionError::TooLarge { .. })` and does not materialize the
205/// oversized buffer. This guards against decompression bombs on the untrusted
206/// decode path. Callers that handle wire input should derive `max_output` from
207/// the compressed length, for example from a bounded ratio plus an absolute
208/// ceiling.
209/// # Errors
210/// Returns an error when input is malformed, compression or decompression fails, or runtime rate-limit state cannot be updated.
211pub fn decompress(
212    ct: CompressionType,
213    data: &[u8],
214    max_output: ByteSize,
215) -> Result<Bytes, CompressionError> {
216    // The per-codec decoders compare against buffer lengths and size
217    // allocations, so they keep the exact `usize` count.
218    let max_output = max_output.bytes_usize();
219    match ct {
220        CompressionType::None => {
221            if data.len() > max_output {
222                Err(CompressionError::TooLarge { limit: max_output })
223            } else {
224                Ok(Bytes::copy_from_slice(data))
225            }
226        }
227        CompressionType::Gzip => gzip_decompress(data, max_output),
228        CompressionType::Snappy => snappy_decompress(data, max_output),
229        CompressionType::Lz4 => lz4_decompress(data, max_output),
230        CompressionType::Zstd => zstd_decompress(data, max_output),
231    }
232}
233
234// --- per-codec dispatch, with feature-gated stubs ------------------------
235
236#[cfg(feature = "gzip")]
237mod gzip;
238#[cfg(feature = "gzip")]
239use crate::gzip::{
240    compress as gzip_compress, compress_with_level as gzip_compress_with_level,
241    decompress as gzip_decompress,
242};
243#[cfg(not(feature = "gzip"))]
244fn gzip_compress(_: &[u8]) -> Result<Bytes, CompressionError> {
245    Err(CompressionError::FeatureDisabled("gzip"))
246}
247#[cfg(not(feature = "gzip"))]
248fn gzip_compress_with_level(_: &[u8], _: i32) -> Result<Bytes, CompressionError> {
249    Err(CompressionError::FeatureDisabled("gzip"))
250}
251#[cfg(not(feature = "gzip"))]
252fn gzip_decompress(_: &[u8], _: usize) -> Result<Bytes, CompressionError> {
253    Err(CompressionError::FeatureDisabled("gzip"))
254}
255
256#[cfg(feature = "snappy")]
257mod snappy;
258#[cfg(feature = "snappy")]
259use crate::snappy::{compress as snappy_compress, decompress as snappy_decompress};
260#[cfg(not(feature = "snappy"))]
261fn snappy_compress(_: &[u8]) -> Result<Bytes, CompressionError> {
262    Err(CompressionError::FeatureDisabled("snappy"))
263}
264#[cfg(not(feature = "snappy"))]
265fn snappy_decompress(_: &[u8], _: usize) -> Result<Bytes, CompressionError> {
266    Err(CompressionError::FeatureDisabled("snappy"))
267}
268
269#[cfg(feature = "lz4")]
270mod lz4;
271#[cfg(feature = "lz4")]
272use crate::lz4::{
273    compress as lz4_compress, compress_with_level as lz4_compress_with_level,
274    decompress as lz4_decompress,
275};
276#[cfg(not(feature = "lz4"))]
277fn lz4_compress(_: &[u8]) -> Result<Bytes, CompressionError> {
278    Err(CompressionError::FeatureDisabled("lz4"))
279}
280#[cfg(not(feature = "lz4"))]
281fn lz4_compress_with_level(_: &[u8], _: i32) -> Result<Bytes, CompressionError> {
282    Err(CompressionError::FeatureDisabled("lz4"))
283}
284#[cfg(not(feature = "lz4"))]
285fn lz4_decompress(_: &[u8], _: usize) -> Result<Bytes, CompressionError> {
286    Err(CompressionError::FeatureDisabled("lz4"))
287}
288
289#[cfg(feature = "zstd")]
290mod zstd;
291#[cfg(feature = "zstd")]
292use crate::zstd::{
293    compress as zstd_compress, compress_with_level as zstd_compress_with_level,
294    decompress as zstd_decompress,
295};
296#[cfg(not(feature = "zstd"))]
297fn zstd_compress(_: &[u8]) -> Result<Bytes, CompressionError> {
298    Err(CompressionError::FeatureDisabled("zstd"))
299}
300#[cfg(not(feature = "zstd"))]
301fn zstd_compress_with_level(_: &[u8], _: i32) -> Result<Bytes, CompressionError> {
302    Err(CompressionError::FeatureDisabled("zstd"))
303}
304#[cfg(not(feature = "zstd"))]
305fn zstd_decompress(_: &[u8], _: usize) -> Result<Bytes, CompressionError> {
306    Err(CompressionError::FeatureDisabled("zstd"))
307}
308
309#[cfg(test)]
310mod tests {
311    use krabka_units::{bytes, fraction, gibibytes, kibibytes, mebibytes};
312
313    use super::*;
314
315    #[test]
316    fn passthrough_none_compress() {
317        let out = compress(CompressionType::None, b"abcdef").unwrap();
318        assert2::assert!(out.as_ref() == b"abcdef");
319    }
320
321    #[test]
322    fn passthrough_none_decompress() {
323        let out = decompress(CompressionType::None, b"abcdef", kibibytes(1)).unwrap();
324        assert2::assert!(out.as_ref() == b"abcdef");
325    }
326
327    #[test]
328    fn passthrough_none_decompress_respects_cap() {
329        // Input larger than the cap is rejected even for the None passthrough.
330        assert2::assert!(matches!(
331            decompress(CompressionType::None, b"abcdef", bytes(3)),
332            Err(CompressionError::TooLarge { limit: 3 })
333        ));
334    }
335
336    #[test]
337    fn passthrough_none_decompress_at_exact_cap() {
338        // Boundary: input of exactly `max_output` bytes is allowed (the cap
339        // check is `len > max_output`, not `>=`).
340        let out = decompress(CompressionType::None, b"abcdef", bytes(6)).unwrap();
341        assert2::assert!(out.as_ref() == b"abcdef");
342    }
343
344    /// The level reaches the codec: a payload that the codec compresses
345    /// better at a higher level gives different bytes at the lowest and the
346    /// highest level, every output decompresses to the input, and the default
347    /// level gives the bytes of `compress`. Lz4's default level (9) also gives
348    /// the bytes of `compress`, but its other levels use HC, which differs
349    /// from the fast compressor at the low and high ends of its range.
350    #[test]
351    fn compress_with_level_applies_the_level() {
352        let payload: Vec<u8> = (0..64 * 1024u32)
353            .map(|i| u8::try_from((i * 7 + i / 13) % 251).unwrap_or(0))
354            .collect();
355        let mut actual = Vec::new();
356        let mut expected = Vec::new();
357        for (codec, low, high, levels_differ) in [
358            (CompressionType::Gzip, 1, 9, true),
359            (CompressionType::Zstd, 1, 19, true),
360            (CompressionType::Lz4, 1, 17, true),
361        ] {
362            let default = compress(codec, &payload).unwrap();
363            let at_default =
364                compress_with_level(codec, &payload, codec.default_level().unwrap()).unwrap();
365            let at_low = compress_with_level(codec, &payload, low).unwrap();
366            let at_high = compress_with_level(codec, &payload, high).unwrap();
367            let round_trips = [&at_default, &at_low, &at_high].iter().all(|out| {
368                decompress(codec, out, mebibytes(1)).unwrap().as_ref() == payload.as_slice()
369            });
370            actual.push((codec, at_default == default, at_low != at_high, round_trips));
371            expected.push((codec, true, levels_differ, true));
372        }
373        assert2::assert!(actual == expected);
374    }
375
376    #[test]
377    fn compress_with_level_rejects_a_level_kafka_rejects() {
378        for (codec, level, reason) in [
379            (
380                CompressionType::Gzip,
381                0,
382                "Value must be between 1 and 9 or equal to -1",
383            ),
384            (CompressionType::Lz4, 18, "Value must be no more than 17"),
385            (CompressionType::Zstd, 23, "Value must be no more than 22"),
386            (
387                CompressionType::Snappy,
388                1,
389                "Compression levels are not defined for this compression type: snappy",
390            ),
391        ] {
392            let error = compress_with_level(codec, b"payload", level).unwrap_err();
393            assert2::assert!(
394                matches!(
395                    &error,
396                    CompressionError::InvalidLevel { codec: name, level: got, reason: text }
397                        if *name == codec.name() && *got == level && text == reason
398                ),
399                "{error:?}"
400            );
401        }
402    }
403
404    #[test]
405    fn record_policy_preserves_existing_budget_curve() {
406        let policy = RecordDecompressionPolicy::default();
407        assert2::check!(policy.output_limit(bytes(1)) == mebibytes(16));
408        assert2::check!(policy.output_limit(mebibytes(1)) == mebibytes(100));
409        assert2::check!(policy.output_limit(mebibytes(11)) == gibibytes(1));
410    }
411
412    /// Every existing test reaches the policy through `default()` or
413    /// `output_limit()`, so nothing asserted that `new()` accepts a valid
414    /// policy, and nothing read the three accessors back.
415    ///
416    /// Both halves matter. `validated_whole_bytes` rejects fractional byte
417    /// counts through `raw.fract() != 0.0`; read as `== 0.0`, or with the
418    /// `!is_finite()` guard beside it inverted, it rejects the whole numbers
419    /// instead -- which only a successful construction notices, because the
420    /// existing test asserts that bad input is refused, not that good input is
421    /// taken. Reading the accessors back is what distinguishes them from
422    /// `Default::default()`, which for these types is a plausible-looking zero.
423    #[test]
424    fn record_policy_accepts_whole_byte_bounds_and_reports_them_back() {
425        let policy = RecordDecompressionPolicy::new(fraction(50.0), mebibytes(8), gibibytes(1))
426            .expect("a finite in-range ratio with whole-byte bounds is a valid policy");
427
428        assert2::check!(policy.max_ratio() == fraction(50.0));
429        assert2::check!(policy.output_floor() == mebibytes(8));
430        assert2::check!(policy.output_ceiling() == gibibytes(1));
431    }
432
433    #[test]
434    fn record_policy_rejects_invalid_or_weakened_security_bounds() {
435        for result in [
436            RecordDecompressionPolicy::new(fraction(0.0), mebibytes(16), gibibytes(1)),
437            RecordDecompressionPolicy::new(fraction(101.0), mebibytes(16), gibibytes(1)),
438            RecordDecompressionPolicy::new(fraction(100.0), gibibytes(1), mebibytes(16)),
439            RecordDecompressionPolicy::new(fraction(100.0), mebibytes(16), gibibytes(2)),
440            RecordDecompressionPolicy::new(
441                fraction(100.0),
442                ByteSize::from_bytes_f64(0.5),
443                gibibytes(1),
444            ),
445        ] {
446            assert2::check!(result.is_err());
447        }
448    }
449}