Skip to main content

thunderstore_engine/
extract.rs

1//! Generic, CRC-verified zip extraction primitives.
2//!
3//! These are the reusable archive operations shared by every Thunderstore mod
4//! manager: extracting a package zip with per-entry CRC32 verification and
5//! path-traversal protection, deciding whether a downloaded version differs
6//! from what is already on disk, and parsing the `Owner-Name-Version.zip`
7//! filename convention. Where the extracted files ultimately land is install
8//! policy and lives in the caller (see [`crate::install`]).
9
10#![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
20/// Size of the reusable read buffer used to stream each archive entry to disk.
21/// This bounds extraction memory to a constant regardless of entry size; it is an
22/// IO buffer, not a cap on legitimate file size.
23const EXTRACT_CHUNK_SIZE: usize = 64 * 1024;
24
25/// Resource limits enforced while extracting an archive, to defend against
26/// decompression bombs (an archive that is small on disk but expands to an
27/// enormous number of entries or bytes).
28///
29/// These bound *disk* usage and entry count; extraction memory is already a
30/// fixed `EXTRACT_CHUNK_SIZE` buffer regardless of these values. The defaults
31/// are generous enough for real mod and texture packs while still rejecting
32/// pathological archives; tune them with [`extract_zip_with_limits`] when a
33/// caller needs different bounds.
34#[derive(Debug, Clone, Copy)]
35pub struct ExtractLimits {
36  /// Maximum number of entries (files + directories) an archive may contain.
37  pub max_entries: u64,
38  /// Maximum uncompressed size of any single entry, in bytes.
39  pub max_file_bytes: u64,
40  /// Maximum total uncompressed size across all entries, in bytes.
41  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,  // 1 GiB
49      max_total_bytes: 4 << 30, // 4 GiB
50    }
51  }
52}
53
54/// Computes a unique sibling temp path next to `path` so a partially-extracted
55/// entry can be written, CRC-verified, then atomically renamed into place — an
56/// interrupted or corrupt entry never leaves a usable file behind.
57fn 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
71/// Streams a single archive entry to `tmp_path`, holding only a fixed-size buffer
72/// in memory, and verifies its CRC32 against `expected_crc32`.
73///
74/// Enforces the byte limits in `limits` against the *actual* number of bytes
75/// streamed (not the archive's declared sizes, which an attacker controls):
76/// `max_file_bytes` for this entry and `max_total_bytes` for the cumulative
77/// `total_written` across the whole archive. Exceeding either returns
78/// [`Error::ExtractLimitExceeded`].
79///
80/// Returns [`Error::ChecksumMismatch`] when the computed CRC differs from the one
81/// recorded in the archive. The caller is responsible for renaming `tmp_path` into
82/// place on success and removing it on any error.
83fn 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
134/// Enforces the streamed-byte limits for one entry: the per-entry
135/// `max_file_bytes` and the cumulative `max_total_bytes` across the archive.
136///
137/// Checked against the *actual* bytes streamed (which an attacker cannot forge,
138/// unlike an archive's declared sizes). Pure — no I/O — so the decompression-bomb
139/// guard is unit-testable. Returns [`Error::ExtractLimitExceeded`] on breach; this
140/// stays distinct from the network `SizeCap`/[`Error::SizeLimitExceeded`] path
141/// because archive limits also bound a non-byte quantity (entry count).
142fn 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
166/// Joins a validated archive entry path onto `dest_dir`, or returns `None` if the
167/// entry would escape the destination.
168///
169/// `entry` is the result of [`zip::read::ZipFile::enclosed_name`] (already `None`
170/// for a rejected path). As defense in depth on top of that, this rejects any
171/// absolute, root, drive-prefix, or `..` component itself, so the traversal guard
172/// is a single pure function testable against crafted paths without a real
173/// archive.
174fn 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/// The permissions to force on an extracted entry, or `None` to keep whatever
188/// [`fs::File::create`] and the process umask already produced.
189///
190/// Only the archive's *execute* bits are consulted, and only to widen the file to
191/// `0o755`. A loader package ships `run_bepinex.sh` / `start_game_bepinex.sh`
192/// marked executable and the generated Steam wrapper `exec`s whichever one it
193/// finds, so an entry that loses that bit turns a modded launch into a bare
194/// `Permission denied`, which under Steam surfaces only as the game silently
195/// never starting.
196///
197/// The recorded mode is deliberately **not** reproduced wholesale. A downloaded
198/// package is untrusted input: applying it verbatim would honour `setuid`,
199/// `setgid`, and sticky bits, and would let an entry declare itself
200/// world-writable. Widening to a fixed `0o755` can do neither. An entry with no
201/// recorded mode at all, which is every archive built on Windows, is left alone
202/// rather than guessed at.
203#[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
216/// Applies an archive entry's recorded execute bit to the file just extracted to
217/// `path`. See [`entry_permissions`] for why only that bit is honoured.
218///
219/// A no-op off Unix, where a zip entry's Unix mode has nothing to map onto.
220fn 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
232/// Extracts a zip archive to `dest_dir` with per-entry CRC32 verification and
233/// the default [`ExtractLimits`].
234///
235/// Entry paths are validated with [`zip::read::ZipFile::enclosed_name`] so that
236/// archive entries containing `..` or absolute paths cannot escape `dest_dir`.
237///
238/// # Errors
239///
240/// Returns an error if the file is not a valid zip archive, if IO fails, if
241/// CRC32 verification fails for any extracted entry ([`Error::ChecksumMismatch`]),
242/// or if the archive exceeds a default extraction limit
243/// ([`Error::ExtractLimitExceeded`]).
244pub fn extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<()> {
245  extract_zip_with_limits(zip_path, dest_dir, &ExtractLimits::default())
246}
247
248/// Extracts a zip archive to `dest_dir` with per-entry CRC32 verification,
249/// enforcing the supplied [`ExtractLimits`] to defend against decompression
250/// bombs.
251///
252/// Entry paths are validated with [`zip::read::ZipFile::enclosed_name`] so that
253/// archive entries containing `..` or absolute paths cannot escape `dest_dir`.
254/// The entry count and the actual streamed byte totals are checked against
255/// `limits`; CRC32 is an integrity check only and does not authenticate content.
256///
257/// # Errors
258///
259/// Returns an error if the file is not a valid zip archive, if IO fails, if
260/// CRC32 verification fails for any extracted entry ([`Error::ChecksumMismatch`]),
261/// or if the archive exceeds a limit in `limits`
262/// ([`Error::ExtractLimitExceeded`]).
263pub 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      // Read before streaming: `write_entry` borrows the entry mutably, and the
315      // mode is needed after the file is renamed into place.
316      let unix_mode = file_in_archive.unix_mode();
317      let tmp_path = temp_sibling_path(&outpath);
318
319      // Stream the entry to a temp file, verifying the CRC as we go. Any failure
320      // (IO, a short/corrupt entry the `zip` reader rejects, or a CRC mismatch)
321      // must remove the temp file so a corrupt entry never lands at `outpath`.
322      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          // Applied to the temp file rather than to `outpath`, so the entry
333          // becomes visible at its final path already carrying its final mode
334          // instead of appearing briefly as non-executable. Any failure here is
335          // treated exactly like a failed rename: remove the temp file so a
336          // half-finished entry never lands.
337          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          // A failed rename must also remove the temp file, so a CRC-verified
344          // entry that cannot be moved into place leaves no orphan behind.
345          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/// Calculates the CRC32 checksum for a byte buffer.
366#[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
374/// Determines whether a mod should be (re)extracted based on version comparison.
375///
376/// Returns `Ok(true)` if `mod_dir` has no `manifest.json`, if its manifest has a
377/// different `version_number` than `version`, or if its manifest cannot be
378/// parsed. Returns `Ok(false)` only when an existing manifest already records
379/// the same version.
380pub 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
428/// The pure re-extract decision, given the version an existing on-disk manifest
429/// records (`None` when there is no manifest, or it could not be parsed) and the
430/// `requested` version.
431///
432/// Re-extract unless an existing manifest already records exactly the requested
433/// version. Extracted from [`should_extract`] so the decision is testable without
434/// writing manifest files to disk.
435fn should_reextract(existing_version: Option<&str>, requested: &str) -> bool {
436  match existing_version {
437    Some(existing) => existing != requested,
438    None => true,
439  }
440}
441
442/// Parses a Thunderstore package zip filename of the form
443/// `Owner-Name-Version.zip`.
444///
445/// Returns the package full name (`Owner-Name`) and the version string, or
446/// `None` if the filename does not contain at least three `-`-separated parts.
447///
448/// Only the first two `-` delimiters are significant: the owner and name never
449/// contain `-`, but everything after the second delimiter is taken verbatim as
450/// the version, so a version that itself contains `-` (e.g. a `1.0.0-beta`
451/// prerelease) is preserved rather than truncated.
452pub 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    // No existing manifest (or an unparseable one) => re-extract.
503    assert!(should_reextract(None, "1.0.0"));
504    // Same version already present => skip.
505    assert!(!should_reextract(Some("1.0.0"), "1.0.0"));
506    // Different version present => re-extract.
507    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    // At the caps is allowed.
519    assert!(check_extract_size(Path::new("f"), 10, 20, &limits).is_ok());
520
521    // Over the per-file cap.
522    assert!(matches!(
523      check_extract_size(Path::new("f"), 11, 11, &limits).unwrap_err(),
524      Error::ExtractLimitExceeded(_)
525    ));
526
527    // Over the whole-archive cap.
528    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    // Success: a small buffer forces multiple read iterations; the file lands and
553    // the running total reflects every byte.
554    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    // CRC mismatch is reported.
574    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    // The per-file byte cap is enforced against the actual streamed bytes.
593    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  /// A loader package ships `start_game_bepinex.sh` / `run_bepinex.sh` marked
661  /// executable in the archive, and the generated Steam wrapper `exec`s whichever
662  /// one it finds. Dropping the archive's execute bit makes that `exec` fail with
663  /// `Permission denied`, which under Steam surfaces only as the game silently
664  /// never starting, so the bit has to survive extraction.
665  #[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    // A non-executable entry must not be widened just because a sibling was
705    // executable: only the archive's own execute bits are honoured.
706    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  /// An archive built on Windows records no Unix mode at all. Those entries must
718  /// still extract, keeping the default permissions rather than erroring or being
719  /// made executable.
720  #[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  /// `setuid`/`setgid`/sticky bits in an archive are never reproduced on disk: a
754  /// downloaded package must not be able to plant a setuid file.
755  #[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    // Larger than EXTRACT_CHUNK_SIZE so the streaming loop runs many iterations.
868    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    // No leftover temp/partial files from the atomic write.
885    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    // A large entry followed by a smaller one: the read buffer is reused across
899    // entries, so the smaller entry must not inherit stale bytes from the larger.
900    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    // Stored compression keeps the payload as raw bytes in the local entry, so
930    // flipping one payload byte makes the actual CRC diverge from the stored one.
931    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    // Corrupt a byte inside the payload (after the marker).
947    bytes[pos + marker.len()] ^= 0xFF;
948    fs::write(&zip_path, &bytes).unwrap();
949
950    let result = extract_zip(&zip_path, &output_dir);
951
952    // A corrupted entry must fail extraction. Depending on compression, the
953    // mismatch is caught either by our CRC check (`ChecksumMismatch`) or by the
954    // `zip` reader's own validation (surfaced as an IO/ZIP error) — either way
955    // extraction errors and nothing is left behind.
956    assert!(result.is_err());
957
958    // The corrupt entry must not leave a usable file (nor a .partial temp).
959    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    // Each entry fits under max_file_bytes, but together they exceed the total.
1043    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    // A crafted archive whose entry tries to climb out of the extraction root,
1076    // alongside a benign entry.
1077    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    // Extraction succeeds by skipping the unsafe entry, the benign file lands
1086    // inside the root, and nothing is written to the parent (outside the root).
1087    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}