1#![deny(missing_docs)]
11
12use crate::error::{Error, Result};
13use crate::manifest::Manifest;
14use std::fs;
15use std::io::{Read, Write};
16use std::path::{Component, Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18use tracing::{debug, error, warn};
19
20const EXTRACT_CHUNK_SIZE: usize = 64 * 1024;
24
25#[derive(Debug, Clone, Copy)]
35pub struct ExtractLimits {
36 pub max_entries: u64,
38 pub max_file_bytes: u64,
40 pub max_total_bytes: u64,
42}
43
44impl Default for ExtractLimits {
45 fn default() -> Self {
46 Self {
47 max_entries: 100_000,
48 max_file_bytes: 1 << 30, max_total_bytes: 4 << 30, }
51 }
52}
53
54fn temp_sibling_path(path: &Path) -> PathBuf {
58 static COUNTER: AtomicU64 = AtomicU64::new(0);
59
60 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
61 let pid = std::process::id();
62 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("entry");
63 let tmp_name = format!(".{name}.{pid}.{seq}.partial");
64
65 match path.parent() {
66 Some(parent) => parent.join(tmp_name),
67 None => PathBuf::from(tmp_name),
68 }
69}
70
71fn write_entry<R: Read>(
84 entry: &mut R,
85 buffer: &mut [u8],
86 tmp_path: &Path,
87 expected_crc32: u32,
88 dest: &Path,
89 limits: &ExtractLimits,
90 total_written: &mut u64,
91) -> Result<()> {
92 let mut outfile = fs::File::create(tmp_path)?;
93 let mut hasher = crc32fast::Hasher::new();
94 let mut file_written: u64 = 0;
95
96 loop {
97 let read = entry.read(&mut buffer[..])?;
98
99 if read == 0 {
100 break;
101 }
102
103 file_written += read as u64;
104 *total_written += read as u64;
105
106 check_extract_size(dest, file_written, *total_written, limits)?;
107
108 hasher.update(&buffer[..read]);
109 outfile.write_all(&buffer[..read])?;
110 }
111
112 let actual_crc32 = hasher.finalize();
113
114 if actual_crc32 != expected_crc32 {
115 error!(
116 "CRC32 verification failed for {}: expected {:08x}, got {:08x}",
117 dest.display(),
118 expected_crc32,
119 actual_crc32
120 );
121
122 return Err(Error::ChecksumMismatch {
123 path: dest.display().to_string(),
124 expected: expected_crc32,
125 actual: actual_crc32,
126 });
127 }
128
129 outfile.flush()?;
130
131 Ok(())
132}
133
134fn check_extract_size(
143 dest: &Path,
144 file_written: u64,
145 total_written: u64,
146 limits: &ExtractLimits,
147) -> Result<()> {
148 if file_written > limits.max_file_bytes {
149 return Err(Error::ExtractLimitExceeded(format!(
150 "entry {} exceeds max file size of {} bytes",
151 dest.display(),
152 limits.max_file_bytes
153 )));
154 }
155
156 if total_written > limits.max_total_bytes {
157 return Err(Error::ExtractLimitExceeded(format!(
158 "archive exceeds max total uncompressed size of {} bytes",
159 limits.max_total_bytes
160 )));
161 }
162
163 Ok(())
164}
165
166fn safe_join(dest_dir: &Path, entry: Option<&Path>) -> Option<PathBuf> {
175 let entry = entry?;
176
177 for component in entry.components() {
178 match component {
179 Component::Normal(_) | Component::CurDir => {}
180 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
181 }
182 }
183
184 Some(dest_dir.join(entry))
185}
186
187#[cfg(unix)]
204fn entry_permissions(unix_mode: Option<u32>) -> Option<fs::Permissions> {
205 use std::os::unix::fs::PermissionsExt;
206
207 let mode = unix_mode?;
208
209 if mode & 0o111 == 0 {
210 return None;
211 }
212
213 Some(fs::Permissions::from_mode(0o755))
214}
215
216fn apply_entry_mode(path: &Path, unix_mode: Option<u32>) -> Result<()> {
221 #[cfg(unix)]
222 if let Some(permissions) = entry_permissions(unix_mode) {
223 fs::set_permissions(path, permissions)?;
224 }
225
226 #[cfg(not(unix))]
227 let _ = (path, unix_mode);
228
229 Ok(())
230}
231
232pub fn extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<()> {
245 extract_zip_with_limits(zip_path, dest_dir, &ExtractLimits::default())
246}
247
248pub fn extract_zip_with_limits(
264 zip_path: &Path,
265 dest_dir: &Path,
266 limits: &ExtractLimits,
267) -> Result<()> {
268 let file = fs::File::open(zip_path)?;
269 let mut archive = zip::ZipArchive::new(file)?;
270
271 if archive.len() as u64 > limits.max_entries {
272 return Err(Error::ExtractLimitExceeded(format!(
273 "archive has {} entries, exceeding the limit of {}",
274 archive.len(),
275 limits.max_entries
276 )));
277 }
278
279 fs::create_dir_all(dest_dir)?;
280
281 let mut total_written: u64 = 0;
282 let mut buffer = vec![0u8; EXTRACT_CHUNK_SIZE];
283
284 for i in 0..archive.len() {
285 let mut file_in_archive = archive.by_index(i)?;
286 let enclosed = file_in_archive.enclosed_name();
287 let outpath = match safe_join(dest_dir, enclosed.as_deref()) {
288 Some(path) => path,
289 None => {
290 warn!("Invalid file path in zip: {:?}", file_in_archive.name());
291 continue;
292 }
293 };
294
295 let comment = file_in_archive.comment();
296
297 if !comment.is_empty() {
298 debug!("File {} comment: {:?}", i, comment);
299 }
300
301 if file_in_archive.name().ends_with('/') {
302 debug!("Creating directory {}", outpath.display());
303 fs::create_dir_all(&outpath)?;
304 } else {
305 debug!("Extracting file {} to {}", i, outpath.display());
306
307 if let Some(parent) = outpath.parent()
308 && !parent.exists()
309 {
310 fs::create_dir_all(parent)?;
311 }
312
313 let expected_crc32 = file_in_archive.crc32();
314 let unix_mode = file_in_archive.unix_mode();
317 let tmp_path = temp_sibling_path(&outpath);
318
319 match write_entry(
323 &mut file_in_archive,
324 &mut buffer,
325 &tmp_path,
326 expected_crc32,
327 &outpath,
328 limits,
329 &mut total_written,
330 ) {
331 Ok(()) => {
332 if let Err(err) = apply_entry_mode(&tmp_path, unix_mode) {
338 let _ = fs::remove_file(&tmp_path);
339
340 return Err(err);
341 }
342
343 if let Err(err) = fs::rename(&tmp_path, &outpath) {
346 let _ = fs::remove_file(&tmp_path);
347
348 return Err(err.into());
349 }
350
351 debug!("CRC32 verification successful for {}", outpath.display());
352 }
353 Err(err) => {
354 let _ = fs::remove_file(&tmp_path);
355
356 return Err(err);
357 }
358 }
359 }
360 }
361
362 Ok(())
363}
364
365#[cfg(test)]
367fn calculate_crc32(data: &[u8]) -> u32 {
368 let mut hasher = crc32fast::Hasher::new();
369 hasher.update(data);
370
371 hasher.finalize()
372}
373
374pub fn should_extract(mod_dir: &Path, version: &str) -> Result<bool> {
381 let manifest_path = mod_dir.join("manifest.json");
382
383 let existing_version = if manifest_path.exists() {
384 match Manifest::from_file(&manifest_path) {
385 Ok(manifest) => Some(manifest.version_number),
386 Err(err) => {
387 warn!(
388 "Failed to parse manifest in {}: {}; re-extracting",
389 mod_dir.display(),
390 err
391 );
392
393 None
394 }
395 }
396 } else {
397 debug!(
398 "No manifest in {}, extracting version {}",
399 mod_dir.display(),
400 version
401 );
402
403 None
404 };
405
406 let reextract = should_reextract(existing_version.as_deref(), version);
407
408 if let Some(existing) = &existing_version {
409 if reextract {
410 debug!(
411 "{} version changed from {} to {}, extracting",
412 mod_dir.display(),
413 existing,
414 version
415 );
416 } else {
417 debug!(
418 "{} already has version {}, skipping",
419 mod_dir.display(),
420 version
421 );
422 }
423 }
424
425 Ok(reextract)
426}
427
428fn should_reextract(existing_version: Option<&str>, requested: &str) -> bool {
436 match existing_version {
437 Some(existing) => existing != requested,
438 None => true,
439 }
440}
441
442pub fn parse_package_filename(file_name: &str) -> Option<(String, String)> {
453 let stem = file_name.strip_suffix(".zip").unwrap_or(file_name);
454 let parts: Vec<&str> = stem.splitn(3, '-').collect();
455
456 if parts.len() < 3 {
457 return None;
458 }
459
460 let full_name = format!("{}-{}", parts[0], parts[1]);
461 let version = parts[2].to_string();
462
463 Some((full_name, version))
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use std::fs::File;
470 use std::io::Write;
471 use tempfile::tempdir;
472 use zip::write::{FileOptions, ZipWriter};
473
474 #[test]
475 fn test_calculate_crc32() {
476 let data = b"test data for crc32 calculation";
477 let crc = calculate_crc32(data);
478
479 let expected_crc: u32 = 1707861357;
480
481 assert_eq!(crc, expected_crc);
482 }
483
484 #[test]
485 fn safe_join_rejects_traversal_and_absolute() {
486 let base = Path::new("/dest");
487
488 assert_eq!(safe_join(base, None), None);
489 assert_eq!(safe_join(base, Some(Path::new("../escape"))), None);
490 assert_eq!(safe_join(base, Some(Path::new("a/../../b"))), None);
491 assert_eq!(safe_join(base, Some(Path::new("/etc/passwd"))), None);
492
493 assert_eq!(
494 safe_join(base, Some(Path::new("plugins/mod.dll"))),
495 Some(PathBuf::from("/dest/plugins/mod.dll"))
496 );
497 assert!(safe_join(base, Some(Path::new("./ok.txt"))).is_some());
498 }
499
500 #[test]
501 fn should_reextract_truth_table() {
502 assert!(should_reextract(None, "1.0.0"));
504 assert!(!should_reextract(Some("1.0.0"), "1.0.0"));
506 assert!(should_reextract(Some("1.0.0"), "1.0.1"));
508 }
509
510 #[test]
511 fn check_extract_size_enforces_both_caps() {
512 let limits = ExtractLimits {
513 max_entries: 1,
514 max_file_bytes: 10,
515 max_total_bytes: 20,
516 };
517
518 assert!(check_extract_size(Path::new("f"), 10, 20, &limits).is_ok());
520
521 assert!(matches!(
523 check_extract_size(Path::new("f"), 11, 11, &limits).unwrap_err(),
524 Error::ExtractLimitExceeded(_)
525 ));
526
527 assert!(matches!(
529 check_extract_size(Path::new("f"), 5, 21, &limits).unwrap_err(),
530 Error::ExtractLimitExceeded(_)
531 ));
532 }
533
534 #[test]
535 fn temp_sibling_path_is_unique_and_sibling() {
536 let base = Path::new("/tmp/x/file.dll");
537
538 let a = temp_sibling_path(base);
539 let b = temp_sibling_path(base);
540
541 assert_ne!(a, b);
542 assert_eq!(a.parent(), Some(Path::new("/tmp/x")));
543 }
544
545 #[test]
546 fn write_entry_streams_verifies_crc_and_enforces_cap() {
547 let dir = tempdir().unwrap();
548 let data = b"hello world, this is a streamed entry";
549 let crc = calculate_crc32(data);
550 let limits = ExtractLimits::default();
551
552 let tmp = dir.path().join("ok.partial");
555 let mut reader = std::io::Cursor::new(data.to_vec());
556 let mut buffer = vec![0u8; 4];
557 let mut total = 0u64;
558
559 write_entry(
560 &mut reader,
561 &mut buffer,
562 &tmp,
563 crc,
564 Path::new("ok"),
565 &limits,
566 &mut total,
567 )
568 .unwrap();
569
570 assert_eq!(std::fs::read(&tmp).unwrap(), data);
571 assert_eq!(total, data.len() as u64);
572
573 let tmp2 = dir.path().join("bad.partial");
575 let mut reader = std::io::Cursor::new(data.to_vec());
576 let mut buffer = vec![0u8; 64];
577 let mut total = 0u64;
578
579 let err = write_entry(
580 &mut reader,
581 &mut buffer,
582 &tmp2,
583 0xDEAD_BEEF,
584 Path::new("bad"),
585 &limits,
586 &mut total,
587 )
588 .unwrap_err();
589
590 assert!(matches!(err, Error::ChecksumMismatch { .. }));
591
592 let tmp3 = dir.path().join("big.partial");
594 let tight = ExtractLimits {
595 max_file_bytes: 4,
596 ..ExtractLimits::default()
597 };
598 let mut reader = std::io::Cursor::new(vec![0u8; 10]);
599 let mut buffer = vec![0u8; 64];
600 let mut total = 0u64;
601
602 let err = write_entry(
603 &mut reader,
604 &mut buffer,
605 &tmp3,
606 0,
607 Path::new("big"),
608 &tight,
609 &mut total,
610 )
611 .unwrap_err();
612
613 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
614 }
615
616 #[test]
617 fn test_parse_package_filename() {
618 assert_eq!(
619 parse_package_filename("Owner-ModName-1.0.0.zip"),
620 Some(("Owner-ModName".to_string(), "1.0.0".to_string()))
621 );
622
623 assert_eq!(parse_package_filename("InvalidFile.zip"), None);
624 }
625
626 #[test]
627 fn test_parse_package_filename_preserves_hyphenated_version() {
628 assert_eq!(
629 parse_package_filename("Owner-ModName-1.0.0-beta.1.zip"),
630 Some(("Owner-ModName".to_string(), "1.0.0-beta.1".to_string()))
631 );
632 }
633
634 #[test]
635 fn test_extract_zip_with_valid_paths() {
636 let temp_dir = tempdir().unwrap();
637 let output_dir = temp_dir.path().join("output");
638 fs::create_dir_all(&output_dir).unwrap();
639
640 let zip_path = temp_dir.path().join("test_invalid_paths.zip");
641 let file = File::create(&zip_path).unwrap();
642 let mut zip = ZipWriter::new(file);
643
644 let options: FileOptions<'_, ()> =
645 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
646
647 zip.start_file("valid_file.txt", options).unwrap();
648 zip.write_all(b"Valid file content").unwrap();
649
650 zip.finish().unwrap();
651
652 let result = extract_zip(&zip_path, &output_dir);
653
654 assert!(result.is_ok());
655
656 let valid_file = output_dir.join("valid_file.txt");
657 assert!(valid_file.exists());
658 }
659
660 #[test]
666 #[cfg(unix)]
667 fn test_extract_zip_preserves_the_executable_bit() {
668 use std::os::unix::fs::PermissionsExt;
669
670 let temp_dir = tempdir().unwrap();
671 let output_dir = temp_dir.path().join("output");
672
673 let zip_path = temp_dir.path().join("test_modes.zip");
674 let file = File::create(&zip_path).unwrap();
675 let mut zip = ZipWriter::new(file);
676
677 let executable: FileOptions<'_, ()> = FileOptions::default()
678 .compression_method(zip::CompressionMethod::Stored)
679 .unix_permissions(0o755);
680 let plain: FileOptions<'_, ()> = FileOptions::default()
681 .compression_method(zip::CompressionMethod::Stored)
682 .unix_permissions(0o644);
683
684 zip.start_file("start_game_bepinex.sh", executable).unwrap();
685 zip.write_all(b"#!/bin/sh\n").unwrap();
686
687 zip.start_file("mods.yml", plain).unwrap();
688 zip.write_all(b"[]").unwrap();
689
690 zip.finish().unwrap();
691
692 extract_zip(&zip_path, &output_dir).unwrap();
693
694 let script_mode = fs::metadata(output_dir.join("start_game_bepinex.sh"))
695 .unwrap()
696 .permissions()
697 .mode();
698
699 assert!(
700 script_mode & 0o111 != 0,
701 "executable entry extracted without an execute bit: {script_mode:o}"
702 );
703
704 let plain_mode = fs::metadata(output_dir.join("mods.yml"))
707 .unwrap()
708 .permissions()
709 .mode();
710
711 assert!(
712 plain_mode & 0o111 == 0,
713 "plain entry gained an execute bit: {plain_mode:o}"
714 );
715 }
716
717 #[test]
721 #[cfg(unix)]
722 fn test_extract_zip_handles_entries_with_no_unix_mode() {
723 use std::os::unix::fs::PermissionsExt;
724
725 let temp_dir = tempdir().unwrap();
726 let output_dir = temp_dir.path().join("output");
727
728 let zip_path = temp_dir.path().join("test_no_mode.zip");
729 let file = File::create(&zip_path).unwrap();
730 let mut zip = ZipWriter::new(file);
731
732 let options: FileOptions<'_, ()> =
733 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
734
735 zip.start_file("plugin.dll", options).unwrap();
736 zip.write_all(b"bytes").unwrap();
737
738 zip.finish().unwrap();
739
740 extract_zip(&zip_path, &output_dir).unwrap();
741
742 let mode = fs::metadata(output_dir.join("plugin.dll"))
743 .unwrap()
744 .permissions()
745 .mode();
746
747 assert!(
748 mode & 0o111 == 0,
749 "entry with no mode made executable: {mode:o}"
750 );
751 }
752
753 #[test]
756 #[cfg(unix)]
757 fn test_extract_zip_drops_setuid_bits() {
758 use std::os::unix::fs::PermissionsExt;
759
760 let temp_dir = tempdir().unwrap();
761 let output_dir = temp_dir.path().join("output");
762
763 let zip_path = temp_dir.path().join("test_setuid.zip");
764 let file = File::create(&zip_path).unwrap();
765 let mut zip = ZipWriter::new(file);
766
767 let options: FileOptions<'_, ()> = FileOptions::default()
768 .compression_method(zip::CompressionMethod::Stored)
769 .unix_permissions(0o4755);
770
771 zip.start_file("evil.sh", options).unwrap();
772 zip.write_all(b"#!/bin/sh\n").unwrap();
773
774 zip.finish().unwrap();
775
776 extract_zip(&zip_path, &output_dir).unwrap();
777
778 let mode = fs::metadata(output_dir.join("evil.sh"))
779 .unwrap()
780 .permissions()
781 .mode();
782
783 assert!(
784 mode & 0o7000 == 0,
785 "setuid/setgid/sticky survived: {mode:o}"
786 );
787 assert!(mode & 0o111 != 0, "execute bit lost: {mode:o}");
788 }
789
790 #[test]
791 fn test_extract_zip_with_directory() {
792 let temp_dir = tempdir().unwrap();
793 let output_dir = temp_dir.path().join("output");
794 fs::create_dir_all(&output_dir).unwrap();
795
796 let zip_path = temp_dir.path().join("test_with_dir.zip");
797 let file = File::create(&zip_path).unwrap();
798 let mut zip = ZipWriter::new(file);
799
800 let options: FileOptions<'_, ()> =
801 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
802
803 zip.add_directory("test_dir/", options).unwrap();
804
805 zip.start_file("test_dir/file_in_dir.txt", options).unwrap();
806 zip.write_all(b"File in directory").unwrap();
807
808 zip.finish().unwrap();
809
810 let result = extract_zip(&zip_path, &output_dir);
811
812 assert!(result.is_ok());
813
814 let dir_path = output_dir.join("test_dir");
815 assert!(dir_path.exists());
816 assert!(dir_path.is_dir());
817
818 let file_in_dir = dir_path.join("file_in_dir.txt");
819 assert!(file_in_dir.exists());
820
821 let content = fs::read_to_string(&file_in_dir).unwrap();
822 assert_eq!(content, "File in directory");
823 }
824
825 #[test]
826 fn test_extract_zip_with_nested_directories() {
827 let temp_dir = tempdir().unwrap();
828 let output_dir = temp_dir.path().join("output");
829
830 let zip_path = temp_dir.path().join("test_nested.zip");
831 let file = File::create(&zip_path).unwrap();
832 let mut zip = ZipWriter::new(file);
833
834 let options: FileOptions<'_, ()> =
835 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
836
837 zip
838 .start_file("deep/nested/path/file.txt", options)
839 .unwrap();
840 zip.write_all(b"Deep nested file").unwrap();
841
842 zip.finish().unwrap();
843
844 let result = extract_zip(&zip_path, &output_dir);
845
846 assert!(result.is_ok());
847
848 let nested_file = output_dir.join("deep/nested/path/file.txt");
849 assert!(nested_file.exists());
850
851 let content = fs::read_to_string(&nested_file).unwrap();
852 assert_eq!(content, "Deep nested file");
853 }
854
855 #[test]
856 fn test_extract_zip_streams_large_file() {
857 let temp_dir = tempdir().unwrap();
858 let output_dir = temp_dir.path().join("output");
859
860 let zip_path = temp_dir.path().join("test_large.zip");
861 let file = File::create(&zip_path).unwrap();
862 let mut zip = ZipWriter::new(file);
863
864 let options: FileOptions<'_, ()> =
865 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
866
867 let payload: Vec<u8> = (0..(64 * 1024 * 3 + 17)).map(|i| (i % 251) as u8).collect();
869
870 zip.start_file("big.bin", options).unwrap();
871 zip.write_all(&payload).unwrap();
872
873 zip.finish().unwrap();
874
875 let result = extract_zip(&zip_path, &output_dir);
876
877 assert!(result.is_ok());
878
879 let extracted = output_dir.join("big.bin");
880 let content = fs::read(&extracted).unwrap();
881
882 assert_eq!(content, payload);
883
884 let leftover = fs::read_dir(&output_dir)
886 .unwrap()
887 .filter_map(|e| e.ok())
888 .any(|e| e.file_name().to_string_lossy().contains(".partial"));
889
890 assert!(!leftover, "no .partial temp files should remain");
891 }
892
893 #[test]
894 fn test_extract_zip_reuses_buffer_across_entries() {
895 let temp_dir = tempdir().unwrap();
896 let output_dir = temp_dir.path().join("output");
897
898 let big: Vec<u8> = (0..(64 * 1024 * 2 + 5)).map(|i| (i % 251) as u8).collect();
901 let small = b"tiny".to_vec();
902 let zip_path = temp_dir.path().join("multi.zip");
903 write_stored_zip(&zip_path, &[("big.bin", &big), ("small.txt", &small)]);
904
905 let result = extract_zip(&zip_path, &output_dir);
906
907 assert!(result.is_ok());
908
909 let big_out = fs::read(output_dir.join("big.bin")).unwrap();
910 let small_out = fs::read(output_dir.join("small.txt")).unwrap();
911
912 assert_eq!(big_out, big);
913 assert_eq!(small_out, small);
914 assert!(no_partial_files(&output_dir));
915 }
916
917 #[test]
918 fn test_extract_zip_leaves_no_file_on_crc_mismatch() {
919 let temp_dir = tempdir().unwrap();
920 let output_dir = temp_dir.path().join("output");
921
922 let zip_path = temp_dir.path().join("test_corrupt.zip");
923 let file = File::create(&zip_path).unwrap();
924 let mut zip = ZipWriter::new(file);
925
926 let options: FileOptions<'_, ()> =
927 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
928
929 let marker = b"UNIQUEMARKERPAYLOAD";
932 let mut payload = marker.to_vec();
933 payload.extend_from_slice(&[7u8; 64]);
934
935 zip.start_file("data.bin", options).unwrap();
936 zip.write_all(&payload).unwrap();
937
938 zip.finish().unwrap();
939
940 let mut bytes = fs::read(&zip_path).unwrap();
941 let pos = bytes
942 .windows(marker.len())
943 .position(|w| w == marker)
944 .expect("payload marker present in archive");
945
946 bytes[pos + marker.len()] ^= 0xFF;
948 fs::write(&zip_path, &bytes).unwrap();
949
950 let result = extract_zip(&zip_path, &output_dir);
951
952 assert!(result.is_err());
957
958 assert!(!output_dir.join("data.bin").exists());
960
961 let leftover = fs::read_dir(&output_dir)
962 .unwrap()
963 .filter_map(|e| e.ok())
964 .any(|e| e.file_name().to_string_lossy().contains(".partial"));
965
966 assert!(!leftover, "no .partial temp files should remain");
967 }
968
969 fn write_stored_zip(zip_path: &Path, entries: &[(&str, &[u8])]) {
970 let file = File::create(zip_path).unwrap();
971 let mut zip = ZipWriter::new(file);
972
973 let options: FileOptions<'_, ()> =
974 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
975
976 for (name, contents) in entries {
977 zip.start_file(*name, options).unwrap();
978 zip.write_all(contents).unwrap();
979 }
980
981 zip.finish().unwrap();
982 }
983
984 fn no_partial_files(dir: &Path) -> bool {
985 !fs::read_dir(dir)
986 .unwrap()
987 .filter_map(|e| e.ok())
988 .any(|e| e.file_name().to_string_lossy().contains(".partial"))
989 }
990
991 #[test]
992 fn test_extract_zip_rejects_too_many_entries() {
993 let temp_dir = tempdir().unwrap();
994 let output_dir = temp_dir.path().join("output");
995
996 let zip_path = temp_dir.path().join("many_entries.zip");
997 write_stored_zip(
998 &zip_path,
999 &[("a.txt", b"a"), ("b.txt", b"b"), ("c.txt", b"c")],
1000 );
1001
1002 let limits = ExtractLimits {
1003 max_entries: 2,
1004 ..Default::default()
1005 };
1006
1007 let result = extract_zip_with_limits(&zip_path, &output_dir, &limits);
1008
1009 assert!(matches!(result, Err(Error::ExtractLimitExceeded(_))));
1010 }
1011
1012 #[test]
1013 fn test_extract_zip_rejects_oversized_entry() {
1014 let temp_dir = tempdir().unwrap();
1015 let output_dir = temp_dir.path().join("output");
1016
1017 let zip_path = temp_dir.path().join("big_entry.zip");
1018 let payload = vec![0u8; 100];
1019 write_stored_zip(&zip_path, &[("big.bin", &payload)]);
1020
1021 let limits = ExtractLimits {
1022 max_file_bytes: 8,
1023 ..Default::default()
1024 };
1025
1026 let result = extract_zip_with_limits(&zip_path, &output_dir, &limits);
1027
1028 assert!(matches!(result, Err(Error::ExtractLimitExceeded(_))));
1029 assert!(!output_dir.join("big.bin").exists());
1030 assert!(no_partial_files(&output_dir));
1031 }
1032
1033 #[test]
1034 fn test_extract_zip_rejects_exceeding_total() {
1035 let temp_dir = tempdir().unwrap();
1036 let output_dir = temp_dir.path().join("output");
1037
1038 let zip_path = temp_dir.path().join("total.zip");
1039 let chunk = vec![7u8; 80];
1040 write_stored_zip(&zip_path, &[("one.bin", &chunk), ("two.bin", &chunk)]);
1041
1042 let limits = ExtractLimits {
1044 max_file_bytes: 1_000,
1045 max_total_bytes: 100,
1046 ..Default::default()
1047 };
1048
1049 let result = extract_zip_with_limits(&zip_path, &output_dir, &limits);
1050
1051 assert!(matches!(result, Err(Error::ExtractLimitExceeded(_))));
1052 assert!(no_partial_files(&output_dir));
1053 }
1054
1055 #[test]
1056 fn test_extract_zip_within_default_limits_succeeds() {
1057 let temp_dir = tempdir().unwrap();
1058 let output_dir = temp_dir.path().join("output");
1059
1060 let zip_path = temp_dir.path().join("normal.zip");
1061 write_stored_zip(&zip_path, &[("plugin.dll", b"content")]);
1062
1063 let result = extract_zip_with_limits(&zip_path, &output_dir, &ExtractLimits::default());
1064
1065 assert!(result.is_ok());
1066 assert!(output_dir.join("plugin.dll").exists());
1067 }
1068
1069 #[test]
1070 fn test_extract_zip_rejects_path_traversal() {
1071 let temp_dir = tempdir().unwrap();
1072 let output_dir = temp_dir.path().join("output");
1073 fs::create_dir_all(&output_dir).unwrap();
1074
1075 let zip_path = temp_dir.path().join("traversal.zip");
1078 write_stored_zip(
1079 &zip_path,
1080 &[("../escaped.txt", b"malicious"), ("safe.txt", b"benign")],
1081 );
1082
1083 let result = extract_zip(&zip_path, &output_dir);
1084
1085 assert!(result.is_ok());
1088 assert!(output_dir.join("safe.txt").exists());
1089 assert!(!temp_dir.path().join("escaped.txt").exists());
1090 assert!(no_partial_files(&output_dir));
1091 }
1092
1093 #[test]
1094 fn test_should_extract() {
1095 let temp_dir = tempdir().unwrap();
1096 let mod_dir = temp_dir.path().join("Owner-ModName");
1097 fs::create_dir_all(&mod_dir).unwrap();
1098
1099 let result = should_extract(&mod_dir, "1.0.0");
1100 assert!(result.unwrap());
1101
1102 let manifest_content = r#"{
1103 "version_number": "1.0.0",
1104 "name": "TestMod",
1105 "description": "Test mod description"
1106 }"#;
1107
1108 let manifest_path = mod_dir.join("manifest.json");
1109 let mut file = File::create(&manifest_path).unwrap();
1110 file.write_all(manifest_content.as_bytes()).unwrap();
1111
1112 let result = should_extract(&mod_dir, "1.0.0");
1113 assert!(!result.unwrap());
1114
1115 let result = should_extract(&mod_dir, "2.0.0");
1116 assert!(result.unwrap());
1117
1118 let mut file = File::create(&manifest_path).unwrap();
1119 file.write_all(b"invalid json content").unwrap();
1120
1121 let result = should_extract(&mod_dir, "1.0.0");
1122 assert!(result.unwrap());
1123 }
1124}