1use crate::ArchiveError;
7use crate::IoContext;
8use crate::NoopProgress;
9use crate::ProgressCallback;
10use crate::Result;
11use crate::config::Validated;
12use crate::creation::config::CreationConfig;
13use crate::creation::progress::ProgressTracker;
14use crate::creation::report::CreationReport;
15use crate::creation::walker::EntryType;
16use crate::creation::walker::collect_entries;
17use std::fs::File;
18use std::io::Read;
19use std::io::Seek;
20use std::io::Write;
21use std::path::Path;
22use zip::CompressionMethod;
23use zip::ZipWriter;
24use zip::write::SimpleFileOptions;
25
26pub fn create_zip<P: AsRef<Path>, Q: AsRef<Path>>(
48 output: P,
49 sources: &[Q],
50 config: &CreationConfig<Validated>,
51) -> Result<CreationReport> {
52 let file = File::create(output.as_ref())?;
53 let (mut report, file) = create_zip_internal(file, sources, config)?;
54 drop(file);
55 report.bytes_compressed = std::fs::metadata(output.as_ref())?.len();
56 Ok(report)
57}
58
59pub fn create_zip_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
133 output: P,
134 sources: &[Q],
135 config: &CreationConfig<Validated>,
136 progress: &mut dyn ProgressCallback,
137) -> Result<CreationReport> {
138 let file = File::create(output.as_ref())?;
139 let (mut report, file) = create_zip_internal_with_progress(file, sources, config, progress)?;
140 drop(file);
141 report.bytes_compressed = std::fs::metadata(output.as_ref())?.len();
142 Ok(report)
143}
144
145fn create_zip_internal_with_progress<W: Write + Seek, P: AsRef<Path>>(
151 writer: W,
152 sources: &[P],
153 config: &CreationConfig<Validated>,
154 progress: &mut dyn ProgressCallback,
155) -> Result<(CreationReport, W)> {
156 let mut zip = ZipWriter::new(writer);
157 let mut report = CreationReport::default();
158 let start = std::time::Instant::now();
159
160 let level = config.compression_level.unwrap_or(6);
165 let options = SimpleFileOptions::default()
166 .compression_method(CompressionMethod::Deflated)
167 .compression_level(Some(i64::from(level)));
168
169 let entries = collect_entries(sources, config)?;
171 let total_entries = entries.len();
172
173 let mut tracker = ProgressTracker::new(progress, total_entries);
174
175 let mut buffer = vec![0u8; 64 * 1024];
176
177 for entry in &entries {
178 match &entry.entry_type {
179 EntryType::File => {
180 tracker.on_entry_start(&entry.archive_path);
181 add_file_to_zip_with_progress_and_buffer(
182 &mut zip,
183 &entry.path,
184 &entry.archive_path,
185 config,
186 &mut report,
187 &options,
188 tracker.callback(),
189 &mut buffer,
190 )?;
191 tracker.on_entry_complete(&entry.archive_path);
192 }
193 EntryType::Directory => {
194 tracker.on_entry_start(&entry.archive_path);
195 if !entry.archive_path.is_empty() {
198 let dir_path = format!("{}/", normalize_zip_path(&entry.archive_path)?);
199 zip.add_directory(&dir_path, options).map_err(|e| {
200 std::io::Error::other(IoContext::new(
201 "failed to add directory to zip archive",
202 e.to_string(),
203 ))
204 })?;
205 report.directories_added += 1;
206 }
207 tracker.on_entry_complete(&entry.archive_path);
208 }
209 EntryType::Symlink { .. } => {
210 tracker.on_entry_start(&entry.archive_path);
211 if config.follow_symlinks {
212 add_file_to_zip_with_progress_and_buffer(
213 &mut zip,
214 &entry.path,
215 &entry.archive_path,
216 config,
217 &mut report,
218 &options,
219 tracker.callback(),
220 &mut buffer,
221 )?;
222 } else {
223 report.files_skipped = report.files_skipped.saturating_add(1);
224 report.add_warning(format!("Skipped symlink: {}", entry.path.display()));
225 }
226 tracker.on_entry_complete(&entry.archive_path);
227 }
228 }
229 }
230
231 let writer = zip.finish().map_err(|e| {
233 std::io::Error::other(IoContext::new(
234 "failed to finish zip archive",
235 e.to_string(),
236 ))
237 })?;
238
239 report.duration = start.elapsed();
240
241 tracker.on_complete();
242
243 Ok((report, writer))
244}
245
246fn create_zip_internal<W: Write + Seek, P: AsRef<Path>>(
247 writer: W,
248 sources: &[P],
249 config: &CreationConfig<Validated>,
250) -> Result<(CreationReport, W)> {
251 create_zip_internal_with_progress(writer, sources, config, &mut NoopProgress)
252}
253
254#[expect(
257 clippy::too_many_arguments,
258 reason = "arguments are threaded through from the caller; a parameter struct adds indirection"
259)]
260fn add_file_to_zip_with_progress_and_buffer<W: Write + Seek>(
261 zip: &mut ZipWriter<W>,
262 file_path: &Path,
263 archive_path: &Path,
264 config: &CreationConfig<Validated>,
265 report: &mut CreationReport,
266 options: &SimpleFileOptions,
267 progress: &mut dyn ProgressCallback,
268 buffer: &mut [u8],
269) -> Result<()> {
270 let mut file = File::open(file_path)?;
271 let metadata = file.metadata()?;
272 let size = metadata.len();
273
274 if let Some(max_size) = config.max_file_size
276 && size > max_size
277 {
278 report.files_skipped = report.files_skipped.saturating_add(1);
279 report.add_warning(format!(
280 "Skipped file (too large): {} ({} bytes)",
281 file_path.display(),
282 size
283 ));
284 return Ok(());
285 }
286
287 let file_options = if config.preserve_permissions {
289 #[cfg(unix)]
290 {
291 use std::os::unix::fs::PermissionsExt;
292 options.unix_permissions(metadata.permissions().mode())
293 }
294 #[cfg(not(unix))]
295 {
296 *options
297 }
298 } else {
299 *options
300 };
301
302 let archive_name = normalize_zip_path(archive_path)?;
303
304 zip.start_file(&archive_name, file_options).map_err(|e| {
305 std::io::Error::other(IoContext::new(
306 "failed to start file in zip archive",
307 e.to_string(),
308 ))
309 })?;
310
311 let mut bytes_written = 0u64;
313 loop {
314 let bytes_read = file.read(buffer)?;
315 if bytes_read == 0 {
316 break;
317 }
318 zip.write_all(&buffer[..bytes_read])?;
319 bytes_written += bytes_read as u64;
320 progress.on_bytes_written(bytes_read as u64);
321 }
322
323 report.files_added += 1;
324 report.bytes_written += bytes_written;
325
326 Ok(())
327}
328
329fn normalize_zip_path(path: &Path) -> Result<String> {
334 let path_str = path.to_str().ok_or_else(|| {
336 ArchiveError::Io(std::io::Error::other(IoContext::new(
337 "archive path is not valid UTF-8",
338 path.display().to_string(),
339 )))
340 })?;
341
342 #[cfg(windows)]
344 let normalized = path_str.replace('\\', "/");
345
346 #[cfg(not(windows))]
347 let normalized = path_str.to_string();
348
349 Ok(normalized)
350}
351
352pub struct ZipCreator;
354
355impl crate::formats::traits::FormatCreator for ZipCreator {
356 fn create(
357 &self,
358 output: &std::path::Path,
359 sources: &[&std::path::Path],
360 config: &CreationConfig<Validated>,
361 progress: &mut dyn ProgressCallback,
362 ) -> crate::Result<crate::creation::CreationReport> {
363 create_zip_with_progress(output, sources, config, progress)
364 }
365
366 fn format_name(&self) -> &'static str {
367 "zip"
368 }
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used)] mod tests {
374 use super::*;
375 use std::assert_matches;
376 use std::fs;
377 use tempfile::TempDir;
378
379 #[test]
380 fn test_create_zip_single_file() {
381 let temp = TempDir::new().unwrap();
382 let output = temp.path().join("output.zip");
383
384 let source_dir = TempDir::new().unwrap();
386 fs::write(source_dir.path().join("test.txt"), "Hello ZIP").unwrap();
387
388 let config = CreationConfig::default()
389 .with_exclude_patterns(vec![])
390 .with_include_hidden(true)
391 .validate()
392 .unwrap();
393
394 let report = create_zip(&output, &[source_dir.path().join("test.txt")], &config).unwrap();
395
396 assert_eq!(report.files_added, 1);
397 assert!(report.bytes_written > 0);
398 assert!(output.exists());
399 }
400
401 #[test]
402 fn test_create_zip_directory() {
403 let temp = TempDir::new().unwrap();
404 let output = temp.path().join("output.zip");
405
406 let source_dir = TempDir::new().unwrap();
408 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
409 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
410 fs::create_dir(source_dir.path().join("subdir")).unwrap();
411 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
412
413 let config = CreationConfig::default()
414 .with_exclude_patterns(vec![])
415 .with_include_hidden(true)
416 .validate()
417 .unwrap();
418
419 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
420
421 assert_eq!(report.files_added, 3);
423 assert_eq!(report.directories_added, 1);
426 assert!(output.exists());
427 }
428
429 #[test]
430 fn test_create_zip_compression() {
431 let temp = TempDir::new().unwrap();
432 let output = temp.path().join("output.zip");
433
434 let source_dir = TempDir::new().unwrap();
436 fs::write(source_dir.path().join("test.txt"), "a".repeat(1000)).unwrap();
437
438 let config = CreationConfig::default()
439 .with_exclude_patterns(vec![])
440 .with_compression_level(9)
441 .unwrap()
442 .validate()
443 .unwrap();
444
445 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
446
447 assert_eq!(report.files_added, 1);
448 assert!(output.exists());
449
450 let data = fs::read(&output).unwrap();
452 assert_eq!(&data[0..4], b"PK\x03\x04"); }
454
455 #[test]
456 fn test_create_zip_compression_levels() {
457 let temp = TempDir::new().unwrap();
458
459 let source_dir = TempDir::new().unwrap();
461 fs::write(source_dir.path().join("test.txt"), "a".repeat(10000)).unwrap();
462
463 for level in [1, 6, 9] {
465 let output = temp.path().join(format!("output_{level}.zip"));
466 let config = CreationConfig::default()
467 .with_exclude_patterns(vec![])
468 .with_compression_level(level)
469 .unwrap()
470 .validate()
471 .unwrap();
472
473 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
474 assert_eq!(report.files_added, 1);
475 assert!(output.exists());
476 }
477 }
478
479 #[test]
480 fn test_create_zip_explicit_directories() {
481 let temp = TempDir::new().unwrap();
482 let output = temp.path().join("output.zip");
483
484 let source_dir = TempDir::new().unwrap();
486 fs::create_dir(source_dir.path().join("dir1")).unwrap();
487 fs::create_dir(source_dir.path().join("dir1/dir2")).unwrap();
488 fs::write(source_dir.path().join("dir1/dir2/file.txt"), "content").unwrap();
489
490 let config = CreationConfig::default()
491 .with_exclude_patterns(vec![])
492 .with_include_hidden(true)
493 .validate()
494 .unwrap();
495
496 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
497
498 assert!(report.directories_added >= 2); assert!(output.exists());
500
501 let file = File::open(&output).unwrap();
503 let mut archive = zip::ZipArchive::new(file).unwrap();
504
505 let mut dir_entries = 0;
506 for i in 0..archive.len() {
507 let entry = archive.by_index(i).unwrap();
508 if entry.is_dir() {
509 dir_entries += 1;
510 assert!(
511 entry.name().unwrap().ends_with('/'),
512 "Directory entry should end with /"
513 );
514 }
515 }
516 assert!(dir_entries >= 2, "Expected at least 2 directory entries");
517 }
518
519 #[cfg(unix)]
520 #[test]
521 fn test_create_zip_preserves_permissions() {
522 use std::os::unix::fs::PermissionsExt;
523
524 let temp = TempDir::new().unwrap();
525 let output = temp.path().join("output.zip");
526
527 let source_dir = TempDir::new().unwrap();
529 let file_path = source_dir.path().join("test.txt");
530 fs::write(&file_path, "content").unwrap();
531 fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755)).unwrap();
532
533 let config = CreationConfig::default()
534 .with_exclude_patterns(vec![])
535 .with_preserve_permissions(true)
536 .validate()
537 .unwrap();
538
539 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
540 assert_eq!(report.files_added, 1);
541
542 let file = File::open(&output).unwrap();
544 let mut archive = zip::ZipArchive::new(file).unwrap();
545
546 for i in 0..archive.len() {
547 let entry = archive.by_index(i).unwrap();
548 if entry.name().unwrap().contains("test.txt")
549 && let Some(mode) = entry.unix_mode()
550 {
551 assert_eq!(mode & 0o777, 0o755, "Permissions should be preserved");
552 }
553 }
554 }
555
556 #[test]
557 fn test_create_zip_report_statistics() {
558 let temp = TempDir::new().unwrap();
559 let output = temp.path().join("output.zip");
560
561 let source_dir = TempDir::new().unwrap();
563 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
564 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
565 fs::create_dir(source_dir.path().join("subdir")).unwrap();
566 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
567
568 let config = CreationConfig::default()
569 .with_exclude_patterns(vec![])
570 .with_include_hidden(true)
571 .validate()
572 .unwrap();
573
574 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
575
576 assert_eq!(report.files_added, 3);
577 assert!(report.directories_added >= 1);
578 assert_eq!(report.files_skipped, 0);
579 assert!(!report.has_warnings());
580 assert!(report.duration.as_nanos() > 0);
581 }
582
583 #[test]
584 fn test_create_zip_roundtrip() {
585 let temp = TempDir::new().unwrap();
586 let output = temp.path().join("output.zip");
587
588 let source_dir = TempDir::new().unwrap();
590 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
591 fs::create_dir(source_dir.path().join("subdir")).unwrap();
592 fs::write(source_dir.path().join("subdir/file2.txt"), "content2").unwrap();
593
594 let config = CreationConfig::default()
595 .with_exclude_patterns(vec![])
596 .with_include_hidden(true)
597 .validate()
598 .unwrap();
599
600 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
602 assert!(report.files_added >= 2);
603
604 let file = File::open(&output).unwrap();
606 let mut archive = zip::ZipArchive::new(file).unwrap();
607
608 let extract_dir = TempDir::new().unwrap();
609
610 for i in 0..archive.len() {
611 let mut entry = archive.by_index(i).unwrap();
612 let outpath = extract_dir.path().join(entry.name().unwrap().as_ref());
613
614 if entry.is_dir() {
615 fs::create_dir_all(&outpath).unwrap();
616 } else {
617 if let Some(parent) = outpath.parent() {
618 fs::create_dir_all(parent).unwrap();
619 }
620 let mut outfile = File::create(&outpath).unwrap();
621 std::io::copy(&mut entry, &mut outfile).unwrap();
622 }
623 }
624
625 let extracted1 = fs::read_to_string(extract_dir.path().join("file1.txt")).unwrap();
627 assert_eq!(extracted1, "content1");
628
629 let extracted2 = fs::read_to_string(extract_dir.path().join("subdir/file2.txt")).unwrap();
630 assert_eq!(extracted2, "content2");
631 }
632
633 #[test]
634 fn test_create_zip_forward_slashes() {
635 let temp = TempDir::new().unwrap();
636 let output = temp.path().join("output.zip");
637
638 let source_dir = TempDir::new().unwrap();
640 fs::create_dir(source_dir.path().join("dir1")).unwrap();
641 fs::write(source_dir.path().join("dir1/file.txt"), "content").unwrap();
642
643 let config = CreationConfig::default()
644 .with_exclude_patterns(vec![])
645 .with_include_hidden(true)
646 .validate()
647 .unwrap();
648
649 create_zip(&output, &[source_dir.path()], &config).unwrap();
650
651 let file = File::open(&output).unwrap();
653 let mut archive = zip::ZipArchive::new(file).unwrap();
654
655 for i in 0..archive.len() {
656 let entry = archive.by_index(i).unwrap();
657 let name = entry.name().unwrap();
658 assert!(
660 !name.contains('\\'),
661 "ZIP path should use forward slashes: {name}"
662 );
663 if name.contains("dir1") && name.contains("file") {
665 assert!(name.contains("dir1/file"), "Expected forward slash in path");
666 }
667 }
668 }
669
670 #[test]
671 fn test_create_zip_source_not_found() {
672 let temp = TempDir::new().unwrap();
673 let output = temp.path().join("output.zip");
674
675 let config = CreationConfig::default().validate().unwrap();
676 let result = create_zip(&output, &[Path::new("/nonexistent/path")], &config);
677
678 assert!(result.is_err());
679 assert_matches!(result.unwrap_err(), ArchiveError::SourceNotFound { .. });
680 }
681
682 #[test]
683 fn test_normalize_zip_path() {
684 let path = Path::new("dir/file.txt");
686 let normalized = normalize_zip_path(path).unwrap();
687 assert_eq!(normalized, "dir/file.txt");
688
689 let path = Path::new("file.txt");
691 let normalized = normalize_zip_path(path).unwrap();
692 assert_eq!(normalized, "file.txt");
693
694 let path = Path::new("a/b/c/file.txt");
696 let normalized = normalize_zip_path(path).unwrap();
697 assert_eq!(normalized, "a/b/c/file.txt");
698 }
699
700 #[cfg(windows)]
701 #[test]
702 fn test_normalize_zip_path_windows() {
703 let path = Path::new("dir\\file.txt");
705 let normalized = normalize_zip_path(path).unwrap();
706 assert_eq!(normalized, "dir/file.txt");
707
708 let path = Path::new("a\\b\\c\\file.txt");
710 let normalized = normalize_zip_path(path).unwrap();
711 assert_eq!(normalized, "a/b/c/file.txt");
712 }
713
714 #[test]
715 fn test_create_zip_max_file_size() {
716 let temp = TempDir::new().unwrap();
717 let output = temp.path().join("output.zip");
718
719 let source_dir = TempDir::new().unwrap();
721 fs::write(source_dir.path().join("small.txt"), "tiny").unwrap(); fs::write(source_dir.path().join("large.txt"), "a".repeat(1000)).unwrap(); let config = CreationConfig::default()
726 .with_exclude_patterns(vec![])
727 .with_max_file_size(Some(100))
728 .validate()
729 .unwrap();
730
731 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
732
733 assert_eq!(report.files_added, 1);
736 assert_eq!(report.files_skipped, 0);
737 }
738
739 #[cfg(unix)]
740 #[test]
741 fn test_create_zip_skips_symlinks() {
742 let temp = TempDir::new().unwrap();
743 let output = temp.path().join("output.zip");
744
745 let source_dir = TempDir::new().unwrap();
747 fs::write(source_dir.path().join("target.txt"), "content").unwrap();
748 std::os::unix::fs::symlink(
749 source_dir.path().join("target.txt"),
750 source_dir.path().join("link.txt"),
751 )
752 .unwrap();
753
754 let config = CreationConfig::default()
756 .with_exclude_patterns(vec![])
757 .with_include_hidden(true)
758 .validate()
759 .unwrap();
760
761 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
762
763 assert_eq!(report.files_added, 1);
765 assert_eq!(report.files_skipped, 1);
766 assert!(report.has_warnings());
767
768 let warning = &report.warnings[0];
769 assert!(warning.contains("Skipped symlink"));
770 }
771
772 #[cfg(unix)]
773 #[test]
774 fn test_create_zip_follow_symlinks_embeds_target_content() {
775 let temp = TempDir::new().unwrap();
776 let output = temp.path().join("output.zip");
777
778 let source_dir = TempDir::new().unwrap();
779 fs::write(source_dir.path().join("target.txt"), "content").unwrap();
780 std::os::unix::fs::symlink(
781 source_dir.path().join("target.txt"),
782 source_dir.path().join("link.txt"),
783 )
784 .unwrap();
785
786 let config = CreationConfig::default()
787 .with_exclude_patterns(vec![])
788 .with_include_hidden(true)
789 .with_follow_symlinks(true)
790 .validate()
791 .unwrap();
792
793 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
794
795 assert_eq!(report.files_added, 2);
798 assert_eq!(report.files_skipped, 0);
799 assert!(!report.has_warnings());
800
801 let file = File::open(&output).unwrap();
802 let mut archive = zip::ZipArchive::new(file).unwrap();
803 let mut entry = archive.by_name("link.txt").unwrap();
804 let mut contents = String::new();
805 entry.read_to_string(&mut contents).unwrap();
806 assert_eq!(contents, "content");
807 }
808
809 #[test]
810 fn test_create_zip_with_progress_callback() {
811 #[derive(Debug, Default, Clone)]
812 struct TestProgress {
813 entries_started: Vec<String>,
814 entries_completed: Vec<String>,
815 bytes_written: u64,
816 completed: bool,
817 }
818
819 impl ProgressCallback for TestProgress {
820 fn on_entry_start(&mut self, path: &Path, _total: usize, _current: usize) {
821 self.entries_started
822 .push(path.to_string_lossy().to_string());
823 }
824
825 fn on_bytes_written(&mut self, bytes: u64) {
826 self.bytes_written += bytes;
827 }
828
829 fn on_entry_complete(&mut self, path: &Path) {
830 self.entries_completed
831 .push(path.to_string_lossy().to_string());
832 }
833
834 fn on_complete(&mut self) {
835 self.completed = true;
836 }
837 }
838
839 let temp = TempDir::new().unwrap();
840 let output = temp.path().join("output.zip");
841
842 let source_dir = TempDir::new().unwrap();
844 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
845 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
846 fs::create_dir(source_dir.path().join("subdir")).unwrap();
847 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
848
849 let config = CreationConfig::default()
850 .with_exclude_patterns(vec![])
851 .with_include_hidden(true)
852 .validate()
853 .unwrap();
854
855 let mut progress = TestProgress::default();
856
857 let report =
858 create_zip_with_progress(&output, &[source_dir.path()], &config, &mut progress)
859 .unwrap();
860
861 assert_eq!(report.files_added, 3);
863 assert!(report.directories_added >= 1);
864
865 assert!(
867 progress.entries_started.len() >= 3,
868 "Expected at least 3 entry starts, got {}",
869 progress.entries_started.len()
870 );
871 assert!(
872 progress.entries_completed.len() >= 3,
873 "Expected at least 3 entry completions, got {}",
874 progress.entries_completed.len()
875 );
876 assert!(
877 progress.bytes_written > 0,
878 "Expected bytes written > 0, got {}",
879 progress.bytes_written
880 );
881 assert!(progress.completed, "Expected on_complete to be called");
882
883 let has_file1 = progress
885 .entries_started
886 .iter()
887 .any(|p| p.contains("file1.txt"));
888 let has_file2 = progress
889 .entries_started
890 .iter()
891 .any(|p| p.contains("file2.txt"));
892 let has_file3 = progress
893 .entries_started
894 .iter()
895 .any(|p| p.contains("file3.txt"));
896
897 assert!(has_file1, "Expected file1.txt in progress callbacks");
898 assert!(has_file2, "Expected file2.txt in progress callbacks");
899 assert!(has_file3, "Expected file3.txt in progress callbacks");
900 }
901}