1use std::fs;
22use std::io::{Read, Write};
23use std::path::{Path, PathBuf};
24
25use rayon::prelude::*;
26
27use crate::central_dir::{self, EntryLocation};
28use crate::entry::{self, ZipError};
29
30const CHUNK_BUFFER_SIZE: usize = 200 * 1024 * 1024; #[derive(Clone)]
35struct ResolvedEntry {
36 dest_rel: PathBuf,
38 buf_offset: usize,
40 buf_len: usize,
41 method: u16,
43 uncompressed_size: u64,
45}
46
47#[derive(Debug, Default)]
49pub struct BatchStats {
50 pub zips_processed: usize,
51 pub entries_extracted: usize,
52 pub compressed_bytes_read: u64,
53 pub decompressed_bytes_written: u64,
54 pub elapsed_secs: f64,
55}
56
57impl BatchStats {
58 pub fn throughput_mb_per_sec(&self) -> f64 {
59 let mb = self.decompressed_bytes_written as f64 / (1024.0 * 1024.0);
60 if self.elapsed_secs > 0.0 { mb / self.elapsed_secs } else { 0.0 }
61 }
62}
63
64pub fn extract_zips(zips: &[PathBuf], dest: &Path, flat: bool) -> Result<BatchStats, ZipError> {
72 let t0 = std::time::Instant::now();
73 let mut stats = BatchStats::default();
74
75 let mut zip_idx = 0;
77 while zip_idx < zips.len() {
78 let (entries, buffer, zips_in_batch) = fill_buffer(&zips[zip_idx..], dest, flat)?;
79 zip_idx += zips_in_batch;
80 stats.zips_processed += zips_in_batch;
81
82 let batch_stats = inflate_and_write(&entries, &buffer, dest)?;
84 stats.entries_extracted += batch_stats.0;
85 stats.decompressed_bytes_written += batch_stats.1;
86 stats.compressed_bytes_read += buffer.len() as u64;
87 }
88
89 stats.elapsed_secs = t0.elapsed().as_secs_f64();
90 Ok(stats)
91}
92
93pub fn decompress_zips(zips: &[PathBuf]) -> Result<(Vec<(String, Vec<u8>)>, BatchStats), ZipError> {
97 let t0 = std::time::Instant::now();
98 let mut stats = BatchStats::default();
99 let mut all_entries = Vec::new();
100
101 let mut zip_idx = 0;
102 while zip_idx < zips.len() {
103 let (entries, buffer, zips_in_batch) = fill_buffer_no_dest(&zips[zip_idx..])?;
104 zip_idx += zips_in_batch;
105 stats.zips_processed += zips_in_batch;
106
107 let results: Vec<Result<(String, Vec<u8>), ZipError>> = crate::thread_pool().install(|| {
109 entries.par_iter().map(|e| {
110 let compressed = &buffer[e.buf_offset..e.buf_offset + e.buf_len];
111 let data = entry::decompress_entry_raw(compressed, e.method, e.uncompressed_size as usize)?;
112 Ok((e.dest_rel.to_string_lossy().into_owned(), data))
113 }).collect()
114 });
115
116 for r in results {
117 let (name, data) = r?;
118 stats.decompressed_bytes_written += data.len() as u64;
119 all_entries.push((name, data));
120 }
121 stats.entries_extracted += entries.len();
122 stats.compressed_bytes_read += buffer.len() as u64;
123 }
124
125 stats.elapsed_secs = t0.elapsed().as_secs_f64();
126 Ok((all_entries, stats))
127}
128
129fn fill_buffer(
132 zips: &[PathBuf],
133 _dest: &Path,
134 flat: bool,
135) -> Result<(Vec<ResolvedEntry>, Vec<u8>, usize), ZipError> {
136 let mut buffer = Vec::with_capacity(CHUNK_BUFFER_SIZE);
137 let mut entries = Vec::new();
138 let mut zips_consumed = 0;
139
140 for zip_path in zips {
141 let file_size = fs::metadata(zip_path)
142 .map_err(|_| ZipError("cannot stat ZIP file"))?
143 .len() as usize;
144
145 if !buffer.is_empty() && buffer.len() + file_size > CHUNK_BUFFER_SIZE {
147 break;
148 }
149
150 let buf_start = buffer.len();
151
152 let mut f = fs::File::open(zip_path)
154 .map_err(|_| ZipError("cannot open ZIP file"))?;
155 buffer.resize(buf_start + file_size, 0);
156 f.read_exact(&mut buffer[buf_start..])
157 .map_err(|_| ZipError("cannot read ZIP file"))?;
158
159 let zip_data = &buffer[buf_start..buf_start + file_size];
161 let locations = central_dir::read_central_directory(zip_data)?;
162
163 let zip_subdir = if flat {
165 PathBuf::new()
166 } else {
167 let stem = zip_path.file_stem()
168 .map(|s| s.to_string_lossy().into_owned())
169 .unwrap_or_else(|| "unknown".into());
170 PathBuf::from(stem)
171 };
172
173 for loc in &locations {
175 if loc.is_directory {
176 continue;
177 }
178 let compressed_range = resolve_compressed_range(zip_data, loc)?;
179 entries.push(ResolvedEntry {
180 dest_rel: zip_subdir.join(&loc.name),
181 buf_offset: buf_start + compressed_range.0,
182 buf_len: compressed_range.1,
183 method: loc.compression_method,
184 uncompressed_size: loc.uncompressed_size,
185 });
186 }
187
188 zips_consumed += 1;
189 }
190
191 Ok((entries, buffer, zips_consumed))
192}
193
194fn fill_buffer_no_dest(
196 zips: &[PathBuf],
197) -> Result<(Vec<ResolvedEntry>, Vec<u8>, usize), ZipError> {
198 let mut buffer = Vec::with_capacity(CHUNK_BUFFER_SIZE);
199 let mut entries = Vec::new();
200 let mut zips_consumed = 0;
201
202 for zip_path in zips {
203 let file_size = fs::metadata(zip_path)
204 .map_err(|_| ZipError("cannot stat ZIP file"))?
205 .len() as usize;
206
207 if !buffer.is_empty() && buffer.len() + file_size > CHUNK_BUFFER_SIZE {
208 break;
209 }
210
211 let buf_start = buffer.len();
212 let mut f = fs::File::open(zip_path)
213 .map_err(|_| ZipError("cannot open ZIP file"))?;
214 buffer.resize(buf_start + file_size, 0);
215 f.read_exact(&mut buffer[buf_start..])
216 .map_err(|_| ZipError("cannot read ZIP file"))?;
217
218 let zip_data = &buffer[buf_start..buf_start + file_size];
219 let locations = central_dir::read_central_directory(zip_data)?;
220
221 let zip_name = zip_path.file_name()
222 .map(|s| s.to_string_lossy().into_owned())
223 .unwrap_or_else(|| "unknown".into());
224
225 for loc in &locations {
226 if loc.is_directory {
227 continue;
228 }
229 let compressed_range = resolve_compressed_range(zip_data, loc)?;
230 entries.push(ResolvedEntry {
231 dest_rel: PathBuf::from(format!("{}/{}", zip_name, loc.name)),
232 buf_offset: buf_start + compressed_range.0,
233 buf_len: compressed_range.1,
234 method: loc.compression_method,
235 uncompressed_size: loc.uncompressed_size,
236 });
237 }
238
239 zips_consumed += 1;
240 }
241
242 Ok((entries, buffer, zips_consumed))
243}
244
245fn resolve_compressed_range(
248 zip_data: &[u8],
249 loc: &EntryLocation,
250) -> Result<(usize, usize), ZipError> {
251 let hdr = loc.local_header_offset as usize;
252
253 let sig = zip_data.get(hdr..hdr + 4)
254 .and_then(|b| b.try_into().ok())
255 .map(u32::from_le_bytes)
256 .ok_or(ZipError("local file header out of bounds"))?;
257 if sig != 0x04034b50 {
258 return Err(ZipError("invalid local file header signature"));
259 }
260
261 let name_len = zip_data.get(hdr + 26..hdr + 28)
262 .and_then(|b| b.try_into().ok())
263 .map(u16::from_le_bytes)
264 .ok_or(ZipError("local header truncated"))? as usize;
265
266 let extra_len = zip_data.get(hdr + 28..hdr + 30)
267 .and_then(|b| b.try_into().ok())
268 .map(u16::from_le_bytes)
269 .ok_or(ZipError("local header truncated"))? as usize;
270
271 let data_start = hdr + 30 + name_len + extra_len;
272 let data_len = loc.compressed_size as usize;
273
274 Ok((data_start, data_len))
275}
276
277fn inflate_and_write(
280 entries: &[ResolvedEntry],
281 buffer: &[u8],
282 dest: &Path,
283) -> Result<(usize, u64), ZipError> {
284 let mut dirs: Vec<&Path> = entries.iter()
286 .filter_map(|e| e.dest_rel.parent())
287 .collect();
288 dirs.sort();
289 dirs.dedup();
290 for dir in dirs {
291 let full = dest.join(dir);
292 if !full.exists() {
293 fs::create_dir_all(&full)
294 .map_err(|_| ZipError("cannot create output directory"))?;
295 }
296 }
297
298 let results: Vec<Result<u64, ZipError>> = crate::thread_pool().install(|| {
300 entries.par_iter().map(|e| {
301 let compressed = &buffer[e.buf_offset..e.buf_offset + e.buf_len];
302 let data = entry::decompress_entry_raw_cow(compressed, e.method, e.uncompressed_size as usize)?;
305
306 let out_path = dest.join(&e.dest_rel);
307 let mut f = fs::File::create(&out_path)
308 .map_err(|_| ZipError("cannot create output file"))?;
309 f.write_all(&data)
310 .map_err(|_| ZipError("write failed"))?;
311
312 Ok(data.len() as u64)
313 }).collect()
314 });
315
316 let mut total_written = 0u64;
317 let mut count = 0usize;
318 for r in results {
319 total_written += r?;
320 count += 1;
321 }
322
323 Ok((count, total_written))
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use std::io::{Cursor, Write};
330
331 fn make_zip_file(items: &[(&str, &[u8])]) -> Vec<u8> {
332 use zip::write::SimpleFileOptions;
333 let mut buf = Vec::new();
334 let mut zw = zip::ZipWriter::new(Cursor::new(&mut buf));
335 let opts = SimpleFileOptions::default()
336 .compression_method(zip::CompressionMethod::Deflated);
337 for (name, data) in items {
338 zw.start_file(*name, opts).unwrap();
339 zw.write_all(data).unwrap();
340 }
341 zw.finish().unwrap();
342 buf
343 }
344
345 #[test]
346 fn batch_extract_two_zips() {
347 let tmp = tempfile::tempdir().unwrap();
348 let zip1_path = tmp.path().join("one.zip");
349 let zip2_path = tmp.path().join("two.zip");
350
351 fs::write(&zip1_path, make_zip_file(&[("a.txt", b"aaa")])).unwrap();
352 fs::write(&zip2_path, make_zip_file(&[("b.txt", b"bbb")])).unwrap();
353
354 let out_dir = tmp.path().join("out");
355 fs::create_dir_all(&out_dir).unwrap();
356
357 let stats = extract_zips(
358 &[zip1_path, zip2_path],
359 &out_dir,
360 false,
361 ).unwrap();
362
363 assert_eq!(stats.zips_processed, 2);
364 assert_eq!(stats.entries_extracted, 2);
365 assert_eq!(fs::read(out_dir.join("one/a.txt")).unwrap(), b"aaa");
366 assert_eq!(fs::read(out_dir.join("two/b.txt")).unwrap(), b"bbb");
367 }
368
369 #[test]
370 fn batch_decompress_in_memory() {
371 let tmp = tempfile::tempdir().unwrap();
372 let zip_path = tmp.path().join("test.zip");
373 fs::write(&zip_path, make_zip_file(&[
374 ("x.txt", b"xxx"),
375 ("y.txt", b"yyy"),
376 ])).unwrap();
377
378 let (entries, stats) = decompress_zips(&[zip_path]).unwrap();
379 assert_eq!(stats.zips_processed, 1);
380 assert_eq!(entries.len(), 2);
381 }
382}