fix(rust): sniff container magic before extension in the extractor

Real 7z archives named .zip (seen live: 'Heresy Lab - HL068 Utela Justina.zip')
were quarantined because dispatch trusted the extension. python's 7z CLI
sniffs content; now so do we for 7z/zip/rar, keeping extension logic for
gz/bz2/xz/tar where the tar-inside decision needs the name.
This commit is contained in:
2026-10-10 20:12:32 +02:00
parent ef5cf04c52
commit 81972afd22
+80 -1
View File
@@ -100,6 +100,27 @@ pub fn is_archive(name: &str) -> bool {
!archive_suffixes(name).is_empty() !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. /// Archive name with all archive suffixes stripped: the unit folder name.
pub fn display_unit(name: &str) -> String { pub fn display_unit(name: &str) -> String {
let mut cur = name; let mut cur = name;
@@ -342,7 +363,13 @@ impl Extractor {
}; };
let name = file_name_string(archive); let name = file_name_string(archive);
let sfx = archive_suffixes(&name); 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<String> = 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(".7z") => extract_7z(archive, &actual),
Some(".zip") => extract_zip(archive, &actual), Some(".zip") => extract_zip(archive, &actual),
Some(".rar") => extract_rar(archive, &actual), Some(".rar") => extract_rar(archive, &actual),
@@ -967,6 +994,58 @@ mod tests {
.collect() .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] #[test]
fn run_extracts_nests_quarantines_and_mirrors() { fn run_extracts_nests_quarantines_and_mirrors() {
let (_c, src, out) = build_fixtures(); let (_c, src, out) = build_fixtures();