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