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(compressed, e.method, e.uncompressed_size as usize)?;
303
304 let out_path = dest.join(&e.dest_rel);
305 let mut f = fs::File::create(&out_path)
306 .map_err(|_| ZipError("cannot create output file"))?;
307 f.write_all(&data)
308 .map_err(|_| ZipError("write failed"))?;
309
310 Ok(data.len() as u64)
311 }).collect()
312 });
313
314 let mut total_written = 0u64;
315 let mut count = 0usize;
316 for r in results {
317 total_written += r?;
318 count += 1;
319 }
320
321 Ok((count, total_written))
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use std::io::{Cursor, Write};
328
329 fn make_zip_file(items: &[(&str, &[u8])]) -> Vec<u8> {
330 use zip::write::SimpleFileOptions;
331 let mut buf = Vec::new();
332 let mut zw = zip::ZipWriter::new(Cursor::new(&mut buf));
333 let opts = SimpleFileOptions::default()
334 .compression_method(zip::CompressionMethod::Deflated);
335 for (name, data) in items {
336 zw.start_file(*name, opts).unwrap();
337 zw.write_all(data).unwrap();
338 }
339 zw.finish().unwrap();
340 buf
341 }
342
343 #[test]
344 fn batch_extract_two_zips() {
345 let tmp = tempfile::tempdir().unwrap();
346 let zip1_path = tmp.path().join("one.zip");
347 let zip2_path = tmp.path().join("two.zip");
348
349 fs::write(&zip1_path, make_zip_file(&[("a.txt", b"aaa")])).unwrap();
350 fs::write(&zip2_path, make_zip_file(&[("b.txt", b"bbb")])).unwrap();
351
352 let out_dir = tmp.path().join("out");
353 fs::create_dir_all(&out_dir).unwrap();
354
355 let stats = extract_zips(
356 &[zip1_path, zip2_path],
357 &out_dir,
358 false,
359 ).unwrap();
360
361 assert_eq!(stats.zips_processed, 2);
362 assert_eq!(stats.entries_extracted, 2);
363 assert_eq!(fs::read(out_dir.join("one/a.txt")).unwrap(), b"aaa");
364 assert_eq!(fs::read(out_dir.join("two/b.txt")).unwrap(), b"bbb");
365 }
366
367 #[test]
368 fn batch_decompress_in_memory() {
369 let tmp = tempfile::tempdir().unwrap();
370 let zip_path = tmp.path().join("test.zip");
371 fs::write(&zip_path, make_zip_file(&[
372 ("x.txt", b"xxx"),
373 ("y.txt", b"yyy"),
374 ])).unwrap();
375
376 let (entries, stats) = decompress_zips(&[zip_path]).unwrap();
377 assert_eq!(stats.zips_processed, 1);
378 assert_eq!(entries.len(), 2);
379 }
380}