Skip to main content

lzip_parallel/
reader.rs

1//! Streaming parallel ZIP decompressor for large files.
2//!
3//! Pipeline mirrors lbzip2-rs's StreamingBz2Read:
4//!   seek to tail → parse Central Directory → sort entries by file offset →
5//!   read compressed bytes sequentially in batches of BATCH_SIZE →
6//!   parallel DEFLATE decode → yield ZipEntry items.
7//!
8//! Only one batch of decompressed output is live at a time, bounding peak
9//! memory regardless of ZIP size.  The file is never fully loaded into RAM.
10//!
11//! For in-memory byte slices use `parallel::decompress_parallel` instead.
12
13use std::collections::VecDeque;
14use std::io::{Read, Seek, SeekFrom};
15
16use crate::central_dir;
17use crate::entry::{self, ZipEntry, ZipError};
18
19/// Entries decoded per parallel batch.
20const BATCH_SIZE: usize = 64;
21
22const LOCAL_HEADER_SIG: u32 = 0x04034b50;
23
24/// Streaming parallel ZIP decompressor.
25///
26/// Parses the Central Directory by seeking to the file tail, sorts entries by
27/// their file offset for sequential I/O, then decodes them in parallel batches.
28/// Implements `Iterator<Item = Result<ZipEntry, ZipError>>`.
29///
30/// ```no_run
31/// use lzip_parallel::reader::StreamingZipRead;
32/// let f = std::fs::File::open("archive.zip").unwrap();
33/// for entry in StreamingZipRead::new(f).unwrap() {
34///     let e = entry.unwrap();
35///     println!("{}: {} bytes", e.name, e.data.len());
36/// }
37/// ```
38pub struct StreamingZipRead<R: Read + Seek> {
39    source: R,
40    /// File entries sorted by local_header_offset for sequential disk reads.
41    locations: Vec<crate::central_dir::EntryLocation>,
42    next_entry: usize,
43    buf: VecDeque<ZipEntry>,
44    error: bool,
45}
46
47impl<R: Read + Seek> StreamingZipRead<R> {
48    /// Open a streaming decompressor over any `Read + Seek` source.
49    ///
50    /// Seeks to the file tail to parse the Central Directory; no full-file
51    /// read is performed here.
52    pub fn new(mut source: R) -> Result<Self, ZipError> {
53        let all_locs = central_dir::read_central_directory_from(&mut source)?;
54        let mut locations: Vec<_> = all_locs.into_iter().filter(|l| !l.is_directory).collect();
55        locations.sort_by_key(|l| l.local_header_offset);
56        Ok(Self {
57            source,
58            locations,
59            next_entry: 0,
60            buf: VecDeque::new(),
61            error: false,
62        })
63    }
64
65    /// Open with a name filter — only entries containing `needle` are decompressed.
66    ///
67    /// Non-matching entries are excluded at the central directory level,
68    /// never read from disk or inflated.
69    pub fn with_filter(mut source: R, needle: &str) -> Result<Self, ZipError> {
70        let all_locs = central_dir::read_central_directory_from(&mut source)?;
71        let mut locations: Vec<_> = all_locs.into_iter()
72            .filter(|l| !l.is_directory && l.name.contains(needle))
73            .collect();
74        locations.sort_by_key(|l| l.local_header_offset);
75        Ok(Self {
76            source,
77            locations,
78            next_entry: 0,
79            buf: VecDeque::new(),
80            error: false,
81        })
82    }
83
84    /// Open with an exact-name filter — only entries whose name is in `names` are decompressed.
85    ///
86    /// Non-matching entries are excluded at the central directory level.
87    pub fn with_filter_set(mut source: R, names: &[&str]) -> Result<Self, ZipError> {
88        use std::collections::HashSet;
89        let name_set: HashSet<&str> = names.iter().copied().collect();
90        let all_locs = central_dir::read_central_directory_from(&mut source)?;
91        let mut locations: Vec<_> = all_locs.into_iter()
92            .filter(|l| !l.is_directory && name_set.contains(l.name.as_str()))
93            .collect();
94        locations.sort_by_key(|l| l.local_header_offset);
95        Ok(Self {
96            source,
97            locations,
98            next_entry: 0,
99            buf: VecDeque::new(),
100            error: false,
101        })
102    }
103
104    /// Open with a suffix filter — only entries whose name ends with one of `suffixes`.
105    ///
106    /// Non-matching entries are excluded at the central directory level.
107    pub fn with_filter_suffixes(mut source: R, suffixes: &[&str]) -> Result<Self, ZipError> {
108        let all_locs = central_dir::read_central_directory_from(&mut source)?;
109        let mut locations: Vec<_> = all_locs.into_iter()
110            .filter(|l| !l.is_directory && suffixes.iter().any(|s| l.name.ends_with(s)))
111            .collect();
112        locations.sort_by_key(|l| l.local_header_offset);
113        Ok(Self {
114            source,
115            locations,
116            next_entry: 0,
117            buf: VecDeque::new(),
118            error: false,
119        })
120    }
121
122    fn fill_batch(&mut self) -> Result<(), ZipError> {
123        let remaining = self.locations.len() - self.next_entry;
124        if remaining == 0 {
125            return Ok(());
126        }
127
128        let batch_end = self.next_entry + remaining.min(BATCH_SIZE);
129        let batch = &self.locations[self.next_entry..batch_end];
130
131        // ── Read compressed bytes sequentially (entries sorted by offset) ──
132        let mut raw: Vec<(String, Vec<u8>, u16, usize)> = Vec::with_capacity(batch.len());
133        for loc in batch {
134            self.source.seek(SeekFrom::Start(loc.local_header_offset))
135                .map_err(|_| ZipError("seek to local header failed"))?;
136
137            let mut hdr = [0u8; 30];
138            self.source.read_exact(&mut hdr)
139                .map_err(|_| ZipError("read local header failed"))?;
140
141            if u32::from_le_bytes(hdr[0..4].try_into().unwrap()) != LOCAL_HEADER_SIG {
142                return Err(ZipError("invalid local file header signature"));
143            }
144
145            // Name and extra lengths in the local header may differ from the CD.
146            let skip = u16::from_le_bytes([hdr[26], hdr[27]]) as i64
147                     + u16::from_le_bytes([hdr[28], hdr[29]]) as i64;
148            self.source.seek(SeekFrom::Current(skip))
149                .map_err(|_| ZipError("seek past local header name/extra failed"))?;
150
151            // Uninitialised read buffer: read_exact fully overwrites it, so the
152            // vec![0u8; len] zero-fill was wasted work on the hot path.
153            let len = loc.compressed_size as usize;
154            let mut compressed = Vec::with_capacity(len);
155            // SAFETY: len <= capacity; read_exact writes exactly `len` bytes
156            // before any byte is read, and errors out (returning) otherwise.
157            #[allow(clippy::uninit_vec)]
158            unsafe {
159                compressed.set_len(len);
160            }
161            self.source.read_exact(&mut compressed)
162                .map_err(|_| ZipError("read compressed data failed"))?;
163
164            raw.push((loc.name.clone(), compressed, loc.compression_method, loc.uncompressed_size as usize));
165        }
166
167        // ── Parallel decode ────────────────────────────────────────────────
168        // Fan out per-entry decode across the shared gatling engine; `raw[i]`
169        // is borrowed (the owned `name` is cloned into its ZipEntry) so the
170        // closure can be a plain `Fn` over the shared index cursor.
171        // LPT / heaviest-first: weight each unit by its uncompressed size so a
172        // skewed archive (one fat entry among many tiny ones) can't tail-choke on
173        // a single core — heavy entries claimed first, cheap tail backfills.
174        let results: Vec<Result<ZipEntry, ZipError>> =
175            gatling::gatling_forkjoin::gatling_for_each_balanced(
176                raw.len(),
177                0,
178                1,
179                |i| raw[i].3 as u64,
180                |i| {
181                    let (name, compressed, method, expected_size) = &raw[i];
182                    let data = entry::decompress_entry_raw(compressed, *method, *expected_size)?;
183                    Ok(ZipEntry { name: name.clone(), data })
184                },
185            );
186
187        // ── Buffer results ─────────────────────────────────────────────────
188        for r in results {
189            self.buf.push_back(r?);
190        }
191        self.next_entry = batch_end;
192        Ok(())
193    }
194}
195
196impl<R: Read + Seek> Iterator for StreamingZipRead<R> {
197    type Item = Result<ZipEntry, ZipError>;
198
199    fn next(&mut self) -> Option<Self::Item> {
200        if self.error {
201            return None;
202        }
203        if let Some(entry) = self.buf.pop_front() {
204            return Some(Ok(entry));
205        }
206        if self.next_entry >= self.locations.len() {
207            return None;
208        }
209        match self.fill_batch() {
210            Err(e) => {
211                self.error = true;
212                Some(Err(e))
213            }
214            Ok(()) => self.buf.pop_front().map(Ok),
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::io::{Cursor, Write};
223
224    fn make_jar(items: &[(&str, &[u8])]) -> Vec<u8> {
225        use zip::write::SimpleFileOptions;
226        let mut buf = Vec::new();
227        let mut zw = zip::ZipWriter::new(Cursor::new(&mut buf));
228        let opts = SimpleFileOptions::default()
229            .compression_method(zip::CompressionMethod::Deflated);
230        for (name, data) in items {
231            zw.start_file(*name, opts).unwrap();
232            zw.write_all(data).unwrap();
233        }
234        zw.finish().unwrap();
235        buf
236    }
237
238    #[test]
239    fn streaming_single() {
240        let jar = make_jar(&[("Hello.class", b"cafebabe")]);
241        let entries: Vec<_> = StreamingZipRead::new(Cursor::new(&jar))
242            .unwrap()
243            .collect::<Result<Vec<_>, _>>()
244            .unwrap();
245        assert_eq!(entries.len(), 1);
246        assert_eq!(entries[0].name, "Hello.class");
247        assert_eq!(entries[0].data, b"cafebabe");
248    }
249
250    #[test]
251    fn streaming_multi() {
252        let jar = make_jar(&[("a.txt", b"aaa"), ("b.txt", b"bbb"), ("c.txt", b"ccc")]);
253        let entries: Vec<_> = StreamingZipRead::new(Cursor::new(&jar))
254            .unwrap()
255            .collect::<Result<Vec<_>, _>>()
256            .unwrap();
257        assert_eq!(entries.len(), 3);
258    }
259
260    #[test]
261    fn streaming_skips_directories() {
262        let jar = make_jar(&[("META-INF/", b""), ("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\n")]);
263        let entries: Vec<_> = StreamingZipRead::new(Cursor::new(&jar))
264            .unwrap()
265            .collect::<Result<Vec<_>, _>>()
266            .unwrap();
267        assert_eq!(entries.len(), 1);
268        assert_eq!(entries[0].name, "META-INF/MANIFEST.MF");
269    }
270
271    #[test]
272    fn streaming_many_batches() {
273        let items: Vec<(String, Vec<u8>)> = (0..200)
274            .map(|i| (format!("Class{i}.class"), format!("data{i}").into_bytes()))
275            .collect();
276        let item_refs: Vec<(&str, &[u8])> = items.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect();
277        let jar = make_jar(&item_refs);
278
279        let entries: Vec<_> = StreamingZipRead::new(Cursor::new(&jar))
280            .unwrap()
281            .collect::<Result<Vec<_>, _>>()
282            .unwrap();
283        assert_eq!(entries.len(), 200);
284    }
285
286    #[test]
287    fn streaming_matches_parallel() {
288        let items: Vec<(String, Vec<u8>)> = (0..100)
289            .map(|i| (format!("{i}.txt"), format!("content {i}").into_bytes()))
290            .collect();
291        let item_refs: Vec<(&str, &[u8])> = items.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect();
292        let jar = make_jar(&item_refs);
293
294        let par = crate::parallel::decompress_parallel(&jar).unwrap();
295        let stream: Vec<_> = StreamingZipRead::new(Cursor::new(&jar))
296            .unwrap()
297            .collect::<Result<Vec<_>, _>>()
298            .unwrap();
299
300        // streaming returns entries in file-offset order (sorted); sort par to match
301        let mut par_sorted = par;
302        par_sorted.sort_by(|a, b| a.name.cmp(&b.name));
303        let mut stream_sorted = stream;
304        stream_sorted.sort_by(|a, b| a.name.cmp(&b.name));
305
306        assert_eq!(par_sorted.len(), stream_sorted.len());
307        for (p, s) in par_sorted.iter().zip(stream_sorted.iter()) {
308            assert_eq!(p.name, s.name);
309            assert_eq!(p.data, s.data);
310        }
311    }
312
313    /// Core-saturation correctness: a deliberately SKEWED archive — one fat
314    /// entry among many tiny ones — must decode byte-for-byte identically under
315    /// the LPT (heaviest-first) balanced fan-out. The rebalance only reorders
316    /// which worker claims which entry; the decoded bytes are unchanged.
317    #[test]
318    fn streaming_skewed_roundtrip_byte_identical() {
319        let big: Vec<u8> = (0..300_000u32)
320            .map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8)
321            .collect();
322        let mut items: Vec<(String, Vec<u8>)> = Vec::new();
323        items.push(("Fat.class".to_string(), big.clone()));
324        for i in 0..300 {
325            items.push((format!("Tiny{i}.class"), format!("t{i}").into_bytes()));
326        }
327        let item_refs: Vec<(&str, &[u8])> =
328            items.iter().map(|(n, d)| (n.as_str(), d.as_slice())).collect();
329        let jar = make_jar(&item_refs);
330
331        let entries: Vec<_> = StreamingZipRead::new(Cursor::new(&jar))
332            .unwrap()
333            .collect::<Result<Vec<_>, _>>()
334            .unwrap();
335
336        assert_eq!(entries.len(), items.len());
337        let got: std::collections::HashMap<&str, &[u8]> =
338            entries.iter().map(|e| (e.name.as_str(), e.data.as_slice())).collect();
339        for (name, data) in &items {
340            assert_eq!(got.get(name.as_str()), Some(&data.as_slice()), "entry {name} mismatch");
341        }
342    }
343}