Skip to main content

asdf_core/
compression.rs

1//! Block compression.
2//!
3//! The standard defines `zlib` and `bzp2` and says implementations should
4//! support both. `lz4` is a de-facto extension that Python asdf and libasdf
5//! both implement, with a framing of their own that this module reproduces
6//! exactly -- see [`lz4`].
7//!
8//! The name is stored in a four-byte field in the block header, so every
9//! identifier is at most four bytes and an all-zero field means uncompressed.
10
11use crate::error::{Result, err};
12
13/// A compression method understood by this library.
14#[derive(Clone, Copy, PartialEq, Eq, Debug)]
15pub enum Compression {
16    /// No compression; the header's compression field is all zeros.
17    None,
18    /// zlib, as defined by the standard.
19    Zlib,
20    /// bzip2, as defined by the standard.
21    Bzp2,
22    /// LZ4, a de-facto extension shared with Python asdf.
23    Lz4,
24}
25
26impl Compression {
27    /// Parse the four-byte identifier.
28    ///
29    /// An empty name means no compression, matching the all-zero field.
30    pub fn from_name(name: &str) -> Result<Self> {
31        match name {
32            "" => Ok(Compression::None),
33            "zlib" => Ok(Compression::Zlib),
34            "bzp2" => Ok(Compression::Bzp2),
35            "lz4" => Ok(Compression::Lz4),
36            other => Err(err!(UnknownCompression, "unknown compression type: {other}")),
37        }
38    }
39
40    /// The identifier as written to the block header.
41    pub fn name(self) -> &'static str {
42        match self {
43            Compression::None => "",
44            Compression::Zlib => "zlib",
45            Compression::Bzp2 => "bzp2",
46            Compression::Lz4 => "lz4",
47        }
48    }
49
50    /// Whether support for this method was compiled in.
51    pub fn is_available(self) -> bool {
52        match self {
53            Compression::None => true,
54            Compression::Zlib => cfg!(feature = "zlib"),
55            Compression::Bzp2 => cfg!(feature = "bzp2"),
56            Compression::Lz4 => cfg!(feature = "lz4"),
57        }
58    }
59
60    /// Decompress `data`, which is expected to expand to `expected_size` bytes.
61    pub fn decompress(self, data: &[u8], expected_size: usize) -> Result<Vec<u8>> {
62        match self {
63            Compression::None => Ok(data.to_vec()),
64            Compression::Zlib => zlib::decompress(data, expected_size),
65            Compression::Bzp2 => bzp2::decompress(data, expected_size),
66            Compression::Lz4 => lz4::decompress(data, expected_size),
67        }
68    }
69
70    /// Compress `data`.
71    pub fn compress(self, data: &[u8]) -> Result<Vec<u8>> {
72        match self {
73            Compression::None => Ok(data.to_vec()),
74            Compression::Zlib => zlib::compress(data),
75            Compression::Bzp2 => bzp2::compress(data),
76            Compression::Lz4 => lz4::compress(data),
77        }
78    }
79}
80
81/// Every method this build supports, for reporting.
82pub fn available() -> Vec<Compression> {
83    [Compression::Zlib, Compression::Bzp2, Compression::Lz4]
84        .into_iter()
85        .filter(|c| c.is_available())
86        .collect()
87}
88
89/// Guard against a corrupt header claiming an absurd decompressed size.
90///
91/// The standard's own limit is the 64-bit size field, but a claim far beyond
92/// the input's plausible expansion is a sign of corruption rather than a
93/// legitimate very large block, and allocating on it is a denial-of-service
94/// waiting to happen.
95const MAX_EXPANSION_RATIO: usize = 4096;
96
97fn check_expected_size(compressed_len: usize, expected: usize) -> Result<()> {
98    let ceiling = compressed_len.saturating_mul(MAX_EXPANSION_RATIO).max(1 << 20);
99    if expected > ceiling {
100        return Err(err!(
101            CompressionFailed,
102            "block claims to decompress {expected} bytes from {compressed_len}, \
103             beyond the {MAX_EXPANSION_RATIO}x sanity limit"
104        ));
105    }
106    Ok(())
107}
108
109mod zlib {
110    use super::*;
111
112    #[cfg(feature = "zlib")]
113    pub fn decompress(data: &[u8], expected: usize) -> Result<Vec<u8>> {
114        use std::io::Read;
115        check_expected_size(data.len(), expected)?;
116        let mut out = Vec::with_capacity(expected);
117        flate2::read::ZlibDecoder::new(data)
118            .read_to_end(&mut out)
119            .map_err(|e| err!(CompressionFailed, "zlib decompression failed: {e}"))?;
120        Ok(out)
121    }
122
123    #[cfg(feature = "zlib")]
124    pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
125        use std::io::Write;
126        let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
127        enc.write_all(data)
128            .and_then(|()| enc.finish())
129            .map_err(|e| err!(CompressionFailed, "zlib compression failed: {e}"))
130    }
131
132    #[cfg(not(feature = "zlib"))]
133    pub fn decompress(_data: &[u8], _expected: usize) -> Result<Vec<u8>> {
134        Err(err!(UnknownCompression, "zlib support was not compiled in"))
135    }
136
137    #[cfg(not(feature = "zlib"))]
138    pub fn compress(_data: &[u8]) -> Result<Vec<u8>> {
139        Err(err!(UnknownCompression, "zlib support was not compiled in"))
140    }
141}
142
143mod bzp2 {
144    use super::*;
145
146    #[cfg(feature = "bzp2")]
147    pub fn decompress(data: &[u8], expected: usize) -> Result<Vec<u8>> {
148        use std::io::Read;
149        check_expected_size(data.len(), expected)?;
150        let mut out = Vec::with_capacity(expected);
151        bzip2::read::BzDecoder::new(data)
152            .read_to_end(&mut out)
153            .map_err(|e| err!(CompressionFailed, "bzip2 decompression failed: {e}"))?;
154        Ok(out)
155    }
156
157    #[cfg(feature = "bzp2")]
158    pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
159        use std::io::Write;
160        // Upstream uses block size 9 and work factor 30.
161        let mut enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::best());
162        enc.write_all(data)
163            .and_then(|()| enc.finish())
164            .map_err(|e| err!(CompressionFailed, "bzip2 compression failed: {e}"))
165    }
166
167    #[cfg(not(feature = "bzp2"))]
168    pub fn decompress(_data: &[u8], _expected: usize) -> Result<Vec<u8>> {
169        Err(err!(UnknownCompression, "bzip2 support was not compiled in"))
170    }
171
172    #[cfg(not(feature = "bzp2"))]
173    pub fn compress(_data: &[u8]) -> Result<Vec<u8>> {
174        Err(err!(UnknownCompression, "bzip2 support was not compiled in"))
175    }
176}
177
178/// ASDF's LZ4 framing.
179///
180/// This is *not* the LZ4 frame format. Both libasdf and Python asdf write a
181/// sequence of chunks, each laid out as:
182///
183/// ```text
184///   [u32 big-endian]     length of everything that follows for this chunk
185///   [u32 little-endian]  the chunk's decompressed size
186///   [bytes]              a raw LZ4 block
187/// ```
188///
189/// The big-endian length *includes* the four-byte little-endian size, which
190/// is python-lz4's own header. The inner pair is therefore exactly
191/// `lz4_flex`'s "size prepended" block format. Chunks are 4 MiB of input
192/// each, matching both existing implementations.
193pub mod lz4 {
194    use super::*;
195
196    /// The uncompressed chunk size both other implementations use.
197    pub const CHUNK_SIZE: usize = 1 << 22;
198
199    /// The per-chunk framing overhead: a big-endian length and a
200    /// little-endian decompressed size.
201    pub const CHUNK_HEADER_SIZE: usize = 8;
202
203    #[cfg(feature = "lz4")]
204    pub fn decompress(data: &[u8], expected: usize) -> Result<Vec<u8>> {
205        check_expected_size(data.len(), expected)?;
206        let mut out = Vec::with_capacity(expected);
207        let mut pos = 0usize;
208
209        while pos < data.len() {
210            if pos + 4 > data.len() {
211                return Err(err!(
212                    CompressionFailed,
213                    "lz4 stream truncated in a chunk length at offset {pos}"
214                ));
215            }
216            let framed_len =
217                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
218                    as usize;
219            pos += 4;
220
221            if framed_len < 4 || pos + framed_len > data.len() {
222                return Err(err!(
223                    CompressionFailed,
224                    "lz4 chunk at offset {pos} claims {framed_len} bytes, \
225                     past the end of the {} byte stream",
226                    data.len()
227                ));
228            }
229
230            // The framed length covers python-lz4's little-endian size header
231            // plus the block, which together are what `decompress_size_prepended`
232            // expects.
233            let chunk = &data[pos..pos + framed_len];
234            let decoded = lz4_flex::block::decompress_size_prepended(chunk)
235                .map_err(|e| err!(CompressionFailed, "lz4 decompression failed: {e}"))?;
236            out.extend_from_slice(&decoded);
237            pos += framed_len;
238        }
239        Ok(out)
240    }
241
242    #[cfg(feature = "lz4")]
243    pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
244        let mut out = Vec::new();
245        // An empty input produces an empty stream, as upstream's loop does.
246        for chunk in data.chunks(CHUNK_SIZE) {
247            let framed = lz4_flex::block::compress_prepend_size(chunk);
248            let len = u32::try_from(framed.len())
249                .map_err(|_| err!(CompressionFailed, "lz4 chunk too large to frame"))?;
250            out.extend_from_slice(&len.to_be_bytes());
251            out.extend_from_slice(&framed);
252        }
253        Ok(out)
254    }
255
256    #[cfg(not(feature = "lz4"))]
257    pub fn decompress(_data: &[u8], _expected: usize) -> Result<Vec<u8>> {
258        Err(err!(UnknownCompression, "lz4 support was not compiled in"))
259    }
260
261    #[cfg(not(feature = "lz4"))]
262    pub fn compress(_data: &[u8]) -> Result<Vec<u8>> {
263        Err(err!(UnknownCompression, "lz4 support was not compiled in"))
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::error::ErrorCode;
271
272    /// A counter array: realistic ndarray data, but nearly incompressible
273    /// for a fast low-ratio codec like LZ4.
274    fn counter_payload() -> Vec<u8> {
275        let mut v = Vec::new();
276        for i in 0..10_000u32 {
277            v.extend_from_slice(&i.to_le_bytes());
278        }
279        v
280    }
281
282    /// Data with real redundancy, of the kind every codec should shrink.
283    fn compressible_payload() -> Vec<u8> {
284        let mut v = Vec::new();
285        for i in 0..10_000u32 {
286            v.extend_from_slice(&(i % 16).to_le_bytes());
287        }
288        v
289    }
290
291    #[test]
292    fn names_round_trip() {
293        for c in [Compression::None, Compression::Zlib, Compression::Bzp2, Compression::Lz4] {
294            assert_eq!(Compression::from_name(c.name()).unwrap(), c);
295            // Every identifier must fit the header's four-byte field.
296            assert!(c.name().len() <= 4, "{:?} name too long", c);
297        }
298    }
299
300    #[test]
301    fn unknown_names_are_rejected() {
302        let e = Compression::from_name("zstd").unwrap_err();
303        assert_eq!(e.code(), ErrorCode::UnknownCompression);
304    }
305
306    #[test]
307    fn round_trips_through_every_method() {
308        for data in [counter_payload(), compressible_payload()] {
309            for c in available() {
310                let packed = c.compress(&data).unwrap_or_else(|e| panic!("{:?}: {e}", c));
311                let unpacked =
312                    c.decompress(&packed, data.len()).unwrap_or_else(|e| panic!("{:?}: {e}", c));
313                assert_eq!(unpacked, data, "{:?} did not round trip", c);
314            }
315        }
316    }
317
318    #[test]
319    fn every_method_shrinks_redundant_data() {
320        // Deliberately separate from the round-trip test: LZ4 trades ratio
321        // for speed and does not shrink a counter array, so asserting a ratio
322        // on arbitrary data would be wrong rather than a real failure.
323        let data = compressible_payload();
324        for c in available() {
325            let packed = c.compress(&data).unwrap();
326            assert!(
327                packed.len() < data.len(),
328                "{:?} grew {} bytes to {}",
329                c,
330                data.len(),
331                packed.len()
332            );
333        }
334    }
335
336    #[test]
337    fn round_trips_empty_and_tiny_inputs() {
338        for c in available() {
339            for data in [vec![], vec![0u8], vec![7u8; 3]] {
340                let packed = c.compress(&data).unwrap();
341                let unpacked = c.decompress(&packed, data.len()).unwrap();
342                assert_eq!(unpacked, data, "{:?} failed on {} bytes", c, data.len());
343            }
344        }
345    }
346
347    #[test]
348    fn none_is_a_passthrough() {
349        let data = b"unchanged".to_vec();
350        assert_eq!(Compression::None.compress(&data).unwrap(), data);
351        assert_eq!(Compression::None.decompress(&data, data.len()).unwrap(), data);
352    }
353
354    #[cfg(feature = "lz4")]
355    #[test]
356    fn lz4_uses_the_asdf_chunk_framing() {
357        // The framing is shared with Python asdf and libasdf, so its shape is
358        // a compatibility contract, not an implementation detail.
359        let data = vec![0xABu8; 1000];
360        let packed = lz4::compress(&data).unwrap();
361
362        assert!(packed.len() > lz4::CHUNK_HEADER_SIZE);
363        let framed_len = u32::from_be_bytes([packed[0], packed[1], packed[2], packed[3]]) as usize;
364        assert_eq!(
365            framed_len,
366            packed.len() - 4,
367            "the big-endian length must cover the rest of the chunk"
368        );
369
370        let decompressed_size =
371            u32::from_le_bytes([packed[4], packed[5], packed[6], packed[7]]) as usize;
372        assert_eq!(
373            decompressed_size,
374            data.len(),
375            "the little-endian header must carry the decompressed size"
376        );
377    }
378
379    #[cfg(feature = "lz4")]
380    #[test]
381    fn lz4_splits_large_inputs_into_chunks() {
382        // Just over one chunk, so the stream must contain two frames.
383        let data = vec![0x5Au8; lz4::CHUNK_SIZE + 1024];
384        let packed = lz4::compress(&data).unwrap();
385        let unpacked = lz4::decompress(&packed, data.len()).unwrap();
386        assert_eq!(unpacked.len(), data.len());
387        assert_eq!(unpacked, data);
388
389        // Walk the frames to confirm there really are two.
390        let mut pos = 0;
391        let mut frames = 0;
392        while pos < packed.len() {
393            let len = u32::from_be_bytes([
394                packed[pos],
395                packed[pos + 1],
396                packed[pos + 2],
397                packed[pos + 3],
398            ]) as usize;
399            pos += 4 + len;
400            frames += 1;
401        }
402        assert_eq!(frames, 2, "a 4 MiB + 1 KiB input should make two chunks");
403    }
404
405    #[cfg(feature = "lz4")]
406    #[test]
407    fn truncated_lz4_streams_are_rejected() {
408        let data = vec![0x11u8; 5000];
409        let packed = lz4::compress(&data).unwrap();
410
411        // Cut inside the compressed body.
412        let e = lz4::decompress(&packed[..packed.len() - 10], data.len()).unwrap_err();
413        assert_eq!(e.code(), ErrorCode::CompressionFailed);
414
415        // Cut inside a length field.
416        let e = lz4::decompress(&packed[..2], data.len()).unwrap_err();
417        assert_eq!(e.code(), ErrorCode::CompressionFailed);
418    }
419
420    #[test]
421    fn corrupt_input_is_an_error_not_a_panic() {
422        let garbage = vec![0xFFu8; 64];
423        for c in available() {
424            let r = c.decompress(&garbage, 1024);
425            // Either it errors, or it happens to decode something; it must
426            // never panic or hang.
427            if let Ok(v) = r {
428                assert!(v.len() <= 1 << 20);
429            }
430        }
431    }
432
433    #[test]
434    fn absurd_expected_sizes_are_refused() {
435        // A corrupt header claiming a huge decompressed size must not cause a
436        // huge allocation.
437        let small = vec![0u8; 16];
438        for c in available() {
439            let e = c.decompress(&small, usize::MAX / 2);
440            assert!(e.is_err(), "{:?} accepted an absurd size", c);
441        }
442    }
443
444    #[test]
445    fn available_reports_compiled_features() {
446        let names: Vec<_> = available().iter().map(|c| c.name()).collect();
447        // The standard requires both of these, and the default build has them.
448        #[cfg(feature = "zlib")]
449        assert!(names.contains(&"zlib"));
450        #[cfg(feature = "bzp2")]
451        assert!(names.contains(&"bzp2"));
452        let _ = names;
453    }
454}