Skip to main content

zip_core/
archive.rs

1//! Pure-Rust ZIP container parser: EOCD + central directory + local file headers,
2//! with a decoding entry reader that verifies CRC-32 on EOF.
3//!
4//! Mirrors the zip-rs surface (`ZipArchive::new` / `by_index` / `by_name` /
5//! `ZipFile` with `name()`/`compression()`/`size()`/`data_start()`) so fleet
6//! consumers migrate with a near-mechanical `zip::` -> `zip_core::` rename.
7
8use std::io::{self, Read, Seek, SeekFrom};
9use std::path::PathBuf;
10
11use crate::bytes::Reader;
12use crate::codec::Decoder;
13use crate::crypto::{read_strong_header, AesInfo, AesReader, StrongAesReader, ZipCryptoReader};
14use crate::{FormatError, ZipCoreError};
15
16const EOCD_SIG: u32 = 0x0605_4b50;
17const CD_HEADER_SIG: u32 = 0x0201_4b50;
18const LFH_SIG: u32 = 0x0403_4b50;
19const ZIP64_EOCD_SIG: u32 = 0x0606_4b50;
20/// Central-directory digital-signature record header.
21const ARCHIVE_SIG_SIG: u32 = 0x0505_4b50;
22const ZIP64_LOCATOR_SIG: u32 = 0x0706_4b50;
23/// Header id of the Zip64 extended-information extra field.
24const ZIP64_EXTRA_ID: u16 = 0x0001;
25/// 32-bit sentinel: the real value lives in a Zip64 record/extra field.
26const U32_SENTINEL: u32 = 0xFFFF_FFFF;
27/// 16-bit sentinel for counts.
28const U16_SENTINEL: u16 = 0xFFFF;
29
30/// Minimum EOCD record length (no comment).
31const EOCD_MIN: usize = 22;
32/// Largest region we scan back from EOF for the EOCD (record + max comment).
33const EOCD_SCAN_MAX: usize = EOCD_MIN + u16::MAX as usize;
34/// Zip64 EOCD locator record length.
35const ZIP64_LOCATOR_LEN: usize = 20;
36/// Fixed portion of a local file header.
37const LFH_FIXED: usize = 30;
38/// Ceiling on entries we will parse, guarding against a lying EOCD count.
39const MAX_ENTRIES: usize = 16_000_000;
40
41/// ZIP compression method, mirroring zip-rs `CompressionMethod` for the common
42/// methods plus an `Unknown(raw)` that preserves the offending value.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum CompressionMethod {
45    /// Method 0 — no compression (raw passthrough / in-place window).
46    Stored,
47    /// Method 8 — classic DEFLATE.
48    Deflated,
49    /// Method 9 — Deflate64 / "enhanced deflate".
50    Deflate64,
51    /// Method 12 — bzip2.
52    Bzip2,
53    /// Method 14 — LZMA (with the 4-byte ZIP wrapper prefix).
54    Lzma,
55    /// Method 93 — Zstandard.
56    Zstd,
57    /// Method 95 — XZ.
58    Xz,
59    /// Method 1 — legacy Shrink (not decoded; recognized so it can be named).
60    Shrunk,
61    /// Methods 2–5 — legacy Reduce (not decoded).
62    Reduced,
63    /// Method 6 — legacy Implode (not decoded).
64    Imploded,
65    /// Method 10 — PKWARE DCL Implode (not decoded).
66    DclImploded,
67    /// Method 16 — IBM z/OS CMPSC (not decoded).
68    IbmCmpsc,
69    /// Method 18 — IBM TERSE (not decoded).
70    IbmTerse,
71    /// Method 19 — IBM LZ77 / PFS (not decoded).
72    IbmLz77,
73    /// Method 94 — MP3 (not decoded).
74    Mp3,
75    /// Method 96 — JPEG variant (not decoded).
76    Jpeg,
77    /// Method 97 — WavPack (not decoded).
78    WavPack,
79    /// Method 98 — PPMd (not decoded).
80    Ppmd,
81    /// Any other method id — value preserved so callers can report it.
82    Unknown(u16),
83}
84
85impl CompressionMethod {
86    pub(crate) fn from_u16(raw: u16) -> Self {
87        match raw {
88            0 => Self::Stored,
89            8 => Self::Deflated,
90            9 => Self::Deflate64,
91            12 => Self::Bzip2,
92            14 => Self::Lzma,
93            93 => Self::Zstd,
94            95 => Self::Xz,
95            1 => Self::Shrunk,
96            2..=5 => Self::Reduced,
97            6 => Self::Imploded,
98            10 => Self::DclImploded,
99            16 => Self::IbmCmpsc,
100            18 => Self::IbmTerse,
101            19 => Self::IbmLz77,
102            94 => Self::Mp3,
103            96 => Self::Jpeg,
104            97 => Self::WavPack,
105            98 => Self::Ppmd,
106            other => Self::Unknown(other),
107        }
108    }
109}
110
111/// Parsed central-directory metadata for one entry.
112#[derive(Debug, Clone)]
113pub(crate) struct CentralEntry {
114    pub(crate) name: String,
115    pub(crate) method: CompressionMethod,
116    pub(crate) flags: u16,
117    pub(crate) crc32: u32,
118    pub(crate) compressed_size: u64,
119    pub(crate) uncompressed_size: u64,
120    pub(crate) lfh_offset: u64,
121    /// DOS mod-time (the ZipCrypto password-check byte when a data descriptor is
122    /// used).
123    pub(crate) last_mod_time: u16,
124    /// WinZip AES parameters when this entry is method-99 encrypted.
125    pub(crate) aes: Option<AesInfo>,
126    /// Disk number holding this entry's local header (0 = this disk).
127    pub(crate) disk_start: u16,
128    /// Parsed common extra fields.
129    pub(crate) extra: ExtraFields,
130}
131
132impl CentralEntry {
133    fn is_dir(&self) -> bool {
134        self.name.ends_with('/') || self.name.ends_with('\\')
135    }
136}
137
138/// Container-level offsets/counts, for the forensic analyzer's structural audits
139/// (trailing data, spanning, etc.). Returned by [`ZipArchive::summary`].
140#[derive(Debug, Clone)]
141pub struct ArchiveSummary {
142    /// Total file length.
143    pub file_len: u64,
144    /// Absolute offset of the central directory.
145    pub central_dir_offset: u64,
146    /// Declared central-directory size in bytes.
147    pub central_dir_size: u64,
148    /// Absolute offset just past the end of the 32-bit EOCD record (incl. its
149    /// comment) — bytes beyond this are trailing data.
150    pub eocd_end_offset: u64,
151    /// EOCD archive-comment length.
152    pub comment_len: u16,
153    /// Disk number recorded in the EOCD (0 for a single-file archive).
154    pub disk_number: u32,
155    /// Disk on which the central directory starts (0 for a single-file archive).
156    pub cd_start_disk: u32,
157    /// Length of the central-directory digital-signature record (header
158    /// 0x05054b50) if present, else `None`. The signature is not verified.
159    pub archive_signature_len: Option<u16>,
160}
161
162/// A parsed ZIP archive over a seekable reader.
163pub struct ZipArchive<R> {
164    reader: R,
165    entries: Vec<CentralEntry>,
166    summary: ArchiveSummary,
167}
168
169impl<R: Read + Seek> ZipArchive<R> {
170    /// Parse the EOCD and central directory of `reader`.
171    pub fn new(mut reader: R) -> Result<Self, ZipCoreError> {
172        let file_len = reader.seek(SeekFrom::End(0))?;
173        let (entries, summary) = parse_central_directory(&mut reader, file_len)?;
174        Ok(Self {
175            reader,
176            entries,
177            summary,
178        })
179    }
180
181    /// Container-level offsets/counts for structural audits.
182    pub fn summary(&self) -> &ArchiveSummary {
183        &self.summary
184    }
185
186    /// Number of entries in the central directory.
187    pub fn len(&self) -> usize {
188        self.entries.len()
189    }
190
191    /// Whether the archive has no entries.
192    pub fn is_empty(&self) -> bool {
193        self.entries.is_empty()
194    }
195
196    /// Iterate entry names in central-directory order.
197    pub fn file_names(&self) -> impl Iterator<Item = &str> {
198        self.entries.iter().map(|e| e.name.as_str())
199    }
200
201    /// Open the entry at index `i` for decoding (mirrors zip-rs `by_index`).
202    pub fn by_index(&mut self, i: usize) -> Result<ZipFile<'_>, ZipCoreError> {
203        let meta = self
204            .entries
205            .get(i)
206            .ok_or(ZipCoreError::IndexOutOfBounds(i))?
207            .clone();
208        self.open(meta)
209    }
210
211    /// Open the named entry for decoding (mirrors zip-rs `by_name`).
212    pub fn by_name(&mut self, name: &str) -> Result<ZipFile<'_>, ZipCoreError> {
213        let meta = self
214            .entries
215            .iter()
216            .find(|e| e.name == name)
217            .ok_or_else(|| ZipCoreError::EntryNotFound(name.to_string()))?
218            .clone();
219        self.open(meta)
220    }
221
222    /// Open the entry at index `i`, decrypting it with `password` (ZipCrypto or
223    /// WinZip AES). Errors with `WrongPassword` if the password fails the check.
224    pub fn by_index_decrypt(
225        &mut self,
226        i: usize,
227        password: &[u8],
228    ) -> Result<ZipFile<'_>, ZipCoreError> {
229        let meta = self
230            .entries
231            .get(i)
232            .ok_or(ZipCoreError::IndexOutOfBounds(i))?
233            .clone();
234        self.open_decrypt(meta, password)
235    }
236
237    /// Open the named entry, decrypting it with `password`.
238    pub fn by_name_decrypt(
239        &mut self,
240        name: &str,
241        password: &[u8],
242    ) -> Result<ZipFile<'_>, ZipCoreError> {
243        let meta = self
244            .entries
245            .iter()
246            .find(|e| e.name == name)
247            .ok_or_else(|| ZipCoreError::EntryNotFound(name.to_string()))?
248            .clone();
249        self.open_decrypt(meta, password)
250    }
251
252    /// Raw structural view for the forensic analyzer: per entry, the header
253    /// fields as recorded in BOTH the central directory and the local file
254    /// header, plus offsets. This is the seam that lets `zip-forensic` compare
255    /// the two copies (tamper signal) without re-implementing a second parser.
256    pub fn structural_view(&mut self) -> Result<Vec<EntryLayout>, ZipCoreError> {
257        let metas = self.entries.clone();
258        let mut out = Vec::with_capacity(metas.len());
259        for (index, m) in metas.iter().enumerate() {
260            let (local, data_start) = read_lfh_fields(&mut self.reader, m.lfh_offset)?;
261            out.push(EntryLayout {
262                index,
263                lfh_offset: m.lfh_offset,
264                data_start,
265                central: HeaderFields {
266                    name: m.name.clone(),
267                    method: m.method,
268                    flags: m.flags,
269                    crc32: m.crc32,
270                    compressed_size: m.compressed_size,
271                    uncompressed_size: m.uncompressed_size,
272                },
273                local,
274                extra: m.extra.clone(),
275            });
276        }
277        Ok(out)
278    }
279
280    fn open(&mut self, meta: CentralEntry) -> Result<ZipFile<'_>, ZipCoreError> {
281        check_local_disk(&meta, self.summary.disk_number, self.summary.cd_start_disk)?;
282        if meta.flags & 0x0001 != 0 {
283            return Err(ZipCoreError::EncryptedNoPassword(meta.name.clone()));
284        }
285        let (_local, data_start) = read_lfh_fields(&mut self.reader, meta.lfh_offset)?;
286        self.reader.seek(SeekFrom::Start(data_start))?;
287        let limited: Box<dyn Read + '_> = Box::new((&mut self.reader).take(meta.compressed_size));
288        let decoder = Decoder::new(meta.method, meta.uncompressed_size, limited)?;
289        Ok(ZipFile {
290            data_start,
291            decoder,
292            hasher: crc32fast::Hasher::new(),
293            bytes_out: 0,
294            verified: false,
295            verify_crc: true,
296            meta,
297        })
298    }
299
300    fn open_decrypt(
301        &mut self,
302        meta: CentralEntry,
303        password: &[u8],
304    ) -> Result<ZipFile<'_>, ZipCoreError> {
305        check_local_disk(&meta, self.summary.disk_number, self.summary.cd_start_disk)?;
306        // Not encrypted -> the password is irrelevant; read normally.
307        if meta.flags & 0x0001 == 0 && meta.aes.is_none() {
308            return self.open(meta);
309        }
310        // Masked / central-directory encryption (GP bit 13) is unsupported — fail
311        // loud rather than misread the stream.
312        if meta.flags & 0x2000 != 0 {
313            return Err(ZipCoreError::UnsupportedEncryption {
314                entry: meta.name,
315                reason: "masked / central-directory encryption (GP flag bit 13)".to_string(),
316            });
317        }
318        // PKWARE strong encryption (GP bit 6): decrypt the password-based AES
319        // variant; every other strong variant (certificate, 3DES-ERD, non-AES) is
320        // refused loud inside `read_strong_header`. Traditional ZipCrypto and
321        // WinZip AES (method 99) take the path below.
322        if meta.aes.is_none() && meta.flags & 0x0040 != 0 {
323            return self.open_strong_decrypt(meta, password);
324        }
325        let (_local, data_start) = read_lfh_fields(&mut self.reader, meta.lfh_offset)?;
326        self.reader.seek(SeekFrom::Start(data_start))?;
327        let take = (&mut self.reader).take(meta.compressed_size);
328        let (reader, method, verify_crc): (Box<dyn Read + '_>, CompressionMethod, bool) =
329            if let Some(aes) = meta.aes {
330                let r = AesReader::new(take, password, aes, meta.compressed_size, &meta.name)?;
331                // AE-2 zeroes the CRC field; its integrity is the HMAC (checked by
332                // AesReader). AE-1 keeps the CRC, so verify it.
333                (
334                    Box::new(r),
335                    CompressionMethod::from_u16(aes.actual_method),
336                    !aes.is_ae2,
337                )
338            } else {
339                // Traditional ZipCrypto: the check byte is the CRC high byte, or the
340                // mod-time high byte when a data descriptor is used (bit 3).
341                let check = zipcrypto_check_byte(meta.flags, meta.crc32, meta.last_mod_time);
342                let r = ZipCryptoReader::new(take, password, check, &meta.name)?;
343                (Box::new(r), meta.method, true)
344            };
345        let decoder = Decoder::new(method, meta.uncompressed_size, reader)?;
346        Ok(ZipFile {
347            data_start,
348            decoder,
349            hasher: crc32fast::Hasher::new(),
350            bytes_out: 0,
351            verified: false,
352            verify_crc,
353            meta,
354        })
355    }
356
357    /// Open a PKWARE strong-encryption entry (password-based AES variant). Parses
358    /// the decryption header prepended to the entry data, derives the AES file key
359    /// (verifying the password), then CBC-decrypts the file data and feeds it to
360    /// the entry's normal decoder. For strong encryption the method field is the
361    /// real compression method (no method-99 indirection), so `meta.method`
362    /// selects the decoder directly.
363    fn open_strong_decrypt(
364        &mut self,
365        meta: CentralEntry,
366        password: &[u8],
367    ) -> Result<ZipFile<'_>, ZipCoreError> {
368        let (_local, data_start) = read_lfh_fields(&mut self.reader, meta.lfh_offset)?;
369        self.reader.seek(SeekFrom::Start(data_start))?;
370        let mut take = (&mut self.reader).take(meta.compressed_size);
371        let hdr = read_strong_header(
372            &mut take,
373            password,
374            meta.crc32,
375            meta.uncompressed_size,
376            &meta.name,
377        )?;
378        // The encrypted file data is what remains of the compressed span after the
379        // decryption header (a multiple of the AES block size). `read_strong_header`
380        // only returns Ok after consuming exactly `header_len` bytes from the stream
381        // capped at `compressed_size`, so `compressed_size >= header_len` always;
382        // `saturating_sub` is therefore exact and infallible.
383        let enc_len = meta.compressed_size.saturating_sub(hdr.header_len);
384        let strong = StrongAesReader::new(take, &hdr.file_key, &hdr.iv, enc_len, &meta.name)?;
385        // A `Stored` entry's compressed length equals its uncompressed length, so
386        // cap the decrypted stream there to drop the trailing CBC pad. A genuinely
387        // compressed entry's decoder self-terminates, leaving the pad unread.
388        let reader: Box<dyn Read + '_> = if meta.method == CompressionMethod::Stored {
389            Box::new(strong.take(meta.uncompressed_size))
390        } else {
391            Box::new(strong)
392        };
393        let decoder = Decoder::new(meta.method, meta.uncompressed_size, reader)?;
394        Ok(ZipFile {
395            data_start,
396            decoder,
397            hasher: crc32fast::Hasher::new(),
398            bytes_out: 0,
399            verified: false,
400            verify_crc: true,
401            meta,
402        })
403    }
404}
405
406/// Header fields as recorded in one header copy (central directory OR local file
407/// header). Exposed via [`ZipArchive::structural_view`] for the forensic seam.
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct HeaderFields {
410    /// Entry name (decoded).
411    pub name: String,
412    /// Compression method.
413    pub method: CompressionMethod,
414    /// General-purpose flag bits.
415    pub flags: u16,
416    /// CRC-32 as recorded in this header copy.
417    pub crc32: u32,
418    /// Compressed size as recorded in this header copy.
419    pub compressed_size: u64,
420    /// Uncompressed size as recorded in this header copy.
421    pub uncompressed_size: u64,
422}
423
424/// One entry's raw structural layout: the central-directory and local-file-header
425/// copies of its fields plus offsets, for cross-checking (tamper detection).
426#[derive(Debug, Clone)]
427pub struct EntryLayout {
428    /// Index in central-directory order.
429    pub index: usize,
430    /// Absolute offset of the local file header.
431    pub lfh_offset: u64,
432    /// Absolute offset of the entry's first data byte.
433    pub data_start: u64,
434    /// Fields as recorded in the central directory.
435    pub central: HeaderFields,
436    /// Fields as recorded in the local file header.
437    pub local: HeaderFields,
438    /// Parsed common extra fields from the central-directory header.
439    pub extra: ExtraFields,
440}
441
442/// Parsed common ZIP extra fields (central-directory copy). Unset fields are
443/// `None`. Timestamps are surfaced verbatim: NTFS times are Windows FILETIME
444/// (100 ns ticks since 1601-01-01 UTC); Unix times are signed seconds since the
445/// epoch.
446#[derive(Debug, Clone, Default, PartialEq, Eq)]
447pub struct ExtraFields {
448    /// NTFS last-modified time (FILETIME), extra id 0x000a.
449    pub ntfs_mtime: Option<u64>,
450    /// NTFS last-access time (FILETIME).
451    pub ntfs_atime: Option<u64>,
452    /// NTFS creation time (FILETIME).
453    pub ntfs_ctime: Option<u64>,
454    /// Unix modified time (seconds), Info-ZIP extended timestamp id 0x5455.
455    pub unix_mtime: Option<i32>,
456    /// Unix access time (seconds).
457    pub unix_atime: Option<i32>,
458    /// Unix creation time (seconds).
459    pub unix_ctime: Option<i32>,
460    /// Info-ZIP Unicode path override (id 0x7075), UTF-8.
461    pub unicode_path: Option<String>,
462    /// Info-ZIP Unicode comment override (id 0x6375), UTF-8.
463    pub unicode_comment: Option<String>,
464}
465
466/// Read and parse the local file header at `lfh_offset`, returning its fields and
467/// the absolute offset of the entry's first data byte
468/// (`lfh_offset + 30 + name_len + extra_len`).
469fn read_lfh_fields<R: Read + Seek>(
470    reader: &mut R,
471    lfh_offset: u64,
472) -> Result<(HeaderFields, u64), ZipCoreError> {
473    reader.seek(SeekFrom::Start(lfh_offset))?;
474    let mut fixed = [0u8; LFH_FIXED];
475    reader.read_exact(&mut fixed)?;
476    let mut r = Reader::new(&fixed);
477    if r.u32()? != LFH_SIG {
478        return Err(FormatError::BadSignature {
479            what: "local file header",
480            offset: lfh_offset,
481        }
482        .into());
483    }
484    let _version_needed = r.u16()?;
485    let flags = r.u16()?;
486    let method = CompressionMethod::from_u16(r.u16()?);
487    let _mod_time = r.u16()?;
488    let _mod_date = r.u16()?;
489    let crc32 = r.u32()?;
490    let compressed_size = u64::from(r.u32()?);
491    let uncompressed_size = u64::from(r.u32()?);
492    let name_len = usize::from(r.u16()?);
493    let extra_len = usize::from(r.u16()?);
494
495    let mut name_buf = vec![0u8; name_len];
496    reader.read_exact(&mut name_buf)?;
497    let name = decode_name(&name_buf, flags);
498    let data_start = lfh_offset + LFH_FIXED as u64 + name_len as u64 + extra_len as u64;
499
500    Ok((
501        HeaderFields {
502            name,
503            method,
504            flags,
505            crc32,
506            compressed_size,
507            uncompressed_size,
508        },
509        data_start,
510    ))
511}
512
513/// Locate + parse the EOCD, then read and parse the central directory.
514/// The 32-bit EOCD fields. Any size/offset/count may be a sentinel for Zip64.
515struct Eocd32 {
516    disk_number: u16,
517    cd_start_disk: u16,
518    total_entries: u16,
519    cd_size: u32,
520    cd_offset: u32,
521    comment_len: u16,
522}
523
524fn parse_central_directory<R: Read + Seek>(
525    reader: &mut R,
526    file_len: u64,
527) -> Result<(Vec<CentralEntry>, ArchiveSummary), ZipCoreError> {
528    let scan_len = file_len.min(EOCD_SCAN_MAX as u64);
529    if scan_len < EOCD_MIN as u64 {
530        return Err(FormatError::NoEocd.into());
531    }
532    let scan_start = file_len - scan_len;
533    reader.seek(SeekFrom::Start(scan_start))?;
534    let mut tail = vec![0u8; scan_len as usize];
535    reader.read_exact(&mut tail)?;
536
537    let eocd_rel = find_eocd(&tail).ok_or(FormatError::NoEocd)?;
538    let eocd = parse_eocd(&tail[eocd_rel..])?;
539    // Absolute end of the 32-bit EOCD record incl. its comment; the EOCD is always
540    // the last structure, so anything past this is trailing data.
541    let eocd_end_offset =
542        scan_start + eocd_rel as u64 + EOCD_MIN as u64 + u64::from(eocd.comment_len);
543
544    // Promote to Zip64 when any base field is a sentinel: the real 64-bit
545    // offset/size/count/disk live in the Zip64 EOCD record reached via its locator.
546    let is_zip64 = eocd.cd_offset == U32_SENTINEL
547        || eocd.cd_size == U32_SENTINEL
548        || eocd.total_entries == U16_SENTINEL;
549    let (cd_offset, cd_size, total_entries, disk_number, cd_start_disk) = if is_zip64 {
550        resolve_zip64_eocd(reader, &tail, eocd_rel)?
551    } else {
552        (
553            u64::from(eocd.cd_offset),
554            u64::from(eocd.cd_size),
555            usize::from(eocd.total_entries),
556            u32::from(eocd.disk_number),
557            u32::from(eocd.cd_start_disk),
558        )
559    };
560
561    // Detect data prepended before the archive (SFX stub / polyglot prefix). A
562    // normal central directory ends exactly at the EOCD; if the recorded
563    // `cd_offset` does not point at a CD header but `cd_offset + N` does — where
564    // `N = eocd_pos - (cd_offset + cd_size)` — the file carries an N-byte prefix
565    // and every recorded offset is relative to the archive start, not the file.
566    // The header check disambiguates a real prefix from a digital-signature
567    // record sitting between the CD and EOCD. Not attempted for Zip64, whose
568    // offsets live in a separately-located record.
569    let eocd_pos = scan_start + eocd_rel as u64;
570    let prefix = if is_zip64 {
571        0
572    } else {
573        match eocd_pos.checked_sub(cd_offset.saturating_add(cd_size)) {
574            Some(n)
575                if n > 0
576                    && !cd_header_at(reader, cd_offset)
577                    && cd_header_at(reader, cd_offset + n) =>
578            {
579                n
580            }
581            _ => 0,
582        }
583    };
584    let actual_cd_offset = cd_offset + prefix;
585
586    match actual_cd_offset.checked_add(cd_size) {
587        Some(end) if end <= file_len => {}
588        _ => return Err(FormatError::CentralDirOutOfRange { cd_offset, cd_size }.into()),
589    }
590    if total_entries > MAX_ENTRIES {
591        return Err(FormatError::TooManyEntries(total_entries).into());
592    }
593
594    reader.seek(SeekFrom::Start(actual_cd_offset))?;
595    let mut cd = vec![0u8; cd_size as usize];
596    reader.read_exact(&mut cd)?;
597
598    let (mut entries, cd_consumed) = parse_cd_entries(&cd, total_entries)?;
599    // Recorded LFH offsets are relative to the archive start; make them absolute
600    // by shifting past any detected prefix so reads land at the right bytes.
601    if prefix > 0 {
602        for e in &mut entries {
603            e.lfh_offset += prefix;
604        }
605    }
606
607    // A CD digital-signature record (header 0x05054b50) sits between the last
608    // central-directory header and the EOCD. Producers disagree on whether its
609    // bytes count toward the EOCD's cd_size: Info-ZIP places it *after* the
610    // cd_size span, while PKWARE SecureZIP includes it *within* cd_size. Detect it
611    // at the point where the headers actually ended, which covers both layouts:
612    // first check the trailing bytes inside the CD buffer, then the bytes that
613    // follow it. The signature is recognized, not verified.
614    let archive_signature_len = {
615        let sig = ARCHIVE_SIG_SIG.to_le_bytes();
616        let trailing = &cd[cd_consumed..];
617        if trailing.len() >= 6 && trailing[..4] == sig {
618            Some(u16::from_le_bytes([trailing[4], trailing[5]]))
619        } else if trailing.is_empty() {
620            // cd_size covered only the headers; the record (if any) follows the
621            // CD block, where the reader is now positioned.
622            let mut hdr = [0u8; 6];
623            match reader.read_exact(&mut hdr) {
624                Ok(()) if hdr[..4] == sig => Some(u16::from_le_bytes([hdr[4], hdr[5]])),
625                _ => None,
626            }
627        } else {
628            None
629        }
630    };
631    let summary = ArchiveSummary {
632        file_len,
633        central_dir_offset: actual_cd_offset,
634        central_dir_size: cd_size,
635        eocd_end_offset,
636        comment_len: eocd.comment_len,
637        disk_number,
638        cd_start_disk,
639        archive_signature_len,
640    };
641    Ok((entries, summary))
642}
643
644/// Whether a central-directory file header signature sits at absolute `offset`.
645/// Used to disambiguate a prepended-data prefix from other inter-record bytes.
646fn cd_header_at<R: Read + Seek>(reader: &mut R, offset: u64) -> bool {
647    if reader.seek(SeekFrom::Start(offset)).is_err() {
648        return false; // cov:unreachable: Cursor/File seek to a u64 offset does not fail
649    }
650    let mut sig = [0u8; 4];
651    match reader.read_exact(&mut sig) {
652        Ok(()) => u32::from_le_bytes(sig) == CD_HEADER_SIG,
653        Err(_) => false, // cov:unreachable: the sole caller only passes offsets < file_len (n>0 ⇒ in-bounds)
654    }
655}
656
657/// Scan backward for the EOCD signature, returning its offset within `tail`.
658fn find_eocd(tail: &[u8]) -> Option<usize> {
659    if tail.len() < EOCD_MIN {
660        return None; // cov:unreachable: parse_central_directory guards scan_len >= EOCD_MIN
661    }
662    let sig = EOCD_SIG.to_le_bytes();
663    // The EOCD starts at most EOCD_MIN bytes before EOF; scan from the latest.
664    (0..=tail.len() - EOCD_MIN)
665        .rev()
666        .find(|&i| tail[i..i + 4] == sig)
667}
668
669/// Parse the fixed EOCD fields. Any size/offset/count may be a Zip64 sentinel.
670fn parse_eocd(buf: &[u8]) -> Result<Eocd32, ZipCoreError> {
671    let mut r = Reader::new(buf);
672    if r.u32()? != EOCD_SIG {
673        return Err(FormatError::NoEocd.into()); // cov:unreachable: find_eocd matched this signature
674    }
675    let disk_number = r.u16()?;
676    let cd_start_disk = r.u16()?;
677    let _entries_this_disk = r.u16()?;
678    let total_entries = r.u16()?;
679    let cd_size = r.u32()?;
680    let cd_offset = r.u32()?;
681    let comment_len = r.u16()?;
682    Ok(Eocd32 {
683        disk_number,
684        cd_start_disk,
685        total_entries,
686        cd_size,
687        cd_offset,
688        comment_len,
689    })
690}
691
692/// Resolve the real central-directory location from the Zip64 EOCD record. The
693/// Zip64 EOCD locator sits immediately before the 32-bit EOCD; it points at the
694/// Zip64 EOCD record holding the true 64-bit offset/size/count.
695fn resolve_zip64_eocd<R: Read + Seek>(
696    reader: &mut R,
697    tail: &[u8],
698    eocd_rel: usize,
699) -> Result<(u64, u64, usize, u32, u32), ZipCoreError> {
700    if eocd_rel < ZIP64_LOCATOR_LEN {
701        return Err(FormatError::Zip64Unsupported.into());
702    }
703    let mut loc = Reader::new(&tail[eocd_rel - ZIP64_LOCATOR_LEN..eocd_rel]);
704    if loc.u32()? != ZIP64_LOCATOR_SIG {
705        return Err(FormatError::Zip64Unsupported.into());
706    }
707    let _disk = loc.u32()?;
708    let z64_eocd_offset = loc.u64()?;
709
710    reader.seek(SeekFrom::Start(z64_eocd_offset))?;
711    let mut rec = [0u8; 56];
712    reader.read_exact(&mut rec)?;
713    let mut r = Reader::new(&rec);
714    if r.u32()? != ZIP64_EOCD_SIG {
715        return Err(FormatError::BadSignature {
716            what: "Zip64 EOCD record",
717            offset: z64_eocd_offset,
718        }
719        .into());
720    }
721    let _record_size = r.u64()?;
722    let _version_made_by = r.u16()?;
723    let _version_needed = r.u16()?;
724    let disk_number = r.u32()?;
725    let cd_start_disk = r.u32()?;
726    let _entries_this_disk = r.u64()?;
727    let total_entries = r.u64()?;
728    let cd_size = r.u64()?;
729    let cd_offset = r.u64()?;
730    let total =
731        usize::try_from(total_entries).map_err(|_| FormatError::TooManyEntries(usize::MAX))?;
732    Ok((cd_offset, cd_size, total, disk_number, cd_start_disk))
733}
734
735/// Parse `total_entries` central-directory file headers from `cd`.
736/// Parse the central-directory headers, returning the entries and the number of
737/// bytes the headers consumed (so the caller can locate a trailing digital
738/// signature record that some producers place inside the `cd_size` span).
739fn parse_cd_entries(
740    cd: &[u8],
741    total_entries: usize,
742) -> Result<(Vec<CentralEntry>, usize), ZipCoreError> {
743    let mut r = Reader::new(cd);
744    let mut entries = Vec::new();
745    for _ in 0..total_entries {
746        if r.remaining() < 46 {
747            return Err(FormatError::Truncated.into());
748        }
749        if r.u32()? != CD_HEADER_SIG {
750            return Err(FormatError::BadSignature {
751                what: "central directory header",
752                offset: (cd.len() - r.remaining()) as u64,
753            }
754            .into());
755        }
756        let _version_made_by = r.u16()?;
757        let _version_needed = r.u16()?;
758        let flags = r.u16()?;
759        let method_raw = r.u16()?;
760        let method = CompressionMethod::from_u16(method_raw);
761        let last_mod_time = r.u16()?;
762        let _mod_date = r.u16()?;
763        let crc32 = r.u32()?;
764        let compressed_size32 = r.u32()?;
765        let uncompressed_size32 = r.u32()?;
766        let name_len = usize::from(r.u16()?);
767        let extra_len = usize::from(r.u16()?);
768        let comment_len = usize::from(r.u16()?);
769        let disk_start = r.u16()?;
770        let _internal_attrs = r.u16()?;
771        let _external_attrs = r.u32()?;
772        let lfh_offset32 = r.u32()?;
773
774        let name_bytes = r.take(name_len)?;
775        let extra = r.take(extra_len)?;
776        let _comment = r.take(comment_len)?;
777
778        // Resolve any 0xFFFFFFFF sentinels from the Zip64 extended-information
779        // extra field (header id 0x0001). Fields appear in a FIXED order and only
780        // when their base field is a sentinel.
781        let mut uncompressed_size = u64::from(uncompressed_size32);
782        let mut compressed_size = u64::from(compressed_size32);
783        let mut lfh_offset = u64::from(lfh_offset32);
784        if uncompressed_size32 == U32_SENTINEL
785            || compressed_size32 == U32_SENTINEL
786            || lfh_offset32 == U32_SENTINEL
787        {
788            apply_zip64_extra(
789                extra,
790                uncompressed_size32 == U32_SENTINEL,
791                compressed_size32 == U32_SENTINEL,
792                lfh_offset32 == U32_SENTINEL,
793                &mut uncompressed_size,
794                &mut compressed_size,
795                &mut lfh_offset,
796            )?;
797        }
798
799        // Filename: UTF-8 when GP flag bit 11 is set, else CP437. We accept either
800        // as best-effort UTF-8 here; a full CP437 table is a follow-up (it only
801        // affects display of non-ASCII names, not entry location).
802        let name = decode_name(name_bytes, flags);
803        // Method 99 = WinZip AES; the AE-x extra field (0x9901) carries the real
804        // method + key strength.
805        let aes = if method_raw == 99 {
806            parse_aes_extra(extra)
807        } else {
808            None
809        };
810
811        entries.push(CentralEntry {
812            name,
813            method,
814            flags,
815            crc32,
816            compressed_size,
817            uncompressed_size,
818            lfh_offset,
819            last_mod_time,
820            aes,
821            disk_start,
822            extra: parse_extra_fields(extra),
823        });
824    }
825    let consumed = cd.len() - r.remaining();
826    Ok((entries, consumed))
827}
828
829/// Override sentinel CD fields from the Zip64 extended-information extra field
830/// (header id 0x0001). The 64-bit fields appear in a fixed order — original size,
831/// compressed size, relative header offset — and ONLY when their base field is a
832/// sentinel. A sentinel with no matching extra field is a malformed Zip64 archive.
833fn apply_zip64_extra(
834    extra: &[u8],
835    need_uncompressed: bool,
836    need_compressed: bool,
837    need_offset: bool,
838    uncompressed_size: &mut u64,
839    compressed_size: &mut u64,
840    lfh_offset: &mut u64,
841) -> Result<(), ZipCoreError> {
842    let mut r = Reader::new(extra);
843    while r.remaining() >= 4 {
844        let id = r.u16()?;
845        let size = usize::from(r.u16()?);
846        if id == ZIP64_EXTRA_ID {
847            let mut z = Reader::new(r.take(size)?);
848            if need_uncompressed {
849                *uncompressed_size = z.u64()?;
850            }
851            if need_compressed {
852                *compressed_size = z.u64()?;
853            }
854            if need_offset {
855                *lfh_offset = z.u64()?;
856            }
857            return Ok(());
858        }
859        r.skip(size)?;
860    }
861    Err(FormatError::Zip64Inconsistent.into())
862}
863
864/// Parse the WinZip AE-x extra field (header id 0x9901) from an entry's extra
865/// data: version (AE-1/AE-2), vendor "AE", AES strength, and the real method.
866fn parse_aes_extra(extra: &[u8]) -> Option<AesInfo> {
867    let mut r = Reader::new(extra);
868    while r.remaining() >= 4 {
869        let id = r.u16().ok()?;
870        let size = usize::from(r.u16().ok()?);
871        if id == 0x9901 {
872            let data = r.take(size).ok()?;
873            let mut d = Reader::new(data);
874            let version = d.u16().ok()?; // 1 = AE-1, 2 = AE-2
875            let _vendor = d.u16().ok()?; // "AE"
876            let strength = d.take(1).ok()?[0];
877            let actual_method = d.u16().ok()?;
878            return Some(AesInfo {
879                strength,
880                actual_method,
881                is_ae2: version == 2,
882            });
883        }
884        r.skip(size).ok()?;
885    }
886    None
887}
888
889/// Parse the common ZIP extra fields from an entry's extra block.
890fn parse_extra_fields(extra: &[u8]) -> ExtraFields {
891    let mut out = ExtraFields::default();
892    let mut r = Reader::new(extra);
893    while r.remaining() >= 4 {
894        let (Ok(id), Ok(size)) = (r.u16(), r.u16()) else {
895            break; // cov:unreachable: the >= 4 guard guarantees two u16 reads succeed
896        };
897        let Ok(data) = r.take(usize::from(size)) else {
898            break;
899        };
900        match id {
901            0x000a => parse_ntfs_times(data, &mut out),
902            0x5455 => parse_unix_times(data, &mut out),
903            0x7075 => out.unicode_path = parse_unicode_extra(data),
904            0x6375 => out.unicode_comment = parse_unicode_extra(data),
905            _ => {}
906        }
907    }
908    out
909}
910
911/// NTFS extra field (0x000a): reserved(4) then tagged attributes; tag 0x0001
912/// carries mtime/atime/ctime as 8-byte FILETIMEs.
913fn parse_ntfs_times(data: &[u8], out: &mut ExtraFields) {
914    let mut r = Reader::new(data);
915    let _ = r.u32(); // reserved
916    while r.remaining() >= 4 {
917        let (Ok(tag), Ok(tsize)) = (r.u16(), r.u16()) else {
918            break; // cov:unreachable: the >= 4 guard guarantees two u16 reads succeed
919        };
920        let Ok(tdata) = r.take(usize::from(tsize)) else {
921            break;
922        };
923        if tag == 0x0001 {
924            let mut s = Reader::new(tdata);
925            if let (Ok(m), Ok(a), Ok(c)) = (s.u64(), s.u64(), s.u64()) {
926                out.ntfs_mtime = Some(m);
927                out.ntfs_atime = Some(a);
928                out.ntfs_ctime = Some(c);
929            }
930        }
931    }
932}
933
934/// Info-ZIP extended timestamp (0x5455): a flags byte then present mtime/atime/
935/// ctime as signed 32-bit seconds, in that order.
936fn parse_unix_times(data: &[u8], out: &mut ExtraFields) {
937    let mut r = Reader::new(data);
938    let Ok(flag_byte) = r.take(1) else {
939        return;
940    };
941    let flags = flag_byte[0];
942    if flags & 0x01 != 0 {
943        out.unix_mtime = take_i32le(&mut r);
944    }
945    if flags & 0x02 != 0 {
946        out.unix_atime = take_i32le(&mut r);
947    }
948    if flags & 0x04 != 0 {
949        out.unix_ctime = take_i32le(&mut r);
950    }
951}
952
953fn take_i32le(r: &mut Reader) -> Option<i32> {
954    r.take(4)
955        .ok()
956        .map(|b| i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
957}
958
959/// Info-ZIP Unicode path/comment (0x7075 / 0x6375): version(1) + name-CRC(4) +
960/// UTF-8 bytes. Returns the UTF-8 string (the CRC linking it to the legacy name
961/// is not re-checked here).
962fn parse_unicode_extra(data: &[u8]) -> Option<String> {
963    if data.len() < 5 {
964        return None;
965    }
966    String::from_utf8(data[5..].to_vec()).ok()
967}
968
969/// The ZipCrypto password-verification byte: the CRC-32 high byte, or the
970/// mod-time high byte when the entry uses a data descriptor (GP flag bit 3),
971/// matching what the encrypter used (PKWARE APPNOTE 6.1.6).
972fn zipcrypto_check_byte(flags: u16, crc32: u32, last_mod_time: u16) -> u8 {
973    if flags & 0x0008 != 0 {
974        (last_mod_time >> 8) as u8
975    } else {
976        (crc32 >> 24) as u8
977    }
978}
979
980/// Decode an entry filename. UTF-8 (flag bit 11) is taken verbatim; otherwise we
981/// map the CP437 high range so non-ASCII names are still legible.
982fn decode_name(bytes: &[u8], flags: u16) -> String {
983    // UTF-8 flag (bit 11) set, or pure ASCII: take the bytes as UTF-8 (lossy).
984    if flags & 0x0800 != 0 || bytes.is_ascii() {
985        return String::from_utf8_lossy(bytes).into_owned();
986    }
987    bytes.iter().map(|&b| crate::cp437::decode(b)).collect()
988}
989
990/// A decoding reader over one ZIP entry. Implements `Read`, yielding decompressed
991/// bytes and verifying CRC-32 at EOF (fail loud on mismatch).
992pub struct ZipFile<'a> {
993    meta: CentralEntry,
994    data_start: u64,
995    decoder: Decoder<Box<dyn Read + 'a>>,
996    hasher: crc32fast::Hasher,
997    bytes_out: u64,
998    verified: bool,
999    /// Whether to verify CRC-32 at EOF. False for `WinZip` AE-2, whose integrity
1000    /// is the HMAC (checked by the AES reader) and whose CD CRC field is zero.
1001    verify_crc: bool,
1002}
1003
1004impl ZipFile<'_> {
1005    /// Entry name (path within the archive).
1006    pub fn name(&self) -> &str {
1007        &self.meta.name
1008    }
1009
1010    /// Compression method.
1011    pub fn compression(&self) -> CompressionMethod {
1012        self.meta.method
1013    }
1014
1015    /// Uncompressed size in bytes (from the central directory).
1016    pub fn size(&self) -> u64 {
1017        self.meta.uncompressed_size
1018    }
1019
1020    /// Compressed size in bytes (from the central directory).
1021    pub fn compressed_size(&self) -> u64 {
1022        self.meta.compressed_size
1023    }
1024
1025    /// Stored CRC-32 (from the central directory).
1026    pub fn crc32(&self) -> u32 {
1027        self.meta.crc32
1028    }
1029
1030    /// Absolute offset of the entry's first data byte in the archive. For a
1031    /// `Stored` entry this is the start of the in-place, zero-copy window.
1032    pub fn data_start(&self) -> u64 {
1033        self.data_start
1034    }
1035
1036    /// General-purpose flag bits (bit 0 encryption, bit 3 data descriptor, ...).
1037    pub fn flags(&self) -> u16 {
1038        self.meta.flags
1039    }
1040
1041    /// Whether the entry names a directory.
1042    pub fn is_dir(&self) -> bool {
1043        self.meta.is_dir()
1044    }
1045
1046    /// A safe relative path for extraction, or `None` if the entry name escapes
1047    /// the destination (parent-dir traversal, absolute, or drive-letter path).
1048    /// The raw [`name`](Self::name) is always preserved as evidence; this is the
1049    /// secure-by-default view a caller should join onto an output directory.
1050    pub fn enclosed_name(&self) -> Option<PathBuf> {
1051        enclosed_name(&self.meta.name)
1052    }
1053}
1054
1055/// Fail loud if the entry's data is not wholly resolvable from the single
1056/// segment we hold — we don't reassemble split volumes, so reading would return
1057/// the wrong bytes. An archive is spanned when the EOCD marks the central
1058/// directory on a later disk (`this_disk`/`cd_start_disk` != 0) *or* the entry's
1059/// own `disk_start` is non-zero. Real Info-ZIP split archives set the former
1060/// while leaving data entries on disk 0, so the per-entry check alone misses them.
1061fn check_local_disk(
1062    meta: &CentralEntry,
1063    this_disk: u32,
1064    cd_start_disk: u32,
1065) -> Result<(), ZipCoreError> {
1066    let disk = if meta.disk_start != 0 {
1067        u32::from(meta.disk_start)
1068    } else if cd_start_disk != 0 {
1069        cd_start_disk
1070    } else if this_disk != 0 {
1071        this_disk
1072    } else {
1073        return Ok(());
1074    };
1075    Err(ZipCoreError::SpannedArchive {
1076        entry: meta.name.clone(),
1077        disk,
1078    })
1079}
1080
1081/// Compute a traversal-safe relative path from a ZIP entry name, treating both
1082/// `/` and `\` as separators (ZIP names may use either) so the check holds on
1083/// every platform regardless of `std::path` separator conventions.
1084fn enclosed_name(name: &str) -> Option<PathBuf> {
1085    if name.is_empty() || name.contains('\0') {
1086        return None;
1087    }
1088    if name.starts_with('/') || name.starts_with('\\') {
1089        return None; // absolute / UNC-style
1090    }
1091    let b = name.as_bytes();
1092    if b.len() >= 2 && b[1] == b':' && b[0].is_ascii_alphabetic() {
1093        return None; // drive-letter prefix (C:\...)
1094    }
1095    let mut out = PathBuf::new();
1096    for comp in name.split(['/', '\\']) {
1097        match comp {
1098            "" | "." => {}
1099            ".." => return None,
1100            other => out.push(other),
1101        }
1102    }
1103    if out.as_os_str().is_empty() {
1104        return None;
1105    }
1106    Some(out)
1107}
1108
1109impl Read for ZipFile<'_> {
1110    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
1111        let n = self.decoder.read(buf)?;
1112        if n == 0 {
1113            if !self.verified {
1114                self.verified = true;
1115                let actual = self.hasher.clone().finalize();
1116                if self.verify_crc && actual != self.meta.crc32 {
1117                    return Err(io::Error::other(ZipCoreError::CrcMismatch {
1118                        entry: self.meta.name.clone(),
1119                        expected: self.meta.crc32,
1120                        actual,
1121                    }));
1122                }
1123            }
1124            return Ok(0);
1125        }
1126        self.hasher.update(&buf[..n]);
1127        self.bytes_out += n as u64;
1128        Ok(n)
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::zipcrypto_check_byte;
1135
1136    #[test]
1137    fn check_byte_selects_crc_or_modtime() {
1138        // No data descriptor (bit 3 clear) -> CRC-32 high byte.
1139        assert_eq!(zipcrypto_check_byte(0x0000, 0xAB12_3456, 0x7890), 0xAB);
1140        // Data descriptor (bit 3 set) -> mod-time high byte.
1141        assert_eq!(zipcrypto_check_byte(0x0008, 0xAB12_3456, 0xCD90), 0xCD);
1142    }
1143}