Skip to main content

dotloom_io/
zip.rs

1//! Minimal, bounded ZIP reader/writer for `.dotl` containers.
2//!
3//! Supported: stored (0) and deflate (8) entries, UTF-8 names, data descriptors.
4//! Rejected with typed errors: encryption, ZIP64, multi-disk archives, other
5//! compression methods, absolute or parent-relative paths, backslashes, duplicate
6//! names, more entries/bytes than the limits allow, suspicious compression ratios,
7//! CRC mismatches and size mismatches. Every offset and length is bounds-checked;
8//! malformed input never panics.
9
10use std::collections::BTreeSet;
11
12use thiserror::Error;
13
14/// Container limits.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ZipLimits {
17    /// Maximum total uncompressed size.
18    pub max_total: u64,
19    /// Maximum uncompressed size of one entry.
20    pub max_entry: u64,
21    /// Maximum number of entries.
22    pub max_entries: usize,
23    /// Maximum uncompressed/compressed ratio for deflated entries.
24    pub max_ratio: u64,
25}
26
27impl Default for ZipLimits {
28    fn default() -> Self {
29        // Deflate cannot exceed ~1032:1; the size limits plus inflation bounded by the
30        // declared size are the real protection, the ratio check rejects absurd headers.
31        Self { max_total: 256 << 20, max_entry: 64 << 20, max_entries: 10_000, max_ratio: 1024 }
32    }
33}
34
35/// ZIP errors.
36#[derive(Debug, Clone, PartialEq, Eq, Error)]
37#[non_exhaustive]
38pub enum ZipError {
39    /// Not a ZIP archive or truncated.
40    #[error("not a valid zip archive: {0}")]
41    Malformed(&'static str),
42    /// Unsupported feature.
43    #[error("unsupported zip feature: {0}")]
44    Unsupported(&'static str),
45    /// Unsafe entry name.
46    #[error("unsafe entry name `{0}`")]
47    UnsafeName(String),
48    /// Duplicate entry.
49    #[error("duplicate entry `{0}`")]
50    Duplicate(String),
51    /// A limit was exceeded.
52    #[error("limit exceeded: {0}")]
53    Limit(String),
54    /// Data corrupt.
55    #[error("corrupt entry `{0}`: {1}")]
56    Corrupt(String, &'static str),
57}
58
59/// One archive entry (metadata).
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Entry {
62    /// Name.
63    pub name: String,
64    method: u16,
65    crc: u32,
66    compressed: u64,
67    /// Uncompressed size.
68    pub size: u64,
69    header_offset: u64,
70}
71
72/// A parsed archive borrowing the input bytes.
73#[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/// CRC-32 (IEEE) of `data`.
105#[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
114/// Validate an entry name: relative, `/`-separated, no `..`, no drive letters.
115pub 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    /// Parse the central directory.
136    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        // End of central directory: search the last 64 KiB + 22 bytes.
141        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    /// Entries.
246    #[must_use]
247    pub fn entries(&self) -> &[Entry] {
248        &self.entries
249    }
250
251    /// Entry by name.
252    #[must_use]
253    pub fn entry(&self, name: &str) -> Option<&Entry> {
254        self.entries.iter().find(|e| e.name == name)
255    }
256
257    /// Read and verify one entry.
258    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
290/// Write an archive. Entries are deflated when that makes them smaller.
291pub 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        // Local file header (UTF-8 flag set, fixed timestamp 1980-01-01 for reproducibility).
305        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 directory record.
319        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(&central);
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        // 1 MiB of zeros deflates far beyond a 100:1 ratio.
380        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        // Flip a payload byte (after the 30-byte header + 5-byte name).
394        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}