1use std::collections::BTreeSet;
11
12use thiserror::Error;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ZipLimits {
17 pub max_total: u64,
19 pub max_entry: u64,
21 pub max_entries: usize,
23 pub max_ratio: u64,
25}
26
27impl Default for ZipLimits {
28 fn default() -> Self {
29 Self { max_total: 256 << 20, max_entry: 64 << 20, max_entries: 10_000, max_ratio: 1024 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Error)]
37#[non_exhaustive]
38pub enum ZipError {
39 #[error("not a valid zip archive: {0}")]
41 Malformed(&'static str),
42 #[error("unsupported zip feature: {0}")]
44 Unsupported(&'static str),
45 #[error("unsafe entry name `{0}`")]
47 UnsafeName(String),
48 #[error("duplicate entry `{0}`")]
50 Duplicate(String),
51 #[error("limit exceeded: {0}")]
53 Limit(String),
54 #[error("corrupt entry `{0}`: {1}")]
56 Corrupt(String, &'static str),
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Entry {
62 pub name: String,
64 method: u16,
65 crc: u32,
66 compressed: u64,
67 pub size: u64,
69 header_offset: u64,
70}
71
72#[derive(Debug, Clone)]
74pub struct Archive<'a> {
75 data: &'a [u8],
76 entries: Vec<Entry>,
77 limits: ZipLimits,
78}
79
80fn u16_at(b: &[u8], i: usize) -> Option<u16> {
81 Some(u16::from_le_bytes([*b.get(i)?, *b.get(i + 1)?]))
82}
83
84fn u32_at(b: &[u8], i: usize) -> Option<u32> {
85 Some(u32::from_le_bytes([*b.get(i)?, *b.get(i + 1)?, *b.get(i + 2)?, *b.get(i + 3)?]))
86}
87
88const CRC_TABLE: [u32; 256] = {
89 let mut t = [0u32; 256];
90 let mut i = 0;
91 while i < 256 {
92 let mut c = i as u32;
93 let mut k = 0;
94 while k < 8 {
95 c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
96 k += 1;
97 }
98 t[i] = c;
99 i += 1;
100 }
101 t
102};
103
104#[must_use]
106pub fn crc32(data: &[u8]) -> u32 {
107 let mut c = 0xFFFF_FFFFu32;
108 for b in data {
109 c = CRC_TABLE[((c ^ u32::from(*b)) & 0xff) as usize] ^ (c >> 8);
110 }
111 c ^ 0xFFFF_FFFF
112}
113
114pub fn check_name(name: &str) -> Result<(), ZipError> {
116 let bad = || ZipError::UnsafeName(name.to_owned());
117 if name.is_empty() || name.len() > 512 || name.starts_with('/') || name.contains('\\') || name.contains('\0') {
118 return Err(bad());
119 }
120 if name.len() >= 2 && name.as_bytes().get(1) == Some(&b':') {
121 return Err(bad());
122 }
123 for seg in name.split('/') {
124 if seg == ".." || seg == "." || (seg.is_empty() && !name.ends_with('/')) {
125 return Err(bad());
126 }
127 }
128 if name.chars().any(char::is_control) {
129 return Err(bad());
130 }
131 Ok(())
132}
133
134impl<'a> Archive<'a> {
135 pub fn parse(data: &'a [u8], limits: ZipLimits) -> Result<Self, ZipError> {
137 if data.len() < 22 {
138 return Err(ZipError::Malformed("too short"));
139 }
140 let start = data.len().saturating_sub(0xFFFF + 22);
142 let mut eocd = None;
143 let mut i = data.len() - 22;
144 loop {
145 if u32_at(data, i) == Some(0x0605_4b50) {
146 let comment_len = u16_at(data, i + 20).ok_or(ZipError::Malformed("eocd"))? as usize;
147 if i + 22 + comment_len == data.len() {
148 eocd = Some(i);
149 break;
150 }
151 }
152 if i == start {
153 break;
154 }
155 i -= 1;
156 }
157 let e = eocd.ok_or(ZipError::Malformed("end of central directory not found"))?;
158 let disk = u16_at(data, e + 4).ok_or(ZipError::Malformed("eocd"))?;
159 let cd_disk = u16_at(data, e + 6).ok_or(ZipError::Malformed("eocd"))?;
160 let n_disk = u16_at(data, e + 8).ok_or(ZipError::Malformed("eocd"))?;
161 let n_total = u16_at(data, e + 10).ok_or(ZipError::Malformed("eocd"))?;
162 let cd_size = u32_at(data, e + 12).ok_or(ZipError::Malformed("eocd"))?;
163 let cd_off = u32_at(data, e + 16).ok_or(ZipError::Malformed("eocd"))?;
164 if disk != 0 || cd_disk != 0 || n_disk != n_total {
165 return Err(ZipError::Unsupported("multi-disk archive"));
166 }
167 if n_total == 0xFFFF || cd_size == 0xFFFF_FFFF || cd_off == 0xFFFF_FFFF {
168 return Err(ZipError::Unsupported("zip64"));
169 }
170 if usize::from(n_total) > limits.max_entries {
171 return Err(ZipError::Limit(format!("{n_total} entries > {}", limits.max_entries)));
172 }
173 let cd_start = cd_off as usize;
174 let cd_end = cd_start.checked_add(cd_size as usize).ok_or(ZipError::Malformed("central directory"))?;
175 if cd_end > e {
176 return Err(ZipError::Malformed("central directory out of bounds"));
177 }
178 let mut p = cd_start;
179 let mut entries = Vec::with_capacity(usize::from(n_total));
180 let mut names = BTreeSet::new();
181 let mut total: u64 = 0;
182 for _ in 0..n_total {
183 if u32_at(data, p) != Some(0x0201_4b50) {
184 return Err(ZipError::Malformed("bad central directory header"));
185 }
186 let flags = u16_at(data, p + 8).ok_or(ZipError::Malformed("cd"))?;
187 let method = u16_at(data, p + 10).ok_or(ZipError::Malformed("cd"))?;
188 let crc = u32_at(data, p + 16).ok_or(ZipError::Malformed("cd"))?;
189 let compressed = u32_at(data, p + 20).ok_or(ZipError::Malformed("cd"))?;
190 let size = u32_at(data, p + 24).ok_or(ZipError::Malformed("cd"))?;
191 let name_len = u16_at(data, p + 28).ok_or(ZipError::Malformed("cd"))? as usize;
192 let extra_len = u16_at(data, p + 30).ok_or(ZipError::Malformed("cd"))? as usize;
193 let comment_len = u16_at(data, p + 32).ok_or(ZipError::Malformed("cd"))? as usize;
194 let disk_start = u16_at(data, p + 34).ok_or(ZipError::Malformed("cd"))?;
195 let header_offset = u32_at(data, p + 42).ok_or(ZipError::Malformed("cd"))?;
196 if flags & 0x0001 != 0 || flags & 0x0040 != 0 {
197 return Err(ZipError::Unsupported("encrypted entry"));
198 }
199 if compressed == 0xFFFF_FFFF || size == 0xFFFF_FFFF || header_offset == 0xFFFF_FFFF {
200 return Err(ZipError::Unsupported("zip64 entry"));
201 }
202 if disk_start != 0 {
203 return Err(ZipError::Unsupported("multi-disk archive"));
204 }
205 if method != 0 && method != 8 {
206 return Err(ZipError::Unsupported("compression method other than stored/deflate"));
207 }
208 let name_bytes = data.get(p + 46..p + 46 + name_len).ok_or(ZipError::Malformed("entry name"))?;
209 let name = core::str::from_utf8(name_bytes)
210 .map_err(|_| ZipError::UnsafeName(String::from_utf8_lossy(name_bytes).into_owned()))?;
211 check_name(name)?;
212 if !names.insert(name.to_owned()) {
213 return Err(ZipError::Duplicate(name.to_owned()));
214 }
215 let (size, compressed) = (u64::from(size), u64::from(compressed));
216 if size > limits.max_entry {
217 return Err(ZipError::Limit(format!("entry `{name}` is {size} bytes")));
218 }
219 if method == 8 && compressed > 0 && size / compressed.max(1) > limits.max_ratio {
220 return Err(ZipError::Limit(format!("entry `{name}` compression ratio too high")));
221 }
222 if method == 0 && compressed != size {
223 return Err(ZipError::Corrupt(name.to_owned(), "stored entry size mismatch"));
224 }
225 total = total.saturating_add(size);
226 if total > limits.max_total {
227 return Err(ZipError::Limit(format!("total uncompressed size exceeds {} bytes", limits.max_total)));
228 }
229 entries.push(Entry {
230 name: name.to_owned(),
231 method,
232 crc,
233 compressed,
234 size,
235 header_offset: u64::from(header_offset),
236 });
237 p = p + 46 + name_len + extra_len + comment_len;
238 if p > cd_end {
239 return Err(ZipError::Malformed("central directory overflow"));
240 }
241 }
242 Ok(Self { data, entries, limits })
243 }
244
245 #[must_use]
247 pub fn entries(&self) -> &[Entry] {
248 &self.entries
249 }
250
251 #[must_use]
253 pub fn entry(&self, name: &str) -> Option<&Entry> {
254 self.entries.iter().find(|e| e.name == name)
255 }
256
257 pub fn read(&self, e: &Entry) -> Result<Vec<u8>, ZipError> {
259 let corrupt = |why| ZipError::Corrupt(e.name.clone(), why);
260 let h = usize::try_from(e.header_offset).map_err(|_| corrupt("offset"))?;
261 if u32_at(self.data, h) != Some(0x0403_4b50) {
262 return Err(corrupt("bad local header"));
263 }
264 let name_len = u16_at(self.data, h + 26).ok_or_else(|| corrupt("local header"))? as usize;
265 let extra_len = u16_at(self.data, h + 28).ok_or_else(|| corrupt("local header"))? as usize;
266 let start = h + 30 + name_len + extra_len;
267 let end = start
268 .checked_add(usize::try_from(e.compressed).map_err(|_| corrupt("size"))?)
269 .ok_or_else(|| corrupt("size"))?;
270 let raw = self.data.get(start..end).ok_or_else(|| corrupt("data out of bounds"))?;
271 let out = match e.method {
272 0 => raw.to_vec(),
273 8 => {
274 let limit = usize::try_from(e.size.min(self.limits.max_entry)).map_err(|_| corrupt("size"))?;
275 miniz_oxide::inflate::decompress_to_vec_with_limit(raw, limit)
276 .map_err(|_| corrupt("inflate failed or larger than declared"))?
277 }
278 _ => return Err(ZipError::Unsupported("compression method")),
279 };
280 if out.len() as u64 != e.size {
281 return Err(corrupt("size mismatch"));
282 }
283 if crc32(&out) != e.crc {
284 return Err(corrupt("crc mismatch"));
285 }
286 Ok(out)
287 }
288}
289
290pub fn write(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>, ZipError> {
292 let mut out = Vec::new();
293 let mut central = Vec::new();
294 let n = u16::try_from(entries.len()).map_err(|_| ZipError::Limit("too many entries".into()))?;
295 for (name, data) in entries {
296 check_name(name)?;
297 let crc = crc32(data);
298 let deflated = miniz_oxide::deflate::compress_to_vec(data, 6);
299 let (method, payload): (u16, &[u8]) = if deflated.len() < data.len() { (8, &deflated) } else { (0, data) };
300 let offset = u32::try_from(out.len()).map_err(|_| ZipError::Unsupported("archive larger than 4 GiB"))?;
301 let csize = u32::try_from(payload.len()).map_err(|_| ZipError::Unsupported("entry larger than 4 GiB"))?;
302 let usize_ = u32::try_from(data.len()).map_err(|_| ZipError::Unsupported("entry larger than 4 GiB"))?;
303 let name_len = u16::try_from(name.len()).map_err(|_| ZipError::UnsafeName(name.clone()))?;
304 out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
306 out.extend_from_slice(&20u16.to_le_bytes());
307 out.extend_from_slice(&0x0800u16.to_le_bytes());
308 out.extend_from_slice(&method.to_le_bytes());
309 out.extend_from_slice(&0u16.to_le_bytes());
310 out.extend_from_slice(&0x0021u16.to_le_bytes());
311 out.extend_from_slice(&crc.to_le_bytes());
312 out.extend_from_slice(&csize.to_le_bytes());
313 out.extend_from_slice(&usize_.to_le_bytes());
314 out.extend_from_slice(&name_len.to_le_bytes());
315 out.extend_from_slice(&0u16.to_le_bytes());
316 out.extend_from_slice(name.as_bytes());
317 out.extend_from_slice(payload);
318 central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
320 central.extend_from_slice(&20u16.to_le_bytes());
321 central.extend_from_slice(&20u16.to_le_bytes());
322 central.extend_from_slice(&0x0800u16.to_le_bytes());
323 central.extend_from_slice(&method.to_le_bytes());
324 central.extend_from_slice(&0u16.to_le_bytes());
325 central.extend_from_slice(&0x0021u16.to_le_bytes());
326 central.extend_from_slice(&crc.to_le_bytes());
327 central.extend_from_slice(&csize.to_le_bytes());
328 central.extend_from_slice(&usize_.to_le_bytes());
329 central.extend_from_slice(&name_len.to_le_bytes());
330 central.extend_from_slice(&[0u8; 12]);
331 central.extend_from_slice(&offset.to_le_bytes());
332 central.extend_from_slice(name.as_bytes());
333 }
334 let cd_off = u32::try_from(out.len()).map_err(|_| ZipError::Unsupported("archive larger than 4 GiB"))?;
335 let cd_size = u32::try_from(central.len()).map_err(|_| ZipError::Unsupported("central directory too large"))?;
336 out.extend_from_slice(¢ral);
337 out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
338 out.extend_from_slice(&[0u8; 4]);
339 out.extend_from_slice(&n.to_le_bytes());
340 out.extend_from_slice(&n.to_le_bytes());
341 out.extend_from_slice(&cd_size.to_le_bytes());
342 out.extend_from_slice(&cd_off.to_le_bytes());
343 out.extend_from_slice(&0u16.to_le_bytes());
344 Ok(out)
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn crc_known_value() {
353 assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
354 }
355
356 #[test]
357 fn roundtrip() {
358 let big = "dotloom ".repeat(1000).into_bytes();
359 let z = write(&[("a.json".into(), b"{}".to_vec()), ("assets/b.bin".into(), big.clone())]).unwrap();
360 let a = Archive::parse(&z, ZipLimits::default()).unwrap();
361 assert_eq!(a.entries().len(), 2);
362 assert_eq!(a.read(a.entry("assets/b.bin").unwrap()).unwrap(), big);
363 assert_eq!(a.read(a.entry("a.json").unwrap()).unwrap(), b"{}");
364 }
365
366 #[test]
367 fn names_are_checked() {
368 for bad in ["../x", "/abs", "a/../b", "C:/x", "a\\b", "", "a//b", "./a"] {
369 assert!(check_name(bad).is_err(), "{bad}");
370 }
371 assert!(check_name("assets/ab12.png").is_ok());
372 assert!(write(&[("../evil".into(), vec![1])]).is_err());
373 }
374
375 #[test]
376 fn bombs_and_limits() {
377 let zeros = vec![0u8; 1 << 20];
378 let z = write(&[("z".into(), zeros)]).unwrap();
379 let strict = ZipLimits { max_ratio: 100, ..ZipLimits::default() };
381 assert!(matches!(Archive::parse(&z, strict), Err(ZipError::Limit(_))));
382 let ok = ZipLimits { max_ratio: u64::MAX, ..ZipLimits::default() };
383 assert!(Archive::parse(&z, ok).is_ok());
384 let small = ZipLimits { max_entry: 1000, max_ratio: u64::MAX, ..ZipLimits::default() };
385 assert!(matches!(Archive::parse(&z, small), Err(ZipError::Limit(_))));
386 let few = ZipLimits { max_entries: 0, ..ZipLimits::default() };
387 assert!(matches!(Archive::parse(&z, few), Err(ZipError::Limit(_))));
388 }
389
390 #[test]
391 fn corruption_is_detected() {
392 let mut z = write(&[("a.txt".into(), b"hello world, hello world".to_vec())]).unwrap();
393 z[36] ^= 0xff;
395 let a = Archive::parse(&z, ZipLimits::default()).unwrap();
396 assert!(a.read(&a.entries()[0]).is_err());
397 assert!(Archive::parse(b"PK\x05\x06", ZipLimits::default()).is_err());
398 assert!(Archive::parse(&[0u8; 100], ZipLimits::default()).is_err());
399 }
400}