diff --git a/version-rust/src/extract.rs b/version-rust/src/extract.rs index c6df56e..91b4ed3 100644 --- a/version-rust/src/extract.rs +++ b/version-rust/src/extract.rs @@ -100,6 +100,27 @@ pub fn is_archive(name: &str) -> bool { !archive_suffixes(name).is_empty() } +/// Sniff the container format from magic bytes (7z/zip/rar). The python +/// version shells out to the 7z CLI, which sniffs content; extensions are +/// unreliable in the wild (we have seen real 7z archives named `.zip`). +fn sniff_container(path: &Path) -> Option<&'static str> { + use std::io::Read; + let mut buf = [0u8; 8]; + let mut f = fs::File::open(path).ok()?; + let n = f.read(&mut buf).ok()?; + let b = &buf[..n]; + if b.starts_with(&[0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]) { + return Some(".7z"); + } + if b.starts_with(b"PK\x03\x04") || b.starts_with(b"PK\x05\x06") { + return Some(".zip"); + } + if b.starts_with(b"Rar!\x1a\x07\x00") || b.starts_with(b"Rar!\x1a\x07\x01\x00") { + return Some(".rar"); + } + None +} + /// Archive name with all archive suffixes stripped: the unit folder name. pub fn display_unit(name: &str) -> String { let mut cur = name; @@ -342,7 +363,13 @@ impl Extractor { }; let name = file_name_string(archive); let sfx = archive_suffixes(&name); - let res: Result<(), String> = match sfx.last().map(|s| s.as_str()) { + // Container formats: trust the magic bytes over the extension (python's + // 7z CLI sniffs content too). Compressed streams (.gz/.bz2/.xz) and tar + // stay extension-driven, where the tar-inside logic needs the name. + let kind: Option = sniff_container(archive) + .map(str::to_string) + .or_else(|| sfx.last().cloned()); + let res: Result<(), String> = match kind.as_deref() { Some(".7z") => extract_7z(archive, &actual), Some(".zip") => extract_zip(archive, &actual), Some(".rar") => extract_rar(archive, &actual), @@ -967,6 +994,58 @@ mod tests { .collect() } + #[test] + fn sniff_container_reads_magic_not_extension() { + let dir = tempfile::tempdir().unwrap(); + // real zip content, .rar name -> sniffed as zip + let z = dir.path().join("x.rar"); + write_zip(&z, &[("a.txt", b"hi".to_vec())]); + assert_eq!(sniff_container(&z), Some(".zip")); + + // 7z magic, .zip name + let seven = dir.path().join("y.zip"); + fs::write(&seven, [0x37u8, 0x7a, 0xbc, 0xaf, 0x27, 0x1c, 0, 4]).unwrap(); + assert_eq!(sniff_container(&seven), Some(".7z")); + + // rar5 magic + let rar = dir.path().join("z.7z"); + fs::write(&rar, b"Rar!\x1a\x07\x01\x00").unwrap(); + assert_eq!(sniff_container(&rar), Some(".rar")); + + // gzip is not a container: extension keeps driving it + let gz = dir.path().join("g.gz"); + fs::write(&gz, [0x1f, 0x8b, 0x08]).unwrap(); + assert_eq!(sniff_container(&gz), None); + } + + #[test] + fn mislabeled_zip_extracts_via_sniffing() { + // /tmp is a small tmpfs; the free-space guard needs a real fs + let dir = test_base().join(format!("dlp-mislabel-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let src = dir.join("src"); + let out = dir.join("out"); + fs::create_dir_all(&src).unwrap(); + // a real zip with a .rar extension: python's 7z CLI would extract it too + write_zip( + &src.join("Pack.rar"), + &[("unit/part.txt", b"payload".to_vec())], + ); + let extractor = Extractor { + out_root: out.clone(), + }; + extractor.run(&[src.clone()]).unwrap(); + assert_eq!( + fs::read_to_string(out.join("src/Pack/unit/part.txt")).unwrap(), + "payload" + ); + let broken_empty = fs::read_dir(out.join(".broken")) + .map(|d| d.count() == 0) + .unwrap_or(true); + assert!(broken_empty, "nothing should be quarantined"); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn run_extracts_nests_quarantines_and_mirrors() { let (_c, src, out) = build_fixtures();