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