Skip to main content

pith_zip/
reader.rs

1//! The ZIP half of the crate: end-of-central-directory discovery, central
2//! directory parsing, and payload extraction for stored and deflated
3//! entries.
4//!
5//! Layout being parsed (PKZIP APPNOTE.TXT §4.3):
6//!
7//! ```text
8//! [local file header + name + extra + payload (+ data descriptor)] ...
9//! [central directory entry] ...
10//! [end of central directory (+ comment)]
11//! ```
12//!
13//! All multi-byte integers are little-endian. The reader is deliberately
14//! trust-the-directory: the central directory carries the authoritative
15//! CRC, sizes and local-header offset for every entry, and the local
16//! header only has to agree with it (method and flags) and say how far the
17//! payload starts.
18
19use pith_digest::{Error, Result, crc32};
20use pith_inflate::{Limits, inflate_raw};
21
22/// `0x04034b50` — signature of a local file header (APPNOTE §4.3.7).
23const SIG_LOCAL: u32 = 0x0403_4b50;
24/// `0x02014b50` — signature of a central directory entry (APPNOTE §4.3.12).
25const SIG_CENTRAL: u32 = 0x0201_4b50;
26/// `0x06054b50` — signature of the end-of-central-directory record
27/// (APPNOTE §4.3.16).
28const SIG_EOCD: u32 = 0x0605_4b50;
29/// `0x08074b50` — optional signature prefix of a data descriptor
30/// (APPNOTE §4.3.9.3).
31const SIG_DESCRIPTOR: u32 = 0x0807_4b50;
32
33/// Compression method 0: bytes stored verbatim.
34const METHOD_STORED: u16 = 0;
35/// Compression method 8: raw RFC 1951 DEFLATE.
36const METHOD_DEFLATE: u16 = 8;
37
38/// Fixed length of the end-of-central-directory record, comment excluded.
39const EOCD_LEN: usize = 22;
40/// Fixed length of a central directory entry, variable fields excluded.
41const CENTRAL_LEN: usize = 46;
42/// Fixed length of a local file header, name and extra excluded.
43const LOCAL_LEN: usize = 30;
44/// Longest legal EOCD comment: the `u16` comment-length field tops out at
45/// 65 535, so the record can sit up to this many bytes before EOF.
46const EOCD_MAX_COMMENT: usize = 65_535;
47
48/// Flags bit 0: traditional PKWARE encryption. Bit 6 (strong encryption)
49/// and bit 13 (encrypted central directory, local header fields masked)
50/// are checked alongside it in [`ZipArchive::extract`].
51const FLAG_ENCRYPTED: u16 = 1 << 0;
52/// Flags bit 3: CRC-32 and both sizes are zero in the local header and
53/// live in a data descriptor after the payload (APPNOTE §4.3.9.1).
54const FLAG_DESCRIPTOR: u16 = 1 << 3;
55/// Strong encryption (APPNOTE §4.4.4): refused, we cannot and will not
56/// decrypt.
57const FLAG_STRONG_ENCRYPTED: u16 = 1 << 6;
58/// Central-directory-encrypted marker: the local header fields are masked
59/// and meaningless (APPNOTE §4.4.6.2).
60const FLAG_MASKED_LOCAL: u16 = 1 << 13;
61
62/// Little-endian `u16` at `off`, or `None` when it runs past the data.
63fn le16(data: &[u8], off: usize) -> Option<u16> {
64    let b = data.get(off..off + 2)?;
65    Some(u16::from_le_bytes([b[0], b[1]]))
66}
67
68/// Little-endian `u32` at `off`, or `None` when it runs past the data.
69fn le32(data: &[u8], off: usize) -> Option<u32> {
70    let b = data.get(off..off + 4)?;
71    Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
72}
73
74/// The `len` bytes starting at `off`, or a named [`Error::Truncated`].
75fn take<'a>(data: &'a [u8], off: usize, len: usize, what: &'static str) -> Result<&'a [u8]> {
76    let end = off
77        .checked_add(len)
78        .ok_or_else(|| Error::truncated(what, usize::MAX, data.len()))?;
79    data.get(off..end)
80        .ok_or_else(|| Error::truncated(what, end, data.len()))
81}
82
83/// The parsed end-of-central-directory record and where it sits.
84struct Eocd {
85    /// Entries in the central directory on this disk.
86    entries: u16,
87    /// Size of the central directory in bytes.
88    cd_size: u32,
89    /// Absolute offset where the central directory starts.
90    cd_offset: u32,
91    /// Absolute offset of the record itself (the directory must end here).
92    offset: usize,
93}
94
95/// Scans backwards from EOF for the EOCD signature and parses the record.
96///
97/// APPNOTE §4.3.16: the record's last field is a comment of up to 65 535
98/// bytes whose length is stored in the record, so the signature can appear
99/// anywhere in the last `EOCD_LEN + EOCD_MAX_COMMENT` bytes. A signature
100/// is only accepted when its comment length matches the bytes actually
101/// left — that check is what lets us keep scanning past a `PK\x05\x06`
102/// that appears inside a comment or a payload.
103fn find_eocd(data: &[u8]) -> Result<Eocd> {
104    let start = data.len().saturating_sub(EOCD_LEN + EOCD_MAX_COMMENT);
105    // A candidate is a *candidate* EOCD: wrong comment length, or a
106    // comment length that does not reach EOF, and we move to the next
107    // signature backwards.
108    let mut off = data.len().saturating_sub(EOCD_LEN);
109    loop {
110        if le32(data, off) == Some(SIG_EOCD) {
111            let comment_len = le16(data, off + 20).ok_or_else(|| {
112                Error::truncated("end of central directory", off + EOCD_LEN, data.len())
113            })? as usize;
114            if off + EOCD_LEN + comment_len == data.len() {
115                return parse_eocd(data, off);
116            }
117        }
118        if off == start {
119            return Err(Error::InvalidMagic {
120                what: "zip end of central directory",
121            });
122        }
123        off -= 1;
124    }
125}
126
127/// Validates the disk fields and ZIP64 sentinels of an EOCD whose comment
128/// length already checks out.
129fn parse_eocd(data: &[u8], off: usize) -> Result<Eocd> {
130    let fields = take(data, off + 4, EOCD_LEN - 4, "end of central directory")?;
131    // fields[0..2]  disk number of this disk
132    // fields[2..4]  disk where the central directory starts
133    // fields[4..6]  entries on this disk
134    // fields[6..8]  total entries
135    // fields[8..12] central directory size
136    // fields[12..16] central directory offset
137    // fields[16..18] comment length (already cross-checked)
138    let disk = u16::from_le_bytes([fields[0], fields[1]]);
139    let cd_disk = u16::from_le_bytes([fields[2], fields[3]]);
140    let entries_disk = u16::from_le_bytes([fields[4], fields[5]]);
141    let entries = u16::from_le_bytes([fields[6], fields[7]]);
142    let cd_size = u32::from_le_bytes([fields[8], fields[9], fields[10], fields[11]]);
143    let cd_offset = u32::from_le_bytes([fields[12], fields[13], fields[14], fields[15]]);
144
145    if disk != 0 || cd_disk != 0 || entries_disk != entries {
146        return Err(Error::Unsupported("multi-disk zip archive"));
147    }
148    if entries == 0xFFFF || cd_size == 0xFFFF_FFFF || cd_offset == 0xFFFF_FFFF {
149        return Err(Error::Unsupported("zip64 archive"));
150    }
151    Ok(Eocd {
152        entries,
153        cd_size,
154        cd_offset,
155        offset: off,
156    })
157}
158
159/// One central directory entry: the authoritative record of a member of
160/// the archive.
161#[derive(Copy, Clone, Debug)]
162pub struct ZipEntry<'a> {
163    name: &'a str,
164    flags: u16,
165    method: u16,
166    /// CRC-32 of the uncompressed payload (APPNOTE §4.4.7).
167    crc32: u32,
168    compressed_size: u32,
169    uncompressed_size: u32,
170    /// Absolute offset of this entry's local file header.
171    local_offset: u32,
172}
173
174impl<'a> ZipEntry<'a> {
175    /// The member name as stored in the central directory. Archives whose
176    /// names are not valid UTF-8 are rejected at
177    /// [`ZipArchive::new`]; the suite only feeds it names it typed.
178    pub fn name(&self) -> &'a str {
179        self.name
180    }
181
182    /// The compression method: `0` (stored) or `8` (DEFLATE). Other
183    /// methods parse fine and refuse at extraction with
184    /// [`Error::Unsupported`].
185    pub fn method(&self) -> u16 {
186        self.method
187    }
188
189    /// Payload length on disk, in bytes.
190    pub fn compressed_size(&self) -> u64 {
191        u64::from(self.compressed_size)
192    }
193
194    /// Expected decompressed length, in bytes. Verification compares the
195    /// produced byte count against this before the CRC is checked.
196    pub fn uncompressed_size(&self) -> u64 {
197        u64::from(self.uncompressed_size)
198    }
199
200    /// CRC-32 of the uncompressed payload, as recorded by the archiver.
201    pub fn crc32(&self) -> u32 {
202        self.crc32
203    }
204
205    /// Whether this entry carries a data descriptor (flags bit 3): the
206    /// local header's CRC and size fields are zero and the real values
207    /// trail the payload.
208    pub fn uses_data_descriptor(&self) -> bool {
209        self.flags & FLAG_DESCRIPTOR != 0
210    }
211}
212
213/// A parsed ZIP archive. Construction finds the end-of-central-directory
214/// record and parses the whole central directory; payload bytes are only
215/// touched by [`ZipArchive::extract`].
216#[derive(Clone, Debug)]
217pub struct ZipArchive<'a> {
218    data: &'a [u8],
219    entries: Vec<ZipEntry<'a>>,
220}
221
222impl<'a> ZipArchive<'a> {
223    /// Parses `data` as a ZIP archive: finds the EOCD (searching backwards
224    /// over a comment of up to 64 KiB), checks the single-disk and
225    /// non-ZIP64 invariants, and parses every central directory entry.
226    ///
227    /// The directory must sit exactly where the EOCD says and end exactly
228    /// where the EOCD starts; archives that smuggle extra bytes between
229    /// directory entries are malformed by definition (APPNOTE §4.3.12).
230    pub fn new(data: &'a [u8]) -> Result<Self> {
231        let eocd = find_eocd(data)?;
232
233        let cd_offset = usize::try_from(eocd.cd_offset).unwrap_or(usize::MAX);
234        let cd_size = usize::try_from(eocd.cd_size).unwrap_or(usize::MAX);
235        let cd_end = cd_offset
236            .checked_add(cd_size)
237            .ok_or_else(|| Error::truncated("zip central directory", usize::MAX, data.len()))?;
238        // The directory must end at the EOCD itself: the record's offset
239        // field is absolute, so a directory that would overlap its own
240        // terminator is corrupt, not extended.
241        if cd_end > eocd.offset {
242            return Err(Error::truncated(
243                "zip central directory",
244                cd_end,
245                eocd.offset,
246            ));
247        }
248        let dir = take(data, cd_offset, cd_size, "zip central directory")?;
249
250        let mut entries = Vec::with_capacity(usize::from(eocd.entries));
251        let mut pos = 0usize;
252        for _ in 0..eocd.entries {
253            let fixed = take(dir, pos, CENTRAL_LEN, "zip central directory entry")?;
254            let magic = u32::from_le_bytes([fixed[0], fixed[1], fixed[2], fixed[3]]);
255            if magic != SIG_CENTRAL {
256                return Err(Error::InvalidMagic {
257                    what: "zip central directory entry",
258                });
259            }
260            let flags = u16::from_le_bytes([fixed[8], fixed[9]]);
261            let method = u16::from_le_bytes([fixed[10], fixed[11]]);
262            let crc32 = u32::from_le_bytes([fixed[16], fixed[17], fixed[18], fixed[19]]);
263            let compressed_size = u32::from_le_bytes([fixed[20], fixed[21], fixed[22], fixed[23]]);
264            let uncompressed_size =
265                u32::from_le_bytes([fixed[24], fixed[25], fixed[26], fixed[27]]);
266            let name_len = usize::from(u16::from_le_bytes([fixed[28], fixed[29]]));
267            let extra_len = usize::from(u16::from_le_bytes([fixed[30], fixed[31]]));
268            let comment_len = usize::from(u16::from_le_bytes([fixed[32], fixed[33]]));
269            let local_offset = u32::from_le_bytes([fixed[42], fixed[43], fixed[44], fixed[45]]);
270
271            if compressed_size == 0xFFFF_FFFF
272                || uncompressed_size == 0xFFFF_FFFF
273                || local_offset == 0xFFFF_FFFF
274            {
275                return Err(Error::Unsupported("zip64 central directory entry"));
276            }
277
278            let name_bytes = take(
279                dir,
280                pos + CENTRAL_LEN,
281                name_len,
282                "zip entry name in central directory",
283            )?;
284            let name = core::str::from_utf8(name_bytes)
285                .map_err(|_| Error::BadValue("zip entry name is not UTF-8"))?;
286
287            let entry_len = CENTRAL_LEN
288                .checked_add(name_len)
289                .and_then(|n| n.checked_add(extra_len))
290                .and_then(|n| n.checked_add(comment_len))
291                .ok_or(Error::BadValue("zip central directory entry length"))?;
292            if entry_len > dir.len() - pos {
293                return Err(Error::truncated(
294                    "zip central directory entry",
295                    pos + entry_len,
296                    dir.len(),
297                ));
298            }
299            entries.push(ZipEntry {
300                name,
301                flags,
302                method,
303                crc32,
304                compressed_size,
305                uncompressed_size,
306                local_offset,
307            });
308            pos += entry_len;
309        }
310        // Leftover bytes between the last entry and the EOCD mean the
311        // directory's declared size disagrees with its contents.
312        if pos != dir.len() {
313            return Err(Error::BadValue("zip central directory size"));
314        }
315        Ok(ZipArchive { data, entries })
316    }
317
318    /// The central directory entries, in archive order.
319    pub fn entries(&self) -> &[ZipEntry<'a>] {
320        &self.entries
321    }
322
323    /// How many members the archive holds.
324    pub fn len(&self) -> usize {
325        self.entries.len()
326    }
327
328    /// Whether the archive holds no members.
329    pub fn is_empty(&self) -> bool {
330        self.entries.is_empty()
331    }
332
333    /// The entry with this exact member name (byte-exact match — the
334    /// archive's own convention), or `None`.
335    pub fn by_name(&self, name: &str) -> Option<&ZipEntry<'a>> {
336        self.entries.iter().find(|e| e.name == name)
337    }
338
339    /// Extracts `entry` and verifies it: sizes first, CRC-32 last. The
340    /// entry must come from this archive's [`entries`](Self::entries) or
341    /// [`by_name`](Self::by_name) — it carries offsets into `self.data`.
342    ///
343    /// Extraction is refused with [`Error::Unsupported`] for encrypted
344    /// entries and compression methods other than stored (0) and DEFLATE
345    /// (8), and [`Error::BadValue`] when the local header disagrees with
346    /// the central directory, a data descriptor's numbers do not match
347    /// the directory's, the decompressed length is wrong, or the CRC-32
348    /// does not match.
349    pub fn extract(&self, entry: &ZipEntry<'a>) -> Result<Vec<u8>> {
350        self.extract_with(entry, &Limits::default())
351    }
352
353    /// Extracts the named entry: [`Error::BadValue`] (`"no such zip
354    /// entry"`) when the name is absent, otherwise exactly
355    /// [`extract`](Self::extract).
356    pub fn extract_by_name(&self, name: &str) -> Result<Vec<u8>> {
357        let entry = self
358            .by_name(name)
359            .ok_or(Error::BadValue("no such zip entry"))?;
360        self.extract(entry)
361    }
362
363    /// [`extract`](Self::extract) with an explicit decompression ceiling.
364    /// `limits.max_output` also bounds stored entries: an archive can
365    /// claim any uncompressed size it likes and the ceiling keeps the
366    /// reader's promise for free.
367    pub fn extract_with(&self, entry: &ZipEntry<'a>, limits: &Limits) -> Result<Vec<u8>> {
368        let base = usize::try_from(entry.local_offset)
369            .map_err(|_| Error::BadValue("zip local header offset"))?;
370        let hdr = take(self.data, base, LOCAL_LEN, "zip local file header")?;
371        let magic = u32::from_le_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
372        if magic != SIG_LOCAL {
373            return Err(Error::InvalidMagic {
374                what: "zip local file header",
375            });
376        }
377
378        // The local header has to agree with the directory on the fields
379        // it cannot legitimately differ on; APPNOTE §4.3.7 makes method
380        // and flags the same record's echo.
381        let local_flags = u16::from_le_bytes([hdr[6], hdr[7]]);
382        let local_method = u16::from_le_bytes([hdr[8], hdr[9]]);
383        if local_flags != entry.flags {
384            return Err(Error::BadValue("zip flags differ local vs central"));
385        }
386        if local_method != entry.method {
387            return Err(Error::BadValue("zip method differs local vs central"));
388        }
389
390        let name_len = usize::from(u16::from_le_bytes([hdr[26], hdr[27]]));
391        let extra_len = usize::from(u16::from_le_bytes([hdr[28], hdr[29]]));
392        let data_start = base
393            .checked_add(LOCAL_LEN)
394            .and_then(|n| n.checked_add(name_len))
395            .and_then(|n| n.checked_add(extra_len))
396            .ok_or(Error::BadValue("zip local header offset"))?;
397
398        if entry.flags & (FLAG_ENCRYPTED | FLAG_STRONG_ENCRYPTED | FLAG_MASKED_LOCAL) != 0 {
399            return Err(Error::Unsupported("encrypted zip entry"));
400        }
401
402        let cs = usize::try_from(entry.compressed_size).unwrap_or(usize::MAX);
403        let payload = take(self.data, data_start, cs, "zip entry payload")?;
404
405        if entry.flags & FLAG_DESCRIPTOR != 0 {
406            let dd_at = data_start
407                .checked_add(cs)
408                .ok_or(Error::BadValue("zip data descriptor offset"))?;
409            self.verify_descriptor(entry, dd_at)?;
410        }
411
412        let out = match entry.method {
413            METHOD_STORED => {
414                if entry.uncompressed_size != entry.compressed_size {
415                    return Err(Error::BadValue("zip stored entry size"));
416                }
417                if cs > limits.max_output {
418                    return Err(Error::too_large("zip entry", limits.max_output));
419                }
420                payload.to_vec()
421            }
422            METHOD_DEFLATE => inflate_raw(payload, limits)?,
423            _ => return Err(Error::Unsupported("zip compression method")),
424        };
425
426        if out.len() as u64 != u64::from(entry.uncompressed_size) {
427            return Err(Error::BadValue("zip entry size mismatch"));
428        }
429        if crc32(&out) != entry.crc32 {
430            return Err(Error::BadValue("zip entry crc32 mismatch"));
431        }
432        Ok(out)
433    }
434
435    /// Verifies the data descriptor trailing a bit-3 entry's payload.
436    /// APPNOTE §4.3.9.3 makes the 0x08074b50 signature optional, so both
437    /// 12- and 16-byte forms are accepted; the numbers must still equal
438    /// the directory's.
439    fn verify_descriptor(&self, entry: &ZipEntry<'a>, at: usize) -> Result<()> {
440        let base = if le32(self.data, at) == Some(SIG_DESCRIPTOR) {
441            at + 4
442        } else {
443            at
444        };
445        let dd = take(self.data, base, 12, "zip data descriptor")?;
446        let dd_crc = u32::from_le_bytes([dd[0], dd[1], dd[2], dd[3]]);
447        let dd_cs = u32::from_le_bytes([dd[4], dd[5], dd[6], dd[7]]);
448        let dd_us = u32::from_le_bytes([dd[8], dd[9], dd[10], dd[11]]);
449        if dd_crc == entry.crc32
450            && dd_cs == entry.compressed_size
451            && dd_us == entry.uncompressed_size
452        {
453            Ok(())
454        } else {
455            Err(Error::BadValue("zip data descriptor mismatch"))
456        }
457    }
458}