Skip to main content

zip_core/
lib.rs

1//! Forensic-grade ZIP reader.
2//!
3//! The headline capability is **deflate-block-indexed random access**: a forensic
4//! image stored in a ZIP (an E01 `Defl:N` entry at ~0% compression) is, at the
5//! deflate level, a run of *stored* blocks (`BTYPE=00`). Those blocks are
6//! byte-aligned, so the uncompressed entry can be addressed at any offset by
7//! seeking directly to the right block — **without inflating from the start**.
8//! This lets a downstream reader (e.g. the EWF parser) random-access a multi-GB
9//! image inside a ZIP with no temp extraction and no repeated decompression.
10//!
11//! Genuinely-compressed entries fall back to a correctness-preserving full
12//! decompress (no worse than extracting the entry), so the type is universal.
13#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
14
15mod archive;
16mod bytes;
17mod codec;
18mod cp437;
19mod crypto;
20mod deflate64_seek;
21#[cfg(feature = "vfs")]
22mod vfs;
23
24pub use archive::{
25    ArchiveSummary, CompressionMethod, EntryLayout, ExtraFields, HeaderFields, ZipArchive, ZipFile,
26};
27
28#[cfg(feature = "vfs")]
29pub use vfs::ZipVfs;
30
31use std::io::Read;
32use std::path::{Path, PathBuf};
33
34/// Errors from opening or reading a ZIP entry.
35#[derive(Debug, thiserror::Error)]
36pub enum ZipCoreError {
37    /// An I/O error occurred.
38    #[error("I/O error: {0}")]
39    Io(#[from] std::io::Error),
40
41    /// The container structure was malformed.
42    #[error("malformed ZIP container: {0}")]
43    Format(#[from] FormatError),
44
45    /// An entry uses a compression method this reader does not (yet) decode.
46    #[error("unsupported compression method: {0:?}")]
47    UnsupportedMethod(CompressionMethod),
48
49    /// The decoded entry's CRC-32 did not match the central-directory value.
50    #[error(
51        "CRC-32 mismatch in entry {entry}: expected {expected:#010x}, computed {actual:#010x}"
52    )]
53    CrcMismatch {
54        /// The entry whose CRC failed.
55        entry: String,
56        /// The CRC recorded in the central directory.
57        expected: u32,
58        /// The CRC computed over the decoded bytes.
59        actual: u32,
60    },
61
62    /// The entry is encrypted but no password was supplied (use `by_*_decrypt`).
63    #[error("entry is encrypted (password required): {0}")]
64    EncryptedNoPassword(String),
65
66    /// The supplied password failed the entry's verification check.
67    #[error("incorrect password for entry: {0}")]
68    WrongPassword(String),
69
70    /// An encrypted entry uses a scheme/parameters this reader cannot handle.
71    #[error("unsupported encryption for entry {entry}: {reason}")]
72    UnsupportedEncryption {
73        /// The entry.
74        entry: String,
75        /// What was unsupported.
76        reason: String,
77    },
78
79    /// No entry with the requested name exists.
80    #[error("entry not found: {0}")]
81    EntryNotFound(String),
82
83    /// The requested entry index is out of range.
84    #[error("entry index out of bounds: {0}")]
85    IndexOutOfBounds(usize),
86
87    /// The entry's data lives on another disk of a spanned/split archive, which
88    /// this reader does not reassemble.
89    #[error("entry {entry} is on disk {disk} of a spanned archive (not supported)")]
90    SpannedArchive {
91        /// The entry.
92        entry: String,
93        /// The disk number holding the entry.
94        disk: u32,
95    },
96
97    /// The entry's deflate stream was malformed (e.g. `LEN`/`NLEN` mismatch).
98    #[error("malformed deflate stream in entry {entry}: {reason}")]
99    Malformed {
100        /// The entry whose stream is malformed.
101        entry: String,
102        /// What was wrong.
103        reason: String,
104    },
105}
106
107/// Structural defects in a ZIP container. Each variant preserves the offending
108/// value/location (CLAUDE.md "Show the unrecognized value").
109#[derive(Debug, thiserror::Error)]
110pub enum FormatError {
111    /// A header read ran past the available bytes.
112    #[error("unexpected end of data")]
113    Truncated,
114
115    /// No End Of Central Directory record was found.
116    #[error("End Of Central Directory record not found")]
117    NoEocd,
118
119    /// A record did not start with its expected signature.
120    #[error("bad signature for {what} at offset {offset}")]
121    BadSignature {
122        /// Which record was expected.
123        what: &'static str,
124        /// Where it was looked for.
125        offset: u64,
126    },
127
128    /// The archive uses Zip64 features not yet implemented.
129    #[error("Zip64 archive not yet supported")]
130    Zip64Unsupported,
131
132    /// A 0xFFFFFFFF sentinel was present but the Zip64 record/extra field that
133    /// should carry the real value is missing or malformed.
134    #[error("Zip64 sentinel without a matching Zip64 record/extra field")]
135    Zip64Inconsistent,
136
137    /// The central directory offset/size fall outside the file.
138    #[error("central directory out of range: offset {cd_offset}, size {cd_size}")]
139    CentralDirOutOfRange {
140        /// Declared central-directory offset.
141        cd_offset: u64,
142        /// Declared central-directory size.
143        cd_size: u64,
144    },
145
146    /// The EOCD declared an entry count beyond the safety ceiling.
147    #[error("declared entry count {0} exceeds the safety ceiling")]
148    TooManyEntries(usize),
149}
150
151/// One byte-addressable stored (`BTYPE=00`) deflate block within an entry.
152#[derive(Debug, Clone, Copy)]
153struct StoredBlock {
154    /// Offset of this block's first byte in the *uncompressed* entry.
155    uncomp_start: u64,
156    /// Number of raw bytes in the block (deflate `LEN`, ≤ 65535).
157    len: u64,
158    /// Offset in the *backing file* where this block's raw bytes begin.
159    file_offset: u64,
160}
161
162/// How an entry is addressed for random access.
163enum Layout {
164    /// The deflate stream is entirely stored blocks — direct seek, no inflation.
165    StoredBlocks(Vec<StoredBlock>),
166    /// A genuinely-compressed Deflate64 (method 9) entry: checkpoint-indexed seek.
167    Deflate64(deflate64_seek::Deflate64Index),
168    /// A genuinely-compressed entry: correctness-preserving full-decompress path.
169    Fallback { path: PathBuf, name: String },
170}
171
172/// A random-access view over one uncompressed ZIP entry.
173pub struct StoredZipEntry {
174    file: std::fs::File,
175    uncompressed_size: u64,
176    layout: Layout,
177}
178
179impl StoredZipEntry {
180    /// The uncompressed length of the entry, in bytes.
181    pub fn len(&self) -> u64 {
182        self.uncompressed_size
183    }
184
185    /// Whether the entry is empty.
186    pub fn is_empty(&self) -> bool {
187        self.uncompressed_size == 0
188    }
189
190    /// `true` when the entry is stored-block addressable (the fast, no-inflation
191    /// path). `false` means reads go through the full-decompress fallback.
192    pub fn is_stored_block_indexed(&self) -> bool {
193        matches!(self.layout, Layout::StoredBlocks(_))
194    }
195
196    /// Number of indexed stored blocks (0 for the other paths).
197    pub fn block_count(&self) -> usize {
198        match &self.layout {
199            Layout::StoredBlocks(b) => b.len(),
200            Layout::Deflate64(_) | Layout::Fallback { .. } => 0,
201        }
202    }
203
204    /// `true` when the entry is a genuinely-compressed Deflate64 (method 9) entry
205    /// served by the checkpoint-indexed seek path.
206    pub fn is_deflate64_checkpoint_indexed(&self) -> bool {
207        matches!(self.layout, Layout::Deflate64(_))
208    }
209
210    /// Number of indexed Deflate64 checkpoints (0 for the other paths).
211    pub fn checkpoint_count(&self) -> usize {
212        match &self.layout {
213            Layout::Deflate64(index) => index.checkpoint_count(),
214            Layout::StoredBlocks(_) | Layout::Fallback { .. } => 0,
215        }
216    }
217
218    /// Read up to `buf.len()` bytes of the **uncompressed** entry starting at
219    /// `offset`. Stored-block entries seek directly to the right block(s) with no
220    /// inflation; this method takes `&self`, so independent reads run lock-free in
221    /// parallel (positioned reads). Returns the number of bytes read (short at EOF).
222    pub fn read_at(&self, buf: &mut [u8], offset: u64) -> std::io::Result<usize> {
223        if offset >= self.uncompressed_size || buf.is_empty() {
224            return Ok(0);
225        }
226        let want_end = (offset + buf.len() as u64).min(self.uncompressed_size);
227        let total = (want_end - offset) as usize;
228        match &self.layout {
229            Layout::StoredBlocks(blocks) => {
230                let mut filled = 0usize;
231                let mut cur = offset;
232                while cur < want_end {
233                    // First block whose uncompressed span extends past `cur`.
234                    let bi = blocks.partition_point(|b| b.uncomp_start + b.len <= cur);
235                    let Some(b) = blocks.get(bi) else {
236                        break; // cov:unreachable: blocks cover [0, uncompressed_size)
237                    };
238                    let within = cur - b.uncomp_start;
239                    let avail = b.len - within;
240                    let n = avail.min(want_end - cur) as usize;
241                    pread_exact(
242                        &self.file,
243                        &mut buf[filled..filled + n],
244                        b.file_offset + within,
245                    )?;
246                    filled += n;
247                    cur += n as u64;
248                }
249                Ok(filled)
250            }
251            Layout::Deflate64(index) => index.read_at(&self.file, buf, offset),
252            Layout::Fallback { path, name } => {
253                // Rare path (genuinely-compressed entry): never hit by 0%-deflate
254                // forensic images. Correct, if O(n) per read. Decoded by the native
255                // pure-Rust parser (no zip-rs), CRC-verified on EOF.
256                let mut archive =
257                    ZipArchive::new(std::fs::File::open(path)?).map_err(std::io::Error::other)?;
258                let mut entry = archive.by_name(name).map_err(std::io::Error::other)?;
259                let mut all = Vec::with_capacity(self.uncompressed_size as usize);
260                entry.read_to_end(&mut all)?;
261                let start = offset as usize;
262                let end = (start + total).min(all.len());
263                let slice = &all[start..end];
264                buf[..slice.len()].copy_from_slice(slice);
265                Ok(slice.len())
266            }
267        }
268    }
269}
270
271/// Open a single entry of a ZIP archive for random access.
272pub fn open_entry(path: &Path, name: &str) -> Result<StoredZipEntry, ZipCoreError> {
273    let file = std::fs::File::open(path)?;
274    let mut archive = ZipArchive::new(std::fs::File::open(path)?)?;
275    let entry = archive.by_name(name)?;
276    let uncompressed_size = entry.size();
277    let compressed_size = entry.compressed_size();
278    let data_start = entry.data_start();
279    let is_deflate = entry.compression() == CompressionMethod::Deflated;
280    let is_deflate64 = entry.compression() == CompressionMethod::Deflate64;
281    let is_stored = entry.compression() == CompressionMethod::Stored;
282    drop(entry);
283    drop(archive);
284
285    let layout = if is_stored {
286        // A method-0 entry is one contiguous run of raw bytes.
287        Layout::StoredBlocks(vec![StoredBlock {
288            uncomp_start: 0,
289            len: uncompressed_size,
290            file_offset: data_start,
291        }])
292    } else if is_deflate {
293        match index_stored_blocks(&file, name, data_start, compressed_size, uncompressed_size)? {
294            Some(blocks) => Layout::StoredBlocks(blocks),
295            None => Layout::Fallback {
296                path: path.to_path_buf(),
297                name: name.to_string(),
298            },
299        }
300    } else if is_deflate64 {
301        // A 0%-compression Deflate64 entry is stored blocks (identical to method 8),
302        // so prefer the zero-copy direct-seek path; genuinely-compressed Deflate64
303        // uses the checkpoint-indexed seek path.
304        match index_stored_blocks(&file, name, data_start, compressed_size, uncompressed_size)? {
305            Some(blocks) => Layout::StoredBlocks(blocks),
306            None => Layout::Deflate64(deflate64_seek::build_index(
307                &file,
308                name,
309                data_start,
310                compressed_size,
311                uncompressed_size,
312                deflate64_seek::DEFAULT_CHECKPOINT_INTERVAL,
313            )?),
314        }
315    } else {
316        Layout::Fallback {
317            path: path.to_path_buf(),
318            name: name.to_string(),
319        }
320    };
321
322    Ok(StoredZipEntry {
323        file,
324        uncompressed_size,
325        layout,
326    })
327}
328
329/// Walk a deflate stream's block headers. Returns `Some(index)` when every block
330/// is stored (`BTYPE=00`) — the byte-addressable fast path — or `None` the moment
331/// a Huffman block appears (alignment is lost; caller uses the fallback).
332fn index_stored_blocks(
333    file: &std::fs::File,
334    name: &str,
335    data_start: u64,
336    compressed_size: u64,
337    uncompressed_size: u64,
338) -> Result<Option<Vec<StoredBlock>>, ZipCoreError> {
339    let end = data_start + compressed_size;
340    let mut blocks = Vec::new();
341    let mut foff = data_start;
342    let mut uoff = 0u64;
343    loop {
344        if foff + 5 > end {
345            // Ran out of stream before a final block — not a clean stored run.
346            return Ok(None);
347        }
348        let mut hdr = [0u8; 5];
349        pread_exact(file, &mut hdr, foff)?;
350        let bfinal = hdr[0] & 1;
351        let btype = (hdr[0] >> 1) & 0b11;
352        if btype != 0 {
353            return Ok(None); // a compressed block → not byte-addressable
354        }
355        let len = u16::from_le_bytes([hdr[1], hdr[2]]);
356        let nlen = u16::from_le_bytes([hdr[3], hdr[4]]);
357        if nlen != !len {
358            return Err(ZipCoreError::Malformed {
359                entry: name.to_string(),
360                reason: format!("stored block LEN/NLEN mismatch at file offset {foff}"),
361            });
362        }
363        let len = u64::from(len);
364        let data_off = foff + 5;
365        if data_off + len > end {
366            return Err(ZipCoreError::Malformed {
367                entry: name.to_string(),
368                reason: format!("stored block overruns compressed data at offset {data_off}"),
369            });
370        }
371        blocks.push(StoredBlock {
372            uncomp_start: uoff,
373            len,
374            file_offset: data_off,
375        });
376        uoff += len;
377        foff = data_off + len;
378        if bfinal == 1 {
379            break;
380        }
381    }
382    if uoff != uncompressed_size {
383        return Err(ZipCoreError::Malformed {
384            entry: name.to_string(),
385            reason: format!(
386                "stored-block total {uoff} != entry uncompressed size {uncompressed_size}"
387            ),
388        });
389    }
390    Ok(Some(blocks))
391}
392
393#[cfg(unix)]
394fn pread_exact(file: &std::fs::File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
395    use std::os::unix::fs::FileExt;
396    file.read_exact_at(buf, offset)
397}
398
399#[cfg(windows)]
400fn pread_exact(file: &std::fs::File, buf: &mut [u8], offset: u64) -> std::io::Result<()> {
401    use std::os::windows::fs::FileExt;
402    let mut read = 0usize;
403    while read < buf.len() {
404        let n = file.seek_read(&mut buf[read..], offset + read as u64)?;
405        if n == 0 {
406            return Err(std::io::Error::new(
407                std::io::ErrorKind::UnexpectedEof,
408                "short positioned read",
409            ));
410        }
411        read += n;
412    }
413    Ok(())
414}