Skip to main content

lzip_parallel/
central_dir.rs

1//! ZIP Central Directory parser.
2//!
3//! Reads the End of Central Directory (EOCD) record — always at the tail of
4//! the file — to locate the Central Directory, then walks each CD header to
5//! produce a `Vec<EntryLocation>`.
6//!
7//! Handles standard ZIP and ZIP64 (large ZIPs with > 65535 entries or
8//! entries > 4 GB).  Multi-disk archives are rejected.
9
10use crate::entry::ZipError;
11
12const EOCD_SIG:     u32 = 0x06054b50;
13const EOCD64_LOC_SIG: u32 = 0x07064b50;
14const EOCD64_SIG:   u32 = 0x06064b50;
15const CD_SIG:       u32 = 0x02014b50;
16const MAX_COMMENT:  usize = 0xFFFF;
17
18/// Location of one JAR entry, derived from the Central Directory.
19#[derive(Debug, Clone)]
20pub struct EntryLocation {
21    pub name:                 String,
22    pub local_header_offset:  u64,
23    pub compressed_size:      u64,
24    pub uncompressed_size:    u64,
25    pub compression_method:   u16,
26    pub crc32:                u32,
27    pub is_directory:         bool,
28}
29
30// ── Byte helpers ─────────────────────────────────────────────────────────────
31
32#[inline]
33fn u16_le(d: &[u8], off: usize) -> Option<u16> {
34    d.get(off..off + 2).map(|b| u16::from_le_bytes([b[0], b[1]]))
35}
36
37#[inline]
38fn u32_le(d: &[u8], off: usize) -> Option<u32> {
39    d.get(off..off + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
40}
41
42#[inline]
43fn u64_le(d: &[u8], off: usize) -> Option<u64> {
44    d.get(off..off + 8)
45        .map(|b| u64::from_le_bytes(b.try_into().unwrap()))
46}
47
48// ── EOCD location ─────────────────────────────────────────────────────────────
49
50/// Scan backward from the end for the EOCD signature.
51fn find_eocd(data: &[u8]) -> Option<usize> {
52    if data.len() < 22 { return None; }
53    let search_start = data.len().saturating_sub(22 + MAX_COMMENT);
54    for i in (search_start..=data.len() - 22).rev() {
55        if u32_le(data, i) == Some(EOCD_SIG) {
56            return Some(i);
57        }
58    }
59    None
60}
61
62/// Returns (entry_count, cd_offset, cd_size) — ZIP64-resolved.
63fn parse_eocd(data: &[u8]) -> Result<(u64, u64, u64), ZipError> {
64    let eocd = find_eocd(data).ok_or(ZipError("EOCD not found — not a JAR/ZIP file"))?;
65
66    // Check for ZIP64 EOCD locator immediately before the standard EOCD.
67    if eocd >= 20 {
68        if u32_le(data, eocd - 20) == Some(EOCD64_LOC_SIG) {
69            let eocd64_off = u64_le(data, eocd - 20 + 8)
70                .ok_or(ZipError("ZIP64 EOCD locator truncated"))? as usize;
71
72            if u32_le(data, eocd64_off) != Some(EOCD64_SIG) {
73                return Err(ZipError("invalid ZIP64 EOCD signature"));
74            }
75            // ZIP64 EOCD layout (offsets relative to record start):
76            //  +32  total entries   (u64)
77            //  +40  CD size         (u64)
78            //  +48  CD offset       (u64)
79            let entry_count = u64_le(data, eocd64_off + 32)
80                .ok_or(ZipError("ZIP64 EOCD truncated at entry count"))?;
81            let cd_size     = u64_le(data, eocd64_off + 40)
82                .ok_or(ZipError("ZIP64 EOCD truncated at CD size"))?;
83            let cd_offset   = u64_le(data, eocd64_off + 48)
84                .ok_or(ZipError("ZIP64 EOCD truncated at CD offset"))?;
85            return Ok((entry_count, cd_offset, cd_size));
86        }
87    }
88
89    // Standard EOCD (offsets relative to EOCD start):
90    //  +8   entries on this disk  (u16)
91    //  +10  total entries         (u16)
92    //  +12  CD size               (u32)
93    //  +16  CD offset             (u32)
94    let entry_count = u16_le(data, eocd + 10).ok_or(ZipError("EOCD truncated"))? as u64;
95    let cd_size     = u32_le(data, eocd + 12).ok_or(ZipError("EOCD truncated"))? as u64;
96    let cd_offset   = u32_le(data, eocd + 16).ok_or(ZipError("EOCD truncated"))? as u64;
97    Ok((entry_count, cd_offset, cd_size))
98}
99
100// ── Central Directory walk ────────────────────────────────────────────────────
101
102/// Walk a CD byte slice and return one `EntryLocation` per entry.
103///
104/// `cd_data` must contain exactly the bytes of the Central Directory (offsets
105/// start at 0).  `local_header_offset` values in each entry are absolute file
106/// offsets and are returned as-is.
107fn walk_cd_entries(cd_data: &[u8], entry_count: u64) -> Result<Vec<EntryLocation>, ZipError> {
108    let mut entries = Vec::with_capacity(entry_count.min(65536) as usize);
109    let mut pos = 0usize;
110
111    while pos < cd_data.len() {
112        if u32_le(cd_data, pos) != Some(CD_SIG) { break; }
113
114        // CD header fixed fields:
115        //  +10  compression method  (u16)
116        //  +16  CRC-32              (u32)
117        //  +20  compressed size     (u32)  — may be 0xFFFFFFFF for ZIP64
118        //  +24  uncompressed size   (u32)  — may be 0xFFFFFFFF for ZIP64
119        //  +28  name len            (u16)
120        //  +30  extra len           (u16)
121        //  +32  comment len         (u16)
122        //  +42  local header offset (u32)  — may be 0xFFFFFFFF for ZIP64
123        let method      = u16_le(cd_data, pos + 10).ok_or(ZipError("CD entry truncated"))?;
124        let crc32       = u32_le(cd_data, pos + 16).ok_or(ZipError("CD entry truncated"))?;
125        let mut csize   = u32_le(cd_data, pos + 20).ok_or(ZipError("CD entry truncated"))? as u64;
126        let mut ucsize  = u32_le(cd_data, pos + 24).ok_or(ZipError("CD entry truncated"))? as u64;
127        let name_len    = u16_le(cd_data, pos + 28).ok_or(ZipError("CD entry truncated"))? as usize;
128        let extra_len   = u16_le(cd_data, pos + 30).ok_or(ZipError("CD entry truncated"))? as usize;
129        let comment_len = u16_le(cd_data, pos + 32).ok_or(ZipError("CD entry truncated"))? as usize;
130        let mut lhoff   = u32_le(cd_data, pos + 42).ok_or(ZipError("CD entry truncated"))? as u64;
131
132        let name_start  = pos + 46;
133        let name_end    = name_start + name_len;
134        let extra_start = name_end;
135        let extra_end   = extra_start + extra_len;
136
137        if extra_end > cd_data.len() {
138            return Err(ZipError("CD entry name/extra truncated"));
139        }
140        let name = String::from_utf8_lossy(&cd_data[name_start..name_end]).into_owned();
141
142        // Resolve ZIP64 extra field (tag 0x0001) if any sentinel value was 0xFFFFFFFF.
143        if csize == 0xFFFF_FFFF || ucsize == 0xFFFF_FFFF || lhoff == 0xFFFF_FFFF {
144            let mut epos = extra_start;
145            while epos + 4 <= extra_end {
146                let tag  = u16_le(cd_data, epos).unwrap_or(0);
147                let size = u16_le(cd_data, epos + 2).unwrap_or(0) as usize;
148                if tag == 0x0001 {
149                    let mut z = epos + 4;
150                    if ucsize == 0xFFFF_FFFF {
151                        ucsize = u64_le(cd_data, z).ok_or(ZipError("ZIP64 extra truncated"))?;
152                        z += 8;
153                    }
154                    if csize == 0xFFFF_FFFF {
155                        csize = u64_le(cd_data, z).ok_or(ZipError("ZIP64 extra truncated"))?;
156                        z += 8;
157                    }
158                    if lhoff == 0xFFFF_FFFF {
159                        lhoff = u64_le(cd_data, z).ok_or(ZipError("ZIP64 extra truncated"))?;
160                    }
161                    break;
162                }
163                epos += 4 + size;
164            }
165        }
166
167        entries.push(EntryLocation {
168            is_directory: name.ends_with('/'),
169            name,
170            local_header_offset: lhoff,
171            compressed_size:     csize,
172            uncompressed_size:   ucsize,
173            compression_method:  method,
174            crc32,
175        });
176
177        pos = extra_end + comment_len;
178    }
179
180    Ok(entries)
181}
182
183/// Parse the ZIP Central Directory and return one `EntryLocation` per file entry.
184///
185/// Directory entries (names ending with `/`) are included with `is_directory = true`;
186/// callers typically filter them out before decompressing.
187pub fn read_central_directory(data: &[u8]) -> Result<Vec<EntryLocation>, ZipError> {
188    let (entry_count, cd_offset, cd_size) = parse_eocd(data)?;
189
190    let cd_start = cd_offset as usize;
191    let cd_end   = cd_start.checked_add(cd_size as usize)
192        .filter(|&e| e <= data.len())
193        .ok_or(ZipError("central directory extends past end of file"))?;
194
195    walk_cd_entries(&data[cd_start..cd_end], entry_count)
196}
197
198// ── Streaming Central Directory reader ───────────────────────────────────────
199
200/// Parse the EOCD (and ZIP64 EOCD if present) from a `Read + Seek` source.
201///
202/// Returns `(entry_count, cd_offset, cd_size)`.
203fn parse_eocd_streaming<R: std::io::Read + std::io::Seek>(
204    source: &mut R,
205) -> Result<(u64, u64, u64), ZipError> {
206    use std::io::SeekFrom;
207
208    let file_size = source.seek(SeekFrom::End(0))
209        .map_err(|_| ZipError("seek failed"))?;
210
211    if file_size < 22 {
212        return Err(ZipError("file too small to be a JAR/ZIP"));
213    }
214
215    // Read enough tail bytes to cover: EOCD (22 + up to 65535 comment) +
216    // ZIP64 locator (20) + ZIP64 EOCD (56).
217    let scan_size = ((22 + MAX_COMMENT + 20 + 56) as u64).min(file_size);
218    let scan_start = file_size - scan_size;
219
220    source.seek(SeekFrom::Start(scan_start))
221        .map_err(|_| ZipError("seek to tail failed"))?;
222    let mut buf = vec![0u8; scan_size as usize];
223    source.read_exact(&mut buf)
224        .map_err(|_| ZipError("read tail failed"))?;
225
226    let eocd_in_buf = find_eocd(&buf)
227        .ok_or(ZipError("EOCD not found — not a JAR/ZIP file"))?;
228    let eocd_abs = scan_start + eocd_in_buf as u64;
229
230    // ZIP64 locator sits exactly 20 bytes before the EOCD.
231    if eocd_abs >= 20 {
232        let loc_abs = eocd_abs - 20;
233
234        let loc_sig = if loc_abs >= scan_start {
235            u32_le(&buf, (loc_abs - scan_start) as usize)
236        } else {
237            // Locator before our scan buffer — seek to read it.
238            source.seek(SeekFrom::Start(loc_abs))
239                .map_err(|_| ZipError("seek to ZIP64 locator failed"))?;
240            let mut tmp = [0u8; 4];
241            source.read_exact(&mut tmp)
242                .map_err(|_| ZipError("read ZIP64 locator sig failed"))?;
243            Some(u32::from_le_bytes(tmp))
244        };
245
246        if loc_sig == Some(EOCD64_LOC_SIG) {
247            let eocd64_off: u64 = if loc_abs >= scan_start {
248                u64_le(&buf, (loc_abs - scan_start) as usize + 8)
249                    .ok_or(ZipError("ZIP64 locator truncated"))?
250            } else {
251                source.seek(SeekFrom::Start(loc_abs + 8))
252                    .map_err(|_| ZipError("seek to ZIP64 locator offset failed"))?;
253                let mut tmp = [0u8; 8];
254                source.read_exact(&mut tmp)
255                    .map_err(|_| ZipError("read ZIP64 EOCD offset failed"))?;
256                u64::from_le_bytes(tmp)
257            };
258
259            // Read ZIP64 EOCD — may already be in buf, or need a seek.
260            let eocd64: [u8; 56] = if eocd64_off >= scan_start
261                && eocd64_off + 56 <= scan_start + scan_size
262            {
263                let s = (eocd64_off - scan_start) as usize;
264                buf[s..s + 56].try_into().unwrap()
265            } else {
266                source.seek(SeekFrom::Start(eocd64_off))
267                    .map_err(|_| ZipError("seek to ZIP64 EOCD failed"))?;
268                let mut tmp = [0u8; 56];
269                source.read_exact(&mut tmp)
270                    .map_err(|_| ZipError("read ZIP64 EOCD failed"))?;
271                tmp
272            };
273
274            if u32::from_le_bytes(eocd64[0..4].try_into().unwrap()) != EOCD64_SIG {
275                return Err(ZipError("invalid ZIP64 EOCD signature"));
276            }
277            let entry_count = u64::from_le_bytes(eocd64[32..40].try_into().unwrap());
278            let cd_size     = u64::from_le_bytes(eocd64[40..48].try_into().unwrap());
279            let cd_offset   = u64::from_le_bytes(eocd64[48..56].try_into().unwrap());
280            return Ok((entry_count, cd_offset, cd_size));
281        }
282    }
283
284    // Standard EOCD.
285    let entry_count = u16_le(&buf, eocd_in_buf + 10).ok_or(ZipError("EOCD truncated"))? as u64;
286    let cd_size     = u32_le(&buf, eocd_in_buf + 12).ok_or(ZipError("EOCD truncated"))? as u64;
287    let cd_offset   = u32_le(&buf, eocd_in_buf + 16).ok_or(ZipError("EOCD truncated"))? as u64;
288    Ok((entry_count, cd_offset, cd_size))
289}
290
291/// Parse the ZIP Central Directory from a `Read + Seek` source without loading
292/// the whole file into memory.
293///
294/// Seeks to the file tail to locate the EOCD, reads only the Central Directory
295/// bytes, then walks the entries.  Suitable for large ZIPs where loading the
296/// full file would be wasteful.
297pub fn read_central_directory_from<R: std::io::Read + std::io::Seek>(
298    source: &mut R,
299) -> Result<Vec<EntryLocation>, ZipError> {
300    use std::io::SeekFrom;
301
302    let (entry_count, cd_offset, cd_size) = parse_eocd_streaming(source)?;
303
304    source.seek(SeekFrom::Start(cd_offset))
305        .map_err(|_| ZipError("seek to central directory failed"))?;
306    let mut cd_data = vec![0u8; cd_size as usize];
307    source.read_exact(&mut cd_data)
308        .map_err(|_| ZipError("read central directory failed"))?;
309
310    walk_cd_entries(&cd_data, entry_count)
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn make_jar(entries: &[(&str, &[u8])]) -> Vec<u8> {
318        use std::io::{Cursor, Write};
319        use zip::write::SimpleFileOptions;
320        let mut buf = Vec::new();
321        let cursor = Cursor::new(&mut buf);
322        let mut zw = zip::ZipWriter::new(cursor);
323        let opts = SimpleFileOptions::default()
324            .compression_method(zip::CompressionMethod::Deflated);
325        for (name, data) in entries {
326            zw.start_file(*name, opts).unwrap();
327            zw.write_all(data).unwrap();
328        }
329        zw.finish().unwrap();
330        buf
331    }
332
333    #[test]
334    fn single_entry() {
335        let jar = make_jar(&[("Hello.class", b"cafebabe")]);
336        let locs = read_central_directory(&jar).unwrap();
337        assert_eq!(locs.len(), 1);
338        assert_eq!(locs[0].name, "Hello.class");
339        assert_eq!(locs[0].uncompressed_size, 8);
340    }
341
342    #[test]
343    fn directory_entry() {
344        let jar = make_jar(&[("META-INF/", b""), ("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\n")]);
345        let locs = read_central_directory(&jar).unwrap();
346        assert_eq!(locs.len(), 2);
347        assert!(locs[0].is_directory);
348        assert!(!locs[1].is_directory);
349    }
350}