//! Verified against python's zipfile/zlib, which are battle-tested implementations //! we did not write. Fixtures are generated at test time so nothing binary is //! checked in. Skips cleanly if python3 is unavailable. use nox_zip::{crc32, inflate, zlib_decompress, Archive}; use std::path::PathBuf; use std::process::Command; const GEN: &str = r#" import os, sys, zipfile, zlib, random out = sys.argv[1] os.makedirs(out, exist_ok=True) random.seed(1234) texts = { 'readme.txt': b'hello world\n' * 400, 'dir/nested/deep.txt': b'the quick brown fox jumps over the lazy dog. ' * 900, 'empty.txt': b'', 'one.bin': bytes([7]), 'random.bin': bytes(random.getrandbits(8) for _ in range(70000)), 'ramp.bin': bytes(i % 251 for i in range(120000)), 'unicode-\u00e9\u00fc\u5b57.txt': 'accented \u00e9\u00fc and CJK \u5b57\n'.encode() * 50, } # one archive per compression setting, so stored and every deflate level is covered for level in [0,1,3,6,9]: mode = zipfile.ZIP_STORED if level == 0 else zipfile.ZIP_DEFLATED path = os.path.join(out, 'level%d.zip' % level) with zipfile.ZipFile(path, 'w', mode, compresslevel=(None if level==0 else level)) as z: for name, body in texts.items(): z.writestr(name, body) z.writestr('dir/', b'') # raw zlib streams at every level, for the inflater for level in range(0, 10): for name, body in [('ramp', texts['ramp.bin']), ('text', texts['readme.txt']), ('rand', texts['random.bin'])]: open(os.path.join(out, 'z_%s_%d.bin' % (name, level)), 'wb').write(zlib.compress(body, level)) open(os.path.join(out, 'z_%s_%d.raw' % (name, level)), 'wb').write(body) # manifest of expected contents with open(os.path.join(out, 'expect.txt'), 'w') as f: for name, body in texts.items(): f.write('%s\t%d\t%d\n' % (name, len(body), zlib.crc32(body) & 0xffffffff)) open(os.path.join(out, 'content_%d.bin' % (zlib.crc32(name.encode()) & 0xffffffff)), 'wb').write(body) print('ok') "#; fn fixtures() -> Option { if Command::new("python3").arg("--version").output().is_err() { return None; } let dir = std::env::temp_dir().join("nox-zip-fixtures"); let script = std::env::temp_dir().join("nox-zip-gen.py"); std::fs::write(&script, GEN).ok()?; let out = Command::new("python3").arg(&script).arg(&dir).output().ok()?; assert!( out.status.success(), "fixture generation failed: {}", String::from_utf8_lossy(&out.stderr) ); Some(dir) } fn expected(dir: &PathBuf) -> Vec<(String, u64, u32, Vec)> { let txt = std::fs::read_to_string(dir.join("expect.txt")).unwrap(); txt.lines() .filter(|l| !l.is_empty()) .map(|l| { let mut it = l.split('\t'); let name = it.next().unwrap().to_string(); let size: u64 = it.next().unwrap().parse().unwrap(); let crc: u32 = it.next().unwrap().parse().unwrap(); let body = std::fs::read(dir.join(format!("content_{}.bin", crc32(name.as_bytes())))) .unwrap_or_default(); (name, size, crc, body) }) .collect() } #[test] fn reads_archives_written_by_python_at_every_level() { let Some(dir) = fixtures() else { return }; let want = expected(&dir); assert!(want.len() >= 7); let mut checked = 0; for level in [0, 1, 3, 6, 9] { let bytes = std::fs::read(dir.join(format!("level{level}.zip"))).unwrap(); let ar = Archive::open(&bytes).unwrap_or_else(|e| panic!("level{level}: {e:?}")); assert_eq!(ar.len(), want.len() + 1, "level{level}: entry count"); for (name, size, crc, body) in &want { let e = ar .find(name) .unwrap_or_else(|| panic!("level{level}: missing {name}")); assert_eq!(e.size, *size, "level{level}: {name} size"); assert_eq!(e.crc, *crc, "level{level}: {name} crc"); let got = ar.read(e).unwrap_or_else(|err| panic!("level{level}: {name}: {err:?}")); assert_eq!(got.len(), body.len(), "level{level}: {name} length"); assert!(got == *body, "level{level}: {name} content differs"); checked += 1; } assert!(ar.find("dir/").map(|e| e.is_dir).unwrap_or(false)); } eprintln!("verified {checked} entries across 5 archives"); } #[test] fn inflates_every_zlib_level() { let Some(dir) = fixtures() else { return }; let mut n = 0; for kind in ["ramp", "text", "rand"] { for level in 0..=9 { let comp = std::fs::read(dir.join(format!("z_{kind}_{level}.bin"))).unwrap(); let raw = std::fs::read(dir.join(format!("z_{kind}_{level}.raw"))).unwrap(); let got = zlib_decompress(&comp, 1 << 24) .unwrap_or_else(|e| panic!("{kind} level {level}: {e:?}")); assert_eq!(got.len(), raw.len(), "{kind} level {level} length"); assert!(got == raw, "{kind} level {level} content"); n += 1; } } eprintln!("inflated {n} zlib streams"); } #[test] fn round_trips_our_own_deflate() { // Our compressor and our decompressor must agree with each other as well as // with python, otherwise one of them is quietly wrong. let cases: Vec> = vec![ Vec::new(), b"a".to_vec(), b"abcabcabcabc".to_vec(), b"the quick brown fox ".repeat(500), (0..70000u32).map(|i| (i % 251) as u8).collect(), { let mut s = 12345u64; (0..50000) .map(|_| { s = s.wrapping_mul(6364136223846793005).wrapping_add(1); (s >> 33) as u8 }) .collect() }, ]; for (i, c) in cases.iter().enumerate() { let z = nox_gfx::deflate::zlib(c, 24); let back = zlib_decompress(&z, 1 << 24).unwrap_or_else(|e| panic!("case {i}: {e:?}")); assert_eq!(&back, c, "case {i} round trip"); } } #[test] fn rejects_hostile_and_broken_archives() { let Some(dir) = fixtures() else { return }; let good = std::fs::read(dir.join("level6.zip")).unwrap(); assert!(Archive::open(&[]).is_err()); assert!(Archive::open(&[0u8; 100]).is_err()); assert!(Archive::open(&good[..good.len() / 2]).is_err()); // Corrupt compressed bytes must be caught by the CRC, not returned as data. let mut bad = good.clone(); let mid = bad.len() / 3; for k in 0..64 { bad[mid + k] ^= 0xff; } if let Ok(ar) = Archive::open(&bad) { let mut errors = 0; for e in &ar.entries { if ar.read(e).is_err() { errors += 1; } } assert!(errors > 0, "corrupting the stream produced no read errors"); } // Fuzz: random mutations must never panic. let mut seed = 99u64; for _ in 0..400 { let mut d = good.clone(); for _ in 0..8 { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); let i = (seed >> 33) as usize % d.len(); d[i] = (seed >> 20) as u8; } if let Ok(ar) = Archive::open(&d) { for e in ar.entries.iter().take(20) { let _ = ar.read(e); } } } } #[test] fn refuses_paths_that_escape_the_destination() { use nox_zip::{classify, EntryPath}; for bad in ["../evil.txt", "a/../../evil.txt", "C:/windows/system32/evil.dll", "..\\evil.txt", ".."] { assert_eq!(classify(bad), EntryPath::Escapes, "{bad:?} should be refused"); } // Root and directory markers are skipped, not refused. Roblox packages ship an // entry named exactly `\`, so rejecting these breaks every real install. for skip in ["", "/", "\\", "//", "./", "\\\\"] { assert_eq!(classify(skip), EntryPath::Skip, "{skip:?} should be skipped"); } // A leading slash is stripped rather than refused; it still cannot escape. assert_eq!(classify("/etc/passwd"), EntryPath::Inside(std::path::PathBuf::from("etc/passwd"))); for ok in ["a.txt", "dir/a.txt", "dir/nested/deep.txt", "./a.txt", "a\\b.txt"] { assert!(matches!(classify(ok), EntryPath::Inside(_)), "{ok:?} should be allowed"); } } #[test] fn extracts_to_disk_correctly() { let Some(dir) = fixtures() else { return }; let want = expected(&dir); let bytes = std::fs::read(dir.join("level6.zip")).unwrap(); let ar = Archive::open(&bytes).unwrap(); let dest = std::env::temp_dir().join("nox-zip-extract"); let _ = std::fs::remove_dir_all(&dest); let n = ar.extract_all(&dest).unwrap(); assert_eq!(n, want.len()); for (name, _, _, body) in &want { let p = dest.join(name); let got = std::fs::read(&p).unwrap_or_else(|_| panic!("missing {p:?}")); assert!(got == *body, "{name} differs on disk"); } }