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#[allow(clippy::too_many_arguments)]
257fn add_file_to_zip_with_progress_and_buffer<W: Write + Seek>(
258 zip: &mut ZipWriter<W>,
259 file_path: &Path,
260 archive_path: &Path,
261 config: &CreationConfig<Validated>,
262 report: &mut CreationReport,
263 options: &SimpleFileOptions,
264 progress: &mut dyn ProgressCallback,
265 buffer: &mut [u8],
266) -> Result<()> {
267 let mut file = File::open(file_path)?;
268 let metadata = file.metadata()?;
269 let size = metadata.len();
270
271 if let Some(max_size) = config.max_file_size
273 && size > max_size
274 {
275 report.files_skipped = report.files_skipped.saturating_add(1);
276 report.add_warning(format!(
277 "Skipped file (too large): {} ({} bytes)",
278 file_path.display(),
279 size
280 ));
281 return Ok(());
282 }
283
284 let file_options = if config.preserve_permissions {
286 #[cfg(unix)]
287 {
288 use std::os::unix::fs::PermissionsExt;
289 options.unix_permissions(metadata.permissions().mode())
290 }
291 #[cfg(not(unix))]
292 {
293 *options
294 }
295 } else {
296 *options
297 };
298
299 let archive_name = normalize_zip_path(archive_path)?;
300
301 zip.start_file(&archive_name, file_options).map_err(|e| {
302 std::io::Error::other(IoContext::new(
303 "failed to start file in zip archive",
304 e.to_string(),
305 ))
306 })?;
307
308 let mut bytes_written = 0u64;
310 loop {
311 let bytes_read = file.read(buffer)?;
312 if bytes_read == 0 {
313 break;
314 }
315 zip.write_all(&buffer[..bytes_read])?;
316 bytes_written += bytes_read as u64;
317 progress.on_bytes_written(bytes_read as u64);
318 }
319
320 report.files_added += 1;
321 report.bytes_written += bytes_written;
322
323 Ok(())
324}
325
326fn normalize_zip_path(path: &Path) -> Result<String> {
331 let path_str = path.to_str().ok_or_else(|| {
333 ArchiveError::Io(std::io::Error::other(IoContext::new(
334 "archive path is not valid UTF-8",
335 path.display().to_string(),
336 )))
337 })?;
338
339 #[cfg(windows)]
341 let normalized = path_str.replace('\\', "/");
342
343 #[cfg(not(windows))]
344 let normalized = path_str.to_string();
345
346 Ok(normalized)
347}
348
349pub struct ZipCreator;
351
352impl crate::formats::traits::FormatCreator for ZipCreator {
353 fn create(
354 &self,
355 output: &std::path::Path,
356 sources: &[&std::path::Path],
357 config: &CreationConfig<Validated>,
358 progress: &mut dyn ProgressCallback,
359 ) -> crate::Result<crate::creation::CreationReport> {
360 create_zip_with_progress(output, sources, config, progress)
361 }
362
363 fn format_name(&self) -> &'static str {
364 "zip"
365 }
366}
367
368#[cfg(test)]
369#[allow(clippy::unwrap_used)] mod tests {
371 use super::*;
372 use std::assert_matches;
373 use std::fs;
374 use tempfile::TempDir;
375
376 #[test]
377 fn test_create_zip_single_file() {
378 let temp = TempDir::new().unwrap();
379 let output = temp.path().join("output.zip");
380
381 let source_dir = TempDir::new().unwrap();
383 fs::write(source_dir.path().join("test.txt"), "Hello ZIP").unwrap();
384
385 let config = CreationConfig::default()
386 .with_exclude_patterns(vec![])
387 .with_include_hidden(true)
388 .validate()
389 .unwrap();
390
391 let report = create_zip(&output, &[source_dir.path().join("test.txt")], &config).unwrap();
392
393 assert_eq!(report.files_added, 1);
394 assert!(report.bytes_written > 0);
395 assert!(output.exists());
396 }
397
398 #[test]
399 fn test_create_zip_directory() {
400 let temp = TempDir::new().unwrap();
401 let output = temp.path().join("output.zip");
402
403 let source_dir = TempDir::new().unwrap();
405 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
406 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
407 fs::create_dir(source_dir.path().join("subdir")).unwrap();
408 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
409
410 let config = CreationConfig::default()
411 .with_exclude_patterns(vec![])
412 .with_include_hidden(true)
413 .validate()
414 .unwrap();
415
416 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
417
418 assert_eq!(report.files_added, 3);
420 assert_eq!(report.directories_added, 1);
423 assert!(output.exists());
424 }
425
426 #[test]
427 fn test_create_zip_compression() {
428 let temp = TempDir::new().unwrap();
429 let output = temp.path().join("output.zip");
430
431 let source_dir = TempDir::new().unwrap();
433 fs::write(source_dir.path().join("test.txt"), "a".repeat(1000)).unwrap();
434
435 let config = CreationConfig::default()
436 .with_exclude_patterns(vec![])
437 .with_compression_level(9)
438 .unwrap()
439 .validate()
440 .unwrap();
441
442 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
443
444 assert_eq!(report.files_added, 1);
445 assert!(output.exists());
446
447 let data = fs::read(&output).unwrap();
449 assert_eq!(&data[0..4], b"PK\x03\x04"); }
451
452 #[test]
453 fn test_create_zip_compression_levels() {
454 let temp = TempDir::new().unwrap();
455
456 let source_dir = TempDir::new().unwrap();
458 fs::write(source_dir.path().join("test.txt"), "a".repeat(10000)).unwrap();
459
460 for level in [1, 6, 9] {
462 let output = temp.path().join(format!("output_{level}.zip"));
463 let config = CreationConfig::default()
464 .with_exclude_patterns(vec![])
465 .with_compression_level(level)
466 .unwrap()
467 .validate()
468 .unwrap();
469
470 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
471 assert_eq!(report.files_added, 1);
472 assert!(output.exists());
473 }
474 }
475
476 #[test]
477 fn test_create_zip_explicit_directories() {
478 let temp = TempDir::new().unwrap();
479 let output = temp.path().join("output.zip");
480
481 let source_dir = TempDir::new().unwrap();
483 fs::create_dir(source_dir.path().join("dir1")).unwrap();
484 fs::create_dir(source_dir.path().join("dir1/dir2")).unwrap();
485 fs::write(source_dir.path().join("dir1/dir2/file.txt"), "content").unwrap();
486
487 let config = CreationConfig::default()
488 .with_exclude_patterns(vec![])
489 .with_include_hidden(true)
490 .validate()
491 .unwrap();
492
493 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
494
495 assert!(report.directories_added >= 2); assert!(output.exists());
497
498 let file = File::open(&output).unwrap();
500 let mut archive = zip::ZipArchive::new(file).unwrap();
501
502 let mut dir_entries = 0;
503 for i in 0..archive.len() {
504 let entry = archive.by_index(i).unwrap();
505 if entry.is_dir() {
506 dir_entries += 1;
507 assert!(
508 entry.name().unwrap().ends_with('/'),
509 "Directory entry should end with /"
510 );
511 }
512 }
513 assert!(dir_entries >= 2, "Expected at least 2 directory entries");
514 }
515
516 #[cfg(unix)]
517 #[test]
518 fn test_create_zip_preserves_permissions() {
519 use std::os::unix::fs::PermissionsExt;
520
521 let temp = TempDir::new().unwrap();
522 let output = temp.path().join("output.zip");
523
524 let source_dir = TempDir::new().unwrap();
526 let file_path = source_dir.path().join("test.txt");
527 fs::write(&file_path, "content").unwrap();
528 fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755)).unwrap();
529
530 let config = CreationConfig::default()
531 .with_exclude_patterns(vec![])
532 .with_preserve_permissions(true)
533 .validate()
534 .unwrap();
535
536 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
537 assert_eq!(report.files_added, 1);
538
539 let file = File::open(&output).unwrap();
541 let mut archive = zip::ZipArchive::new(file).unwrap();
542
543 for i in 0..archive.len() {
544 let entry = archive.by_index(i).unwrap();
545 if entry.name().unwrap().contains("test.txt")
546 && let Some(mode) = entry.unix_mode()
547 {
548 assert_eq!(mode & 0o777, 0o755, "Permissions should be preserved");
549 }
550 }
551 }
552
553 #[test]
554 fn test_create_zip_report_statistics() {
555 let temp = TempDir::new().unwrap();
556 let output = temp.path().join("output.zip");
557
558 let source_dir = TempDir::new().unwrap();
560 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
561 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
562 fs::create_dir(source_dir.path().join("subdir")).unwrap();
563 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
564
565 let config = CreationConfig::default()
566 .with_exclude_patterns(vec![])
567 .with_include_hidden(true)
568 .validate()
569 .unwrap();
570
571 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
572
573 assert_eq!(report.files_added, 3);
574 assert!(report.directories_added >= 1);
575 assert_eq!(report.files_skipped, 0);
576 assert!(!report.has_warnings());
577 assert!(report.duration.as_nanos() > 0);
578 }
579
580 #[test]
581 fn test_create_zip_roundtrip() {
582 let temp = TempDir::new().unwrap();
583 let output = temp.path().join("output.zip");
584
585 let source_dir = TempDir::new().unwrap();
587 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
588 fs::create_dir(source_dir.path().join("subdir")).unwrap();
589 fs::write(source_dir.path().join("subdir/file2.txt"), "content2").unwrap();
590
591 let config = CreationConfig::default()
592 .with_exclude_patterns(vec![])
593 .with_include_hidden(true)
594 .validate()
595 .unwrap();
596
597 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
599 assert!(report.files_added >= 2);
600
601 let file = File::open(&output).unwrap();
603 let mut archive = zip::ZipArchive::new(file).unwrap();
604
605 let extract_dir = TempDir::new().unwrap();
606
607 for i in 0..archive.len() {
608 let mut entry = archive.by_index(i).unwrap();
609 let outpath = extract_dir.path().join(entry.name().unwrap().as_ref());
610
611 if entry.is_dir() {
612 fs::create_dir_all(&outpath).unwrap();
613 } else {
614 if let Some(parent) = outpath.parent() {
615 fs::create_dir_all(parent).unwrap();
616 }
617 let mut outfile = File::create(&outpath).unwrap();
618 std::io::copy(&mut entry, &mut outfile).unwrap();
619 }
620 }
621
622 let extracted1 = fs::read_to_string(extract_dir.path().join("file1.txt")).unwrap();
624 assert_eq!(extracted1, "content1");
625
626 let extracted2 = fs::read_to_string(extract_dir.path().join("subdir/file2.txt")).unwrap();
627 assert_eq!(extracted2, "content2");
628 }
629
630 #[test]
631 fn test_create_zip_forward_slashes() {
632 let temp = TempDir::new().unwrap();
633 let output = temp.path().join("output.zip");
634
635 let source_dir = TempDir::new().unwrap();
637 fs::create_dir(source_dir.path().join("dir1")).unwrap();
638 fs::write(source_dir.path().join("dir1/file.txt"), "content").unwrap();
639
640 let config = CreationConfig::default()
641 .with_exclude_patterns(vec![])
642 .with_include_hidden(true)
643 .validate()
644 .unwrap();
645
646 create_zip(&output, &[source_dir.path()], &config).unwrap();
647
648 let file = File::open(&output).unwrap();
650 let mut archive = zip::ZipArchive::new(file).unwrap();
651
652 for i in 0..archive.len() {
653 let entry = archive.by_index(i).unwrap();
654 let name = entry.name().unwrap();
655 assert!(
657 !name.contains('\\'),
658 "ZIP path should use forward slashes: {name}"
659 );
660 if name.contains("dir1") && name.contains("file") {
662 assert!(name.contains("dir1/file"), "Expected forward slash in path");
663 }
664 }
665 }
666
667 #[test]
668 fn test_create_zip_source_not_found() {
669 let temp = TempDir::new().unwrap();
670 let output = temp.path().join("output.zip");
671
672 let config = CreationConfig::default().validate().unwrap();
673 let result = create_zip(&output, &[Path::new("/nonexistent/path")], &config);
674
675 assert!(result.is_err());
676 assert_matches!(result.unwrap_err(), ArchiveError::SourceNotFound { .. });
677 }
678
679 #[test]
680 fn test_normalize_zip_path() {
681 let path = Path::new("dir/file.txt");
683 let normalized = normalize_zip_path(path).unwrap();
684 assert_eq!(normalized, "dir/file.txt");
685
686 let path = Path::new("file.txt");
688 let normalized = normalize_zip_path(path).unwrap();
689 assert_eq!(normalized, "file.txt");
690
691 let path = Path::new("a/b/c/file.txt");
693 let normalized = normalize_zip_path(path).unwrap();
694 assert_eq!(normalized, "a/b/c/file.txt");
695 }
696
697 #[cfg(windows)]
698 #[test]
699 fn test_normalize_zip_path_windows() {
700 let path = Path::new("dir\\file.txt");
702 let normalized = normalize_zip_path(path).unwrap();
703 assert_eq!(normalized, "dir/file.txt");
704
705 let path = Path::new("a\\b\\c\\file.txt");
707 let normalized = normalize_zip_path(path).unwrap();
708 assert_eq!(normalized, "a/b/c/file.txt");
709 }
710
711 #[test]
712 fn test_create_zip_max_file_size() {
713 let temp = TempDir::new().unwrap();
714 let output = temp.path().join("output.zip");
715
716 let source_dir = TempDir::new().unwrap();
718 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()
723 .with_exclude_patterns(vec![])
724 .with_max_file_size(Some(100))
725 .validate()
726 .unwrap();
727
728 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
729
730 assert_eq!(report.files_added, 1);
733 assert_eq!(report.files_skipped, 0);
734 }
735
736 #[cfg(unix)]
737 #[test]
738 fn test_create_zip_skips_symlinks() {
739 let temp = TempDir::new().unwrap();
740 let output = temp.path().join("output.zip");
741
742 let source_dir = TempDir::new().unwrap();
744 fs::write(source_dir.path().join("target.txt"), "content").unwrap();
745 std::os::unix::fs::symlink(
746 source_dir.path().join("target.txt"),
747 source_dir.path().join("link.txt"),
748 )
749 .unwrap();
750
751 let config = CreationConfig::default()
753 .with_exclude_patterns(vec![])
754 .with_include_hidden(true)
755 .validate()
756 .unwrap();
757
758 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
759
760 assert_eq!(report.files_added, 1);
762 assert_eq!(report.files_skipped, 1);
763 assert!(report.has_warnings());
764
765 let warning = &report.warnings[0];
766 assert!(warning.contains("Skipped symlink"));
767 }
768
769 #[cfg(unix)]
770 #[test]
771 fn test_create_zip_follow_symlinks_embeds_target_content() {
772 let temp = TempDir::new().unwrap();
773 let output = temp.path().join("output.zip");
774
775 let source_dir = TempDir::new().unwrap();
776 fs::write(source_dir.path().join("target.txt"), "content").unwrap();
777 std::os::unix::fs::symlink(
778 source_dir.path().join("target.txt"),
779 source_dir.path().join("link.txt"),
780 )
781 .unwrap();
782
783 let config = CreationConfig::default()
784 .with_exclude_patterns(vec![])
785 .with_include_hidden(true)
786 .with_follow_symlinks(true)
787 .validate()
788 .unwrap();
789
790 let report = create_zip(&output, &[source_dir.path()], &config).unwrap();
791
792 assert_eq!(report.files_added, 2);
795 assert_eq!(report.files_skipped, 0);
796 assert!(!report.has_warnings());
797
798 let file = File::open(&output).unwrap();
799 let mut archive = zip::ZipArchive::new(file).unwrap();
800 let mut entry = archive.by_name("link.txt").unwrap();
801 let mut contents = String::new();
802 entry.read_to_string(&mut contents).unwrap();
803 assert_eq!(contents, "content");
804 }
805
806 #[test]
807 fn test_create_zip_with_progress_callback() {
808 #[derive(Debug, Default, Clone)]
809 struct TestProgress {
810 entries_started: Vec<String>,
811 entries_completed: Vec<String>,
812 bytes_written: u64,
813 completed: bool,
814 }
815
816 impl ProgressCallback for TestProgress {
817 fn on_entry_start(&mut self, path: &Path, _total: usize, _current: usize) {
818 self.entries_started
819 .push(path.to_string_lossy().to_string());
820 }
821
822 fn on_bytes_written(&mut self, bytes: u64) {
823 self.bytes_written += bytes;
824 }
825
826 fn on_entry_complete(&mut self, path: &Path) {
827 self.entries_completed
828 .push(path.to_string_lossy().to_string());
829 }
830
831 fn on_complete(&mut self) {
832 self.completed = true;
833 }
834 }
835
836 let temp = TempDir::new().unwrap();
837 let output = temp.path().join("output.zip");
838
839 let source_dir = TempDir::new().unwrap();
841 fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
842 fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
843 fs::create_dir(source_dir.path().join("subdir")).unwrap();
844 fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
845
846 let config = CreationConfig::default()
847 .with_exclude_patterns(vec![])
848 .with_include_hidden(true)
849 .validate()
850 .unwrap();
851
852 let mut progress = TestProgress::default();
853
854 let report =
855 create_zip_with_progress(&output, &[source_dir.path()], &config, &mut progress)
856 .unwrap();
857
858 assert_eq!(report.files_added, 3);
860 assert!(report.directories_added >= 1);
861
862 assert!(
864 progress.entries_started.len() >= 3,
865 "Expected at least 3 entry starts, got {}",
866 progress.entries_started.len()
867 );
868 assert!(
869 progress.entries_completed.len() >= 3,
870 "Expected at least 3 entry completions, got {}",
871 progress.entries_completed.len()
872 );
873 assert!(
874 progress.bytes_written > 0,
875 "Expected bytes written > 0, got {}",
876 progress.bytes_written
877 );
878 assert!(progress.completed, "Expected on_complete to be called");
879
880 let has_file1 = progress
882 .entries_started
883 .iter()
884 .any(|p| p.contains("file1.txt"));
885 let has_file2 = progress
886 .entries_started
887 .iter()
888 .any(|p| p.contains("file2.txt"));
889 let has_file3 = progress
890 .entries_started
891 .iter()
892 .any(|p| p.contains("file3.txt"));
893
894 assert!(has_file1, "Expected file1.txt in progress callbacks");
895 assert!(has_file2, "Expected file2.txt in progress callbacks");
896 assert!(has_file3, "Expected file3.txt in progress callbacks");
897 }
898}