nitai/projects
noxstrap / src / crates / nox-zip / tests / against_python.rs
226 lines · 8.7 KB Raw
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
5use nox_zip::{crc32, inflate, zlib_decompress, Archive};
6use std::path::PathBuf;
7use std::process::Command;
8
9const GEN: &str = r#"
10import os, sys, zipfile, zlib, random
11out = sys.argv[1]
12os.makedirs(out, exist_ok=True)
13random.seed(1234)
14
15texts = {
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
26for 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
35for 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
41with 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)
45print('ok')
46"#;
47
48fn 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
64fn 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]
81fn 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]
107fn 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]
125fn 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]
152fn 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]
194fn 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]
212fn 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}