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