1mod 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
49pub const RECORD_DECOMPRESSION_HARD_MAX_RATIO: Ratio = fraction(100.0);
51
52pub 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#[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 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 #[must_use]
101 pub fn max_ratio(self) -> Ratio {
102 self.max_ratio
103 }
104
105 #[must_use]
107 pub fn output_floor(self) -> ByteSize {
108 self.output_floor
109 }
110
111 #[must_use]
113 pub fn output_ceiling(self) -> ByteSize {
114 self.output_ceiling
115 }
116
117 #[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
148pub 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
166pub 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 CompressionType::None | CompressionType::Snappy => compress(ct, data),
197 }
198}
199
200pub fn decompress(
212 ct: CompressionType,
213 data: &[u8],
214 max_output: ByteSize,
215) -> Result<Bytes, CompressionError> {
216 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#[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 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 let out = decompress(CompressionType::None, b"abcdef", bytes(6)).unwrap();
341 assert2::assert!(out.as_ref() == b"abcdef");
342 }
343
344 #[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 #[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}