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:
@@ -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<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(".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();
|
||||
|
||||
Reference in New Issue
Block a user