this repo has no description
1use super::containers::total_input_bytes;
2use crate::progress::{OutputTarget, ProgressArgs, ProgressReader, create_progress_bar};
3use crate::utils::{
4 CmprssInput, CmprssOutput, CommonArgs, CompressionLevelValidator, Compressor,
5 DefaultCompressionValidator, ExtractedTarget, LevelArgs, Result,
6};
7use anyhow::{anyhow, bail};
8use clap::Args;
9use indicatif::ProgressBar;
10use std::fs::{File, OpenOptions};
11use std::io::{self, Seek, SeekFrom, Write};
12use std::path::Path;
13use tempfile::tempfile;
14use zip::read::ZipArchive;
15use zip::write::FileOptions;
16use zip::{CompressionMethod, ZipWriter};
17
18#[derive(Args, Debug)]
19pub struct ZipArgs {
20 #[clap(flatten)]
21 pub common_args: CommonArgs,
22
23 #[clap(flatten)]
24 pub level_args: LevelArgs,
25
26 #[clap(flatten)]
27 pub progress_args: ProgressArgs,
28}
29
30#[derive(Clone)]
31pub struct Zip {
32 pub compression_level: i32,
33 pub progress_args: ProgressArgs,
34}
35
36impl Default for Zip {
37 fn default() -> Self {
38 Zip {
39 compression_level: DefaultCompressionValidator.default_level(),
40 progress_args: ProgressArgs::default(),
41 }
42 }
43}
44
45impl Zip {
46 pub fn new(args: &ZipArgs) -> Zip {
47 Zip {
48 compression_level: args.level_args.resolve(&DefaultCompressionValidator),
49 progress_args: args.progress_args,
50 }
51 }
52
53 fn file_options(&self) -> FileOptions<'static, ()> {
54 FileOptions::<()>::default()
55 .compression_method(CompressionMethod::Deflated)
56 .compression_level(Some(self.compression_level as i64))
57 .large_file(true)
58 }
59
60 fn extract_seekable<R: std::io::Read + Seek>(
61 &self,
62 reader: R,
63 size: u64,
64 out_dir: &Path,
65 ) -> Result {
66 let bar = create_progress_bar(Some(size), self.progress_args.progress, OutputTarget::File);
67 let reader = ProgressReader::new(reader, bar.clone());
68 let mut archive = ZipArchive::new(reader)?;
69 archive.extract(out_dir)?;
70 if let Some(b) = bar {
71 b.finish();
72 }
73 Ok(())
74 }
75
76 fn compress_to_file<W: Write + Seek>(
77 &self,
78 input: CmprssInput,
79 writer: W,
80 bar: Option<&ProgressBar>,
81 ) -> Result {
82 let mut zip_writer = ZipWriter::new(writer);
83 self.add_entries(&mut zip_writer, input, bar)?;
84 zip_writer.finish()?;
85 Ok(())
86 }
87
88 /// Add the given input as entries to an existing `ZipWriter`. Shared by
89 /// `compress_to_file` and the append path, which respectively initialize
90 /// the writer via `ZipWriter::new` and `ZipWriter::new_append`.
91 fn add_entries<W: Write + Seek>(
92 &self,
93 zip_writer: &mut ZipWriter<W>,
94 input: CmprssInput,
95 bar: Option<&ProgressBar>,
96 ) -> Result {
97 let options = self.file_options();
98 match input {
99 CmprssInput::Path(paths) => {
100 for path in paths {
101 if path.is_file() {
102 let name = path
103 .file_name()
104 .ok_or_else(|| anyhow!("input path has no file name: {:?}", path))?
105 .to_string_lossy();
106 zip_writer.start_file(name, options)?;
107 let f = File::open(&path)?;
108 let mut reader = ProgressReader::new(f, bar.cloned());
109 io::copy(&mut reader, zip_writer)?;
110 } else if path.is_dir() {
111 // Use the directory as the base and add its contents
112 let base = path.parent().unwrap_or(&path);
113 add_directory(zip_writer, base, &path, options, bar)?;
114 } else {
115 bail!("zip does not support this file type");
116 }
117 }
118 }
119 CmprssInput::Pipe(mut pipe) => {
120 // For pipe input, we'll create a single file named "archive"
121 zip_writer.start_file("archive", options)?;
122 io::copy(&mut pipe, zip_writer)?;
123 }
124 CmprssInput::Reader(_) => {
125 bail!("zip does not accept an in-memory reader input");
126 }
127 }
128 Ok(())
129 }
130}
131
132impl Compressor for Zip {
133 fn name(&self) -> &str {
134 "zip"
135 }
136
137 /// Zip extracts to a directory by default
138 fn default_extracted_target(&self) -> ExtractedTarget {
139 ExtractedTarget::Directory
140 }
141
142 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result {
143 match output {
144 CmprssOutput::Path(ref path) => {
145 let total = match &input {
146 CmprssInput::Path(paths) => Some(total_input_bytes(paths)),
147 _ => None,
148 };
149 let bar =
150 create_progress_bar(total, self.progress_args.progress, OutputTarget::File);
151 let file = File::create(path)?;
152 self.compress_to_file(input, file, bar.as_ref())?;
153 if let Some(b) = bar {
154 b.finish();
155 }
156 Ok(())
157 }
158 CmprssOutput::Pipe(mut pipe) => {
159 // Create a temporary file to write the zip to
160 let mut temp_file = tempfile()?;
161 self.compress_to_file(input, &mut temp_file, None)?;
162
163 // Reset the file position to the beginning
164 temp_file.seek(SeekFrom::Start(0))?;
165
166 // Copy the temporary file to the pipe
167 io::copy(&mut temp_file, &mut pipe)?;
168 Ok(())
169 }
170 CmprssOutput::Writer(mut writer) => {
171 let mut temp_file = tempfile()?;
172 self.compress_to_file(input, &mut temp_file, None)?;
173 temp_file.seek(SeekFrom::Start(0))?;
174 io::copy(&mut temp_file, &mut writer)?;
175 Ok(())
176 }
177 }
178 }
179
180 fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result {
181 match output {
182 CmprssOutput::Path(ref out_dir) => {
183 // Create the output directory if it doesn't exist
184 if !out_dir.exists() {
185 std::fs::create_dir_all(out_dir)?;
186 } else if !out_dir.is_dir() {
187 bail!("zip extraction output must be a directory");
188 }
189
190 match input {
191 CmprssInput::Path(paths) => {
192 if paths.len() != 1 {
193 bail!("zip extraction expects exactly one archive file");
194 }
195 let file = File::open(&paths[0])?;
196 let size = file.metadata()?.len();
197 self.extract_seekable(file, size, out_dir)
198 }
199 CmprssInput::Pipe(mut pipe) => {
200 // Create a temporary file to store the zip content
201 let mut temp_file = tempfile()?;
202
203 // Copy from pipe to temporary file
204 io::copy(&mut pipe, &mut temp_file)?;
205
206 // Reset the file position to the beginning
207 temp_file.seek(SeekFrom::Start(0))?;
208 let size = temp_file.metadata()?.len();
209 self.extract_seekable(temp_file, size, out_dir)
210 }
211 CmprssInput::Reader(_) => {
212 bail!(
213 "zip extraction does not accept an in-memory reader input (requires seekable input)"
214 )
215 }
216 }
217 }
218 CmprssOutput::Pipe(_) => bail!("zip extraction to stdout is not supported"),
219 CmprssOutput::Writer(mut writer) => match input {
220 CmprssInput::Path(paths) => {
221 if paths.len() != 1 {
222 bail!("zip extraction expects exactly one archive file");
223 }
224 let mut file = File::open(&paths[0])?;
225 io::copy(&mut file, &mut writer)?;
226 Ok(())
227 }
228 CmprssInput::Pipe(mut pipe) => {
229 io::copy(&mut pipe, &mut writer)?;
230 Ok(())
231 }
232 CmprssInput::Reader(mut reader) => {
233 io::copy(&mut reader, &mut writer)?;
234 Ok(())
235 }
236 },
237 }
238 }
239
240 fn append(&self, input: CmprssInput, output: CmprssOutput) -> Result {
241 let path = match output {
242 CmprssOutput::Path(p) => p,
243 _ => bail!("zip append requires the archive path as the output target"),
244 };
245 if !path.is_file() {
246 bail!("zip append target must be an existing file: {:?}", path);
247 }
248
249 let total = match &input {
250 CmprssInput::Path(paths) => Some(total_input_bytes(paths)),
251 _ => None,
252 };
253 let bar = create_progress_bar(total, self.progress_args.progress, OutputTarget::File);
254
255 let file = OpenOptions::new().read(true).write(true).open(&path)?;
256 let mut zip_writer = ZipWriter::new_append(file)?;
257 self.add_entries(&mut zip_writer, input, bar.as_ref())?;
258 zip_writer.finish()?;
259 if let Some(b) = bar {
260 b.finish();
261 }
262 Ok(())
263 }
264
265 fn list(&self, input: CmprssInput) -> Result {
266 // ZipArchive requires a seekable reader. For non-path inputs we must
267 // buffer into a tempfile first.
268 let stdout = io::stdout();
269 let mut out = stdout.lock();
270 match input {
271 CmprssInput::Path(paths) => {
272 if paths.len() != 1 {
273 bail!("zip listing expects exactly one archive file");
274 }
275 let archive = ZipArchive::new(File::open(&paths[0])?)?;
276 for name in archive.file_names() {
277 writeln!(out, "{}", name)?;
278 }
279 }
280 CmprssInput::Pipe(mut pipe) => {
281 let mut temp = tempfile()?;
282 io::copy(&mut pipe, &mut temp)?;
283 temp.seek(SeekFrom::Start(0))?;
284 let archive = ZipArchive::new(temp)?;
285 for name in archive.file_names() {
286 writeln!(out, "{}", name)?;
287 }
288 }
289 CmprssInput::Reader(mut reader) => {
290 let mut temp = tempfile()?;
291 io::copy(&mut reader, &mut temp)?;
292 temp.seek(SeekFrom::Start(0))?;
293 let archive = ZipArchive::new(temp)?;
294 for name in archive.file_names() {
295 writeln!(out, "{}", name)?;
296 }
297 }
298 }
299 Ok(())
300 }
301}
302
303fn add_directory<W: Write + Seek>(
304 zip: &mut ZipWriter<W>,
305 base: &Path,
306 path: &Path,
307 options: FileOptions<'static, ()>,
308 bar: Option<&ProgressBar>,
309) -> Result {
310 for entry in std::fs::read_dir(path)? {
311 let entry = entry?;
312 let entry_path = entry.path();
313 // Get relative path for archive entry
314 // `entry_path` is a direct child of `path`, which itself sits under
315 // `base`, so stripping always succeeds.
316 let name = entry_path
317 .strip_prefix(base)
318 .expect("entry path is under base")
319 .to_string_lossy()
320 .replace('\\', "/");
321 if entry_path.is_file() {
322 zip.start_file(name, options)?;
323 let f = File::open(&entry_path)?;
324 let mut reader = ProgressReader::new(f, bar.cloned());
325 io::copy(&mut reader, zip)?;
326 } else if entry_path.is_dir() {
327 // Ensure directory entry ends with '/'
328 let dir_name = name.clone() + "/";
329 zip.add_directory(dir_name, options)?;
330 add_directory(zip, base, &entry_path, options, bar)?;
331 }
332 }
333 Ok(())
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::test_utils::*;
340 use assert_fs::prelude::*;
341 use predicates::prelude::*;
342 use std::path::PathBuf;
343
344 /// Test the basic interface of the Zip compressor
345 #[test]
346 fn test_zip_interface() {
347 let compressor = Zip::default();
348 test_compressor_interface(&compressor, "zip", Some("zip"));
349 }
350
351 /// Test the default compression level
352 #[test]
353 fn test_zip_default_compression() -> Result {
354 let compressor = Zip::default();
355 test_compression(&compressor)
356 }
357
358 /// Test fast compression level
359 #[test]
360 fn test_zip_fast_compression() -> Result {
361 let fast_compressor = Zip {
362 compression_level: 1,
363 progress_args: ProgressArgs::default(),
364 };
365 test_compression(&fast_compressor)
366 }
367
368 /// Test best compression level
369 #[test]
370 fn test_zip_best_compression() -> Result {
371 let best_compressor = Zip {
372 compression_level: 9,
373 progress_args: ProgressArgs::default(),
374 };
375 test_compression(&best_compressor)
376 }
377
378 /// Append new entries into an existing zip and confirm both old and new
379 /// entries extract correctly.
380 #[test]
381 fn test_append_adds_entries() -> Result {
382 let compressor = Zip::default();
383 let working_dir = assert_fs::TempDir::new()?;
384
385 let original = working_dir.child("original.txt");
386 original.write_str("original contents")?;
387 let extra = working_dir.child("extra.txt");
388 extra.write_str("appended contents")?;
389
390 let archive = working_dir.child("archive.zip");
391 compressor.compress(
392 CmprssInput::Path(vec![original.path().to_path_buf()]),
393 CmprssOutput::Path(archive.path().to_path_buf()),
394 )?;
395 let size_before = std::fs::metadata(archive.path())?.len();
396
397 compressor.append(
398 CmprssInput::Path(vec![extra.path().to_path_buf()]),
399 CmprssOutput::Path(archive.path().to_path_buf()),
400 )?;
401 let size_after = std::fs::metadata(archive.path())?.len();
402 assert!(
403 size_after > size_before,
404 "archive did not grow after append: {size_before} -> {size_after}",
405 );
406
407 let extract_dir = working_dir.child("extracted");
408 std::fs::create_dir_all(extract_dir.path())?;
409 compressor.extract(
410 CmprssInput::Path(vec![archive.path().to_path_buf()]),
411 CmprssOutput::Path(extract_dir.path().to_path_buf()),
412 )?;
413 extract_dir
414 .child("original.txt")
415 .assert(predicate::path::eq_file(original.path()));
416 extract_dir
417 .child("extra.txt")
418 .assert(predicate::path::eq_file(extra.path()));
419 Ok(())
420 }
421
422 /// Test zip-specific functionality: directory handling
423 #[test]
424 fn test_directory_handling() -> Result {
425 let compressor = Zip::default();
426 let dir = assert_fs::TempDir::new()?;
427 let file_path = dir.child("file.txt");
428 file_path.write_str("directory test data")?;
429 let working_dir = assert_fs::TempDir::new()?;
430 let archive = working_dir.child("dir_archive.zip");
431 archive.assert(predicate::path::missing());
432
433 compressor.compress(
434 CmprssInput::Path(vec![dir.path().to_path_buf()]),
435 CmprssOutput::Path(archive.path().to_path_buf()),
436 )?;
437 archive.assert(predicate::path::is_file());
438
439 let extract_dir = working_dir.child("extracted");
440 std::fs::create_dir_all(extract_dir.path())?;
441 compressor.extract(
442 CmprssInput::Path(vec![archive.path().to_path_buf()]),
443 CmprssOutput::Path(extract_dir.path().to_path_buf()),
444 )?;
445 // When extracting a directory from a zip, the directory name is included in the path
446 // Since the archive stores the entire directory, the extracted file is contained in the directory
447 let dir_name: PathBuf = dir.path().file_name().unwrap().into();
448 extract_dir
449 .child(dir_name)
450 .child("file.txt")
451 .assert(predicate::path::eq_file(file_path.path()));
452 Ok(())
453 }
454}