Skip to main content

delivery_blob/
compression.rs

1// Copyright 2023 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Implementation of chunked-compression library in Rust. Archives can be created by making a new
6//! [`ChunkedArchive`] and serializing/writing it. An archive's header can be verified and seek
7//! table decoded using [`decode_archive`].
8
9use itertools::Itertools;
10use rayon::prelude::*;
11use std::ops::Range;
12use thiserror::Error;
13use zerocopy::byteorder::{LE, U16, U32, U64};
14use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, Ref, Unaligned};
15
16mod compression_algorithm;
17pub use compression_algorithm::{
18    CompressionAlgorithm, Compressor, Decompressor, ThreadLocalCompressor, ThreadLocalDecompressor,
19};
20mod compression_info;
21pub use compression_info::{CompressionInfo, DataBuffer, StreamingDecompressor};
22
23/// Validated chunk information from an archive. Compressed ranges are relative to the start of
24/// compressed data (i.e. they start after the header and seek table).
25#[derive(Copy, Clone, Eq, PartialEq)]
26pub struct ZstdError(pub usize);
27
28impl std::fmt::Display for ZstdError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        let msg = zstd::zstd_safe::get_error_name(self.0);
31        let enum_code = unsafe { zstd::zstd_safe::zstd_sys::ZSTD_getErrorCode(self.0) };
32        write!(f, "{:?} ({})", enum_code, msg)
33    }
34}
35
36impl std::fmt::Debug for ZstdError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        std::fmt::Display::fmt(self, f)
39    }
40}
41
42#[cfg(target_os = "fuchsia")]
43impl From<ZstdError> for zx::Status {
44    fn from(err: ZstdError) -> Self {
45        use zstd::zstd_safe::zstd_sys::ZSTD_ErrorCode::*;
46        let code = unsafe { zstd::zstd_safe::zstd_sys::ZSTD_getErrorCode(err.0) };
47        match code {
48            ZSTD_error_corruption_detected
49            | ZSTD_error_checksum_wrong
50            | ZSTD_error_literals_headerWrong
51            | ZSTD_error_dictionary_corrupted
52            | ZSTD_error_prefix_unknown => zx::Status::IO_DATA_INTEGRITY,
53
54            ZSTD_error_version_unsupported
55            | ZSTD_error_frameParameter_unsupported
56            | ZSTD_error_parameter_unsupported => zx::Status::NOT_SUPPORTED,
57
58            ZSTD_error_parameter_outOfBound
59            | ZSTD_error_srcSize_wrong
60            | ZSTD_error_dstSize_tooSmall => zx::Status::INVALID_ARGS,
61
62            ZSTD_error_no_error
63            | ZSTD_error_GENERIC
64            | ZSTD_error_frameParameter_windowTooLarge
65            | ZSTD_error_dictionary_wrong
66            | ZSTD_error_dictionaryCreation_failed
67            | ZSTD_error_parameter_combination_unsupported
68            | ZSTD_error_tableLog_tooLarge
69            | ZSTD_error_maxSymbolValue_tooLarge
70            | ZSTD_error_maxSymbolValue_tooSmall
71            | ZSTD_error_stabilityCondition_notRespected
72            | ZSTD_error_stage_wrong
73            | ZSTD_error_init_missing
74            | ZSTD_error_memory_allocation
75            | ZSTD_error_workSpace_tooSmall
76            | ZSTD_error_dstBuffer_null
77            | ZSTD_error_noForwardProgress_destFull
78            | ZSTD_error_noForwardProgress_inputEmpty
79            | ZSTD_error_frameIndex_tooLarge
80            | ZSTD_error_seekableIO
81            | ZSTD_error_dstBuffer_wrong
82            | ZSTD_error_srcBuffer_wrong
83            | ZSTD_error_sequenceProducer_failed
84            | ZSTD_error_externalSequences_invalid
85            | ZSTD_error_cannotProduce_uncompressedBlock
86            | ZSTD_error_maxCode => zx::Status::INTERNAL,
87        }
88    }
89}
90
91#[derive(Debug, Error)]
92pub enum FormatError {
93    #[error("Zstd error: {0}")]
94    Zstd(ZstdError),
95    #[error("LZ4 error: {0}")]
96    Lz4(lz4::Error),
97}
98
99#[cfg(target_os = "fuchsia")]
100impl From<&FormatError> for zx::Status {
101    fn from(err: &FormatError) -> Self {
102        match err {
103            FormatError::Zstd(e) => zx::Status::from(*e),
104            FormatError::Lz4(_) => zx::Status::IO_DATA_INTEGRITY,
105        }
106    }
107}
108
109// *NOTE*: Use caution when using the `#[source]` attribute or naming fields `source`. Some callers
110// attempt to downcast library errors into the concrete type of the root cause.
111// See https://docs.rs/thiserror/latest/thiserror/ for more information.
112#[derive(Debug, Error)]
113pub enum ChunkedArchiveError {
114    #[error("Invalid or unsupported archive version.")]
115    InvalidVersion,
116
117    #[error("Archive header has incorrect magic.")]
118    BadMagic,
119
120    #[error("Integrity checks failed (e.g. incorrect CRC, inconsistent header fields).")]
121    IntegrityError,
122
123    #[error("Value is out of range or cannot be represented in specified type.")]
124    OutOfRange,
125
126    #[error("Error decompressing chunk {index}: {error}")]
127    DecompressionError { index: usize, error: FormatError },
128
129    #[error("Error compressing chunk {index}: {error}")]
130    CompressionError { index: usize, error: FormatError },
131}
132
133/// Options for constructing a chunked archive.
134#[derive(Copy, Clone, Debug, Eq, PartialEq)]
135pub enum ChunkedArchiveOptions {
136    /// A chunked-compression V2 archive will be created.
137    V2 {
138        /// Chunked-compression V2 has a limit of 1023 chunks. If splitting the data up into
139        /// `minimum_chunk_size`d chunks would exceed this limit then the chunk size increased by
140        /// `chunk_alignment` until fewer than 1024 are required. `minimum_chunk_size` must be a
141        /// multiple of `chunk_alignment`.
142        minimum_chunk_size: usize,
143        /// The chosen uncompressed chunk size must always be a multiple of this value.
144        chunk_alignment: usize,
145        /// The Zstd compression level to use when compressing chunks.
146        compression_level: i32,
147    },
148    /// A chunked-compression V3 archive will be created.
149    V3 {
150        /// The compression algorithm to use to compress the chunks.
151        compression_algorithm: CompressionAlgorithm,
152    },
153}
154
155impl ChunkedArchiveOptions {
156    const V2_VERSION: u16 = 2;
157    const V2_MAX_CHUNKS: usize = 1023;
158
159    const V3_VERSION: u16 = 3;
160    const V3_MAX_CHUNKS: usize = u32::MAX as usize;
161    const V3_CHUNK_SIZE: usize = 32 * 1024;
162    const V3_ZSTD_COMPRESSION_LEVEL: i32 = 22;
163
164    /// Which version of chunked-compression archive should be constructed.
165    fn version(&self) -> u16 {
166        match self {
167            Self::V2 { .. } => Self::V2_VERSION,
168            Self::V3 { .. } => Self::V3_VERSION,
169        }
170    }
171
172    /// The compression algorithm to use to compress the chunks.
173    fn compression_algorithm(&self) -> CompressionAlgorithm {
174        match self {
175            Self::V2 { .. } => CompressionAlgorithm::Zstd,
176            Self::V3 { compression_algorithm } => *compression_algorithm,
177        }
178    }
179
180    /// Calculate how large chunks must be for a given amount of data.
181    fn chunk_size_for(&self, data_size: usize) -> usize {
182        match self {
183            Self::V2 { chunk_alignment, minimum_chunk_size: target_chunk_size, .. } => {
184                if data_size <= (Self::V2_MAX_CHUNKS * target_chunk_size) {
185                    *target_chunk_size
186                } else {
187                    let chunk_size = data_size.div_ceil(Self::V2_MAX_CHUNKS);
188                    chunk_size.checked_next_multiple_of(*chunk_alignment).unwrap()
189                }
190            }
191            Self::V3 { .. } => {
192                assert!(
193                    data_size.div_ceil(Self::V3_CHUNK_SIZE) <= Self::V3_MAX_CHUNKS,
194                    "Chunked-compression V3 only supports data up to ~140TB"
195                );
196                Self::V3_CHUNK_SIZE
197            }
198        }
199    }
200
201    /// Constructs a compressor to compress chunks based on the specified options.
202    pub fn compressor(&self) -> Compressor {
203        match self {
204            Self::V2 { compression_level, .. } => {
205                let mut cctx = zstd::zstd_safe::CCtx::create();
206                cctx.set_parameter(zstd::zstd_safe::CParameter::CompressionLevel(
207                    *compression_level,
208                ))
209                .expect("setting the compression level should never fail");
210                Compressor::Zstd(cctx)
211            }
212            Self::V3 { compression_algorithm: CompressionAlgorithm::Zstd } => {
213                let mut cctx = zstd::zstd_safe::CCtx::create();
214                cctx.set_parameter(zstd::zstd_safe::CParameter::CompressionLevel(
215                    Self::V3_ZSTD_COMPRESSION_LEVEL,
216                ))
217                .expect("setting the compression level should never fail");
218                Compressor::Zstd(cctx)
219            }
220            Self::V3 { compression_algorithm: CompressionAlgorithm::Lz4 } => {
221                Compressor::Lz4 { compression_level: lz4::HcCompressionLevel::custom(12) }
222            }
223        }
224    }
225
226    /// Constructs a compressor object that uses a thread local compressor to compress chunks based
227    /// on the specified options.
228    pub fn thread_local_compressor(&self) -> ThreadLocalCompressor {
229        match self {
230            Self::V2 { compression_level, .. } => {
231                ThreadLocalCompressor::Zstd { compression_level: *compression_level }
232            }
233            Self::V3 { compression_algorithm: CompressionAlgorithm::Zstd } => {
234                ThreadLocalCompressor::Zstd { compression_level: Self::V3_ZSTD_COMPRESSION_LEVEL }
235            }
236            Self::V3 { compression_algorithm: CompressionAlgorithm::Lz4 } => {
237                ThreadLocalCompressor::Lz4 {
238                    compression_level: lz4::HcCompressionLevel::custom(12),
239                }
240            }
241        }
242    }
243
244    /// Returns true if `version` is a valid chunked-compression version.
245    fn is_valid_version(version: u16) -> bool {
246        match version {
247            Self::V2_VERSION => true,
248            Self::V3_VERSION => true,
249            _ => false,
250        }
251    }
252
253    /// Returns the maximum number of chunks supported by the chunked-compression format at the
254    /// specified version.
255    fn max_chunks_for_version(version: u16) -> Result<usize, ChunkedArchiveError> {
256        match version {
257            Self::V2_VERSION => Ok(Self::V2_MAX_CHUNKS),
258            Self::V3_VERSION => Ok(Self::V3_MAX_CHUNKS),
259            _ => Err(ChunkedArchiveError::InvalidVersion),
260        }
261    }
262}
263
264/// Validated chunk information from an archive. Compressed ranges are relative to the start of
265/// compressed data (i.e. they start after the header and seek table).
266#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct ChunkInfo {
268    pub decompressed_range: Range<usize>,
269    pub compressed_range: Range<usize>,
270}
271
272impl ChunkInfo {
273    fn from_entry(
274        entry: &SeekTableEntry,
275        header_length: usize,
276    ) -> Result<Self, ChunkedArchiveError> {
277        let decompressed_start = entry.decompressed_offset.get() as usize;
278        let decompressed_size = entry.decompressed_size.get() as usize;
279        let decompressed_range = decompressed_start
280            ..decompressed_start
281                .checked_add(decompressed_size)
282                .ok_or(ChunkedArchiveError::OutOfRange)?;
283
284        let compressed_offset = entry.compressed_offset.get() as usize;
285        let compressed_start = compressed_offset
286            .checked_sub(header_length)
287            .ok_or(ChunkedArchiveError::IntegrityError)?;
288        let compressed_size = entry.compressed_size.get() as usize;
289        let compressed_range = compressed_start
290            ..compressed_start
291                .checked_add(compressed_size)
292                .ok_or(ChunkedArchiveError::OutOfRange)?;
293
294        Ok(Self { decompressed_range, compressed_range })
295    }
296}
297
298/// Validated information from decoding an archive.
299#[derive(Debug)]
300pub struct DecodedArchive {
301    compression_algorithm: CompressionAlgorithm,
302    seek_table: Vec<ChunkInfo>,
303}
304
305impl DecodedArchive {
306    /// The total size of decompressing all of the chunks in the archive.
307    pub fn decompressed_size(&self) -> usize {
308        self.seek_table.last().map_or(0, |entry| entry.decompressed_range.end)
309    }
310
311    pub fn seek_table(&self) -> &[ChunkInfo] {
312        &self.seek_table
313    }
314}
315
316/// Decodes a chunked archive header. Returns a `DecodedArchive` and any remaining bytes that are
317/// part of the chunk data. Returns `Ok(None)` if `data` is not large enough to decode the archive
318/// header & seek table.
319pub fn decode_archive(
320    data: &[u8],
321    archive_length: usize,
322) -> Result<Option<(DecodedArchive, /*archive_data*/ &[u8])>, ChunkedArchiveError> {
323    match Ref::<_, ChunkedArchiveHeader>::from_prefix(data).map_err(Into::into) {
324        Ok((header, data)) => header.decode_archive(data, archive_length as u64),
325        Err(zerocopy::SizeError { .. }) => Ok(None), // Not enough data.
326    }
327}
328
329/// Chunked archive header.
330#[derive(IntoBytes, KnownLayout, FromBytes, Immutable, Unaligned, Clone, Copy, Debug)]
331#[repr(C)]
332struct ChunkedArchiveHeader {
333    magic: [u8; 8],
334    version: U16<LE>,
335    // This field was added in V3 and should not be used if `version` is 2. Technically, this field
336    // should be 0 in V2, Zstd has the value 0, and V2 always uses Zstd so accessing this field in
337    // V2 should give the correct result.
338    compression_algorithm: u8,
339    reserved_0: u8,
340    num_entries: U32<LE>,
341    checksum: U32<LE>,
342    reserved_1: U32<LE>,
343    reserved_2: U64<LE>,
344}
345
346/// Chunked archive seek table entry.
347#[derive(IntoBytes, KnownLayout, FromBytes, Immutable, Unaligned, Clone, Copy, Debug)]
348#[repr(C)]
349struct SeekTableEntry {
350    decompressed_offset: U64<LE>,
351    decompressed_size: U64<LE>,
352    compressed_offset: U64<LE>,
353    compressed_size: U64<LE>,
354}
355
356impl ChunkedArchiveHeader {
357    const CHUNKED_ARCHIVE_MAGIC: [u8; 8] = [0x46, 0x9b, 0x78, 0xef, 0x0f, 0xd0, 0xb2, 0x03];
358    const CHUNKED_ARCHIVE_CHECKSUM_OFFSET: usize = 16;
359
360    fn new(
361        seek_table: &[SeekTableEntry],
362        options: ChunkedArchiveOptions,
363    ) -> Result<Self, ChunkedArchiveError> {
364        let header: ChunkedArchiveHeader = Self {
365            magic: Self::CHUNKED_ARCHIVE_MAGIC,
366            version: options.version().into(),
367            compression_algorithm: options.compression_algorithm().into(),
368            reserved_0: 0.into(),
369            num_entries: TryInto::<u32>::try_into(seek_table.len())
370                .or(Err(ChunkedArchiveError::OutOfRange))?
371                .into(),
372            checksum: 0.into(), // `checksum` is calculated below.
373            reserved_1: 0.into(),
374            reserved_2: 0.into(),
375        };
376        Ok(Self { checksum: header.checksum(seek_table).into(), ..header })
377    }
378
379    /// Calculate the checksum of the header + all seek table entries.
380    fn checksum(&self, entries: &[SeekTableEntry]) -> u32 {
381        let crc_algo = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
382        let mut digest = crc_algo.digest();
383        digest.update(&self.as_bytes()[..Self::CHUNKED_ARCHIVE_CHECKSUM_OFFSET]);
384        digest.update(
385            &self.as_bytes()
386                [Self::CHUNKED_ARCHIVE_CHECKSUM_OFFSET + self.checksum.as_bytes().len()..],
387        );
388        digest.update(entries.as_bytes());
389        digest.finalize()
390    }
391
392    /// Calculate the total header length of an archive *including* all seek table entries.
393    fn header_length(num_entries: usize) -> usize {
394        std::mem::size_of::<ChunkedArchiveHeader>()
395            + (std::mem::size_of::<SeekTableEntry>() * num_entries)
396    }
397
398    /// Validates the archive header and decodes the seek table.
399    fn decode_archive(
400        self,
401        data: &[u8],
402        archive_length: u64,
403    ) -> Result<Option<(DecodedArchive, /*chunk_data*/ &[u8])>, ChunkedArchiveError> {
404        // Deserialize seek table.
405        let num_entries = self.num_entries.get() as usize;
406        let Ok((entries, chunk_data)) =
407            Ref::<_, [SeekTableEntry]>::from_prefix_with_elems(data, num_entries)
408        else {
409            return Ok(None);
410        };
411        let entries: &[SeekTableEntry] = Ref::into_ref(entries);
412
413        // Validate archive header.
414        if self.magic != Self::CHUNKED_ARCHIVE_MAGIC {
415            return Err(ChunkedArchiveError::BadMagic);
416        }
417        let version = self.version.get();
418        if !ChunkedArchiveOptions::is_valid_version(version) {
419            return Err(ChunkedArchiveError::InvalidVersion);
420        }
421        if self.checksum.get() != self.checksum(entries) {
422            return Err(ChunkedArchiveError::IntegrityError);
423        }
424        if entries.len() > ChunkedArchiveOptions::max_chunks_for_version(version)? {
425            return Err(ChunkedArchiveError::IntegrityError);
426        }
427        let compression_algorithm = CompressionAlgorithm::try_from(self.compression_algorithm)?;
428
429        // Validate seek table using invariants I0 through I5.
430
431        // I0: The first seek table entry, if any, must have decompressed offset 0.
432        if !entries.is_empty() && entries[0].decompressed_offset.get() != 0 {
433            return Err(ChunkedArchiveError::IntegrityError);
434        }
435
436        // I1: The compressed offsets of all seek table entries must not overlap with the header.
437        let header_length = Self::header_length(entries.len());
438        if entries.iter().any(|entry| entry.compressed_offset.get() < header_length as u64) {
439            return Err(ChunkedArchiveError::IntegrityError);
440        }
441
442        // I2: Each entry's decompressed offset must be equal to the end of the previous frame
443        //     (i.e. to the previous frame's decompressed offset + length).
444        for (prev, curr) in entries.iter().tuple_windows() {
445            if (prev.decompressed_offset.get() + prev.decompressed_size.get())
446                != curr.decompressed_offset.get()
447            {
448                return Err(ChunkedArchiveError::IntegrityError);
449            }
450        }
451
452        // I3: Each entry's compressed offset must be greater than or equal to the end of the
453        //     previous frame (i.e. to the previous frame's compressed offset + length).
454        for (prev, curr) in entries.iter().tuple_windows() {
455            if (prev.compressed_offset.get() + prev.compressed_size.get())
456                > curr.compressed_offset.get()
457            {
458                return Err(ChunkedArchiveError::IntegrityError);
459            }
460        }
461
462        // I4: Each entry must have a non-zero decompressed and compressed length.
463        for entry in entries.iter() {
464            if entry.decompressed_size.get() == 0 || entry.compressed_size.get() == 0 {
465                return Err(ChunkedArchiveError::IntegrityError);
466            }
467        }
468
469        // I5: Data referenced by each entry must fit within the specified file size.
470        for entry in entries.iter() {
471            let compressed_end = entry.compressed_offset.get() + entry.compressed_size.get();
472            if compressed_end > archive_length {
473                return Err(ChunkedArchiveError::IntegrityError);
474            }
475        }
476
477        let seek_table = entries
478            .iter()
479            .map(|entry| ChunkInfo::from_entry(entry, header_length))
480            .try_collect()?;
481        Ok(Some((DecodedArchive { seek_table, compression_algorithm }, chunk_data)))
482    }
483}
484
485/// In-memory representation of a compressed chunk.
486pub struct CompressedChunk {
487    /// Compressed data for this chunk.
488    pub compressed_data: Vec<u8>,
489    /// Size of this chunk when decompressed.
490    pub decompressed_size: usize,
491}
492
493/// In-memory representation of a compressed chunked archive.
494pub struct ChunkedArchive {
495    /// Chunks this archive contains, in order. Right now we only allow creating archives with
496    /// contiguous compressed and decompressed space.
497    chunks: Vec<CompressedChunk>,
498    /// Size used to chunk input when creating this archive. Last chunk may be smaller than this
499    /// amount.
500    chunk_size: usize,
501    /// The options used to construct this archive.
502    options: ChunkedArchiveOptions,
503}
504
505impl ChunkedArchive {
506    /// Create a ChunkedArchive for `data` compressing each chunk in parallel. This function uses
507    /// the `rayon` crate for parallelism. By default compression happens in the global thread pool,
508    /// but this function can also be executed within a locally scoped pool.
509    pub fn new(data: &[u8], options: ChunkedArchiveOptions) -> Result<Self, ChunkedArchiveError> {
510        let chunk_size = options.chunk_size_for(data.len());
511        let mut chunks: Vec<Result<CompressedChunk, ChunkedArchiveError>> = vec![];
512        let compressor = options.thread_local_compressor();
513        data.par_chunks(chunk_size)
514            .enumerate()
515            .map(|(index, chunk)| {
516                let compressed_data = compressor.compress(chunk, index)?;
517                Ok(CompressedChunk { compressed_data, decompressed_size: chunk.len() })
518            })
519            .collect_into_vec(&mut chunks);
520        let chunks: Vec<_> = chunks.into_iter().try_collect()?;
521        Ok(ChunkedArchive { chunks, chunk_size, options })
522    }
523
524    /// Accessor for compressed chunk data.
525    pub fn chunks(&self) -> &Vec<CompressedChunk> {
526        &self.chunks
527    }
528
529    /// The chunk size calculated for this archive during compression. Represents how input data
530    /// was chunked for compression. Note that the final chunk may be smaller than this amount
531    /// when decompressed.
532    pub fn chunk_size(&self) -> usize {
533        self.chunk_size
534    }
535
536    /// Sum of sizes of all compressed chunks.
537    pub fn compressed_data_size(&self) -> usize {
538        self.chunks.iter().map(|chunk| chunk.compressed_data.len()).sum()
539    }
540
541    /// Total size of the archive in bytes.
542    pub fn serialized_size(&self) -> usize {
543        ChunkedArchiveHeader::header_length(self.chunks.len()) + self.compressed_data_size()
544    }
545
546    /// Write the archive to `writer`.
547    pub fn write(self, mut writer: impl std::io::Write) -> Result<(), std::io::Error> {
548        let seek_table = self.make_seek_table();
549        let header = ChunkedArchiveHeader::new(&seek_table, self.options).unwrap();
550        writer.write_all(header.as_bytes())?;
551        writer.write_all(seek_table.as_slice().as_bytes())?;
552        for chunk in self.chunks {
553            writer.write_all(&chunk.compressed_data)?;
554        }
555        Ok(())
556    }
557
558    /// Create the seek table for this archive.
559    fn make_seek_table(&self) -> Vec<SeekTableEntry> {
560        let header_length = ChunkedArchiveHeader::header_length(self.chunks.len());
561        let mut seek_table = vec![];
562        seek_table.reserve(self.chunks.len());
563        let mut compressed_size: usize = 0;
564        let mut decompressed_offset: usize = 0;
565        for chunk in &self.chunks {
566            seek_table.push(SeekTableEntry {
567                decompressed_offset: (decompressed_offset as u64).into(),
568                decompressed_size: (chunk.decompressed_size as u64).into(),
569                compressed_offset: ((header_length + compressed_size) as u64).into(),
570                compressed_size: (chunk.compressed_data.len() as u64).into(),
571            });
572            compressed_size += chunk.compressed_data.len();
573            decompressed_offset += chunk.decompressed_size;
574        }
575        seek_table
576    }
577}
578
579/// Streaming decompressor for chunked archives. Example:
580/// ```
581/// // Create a chunked archive:
582/// let data: Vec<u8> = vec![3; 1024];
583/// let compressed = ChunkedArchive::new(&data, /*block_size*/ 8192).serialize().unwrap();
584/// // Verify the header + decode the seek table:
585/// let (seek_table, archive_data) = decode_archive(&compressed, compressed.len())?.unwrap();
586/// let mut decompressed: Vec<u8> = vec![];
587/// let mut on_chunk = |data: &[u8]| { decompressed.extend_from_slice(data); };
588/// let mut decompressor = ChunkedDecompressor(seek_table);
589/// // `on_chunk` is invoked as each slice is made available. Archive can be provided as chunks.
590/// decompressor.update(archive_data, &mut on_chunk);
591/// assert_eq!(data.as_slice(), decompressed.as_slice());
592/// ```
593pub struct ChunkedDecompressor {
594    seek_table: Vec<ChunkInfo>,
595    buffer: Vec<u8>,
596    data_written: usize,
597    curr_chunk: usize,
598    total_compressed_size: usize,
599    decompressor: Decompressor,
600    decompressed_buffer: Vec<u8>,
601    error_handler: Option<ErrorHandler>,
602}
603
604type ErrorHandler = Box<dyn Fn(usize, ChunkInfo, &[u8]) -> () + Send + 'static>;
605
606impl ChunkedDecompressor {
607    /// Create a new decompressor to decode an archive from a validated seek table.
608    pub fn new(decoded_archive: DecodedArchive) -> Result<Self, ChunkedArchiveError> {
609        let DecodedArchive { compression_algorithm, seek_table } = decoded_archive;
610        let total_compressed_size =
611            seek_table.last().map_or(0, |last_chunk| last_chunk.compressed_range.end);
612        let decompressed_buffer =
613            vec![0u8; seek_table.first().map_or(0, |c| c.decompressed_range.len())];
614        Ok(Self {
615            seek_table,
616            buffer: vec![],
617            data_written: 0,
618            curr_chunk: 0,
619            total_compressed_size,
620            decompressor: compression_algorithm.decompressor(),
621            decompressed_buffer,
622            error_handler: None,
623        })
624    }
625
626    /// Creates a new decompressor with an additional error handler invoked when a chunk fails to be
627    /// decompressed.
628    pub fn new_with_error_handler(
629        decoded_archive: DecodedArchive,
630        error_handler: ErrorHandler,
631    ) -> Result<Self, ChunkedArchiveError> {
632        Ok(Self { error_handler: Some(error_handler), ..Self::new(decoded_archive)? })
633    }
634
635    /// Returns the compression algorithm used by this decompressor.
636    pub fn algorithm(&self) -> CompressionAlgorithm {
637        match &self.decompressor {
638            Decompressor::Zstd(_) => CompressionAlgorithm::Zstd,
639            Decompressor::Lz4 => CompressionAlgorithm::Lz4,
640        }
641    }
642
643    pub fn seek_table(&self) -> &Vec<ChunkInfo> {
644        &self.seek_table
645    }
646
647    fn finish_chunk(
648        &mut self,
649        data: &[u8],
650        chunk_callback: &mut impl FnMut(&[u8]) -> (),
651    ) -> Result<(), ChunkedArchiveError> {
652        debug_assert_eq!(data.len(), self.seek_table[self.curr_chunk].compressed_range.len());
653        let chunk = &self.seek_table[self.curr_chunk];
654        let decompressed_size = self
655            .decompressor
656            .decompress_into(data, self.decompressed_buffer.as_mut_slice(), self.curr_chunk)
657            .inspect_err(|_| {
658                if let Some(error_handler) = &self.error_handler {
659                    error_handler(self.curr_chunk, chunk.clone(), data.as_bytes());
660                }
661            })?;
662        if decompressed_size != chunk.decompressed_range.len() {
663            return Err(ChunkedArchiveError::IntegrityError);
664        }
665        chunk_callback(&self.decompressed_buffer[..decompressed_size]);
666        self.curr_chunk += 1;
667        Ok(())
668    }
669
670    /// Update the decompressor with more data.
671    pub fn update(
672        &mut self,
673        mut data: &[u8],
674        chunk_callback: &mut impl FnMut(&[u8]) -> (),
675    ) -> Result<(), ChunkedArchiveError> {
676        // Caller must not provide too much data.
677        if self.data_written + data.len() > self.total_compressed_size {
678            return Err(ChunkedArchiveError::OutOfRange);
679        }
680        self.data_written += data.len();
681
682        // If we had leftover data from a previous read, append until we've filled a chunk.
683        if !self.buffer.is_empty() {
684            let to_read = std::cmp::min(
685                data.len(),
686                self.seek_table[self.curr_chunk]
687                    .compressed_range
688                    .len()
689                    .checked_sub(self.buffer.len())
690                    .unwrap(),
691            );
692            self.buffer.extend_from_slice(&data[..to_read]);
693            if self.buffer.len() == self.seek_table[self.curr_chunk].compressed_range.len() {
694                // Take self.buffer temporarily (so we don't have to split borrows).
695                // That way we don't have to re-commit the pages we've already used in the buffer
696                // for next time.
697                let full_chunk = std::mem::take(&mut self.buffer);
698                self.finish_chunk(&full_chunk[..], chunk_callback)?;
699                self.buffer = full_chunk;
700                // Draining the buffer will set the length to 0 but keep the capacity the same.
701                self.buffer.clear();
702            }
703            data = &data[to_read..];
704        }
705
706        // Decode as many full chunks as we can.
707        while !data.is_empty()
708            && self.curr_chunk < self.seek_table.len()
709            && self.seek_table[self.curr_chunk].compressed_range.len() <= data.len()
710        {
711            let len = self.seek_table[self.curr_chunk].compressed_range.len();
712            self.finish_chunk(&data[..len], chunk_callback)?;
713            data = &data[len..];
714        }
715
716        // Buffer the rest for the next call.
717        if !data.is_empty() {
718            debug_assert!(self.curr_chunk < self.seek_table.len());
719            debug_assert!(self.data_written < self.total_compressed_size);
720            self.buffer.extend_from_slice(data);
721        }
722
723        debug_assert!(
724            self.data_written < self.total_compressed_size
725                || self.curr_chunk == self.seek_table.len()
726        );
727
728        Ok(())
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use crate::Type1Blob;
735
736    use super::*;
737    use rand::Rng;
738    use std::matches;
739
740    /// Create a compressed archive and ensure we can decode it as a valid archive that passes all
741    /// required integrity checks.
742    #[test]
743    fn compress_simple() {
744        let data: Vec<u8> = vec![0; 32 * 1024 * 16];
745        let archive = ChunkedArchive::new(&data, Type1Blob::CHUNKED_ARCHIVE_OPTIONS).unwrap();
746        // This data is highly compressible, so the result should be smaller than the original.
747        let mut compressed: Vec<u8> = vec![];
748        archive.write(&mut compressed).unwrap();
749        assert!(compressed.len() <= data.len());
750        // We should be able to decode and verify the archive's integrity in-place.
751        assert!(decode_archive(&compressed, compressed.len()).unwrap().is_some());
752    }
753
754    /// Generate a header + seek table for verifying invariants/integrity checks.
755    fn generate_archive(
756        num_entries: usize,
757        options: ChunkedArchiveOptions,
758    ) -> (ChunkedArchiveHeader, Vec<SeekTableEntry>, /*archive_length*/ u64) {
759        let mut seek_table = Vec::with_capacity(num_entries);
760        let header_length = ChunkedArchiveHeader::header_length(num_entries) as u64;
761        const COMPRESSED_CHUNK_SIZE: u64 = 1024;
762        const DECOMPRESSED_CHUNK_SIZE: u64 = 2048;
763        for n in 0..(num_entries as u64) {
764            seek_table.push(SeekTableEntry {
765                compressed_offset: (header_length + (n * COMPRESSED_CHUNK_SIZE)).into(),
766                compressed_size: COMPRESSED_CHUNK_SIZE.into(),
767                decompressed_offset: (n * DECOMPRESSED_CHUNK_SIZE).into(),
768                decompressed_size: DECOMPRESSED_CHUNK_SIZE.into(),
769            });
770        }
771        let header = ChunkedArchiveHeader::new(&seek_table, options).unwrap();
772        let archive_length: u64 = header_length + (num_entries as u64 * COMPRESSED_CHUNK_SIZE);
773        (header, seek_table, archive_length)
774    }
775
776    #[test]
777    fn should_validate_self() {
778        let (header, seek_table, archive_length) =
779            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
780        let serialized_table = seek_table.as_slice().as_bytes();
781        assert!(header.decode_archive(serialized_table, archive_length).unwrap().is_some());
782    }
783
784    #[test]
785    fn should_validate_empty() {
786        let (header, _, archive_length) = generate_archive(0, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
787        assert!(header.decode_archive(&[], archive_length).unwrap().is_some());
788    }
789
790    #[test]
791    fn should_detect_bad_magic() {
792        let (header, seek_table, archive_length) =
793            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
794        let mut corrupt_magic = ChunkedArchiveHeader::CHUNKED_ARCHIVE_MAGIC;
795        corrupt_magic[0] = !corrupt_magic[0];
796        let bad_magic = ChunkedArchiveHeader { magic: corrupt_magic, ..header };
797        let serialized_table = seek_table.as_slice().as_bytes();
798        assert!(matches!(
799            bad_magic.decode_archive(serialized_table, archive_length).unwrap_err(),
800            ChunkedArchiveError::BadMagic
801        ));
802    }
803    #[test]
804    fn should_detect_wrong_version() {
805        let (header, seek_table, archive_length) =
806            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
807        let invalid_version = ChunkedArchiveHeader { version: u16::MAX.into(), ..header };
808        let serialized_table = seek_table.as_slice().as_bytes();
809        assert!(matches!(
810            invalid_version.decode_archive(serialized_table, archive_length).unwrap_err(),
811            ChunkedArchiveError::InvalidVersion
812        ));
813    }
814
815    #[test]
816    fn should_detect_corrupt_checksum() {
817        let (header, seek_table, archive_length) =
818            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
819        let corrupt_checksum =
820            ChunkedArchiveHeader { checksum: (!header.checksum.get()).into(), ..header };
821        let serialized_table = seek_table.as_slice().as_bytes();
822        assert!(matches!(
823            corrupt_checksum.decode_archive(serialized_table, archive_length).unwrap_err(),
824            ChunkedArchiveError::IntegrityError
825        ));
826    }
827
828    #[test]
829    fn should_reject_too_many_entries_v2() {
830        let (too_many_entries, seek_table, archive_length) = generate_archive(
831            ChunkedArchiveOptions::V2_MAX_CHUNKS + 1,
832            Type1Blob::CHUNKED_ARCHIVE_OPTIONS,
833        );
834
835        let serialized_table = seek_table.as_slice().as_bytes();
836        assert!(matches!(
837            too_many_entries.decode_archive(serialized_table, archive_length).unwrap_err(),
838            ChunkedArchiveError::IntegrityError
839        ));
840    }
841
842    #[test]
843    fn invariant_i0_first_entry_zero() {
844        let (header, mut seek_table, archive_length) =
845            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
846        assert_eq!(seek_table[0].decompressed_offset.get(), 0);
847        seek_table[0].decompressed_offset = 1.into();
848
849        let serialized_table = seek_table.as_slice().as_bytes();
850        assert!(matches!(
851            header.decode_archive(serialized_table, archive_length).unwrap_err(),
852            ChunkedArchiveError::IntegrityError
853        ));
854    }
855
856    #[test]
857    fn invariant_i1_no_header_overlap() {
858        let (header, mut seek_table, archive_length) =
859            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
860        let header_end = ChunkedArchiveHeader::header_length(seek_table.len()) as u64;
861        assert!(seek_table[0].compressed_offset.get() >= header_end);
862        seek_table[0].compressed_offset = (header_end - 1).into();
863        let serialized_table = seek_table.as_slice().as_bytes();
864        assert!(matches!(
865            header.decode_archive(serialized_table, archive_length).unwrap_err(),
866            ChunkedArchiveError::IntegrityError
867        ));
868    }
869
870    #[test]
871    fn invariant_i2_decompressed_monotonic() {
872        let (header, mut seek_table, archive_length) =
873            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
874        assert_eq!(
875            seek_table[0].decompressed_offset.get() + seek_table[0].decompressed_size.get(),
876            seek_table[1].decompressed_offset.get()
877        );
878        seek_table[1].decompressed_offset = (seek_table[1].decompressed_offset.get() - 1).into();
879        let serialized_table = seek_table.as_slice().as_bytes();
880        assert!(matches!(
881            header.decode_archive(serialized_table, archive_length).unwrap_err(),
882            ChunkedArchiveError::IntegrityError
883        ));
884    }
885
886    #[test]
887    fn invariant_i3_compressed_monotonic() {
888        let (header, mut seek_table, archive_length) =
889            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
890        assert!(
891            (seek_table[0].compressed_offset.get() + seek_table[0].compressed_size.get())
892                <= seek_table[1].compressed_offset.get()
893        );
894        seek_table[1].compressed_offset = (seek_table[1].compressed_offset.get() - 1).into();
895        let serialized_table = seek_table.as_slice().as_bytes();
896        assert!(matches!(
897            header.decode_archive(serialized_table, archive_length).unwrap_err(),
898            ChunkedArchiveError::IntegrityError
899        ));
900    }
901
902    #[test]
903    fn invariant_i4_nonzero_compressed_size() {
904        let (header, mut seek_table, archive_length) =
905            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
906        assert!(seek_table[0].compressed_size.get() > 0);
907        seek_table[0].compressed_size = 0.into();
908        let serialized_table = seek_table.as_slice().as_bytes();
909        assert!(matches!(
910            header.decode_archive(serialized_table, archive_length).unwrap_err(),
911            ChunkedArchiveError::IntegrityError
912        ));
913    }
914
915    #[test]
916    fn invariant_i4_nonzero_decompressed_size() {
917        let (header, mut seek_table, archive_length) =
918            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
919        assert!(seek_table[0].decompressed_size.get() > 0);
920        seek_table[0].decompressed_size = 0.into();
921        let serialized_table = seek_table.as_slice().as_bytes();
922        assert!(matches!(
923            header.decode_archive(serialized_table, archive_length).unwrap_err(),
924            ChunkedArchiveError::IntegrityError
925        ));
926    }
927
928    #[test]
929    fn invariant_i5_within_archive() {
930        let (header, mut seek_table, archive_length) =
931            generate_archive(4, Type1Blob::CHUNKED_ARCHIVE_OPTIONS);
932        let last_entry = seek_table.last_mut().unwrap();
933        assert!(
934            (last_entry.compressed_offset.get() + last_entry.compressed_size.get())
935                <= archive_length
936        );
937        last_entry.compressed_offset = (archive_length + 1).into();
938        let serialized_table = seek_table.as_slice().as_bytes();
939        assert!(matches!(
940            header.decode_archive(serialized_table, archive_length).unwrap_err(),
941            ChunkedArchiveError::IntegrityError
942        ));
943    }
944
945    #[test]
946    fn max_chunks() {
947        let ChunkedArchiveOptions::V2 { minimum_chunk_size, chunk_alignment, .. } =
948            Type1Blob::CHUNKED_ARCHIVE_OPTIONS
949        else {
950            panic!()
951        };
952        assert_eq!(
953            Type1Blob::CHUNKED_ARCHIVE_OPTIONS
954                .chunk_size_for(minimum_chunk_size * ChunkedArchiveOptions::V2_MAX_CHUNKS),
955            minimum_chunk_size
956        );
957        assert_eq!(
958            Type1Blob::CHUNKED_ARCHIVE_OPTIONS
959                .chunk_size_for(minimum_chunk_size * ChunkedArchiveOptions::V2_MAX_CHUNKS + 1),
960            minimum_chunk_size + chunk_alignment
961        );
962    }
963
964    #[test]
965    fn test_decompressor_empty_archive() {
966        let mut compressed: Vec<u8> = vec![];
967        ChunkedArchive::new(&[], Type1Blob::CHUNKED_ARCHIVE_OPTIONS)
968            .expect("compress")
969            .write(&mut compressed)
970            .expect("write archive");
971        let (decoded_archive, chunk_data) =
972            decode_archive(&compressed, compressed.len()).unwrap().unwrap();
973        assert!(decoded_archive.seek_table.is_empty());
974        let mut decompressor = ChunkedDecompressor::new(decoded_archive).unwrap();
975        let mut chunk_callback = |_chunk: &[u8]| panic!("Archive doesn't have any chunks.");
976        // Stream data into the decompressor in small chunks to exhaust more edge cases.
977        chunk_data
978            .chunks(4)
979            .for_each(|data| decompressor.update(data, &mut chunk_callback).unwrap());
980    }
981
982    #[test]
983    fn test_decompressor() {
984        const UNCOMPRESSED_LENGTH: usize = 3_000_000;
985        let data: Vec<u8> = {
986            let range = rand::distr::Uniform::<u8>::new_inclusive(0, 255).unwrap();
987            rand::rng().sample_iter(&range).take(UNCOMPRESSED_LENGTH).collect()
988        };
989        let mut compressed: Vec<u8> = vec![];
990        ChunkedArchive::new(&data, Type1Blob::CHUNKED_ARCHIVE_OPTIONS)
991            .expect("compress")
992            .write(&mut compressed)
993            .expect("write archive");
994        let (decoded_archive, chunk_data) =
995            decode_archive(&compressed, compressed.len()).unwrap().unwrap();
996
997        // Make sure we have multiple chunks for this test.
998        let num_chunks = decoded_archive.seek_table.len();
999        assert!(num_chunks > 1);
1000
1001        let mut decompressor = ChunkedDecompressor::new(decoded_archive).unwrap();
1002
1003        let mut decoded_chunks: usize = 0;
1004        let mut decompressed_offset: usize = 0;
1005        let mut chunk_callback = |decompressed_chunk: &[u8]| {
1006            assert!(
1007                decompressed_chunk
1008                    == &data[decompressed_offset..decompressed_offset + decompressed_chunk.len()]
1009            );
1010            decompressed_offset += decompressed_chunk.len();
1011            decoded_chunks += 1;
1012        };
1013
1014        // Stream data into the decompressor in small chunks to exhaust more edge cases.
1015        chunk_data
1016            .chunks(4)
1017            .for_each(|data| decompressor.update(data, &mut chunk_callback).unwrap());
1018        assert_eq!(decoded_chunks, num_chunks);
1019    }
1020
1021    #[test]
1022    fn test_decompressor_corrupt_decompressed_size() {
1023        let data = vec![0; 3_000_000];
1024        let mut compressed: Vec<u8> = vec![];
1025        ChunkedArchive::new(&data, Type1Blob::CHUNKED_ARCHIVE_OPTIONS)
1026            .expect("compress")
1027            .write(&mut compressed)
1028            .expect("write archive");
1029        let (mut decoded_archive, chunk_data) =
1030            decode_archive(&compressed, compressed.len()).unwrap().unwrap();
1031
1032        // Corrupt the decompressed size of the chunk.
1033        decoded_archive.seek_table[0].decompressed_range =
1034            decoded_archive.seek_table[0].decompressed_range.start
1035                ..decoded_archive.seek_table[0].decompressed_range.end + 1;
1036
1037        let mut decompressor = ChunkedDecompressor::new(decoded_archive).unwrap();
1038        assert!(matches!(
1039            decompressor.update(&chunk_data, &mut |_chunk| {}),
1040            Err(ChunkedArchiveError::IntegrityError)
1041        ));
1042    }
1043
1044    #[test]
1045    fn test_decompressor_corrupt_compressed_size() {
1046        let data = vec![0; 3_000_000];
1047        let mut compressed: Vec<u8> = vec![];
1048        ChunkedArchive::new(&data, Type1Blob::CHUNKED_ARCHIVE_OPTIONS)
1049            .expect("compress")
1050            .write(&mut compressed)
1051            .expect("write archive");
1052        let (mut decoded_archive, chunk_data) =
1053            decode_archive(&compressed, compressed.len()).unwrap().unwrap();
1054
1055        // Corrupt the compressed size of the chunk.
1056        decoded_archive.seek_table[0].compressed_range =
1057            decoded_archive.seek_table[0].compressed_range.start
1058                ..decoded_archive.seek_table[0].compressed_range.end - 1;
1059        let first_chunk_info = decoded_archive.seek_table[0].clone();
1060        let error_handler = move |chunk_index: usize, chunk_info: ChunkInfo, chunk_data: &[u8]| {
1061            assert_eq!(chunk_index, 0);
1062            assert_eq!(chunk_info, first_chunk_info);
1063            assert_eq!(chunk_data.len(), chunk_info.compressed_range.len());
1064        };
1065
1066        let mut decompressor =
1067            ChunkedDecompressor::new_with_error_handler(decoded_archive, Box::new(error_handler))
1068                .unwrap();
1069        assert!(matches!(
1070            decompressor.update(&chunk_data, &mut |_chunk| {}),
1071            Err(ChunkedArchiveError::DecompressionError { .. })
1072        ));
1073    }
1074
1075    #[test]
1076    fn test_decompressor_zstd_data_corruption() {
1077        let data = vec![0; 3_000_000];
1078        let mut compressed: Vec<u8> = vec![];
1079        let archive = match ChunkedArchive::new(&data, Type1Blob::CHUNKED_ARCHIVE_OPTIONS) {
1080            Ok(a) => a,
1081            Err(e) => {
1082                panic!("Failed to compress in test: {:?}", e);
1083            }
1084        };
1085        archive.write(&mut compressed).expect("write archive");
1086        let (decoded_archive, chunk_data) =
1087            decode_archive(&compressed, compressed.len()).unwrap().unwrap();
1088
1089        let mut corrupt_data = chunk_data.to_vec();
1090        if corrupt_data.len() > 100 {
1091            corrupt_data[100] = !corrupt_data[100];
1092        }
1093
1094        let mut decompressor = ChunkedDecompressor::new(decoded_archive).unwrap();
1095        let result = decompressor.update(&corrupt_data, &mut |_chunk| {});
1096        assert!(matches!(result, Err(ChunkedArchiveError::DecompressionError { .. })));
1097    }
1098
1099    #[test]
1100    fn test_v3_zstd_roundtrip() {
1101        let data = vec![0; 3_000_000];
1102        let options =
1103            ChunkedArchiveOptions::V3 { compression_algorithm: CompressionAlgorithm::Zstd };
1104        let mut compressed = vec![];
1105        ChunkedArchive::new(&data, options)
1106            .expect("compress")
1107            .write(&mut compressed)
1108            .expect("write");
1109
1110        // Verify header.
1111        let (header, _) =
1112            Ref::<_, ChunkedArchiveHeader>::from_prefix(compressed.as_slice()).unwrap();
1113        assert_eq!(header.version.get(), 3);
1114        assert_eq!(header.compression_algorithm, CompressionAlgorithm::Zstd as u8);
1115
1116        let (decoded_archive, chunk_data) =
1117            decode_archive(&compressed, compressed.len()).unwrap().unwrap();
1118
1119        // Decompress.
1120        let mut decompressor = ChunkedDecompressor::new(decoded_archive).unwrap();
1121        let mut decompressed: Vec<u8> = vec![];
1122        let mut chunk_callback = |chunk: &[u8]| decompressed.extend_from_slice(chunk);
1123        decompressor.update(chunk_data, &mut chunk_callback).unwrap();
1124
1125        assert_eq!(decompressed, data);
1126    }
1127
1128    #[test]
1129    fn test_v3_lz4_roundtrip() {
1130        let data = vec![0; 3_000_000];
1131        let options =
1132            ChunkedArchiveOptions::V3 { compression_algorithm: CompressionAlgorithm::Lz4 };
1133        let mut compressed = vec![];
1134        ChunkedArchive::new(&data, options)
1135            .expect("compress")
1136            .write(&mut compressed)
1137            .expect("write");
1138
1139        // Verify header.
1140        let (header, _) =
1141            Ref::<_, ChunkedArchiveHeader>::from_prefix(compressed.as_slice()).unwrap();
1142        assert_eq!(header.version.get(), 3);
1143        assert_eq!(header.compression_algorithm, CompressionAlgorithm::Lz4 as u8);
1144
1145        let (decoded_archive, chunk_data) =
1146            decode_archive(&compressed, compressed.len()).unwrap().unwrap();
1147
1148        // Decompress.
1149        let mut decompressor = ChunkedDecompressor::new(decoded_archive).unwrap();
1150        let mut decompressed: Vec<u8> = vec![];
1151        let mut chunk_callback = |chunk: &[u8]| decompressed.extend_from_slice(chunk);
1152        decompressor.update(chunk_data, &mut chunk_callback).unwrap();
1153
1154        assert_eq!(decompressed, data);
1155    }
1156}