Skip to main content

omnizip_lz4/
lib.rs

1//! Pure-Rust LZ4 codec — in-house block + frame encoder + decoder.
2//!
3//! Two variants are registered as separate codecs:
4//!
5//! - [`Lz4FastCodec`] (codec id `LZ4`): port of the C reference's fast
6//!   loop (`lz4 -1`); ratio within 0.4% of the reference CLI.
7//! - [`Lz4HcCodec`] (codec id `LZ4_HC`): high-compression encoder with
8//!   hash-chain match finder + lazy parsing (see [`hc`]). 2-3× better
9//!   ratio at the cost of slower encode.
10//!
11//! Both use the same LZ4 block format with a 4-byte LE original-size
12//! prefix. The in-house encoder + decoder are implemented from spec in
13//! [`block`] and [`frame`].
14
15#![forbid(unsafe_code)]
16#![warn(clippy::pedantic)]
17
18pub mod block;
19pub mod frame;
20mod hc;
21pub mod streaming;
22
23use omnizip_codecs::{Codec, CodecId, CompressionLevel, OmnizipError};
24
25/// LZ4 fast codec — port of `LZ4_compress_generic` (see [`block`]).
26pub struct Lz4FastCodec;
27
28/// LZ4 high-compression codec. Uses an in-house hash-chain match
29/// finder + lazy parsing (see [`hc`]). Same decode path as
30/// [`Lz4FastCodec`] — produces LZ4 block-format bytes that any
31/// LZ4 decoder can read.
32pub struct Lz4HcCodec;
33
34impl Codec for Lz4FastCodec {
35    fn id(&self) -> CodecId {
36        CodecId::LZ4
37    }
38    fn name(&self) -> &'static str {
39        "lz4"
40    }
41    fn compress(
42        &self,
43        plaintext: &[u8],
44        _level: CompressionLevel,
45    ) -> Result<Vec<u8>, OmnizipError> {
46        let compressed = block::compress_block(plaintext);
47        let mut out = Vec::with_capacity(4 + compressed.len());
48        out.extend_from_slice(&(plaintext.len() as u32).to_le_bytes());
49        out.extend_from_slice(&compressed);
50        Ok(out)
51    }
52    fn decompress(&self, compressed: &[u8], expected_len: u32) -> Result<Vec<u8>, OmnizipError> {
53        decompress_lz4(compressed, expected_len, CodecId::LZ4)
54    }
55
56    fn default_fast_level(&self) -> u8 {
57        1
58    }
59    fn default_balanced_level(&self) -> u8 {
60        1
61    }
62    fn default_max_ratio_level(&self) -> u8 {
63        1
64    }
65
66    fn capabilities(&self) -> omnizip_codecs::Capabilities {
67        omnizip_codecs::Capabilities {
68            min_level: 1,
69            max_level: 1,
70            streaming: true, // Lz4StreamingEncoder/Decoder landed
71            parallel_batch: true,
72            has_static_dictionary: false,
73            content_type_aware: false,
74            approx_throughput_mbps: 500,
75        }
76    }
77}
78
79/// LZ4 Fast: minimal memory — just input + output + 16 KB hash table.
80impl omnizip_codecs::MemoryBudget for Lz4FastCodec {
81    fn estimated_compress_memory(&self, input_len: usize, _level: CompressionLevel) -> usize {
82        let hash_table = 16 * 1024; // 4 KB hash buckets
83        input_len + input_len / 2 + hash_table
84    }
85}
86
87impl Codec for Lz4HcCodec {
88    fn id(&self) -> CodecId {
89        CodecId::LZ4_HC
90    }
91    fn name(&self) -> &'static str {
92        "lz4-hc"
93    }
94    fn compress(
95        &self,
96        plaintext: &[u8],
97        _level: CompressionLevel,
98    ) -> Result<Vec<u8>, OmnizipError> {
99        let compressed = hc::compress(plaintext);
100        let mut out = Vec::with_capacity(4 + compressed.len());
101        out.extend_from_slice(&(plaintext.len() as u32).to_le_bytes());
102        out.extend_from_slice(&compressed);
103        Ok(out)
104    }
105    fn decompress(&self, compressed: &[u8], expected_len: u32) -> Result<Vec<u8>, OmnizipError> {
106        decompress_lz4(compressed, expected_len, CodecId::LZ4_HC)
107    }
108
109    fn default_fast_level(&self) -> u8 {
110        4
111    }
112    fn default_balanced_level(&self) -> u8 {
113        9
114    }
115    fn default_max_ratio_level(&self) -> u8 {
116        12
117    }
118
119    fn capabilities(&self) -> omnizip_codecs::Capabilities {
120        omnizip_codecs::Capabilities {
121            min_level: 1,
122            max_level: 12,
123            streaming: false, // HC mode is one-shot
124            parallel_batch: true,
125            has_static_dictionary: false,
126            content_type_aware: false,
127            approx_throughput_mbps: 200,
128        }
129    }
130}
131
132/// LZ4 HC: larger hash table + chain for deeper search.
133impl omnizip_codecs::MemoryBudget for Lz4HcCodec {
134    fn estimated_compress_memory(&self, input_len: usize, level: CompressionLevel) -> usize {
135        let table_size = if level.as_u8() >= 9 {
136            64 * 1024 // larger hash + chain for high levels
137        } else {
138            16 * 1024
139        };
140        input_len + input_len / 2 + table_size
141    }
142}
143
144/// Decompress a size-prepended LZ4 block (4-byte LE size + block data).
145fn decompress_lz4(
146    compressed: &[u8],
147    expected_len: u32,
148    codec: CodecId,
149) -> Result<Vec<u8>, OmnizipError> {
150    if compressed.len() < 4 {
151        return Err(OmnizipError::Corrupt {
152            codec,
153            reason: "input too short for size prefix".into(),
154        });
155    }
156    let stored_len =
157        u32::from_le_bytes([compressed[0], compressed[1], compressed[2], compressed[3]]) as usize;
158    let block_data = &compressed[4..];
159    let decoded = block::decompress_block(block_data, stored_len).map_err(|reason| {
160        OmnizipError::DecodeFailed {
161            codec,
162            reason: format!("lz4 block decode failed: {reason}"),
163        }
164    })?;
165
166    let expected_us = usize::try_from(expected_len).map_err(|_| OmnizipError::Corrupt {
167        codec,
168        reason: format!("expected_len {expected_len} exceeds usize"),
169    })?;
170    if decoded.len() != expected_us {
171        return Err(OmnizipError::LengthMismatch {
172            codec,
173            expected: expected_len,
174            actual: decoded.len(),
175        });
176    }
177    Ok(decoded)
178}
179
180/// Compress using LZ4 frame format (compatible with `lz4 -d` CLI).
181///
182/// Uses the in-house frame encoder from [`frame`].
183///
184/// # Errors
185///
186/// Currently infallible; returns `Ok` always.
187pub fn compress_frame(plaintext: &[u8]) -> Result<Vec<u8>, OmnizipError> {
188    Ok(frame::compress_frame(plaintext))
189}
190
191/// Decompress an LZ4 frame (compatible with `lz4` CLI output).
192///
193/// # Errors
194///
195/// Returns [`OmnizipError::DecodeFailed`] on malformed frame.
196pub fn decompress_frame(compressed: &[u8]) -> Result<Vec<u8>, OmnizipError> {
197    frame::decompress_frame(compressed).map_err(|reason| OmnizipError::DecodeFailed {
198        codec: CodecId::LZ4,
199        reason: format!("lz4 frame decode failed: {reason}"),
200    })
201}
202
203#[cfg(test)]
204#[allow(clippy::cast_possible_truncation)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn fast_round_trips_text() {
210        let data = b"Lorem ipsum dolor sit amet. ".repeat(100);
211        let compressed = Lz4FastCodec
212            .compress(&data, CompressionLevel::default())
213            .expect("compress");
214        let decompressed = Lz4FastCodec
215            .decompress(&compressed, data.len() as u32)
216            .expect("decompress");
217        assert_eq!(decompressed, data);
218    }
219
220    #[test]
221    fn hc_round_trips_text() {
222        let data = b"Lorem ipsum dolor sit amet. ".repeat(100);
223        let compressed = Lz4HcCodec
224            .compress(&data, CompressionLevel::default())
225            .expect("compress");
226        let decompressed = Lz4HcCodec
227            .decompress(&compressed, data.len() as u32)
228            .expect("decompress");
229        assert_eq!(decompressed, data);
230    }
231
232    #[test]
233    fn fast_and_hc_share_decoder_format() {
234        let data = b"The quick brown fox. ".repeat(1000);
235        let fast_compressed = Lz4FastCodec
236            .compress(&data, CompressionLevel::default())
237            .expect("fast compress");
238        let hc_compressed = Lz4HcCodec
239            .compress(&data, CompressionLevel::default())
240            .expect("hc compress");
241        let from_fast = Lz4FastCodec
242            .decompress(&hc_compressed, data.len() as u32)
243            .expect("cross-decode fast from hc");
244        let from_hc = Lz4HcCodec
245            .decompress(&fast_compressed, data.len() as u32)
246            .expect("cross-decode hc from fast");
247        assert_eq!(from_fast, data);
248        assert_eq!(from_hc, data);
249    }
250
251    #[test]
252    fn compresses_repetitive_data() {
253        let data = vec![0x41u8; 10_000];
254        let fast = Lz4FastCodec
255            .compress(&data, CompressionLevel::default())
256            .expect("fast");
257        assert!(fast.len() < data.len());
258    }
259
260    #[test]
261    fn hc_produces_different_output_than_fast() {
262        let data: Vec<u8> = (0..10_000)
263            .map(|i| {
264                if i % 100 < 50 {
265                    (i % 26 + b'a' as i32) as u8
266                } else {
267                    (i % 256) as u8
268                }
269            })
270            .collect();
271        let fast = Lz4FastCodec
272            .compress(&data, CompressionLevel::default())
273            .expect("fast");
274        let hc = Lz4HcCodec
275            .compress(&data, CompressionLevel::default())
276            .expect("hc");
277        assert_ne!(fast, hc, "HC must produce different output");
278    }
279
280    #[test]
281    fn frame_round_trip() {
282        let data = b"the quick brown fox jumps over the lazy dog. ".repeat(10);
283        let compressed = compress_frame(&data).expect("frame compress");
284        let decompressed = decompress_frame(&compressed).expect("frame decompress");
285        assert_eq!(decompressed.as_slice(), data.as_slice());
286    }
287}