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
109/// Decompress into a buffer no larger than the block says it needs.
110///
111/// [`check_expected_size`] bounds what the header *claims*, which is the
112/// wrong quantity on its own: nothing there bounds what the codec actually
113/// produces, so understating `data_size` walks straight past the ratio check
114/// and `read_to_end` then expands the stream until memory runs out. A block
115/// header that lies downward is as much a lie as one that lies upward.
116///
117/// The destination is therefore capped at `expected` and the stream is read
118/// one byte further, so a stream with more in it than the block accounts for
119/// is an error rather than an allocation. This is the shape upstream libasdf
120/// uses -- it sizes the destination from `data_size` and fills it -- and it
121/// makes the declared size load-bearing in both directions.
122fn read_bounded(mut reader: impl std::io::Read, expected: usize, what: &str) -> Result<Vec<u8>> {
123    use std::io::Read as _;
124
125    let mut out = Vec::new();
126    // One byte past the limit distinguishes "exactly full" from "there was
127    // more", which `take` alone cannot.
128    let read = (&mut reader)
129        .take(expected as u64 + 1)
130        .read_to_end(&mut out)
131        .map_err(|e| err!(CompressionFailed, "{what} decompression failed: {e}"))?;
132
133    if read > expected {
134        return Err(err!(
135            CompressionFailed,
136            "{what} stream expands past the {expected} bytes the block header declares"
137        ));
138    }
139    Ok(out)
140}
141
142mod zlib {
143    use super::*;
144
145    #[cfg(feature = "zlib")]
146    pub fn decompress(data: &[u8], expected: usize) -> Result<Vec<u8>> {
147        check_expected_size(data.len(), expected)?;
148        read_bounded(flate2::read::ZlibDecoder::new(data), expected, "zlib")
149    }
150
151    #[cfg(feature = "zlib")]
152    pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
153        use std::io::Write;
154        let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
155        enc.write_all(data)
156            .and_then(|()| enc.finish())
157            .map_err(|e| err!(CompressionFailed, "zlib compression failed: {e}"))
158    }
159
160    #[cfg(not(feature = "zlib"))]
161    pub fn decompress(_data: &[u8], _expected: usize) -> Result<Vec<u8>> {
162        Err(err!(UnknownCompression, "zlib support was not compiled in"))
163    }
164
165    #[cfg(not(feature = "zlib"))]
166    pub fn compress(_data: &[u8]) -> Result<Vec<u8>> {
167        Err(err!(UnknownCompression, "zlib support was not compiled in"))
168    }
169}
170
171mod bzp2 {
172    use super::*;
173
174    #[cfg(feature = "bzp2")]
175    pub fn decompress(data: &[u8], expected: usize) -> Result<Vec<u8>> {
176        check_expected_size(data.len(), expected)?;
177        read_bounded(bzip2::read::BzDecoder::new(data), expected, "bzip2")
178    }
179
180    #[cfg(feature = "bzp2")]
181    pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
182        use std::io::Write;
183        // Upstream uses block size 9 and work factor 30.
184        let mut enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::best());
185        enc.write_all(data)
186            .and_then(|()| enc.finish())
187            .map_err(|e| err!(CompressionFailed, "bzip2 compression failed: {e}"))
188    }
189
190    #[cfg(not(feature = "bzp2"))]
191    pub fn decompress(_data: &[u8], _expected: usize) -> Result<Vec<u8>> {
192        Err(err!(UnknownCompression, "bzip2 support was not compiled in"))
193    }
194
195    #[cfg(not(feature = "bzp2"))]
196    pub fn compress(_data: &[u8]) -> Result<Vec<u8>> {
197        Err(err!(UnknownCompression, "bzip2 support was not compiled in"))
198    }
199}
200
201/// ASDF's LZ4 framing.
202///
203/// This is *not* the LZ4 frame format. Both libasdf and Python asdf write a
204/// sequence of chunks, each laid out as:
205///
206/// ```text
207///   [u32 big-endian]     length of everything that follows for this chunk
208///   [u32 little-endian]  the chunk's decompressed size
209///   [bytes]              a raw LZ4 block
210/// ```
211///
212/// The big-endian length *includes* the four-byte little-endian size, which
213/// is python-lz4's own header. The inner pair is therefore exactly
214/// `lz4_flex`'s "size prepended" block format. Chunks are 4 MiB of input
215/// each, matching both existing implementations.
216pub mod lz4 {
217    use super::*;
218
219    /// The uncompressed chunk size both other implementations use.
220    pub const CHUNK_SIZE: usize = 1 << 22;
221
222    /// The per-chunk framing overhead: a big-endian length and a
223    /// little-endian decompressed size.
224    pub const CHUNK_HEADER_SIZE: usize = 8;
225
226    #[cfg(feature = "lz4")]
227    pub fn decompress(data: &[u8], expected: usize) -> Result<Vec<u8>> {
228        check_expected_size(data.len(), expected)?;
229        let mut out = Vec::new();
230        let mut pos = 0usize;
231
232        while pos < data.len() {
233            if pos + 4 > data.len() {
234                return Err(err!(
235                    CompressionFailed,
236                    "lz4 stream truncated in a chunk length at offset {pos}"
237                ));
238            }
239            let framed_len =
240                u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]])
241                    as usize;
242            pos += 4;
243
244            if framed_len < 4 || pos + framed_len > data.len() {
245                return Err(err!(
246                    CompressionFailed,
247                    "lz4 chunk at offset {pos} claims {framed_len} bytes, \
248                     past the end of the {} byte stream",
249                    data.len()
250                ));
251            }
252
253            // The framed length covers python-lz4's little-endian size header
254            // plus the block, which together are what `decompress_size_prepended`
255            // expects.
256            let chunk = &data[pos..pos + framed_len];
257            let decoded = lz4_flex::block::decompress_size_prepended(chunk)
258                .map_err(|e| err!(CompressionFailed, "lz4 decompression failed: {e}"))?;
259            // Each chunk carries its own decompressed size, so the total is
260            // checked as it accumulates rather than trusted at the end.
261            if out.len() + decoded.len() > expected {
262                return Err(err!(
263                    CompressionFailed,
264                    "lz4 stream expands past the {expected} bytes the block header declares"
265                ));
266            }
267            out.extend_from_slice(&decoded);
268            pos += framed_len;
269        }
270        Ok(out)
271    }
272
273    #[cfg(feature = "lz4")]
274    pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
275        let mut out = Vec::new();
276        // An empty input produces an empty stream, as upstream's loop does.
277        for chunk in data.chunks(CHUNK_SIZE) {
278            let framed = lz4_flex::block::compress_prepend_size(chunk);
279            let len = u32::try_from(framed.len())
280                .map_err(|_| err!(CompressionFailed, "lz4 chunk too large to frame"))?;
281            out.extend_from_slice(&len.to_be_bytes());
282            out.extend_from_slice(&framed);
283        }
284        Ok(out)
285    }
286
287    #[cfg(not(feature = "lz4"))]
288    pub fn decompress(_data: &[u8], _expected: usize) -> Result<Vec<u8>> {
289        Err(err!(UnknownCompression, "lz4 support was not compiled in"))
290    }
291
292    #[cfg(not(feature = "lz4"))]
293    pub fn compress(_data: &[u8]) -> Result<Vec<u8>> {
294        Err(err!(UnknownCompression, "lz4 support was not compiled in"))
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::error::ErrorCode;
302
303    /// A counter array: realistic ndarray data, but nearly incompressible
304    /// for a fast low-ratio codec like LZ4.
305    fn counter_payload() -> Vec<u8> {
306        let mut v = Vec::new();
307        for i in 0..10_000u32 {
308            v.extend_from_slice(&i.to_le_bytes());
309        }
310        v
311    }
312
313    /// Data with real redundancy, of the kind every codec should shrink.
314    fn compressible_payload() -> Vec<u8> {
315        let mut v = Vec::new();
316        for i in 0..10_000u32 {
317            v.extend_from_slice(&(i % 16).to_le_bytes());
318        }
319        v
320    }
321
322    #[test]
323    fn names_round_trip() {
324        for c in [Compression::None, Compression::Zlib, Compression::Bzp2, Compression::Lz4] {
325            assert_eq!(Compression::from_name(c.name()).unwrap(), c);
326            // Every identifier must fit the header's four-byte field.
327            assert!(c.name().len() <= 4, "{:?} name too long", c);
328        }
329    }
330
331    #[test]
332    fn unknown_names_are_rejected() {
333        let e = Compression::from_name("zstd").unwrap_err();
334        assert_eq!(e.code(), ErrorCode::UnknownCompression);
335    }
336
337    #[test]
338    fn round_trips_through_every_method() {
339        for data in [counter_payload(), compressible_payload()] {
340            for c in available() {
341                let packed = c.compress(&data).unwrap_or_else(|e| panic!("{:?}: {e}", c));
342                let unpacked =
343                    c.decompress(&packed, data.len()).unwrap_or_else(|e| panic!("{:?}: {e}", c));
344                assert_eq!(unpacked, data, "{:?} did not round trip", c);
345            }
346        }
347    }
348
349    #[test]
350    fn every_method_shrinks_redundant_data() {
351        // Deliberately separate from the round-trip test: LZ4 trades ratio
352        // for speed and does not shrink a counter array, so asserting a ratio
353        // on arbitrary data would be wrong rather than a real failure.
354        let data = compressible_payload();
355        for c in available() {
356            let packed = c.compress(&data).unwrap();
357            assert!(
358                packed.len() < data.len(),
359                "{:?} grew {} bytes to {}",
360                c,
361                data.len(),
362                packed.len()
363            );
364        }
365    }
366
367    #[test]
368    fn round_trips_empty_and_tiny_inputs() {
369        for c in available() {
370            for data in [vec![], vec![0u8], vec![7u8; 3]] {
371                let packed = c.compress(&data).unwrap();
372                let unpacked = c.decompress(&packed, data.len()).unwrap();
373                assert_eq!(unpacked, data, "{:?} failed on {} bytes", c, data.len());
374            }
375        }
376    }
377
378    #[test]
379    fn none_is_a_passthrough() {
380        let data = b"unchanged".to_vec();
381        assert_eq!(Compression::None.compress(&data).unwrap(), data);
382        assert_eq!(Compression::None.decompress(&data, data.len()).unwrap(), data);
383    }
384
385    #[cfg(feature = "lz4")]
386    #[test]
387    fn lz4_uses_the_asdf_chunk_framing() {
388        // The framing is shared with Python asdf and libasdf, so its shape is
389        // a compatibility contract, not an implementation detail.
390        let data = vec![0xABu8; 1000];
391        let packed = lz4::compress(&data).unwrap();
392
393        assert!(packed.len() > lz4::CHUNK_HEADER_SIZE);
394        let framed_len = u32::from_be_bytes([packed[0], packed[1], packed[2], packed[3]]) as usize;
395        assert_eq!(
396            framed_len,
397            packed.len() - 4,
398            "the big-endian length must cover the rest of the chunk"
399        );
400
401        let decompressed_size =
402            u32::from_le_bytes([packed[4], packed[5], packed[6], packed[7]]) as usize;
403        assert_eq!(
404            decompressed_size,
405            data.len(),
406            "the little-endian header must carry the decompressed size"
407        );
408    }
409
410    #[cfg(feature = "lz4")]
411    #[test]
412    fn lz4_splits_large_inputs_into_chunks() {
413        // Just over one chunk, so the stream must contain two frames.
414        let data = vec![0x5Au8; lz4::CHUNK_SIZE + 1024];
415        let packed = lz4::compress(&data).unwrap();
416        let unpacked = lz4::decompress(&packed, data.len()).unwrap();
417        assert_eq!(unpacked.len(), data.len());
418        assert_eq!(unpacked, data);
419
420        // Walk the frames to confirm there really are two.
421        let mut pos = 0;
422        let mut frames = 0;
423        while pos < packed.len() {
424            let len = u32::from_be_bytes([
425                packed[pos],
426                packed[pos + 1],
427                packed[pos + 2],
428                packed[pos + 3],
429            ]) as usize;
430            pos += 4 + len;
431            frames += 1;
432        }
433        assert_eq!(frames, 2, "a 4 MiB + 1 KiB input should make two chunks");
434    }
435
436    #[cfg(feature = "lz4")]
437    #[test]
438    fn truncated_lz4_streams_are_rejected() {
439        let data = vec![0x11u8; 5000];
440        let packed = lz4::compress(&data).unwrap();
441
442        // Cut inside the compressed body.
443        let e = lz4::decompress(&packed[..packed.len() - 10], data.len()).unwrap_err();
444        assert_eq!(e.code(), ErrorCode::CompressionFailed);
445
446        // Cut inside a length field.
447        let e = lz4::decompress(&packed[..2], data.len()).unwrap_err();
448        assert_eq!(e.code(), ErrorCode::CompressionFailed);
449    }
450
451    #[test]
452    fn corrupt_input_is_an_error_not_a_panic() {
453        let garbage = vec![0xFFu8; 64];
454        for c in available() {
455            let r = c.decompress(&garbage, 1024);
456            // Either it errors, or it happens to decode something; it must
457            // never panic or hang.
458            if let Ok(v) = r {
459                assert!(v.len() <= 1 << 20);
460            }
461        }
462    }
463
464    #[test]
465    fn absurd_expected_sizes_are_refused() {
466        // A corrupt header claiming a huge decompressed size must not cause a
467        // huge allocation.
468        let small = vec![0u8; 16];
469        for c in available() {
470            let e = c.decompress(&small, usize::MAX / 2);
471            assert!(e.is_err(), "{:?} accepted an absurd size", c);
472        }
473    }
474
475    #[test]
476    fn available_reports_compiled_features() {
477        let names: Vec<_> = available().iter().map(|c| c.name()).collect();
478        // The standard requires both of these, and the default build has them.
479        #[cfg(feature = "zlib")]
480        assert!(names.contains(&"zlib"));
481        #[cfg(feature = "bzp2")]
482        assert!(names.contains(&"bzp2"));
483        let _ = names;
484    }
485}