| 1 | //! Verified against python's zipfile/zlib, which are battle-tested implementations |
| 2 | //! we did not write. Fixtures are generated at test time so nothing binary is |
| 3 | //! checked in. Skips cleanly if python3 is unavailable. |
| 4 | |
| 5 | use nox_zip::{crc32, inflate, zlib_decompress, Archive}; |
| 6 | use std::path::PathBuf; |
| 7 | use std::process::Command; |
| 8 | |
| 9 | const GEN: &str = r#" |
| 10 | import os, sys, zipfile, zlib, random |
| 11 | out = sys.argv[1] |
| 12 | os.makedirs(out, exist_ok=True) |
| 13 | random.seed(1234) |
| 14 | |
| 15 | texts = { |
| 16 | 'readme.txt': b'hello world\n' * 400, |
| 17 | 'dir/nested/deep.txt': b'the quick brown fox jumps over the lazy dog. ' * 900, |
| 18 | 'empty.txt': b'', |
| 19 | 'one.bin': bytes([7]), |
| 20 | 'random.bin': bytes(random.getrandbits(8) for _ in range(70000)), |
| 21 | 'ramp.bin': bytes(i % 251 for i in range(120000)), |
| 22 | 'unicode-\u00e9\u00fc\u5b57.txt': 'accented \u00e9\u00fc and CJK \u5b57\n'.encode() * 50, |
| 23 | } |
| 24 | |
| 25 | # one archive per compression setting, so stored and every deflate level is covered |
| 26 | for level in [0,1,3,6,9]: |
| 27 | mode = zipfile.ZIP_STORED if level == 0 else zipfile.ZIP_DEFLATED |
| 28 | path = os.path.join(out, 'level%d.zip' % level) |
| 29 | with zipfile.ZipFile(path, 'w', mode, compresslevel=(None if level==0 else level)) as z: |
| 30 | for name, body in texts.items(): |
| 31 | z.writestr(name, body) |
| 32 | z.writestr('dir/', b'') |
| 33 | |
| 34 | # raw zlib streams at every level, for the inflater |
| 35 | for level in range(0, 10): |
| 36 | for name, body in [('ramp', texts['ramp.bin']), ('text', texts['readme.txt']), ('rand', texts['random.bin'])]: |
| 37 | open(os.path.join(out, 'z_%s_%d.bin' % (name, level)), 'wb').write(zlib.compress(body, level)) |
| 38 | open(os.path.join(out, 'z_%s_%d.raw' % (name, level)), 'wb').write(body) |
| 39 | |
| 40 | # manifest of expected contents |
| 41 | with open(os.path.join(out, 'expect.txt'), 'w') as f: |
| 42 | for name, body in texts.items(): |
| 43 | f.write('%s\t%d\t%d\n' % (name, len(body), zlib.crc32(body) & 0xffffffff)) |
| 44 | open(os.path.join(out, 'content_%d.bin' % (zlib.crc32(name.encode()) & 0xffffffff)), 'wb').write(body) |
| 45 | print('ok') |
| 46 | "#; |
| 47 | |
| 48 | fn fixtures() -> Option<PathBuf> { |
| 49 | if Command::new("python3").arg("--version").output().is_err() { |
| 50 | return None; |
| 51 | } |
| 52 | let dir = std::env::temp_dir().join("nox-zip-fixtures"); |
| 53 | let script = std::env::temp_dir().join("nox-zip-gen.py"); |
| 54 | std::fs::write(&script, GEN).ok()?; |
| 55 | let out = Command::new("python3").arg(&script).arg(&dir).output().ok()?; |
| 56 | assert!( |
| 57 | out.status.success(), |
| 58 | "fixture generation failed: {}", |
| 59 | String::from_utf8_lossy(&out.stderr) |
| 60 | ); |
| 61 | Some(dir) |
| 62 | } |
| 63 | |
| 64 | fn expected(dir: &PathBuf) -> Vec<(String, u64, u32, Vec<u8>)> { |
| 65 | let txt = std::fs::read_to_string(dir.join("expect.txt")).unwrap(); |
| 66 | txt.lines() |
| 67 | .filter(|l| !l.is_empty()) |
| 68 | .map(|l| { |
| 69 | let mut it = l.split('\t'); |
| 70 | let name = it.next().unwrap().to_string(); |
| 71 | let size: u64 = it.next().unwrap().parse().unwrap(); |
| 72 | let crc: u32 = it.next().unwrap().parse().unwrap(); |
| 73 | let body = std::fs::read(dir.join(format!("content_{}.bin", crc32(name.as_bytes())))) |
| 74 | .unwrap_or_default(); |
| 75 | (name, size, crc, body) |
| 76 | }) |
| 77 | .collect() |
| 78 | } |
| 79 | |
| 80 | #[test] |
| 81 | fn reads_archives_written_by_python_at_every_level() { |
| 82 | let Some(dir) = fixtures() else { return }; |
| 83 | let want = expected(&dir); |
| 84 | assert!(want.len() >= 7); |
| 85 | let mut checked = 0; |
| 86 | for level in [0, 1, 3, 6, 9] { |
| 87 | let bytes = std::fs::read(dir.join(format!("level{level}.zip"))).unwrap(); |
| 88 | let ar = Archive::open(&bytes).unwrap_or_else(|e| panic!("level{level}: {e:?}")); |
| 89 | assert_eq!(ar.len(), want.len() + 1, "level{level}: entry count"); |
| 90 | for (name, size, crc, body) in &want { |
| 91 | let e = ar |
| 92 | .find(name) |
| 93 | .unwrap_or_else(|| panic!("level{level}: missing {name}")); |
| 94 | assert_eq!(e.size, *size, "level{level}: {name} size"); |
| 95 | assert_eq!(e.crc, *crc, "level{level}: {name} crc"); |
| 96 | let got = ar.read(e).unwrap_or_else(|err| panic!("level{level}: {name}: {err:?}")); |
| 97 | assert_eq!(got.len(), body.len(), "level{level}: {name} length"); |
| 98 | assert!(got == *body, "level{level}: {name} content differs"); |
| 99 | checked += 1; |
| 100 | } |
| 101 | assert!(ar.find("dir/").map(|e| e.is_dir).unwrap_or(false)); |
| 102 | } |
| 103 | eprintln!("verified {checked} entries across 5 archives"); |
| 104 | } |
| 105 | |
| 106 | #[test] |
| 107 | fn inflates_every_zlib_level() { |
| 108 | let Some(dir) = fixtures() else { return }; |
| 109 | let mut n = 0; |
| 110 | for kind in ["ramp", "text", "rand"] { |
| 111 | for level in 0..=9 { |
| 112 | let comp = std::fs::read(dir.join(format!("z_{kind}_{level}.bin"))).unwrap(); |
| 113 | let raw = std::fs::read(dir.join(format!("z_{kind}_{level}.raw"))).unwrap(); |
| 114 | let got = zlib_decompress(&comp, 1 << 24) |
| 115 | .unwrap_or_else(|e| panic!("{kind} level {level}: {e:?}")); |
| 116 | assert_eq!(got.len(), raw.len(), "{kind} level {level} length"); |
| 117 | assert!(got == raw, "{kind} level {level} content"); |
| 118 | n += 1; |
| 119 | } |
| 120 | } |
| 121 | eprintln!("inflated {n} zlib streams"); |
| 122 | } |
| 123 | |
| 124 | #[test] |
| 125 | fn round_trips_our_own_deflate() { |
| 126 | // Our compressor and our decompressor must agree with each other as well as |
| 127 | // with python, otherwise one of them is quietly wrong. |
| 128 | let cases: Vec<Vec<u8>> = vec![ |
| 129 | Vec::new(), |
| 130 | b"a".to_vec(), |
| 131 | b"abcabcabcabc".to_vec(), |
| 132 | b"the quick brown fox ".repeat(500), |
| 133 | (0..70000u32).map(|i| (i % 251) as u8).collect(), |
| 134 | { |
| 135 | let mut s = 12345u64; |
| 136 | (0..50000) |
| 137 | .map(|_| { |
| 138 | s = s.wrapping_mul(6364136223846793005).wrapping_add(1); |
| 139 | (s >> 33) as u8 |
| 140 | }) |
| 141 | .collect() |
| 142 | }, |
| 143 | ]; |
| 144 | for (i, c) in cases.iter().enumerate() { |
| 145 | let z = nox_gfx::deflate::zlib(c, 24); |
| 146 | let back = zlib_decompress(&z, 1 << 24).unwrap_or_else(|e| panic!("case {i}: {e:?}")); |
| 147 | assert_eq!(&back, c, "case {i} round trip"); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | #[test] |
| 152 | fn rejects_hostile_and_broken_archives() { |
| 153 | let Some(dir) = fixtures() else { return }; |
| 154 | let good = std::fs::read(dir.join("level6.zip")).unwrap(); |
| 155 | |
| 156 | assert!(Archive::open(&[]).is_err()); |
| 157 | assert!(Archive::open(&[0u8; 100]).is_err()); |
| 158 | assert!(Archive::open(&good[..good.len() / 2]).is_err()); |
| 159 | |
| 160 | // Corrupt compressed bytes must be caught by the CRC, not returned as data. |
| 161 | let mut bad = good.clone(); |
| 162 | let mid = bad.len() / 3; |
| 163 | for k in 0..64 { |
| 164 | bad[mid + k] ^= 0xff; |
| 165 | } |
| 166 | if let Ok(ar) = Archive::open(&bad) { |
| 167 | let mut errors = 0; |
| 168 | for e in &ar.entries { |
| 169 | if ar.read(e).is_err() { |
| 170 | errors += 1; |
| 171 | } |
| 172 | } |
| 173 | assert!(errors > 0, "corrupting the stream produced no read errors"); |
| 174 | } |
| 175 | |
| 176 | // Fuzz: random mutations must never panic. |
| 177 | let mut seed = 99u64; |
| 178 | for _ in 0..400 { |
| 179 | let mut d = good.clone(); |
| 180 | for _ in 0..8 { |
| 181 | seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); |
| 182 | let i = (seed >> 33) as usize % d.len(); |
| 183 | d[i] = (seed >> 20) as u8; |
| 184 | } |
| 185 | if let Ok(ar) = Archive::open(&d) { |
| 186 | for e in ar.entries.iter().take(20) { |
| 187 | let _ = ar.read(e); |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | fn refuses_paths_that_escape_the_destination() { |
| 195 | use nox_zip::{classify, EntryPath}; |
| 196 | for bad in ["../evil.txt", "a/../../evil.txt", "C:/windows/system32/evil.dll", "..\\evil.txt", ".."] { |
| 197 | assert_eq!(classify(bad), EntryPath::Escapes, "{bad:?} should be refused"); |
| 198 | } |
| 199 | // Root and directory markers are skipped, not refused. Roblox packages ship an |
| 200 | // entry named exactly `\`, so rejecting these breaks every real install. |
| 201 | for skip in ["", "/", "\\", "//", "./", "\\\\"] { |
| 202 | assert_eq!(classify(skip), EntryPath::Skip, "{skip:?} should be skipped"); |
| 203 | } |
| 204 | // A leading slash is stripped rather than refused; it still cannot escape. |
| 205 | assert_eq!(classify("/etc/passwd"), EntryPath::Inside(std::path::PathBuf::from("etc/passwd"))); |
| 206 | for ok in ["a.txt", "dir/a.txt", "dir/nested/deep.txt", "./a.txt", "a\\b.txt"] { |
| 207 | assert!(matches!(classify(ok), EntryPath::Inside(_)), "{ok:?} should be allowed"); |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | #[test] |
| 212 | fn extracts_to_disk_correctly() { |
| 213 | let Some(dir) = fixtures() else { return }; |
| 214 | let want = expected(&dir); |
| 215 | let bytes = std::fs::read(dir.join("level6.zip")).unwrap(); |
| 216 | let ar = Archive::open(&bytes).unwrap(); |
| 217 | let dest = std::env::temp_dir().join("nox-zip-extract"); |
| 218 | let _ = std::fs::remove_dir_all(&dest); |
| 219 | let n = ar.extract_all(&dest).unwrap(); |
| 220 | assert_eq!(n, want.len()); |
| 221 | for (name, _, _, body) in &want { |
| 222 | let p = dest.join(name); |
| 223 | let got = std::fs::read(&p).unwrap_or_else(|_| panic!("missing {p:?}")); |
| 224 | assert!(got == *body, "{name} differs on disk"); |
| 225 | } |
| 226 | } |