Skip to main content

s_zip/
reader.rs

1//! Streaming ZIP reader - reads ZIP files without loading entire central directory
2//!
3//! This is a minimal ZIP reader that can extract specific files from a ZIP archive
4//! without loading the entire central directory into memory.
5
6use crate::error::{Result, SZipError};
7use flate2::read::DeflateDecoder;
8use std::fs::File;
9use std::io::{BufReader, Read, Seek, SeekFrom};
10use std::path::{Component, Path, PathBuf};
11
12#[cfg(feature = "encryption")]
13use crate::encryption::{AesDecryptor, AesStrength};
14
15/// ZIP local file header signature
16const LOCAL_FILE_HEADER_SIGNATURE: u32 = 0x04034b50;
17
18/// ZIP central directory signature
19const CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x02014b50;
20
21/// ZIP end of central directory signature
22const END_OF_CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x06054b50;
23
24/// ZIP64 end of central directory record signature
25const ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x06064b50;
26
27/// Maximum single-entry allocation (2 GiB).
28///
29/// Prevents OOM when reading a corrupt or maliciously crafted ZIP that
30/// advertises a huge compressed_size (e.g. u64::MAX) in its central
31/// directory. Entries genuinely larger than this threshold must use the
32/// streaming API (`read_entry_streaming`).
33const MAX_ENTRY_ALLOC: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB
34
35// ZIP64 end of central directory locator signature (not used as a u32 constant)
36
37/// Entry in the ZIP central directory
38#[derive(Debug, Clone)]
39pub struct ZipEntry {
40    pub name: String,
41    pub compressed_size: u64,
42    pub uncompressed_size: u64,
43    pub compression_method: u16,
44    pub offset: u64,
45    #[cfg(feature = "encryption")]
46    pub is_encrypted: bool,
47}
48
49impl ZipEntry {
50    /// Return a sanitized extraction path that is safe against zip-slip attacks.
51    ///
52    /// Strips:
53    /// - Leading `/` and `\` (absolute paths)
54    /// - Any `..` components (parent directory traversal)
55    /// - Windows drive prefixes (e.g. `C:`)
56    ///
57    /// ```
58    /// # use s_zip::reader::ZipEntry;
59    /// // A malicious entry name like "../../../etc/passwd" becomes "etc/passwd"
60    /// ```
61    ///
62    /// Always use this method when extracting entries to disk. Never use
63    /// `entry.name` directly as a filesystem path.
64    ///
65    /// # Example
66    /// ```no_run
67    /// # use s_zip::StreamingZipReader;
68    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
69    /// let mut reader = StreamingZipReader::open("archive.zip")?;
70    /// let output_dir = std::path::Path::new("./output");
71    /// for entry in reader.entries() {
72    ///     let dest = output_dir.join(entry.safe_path());
73    ///     // dest is guaranteed to be inside output_dir
74    /// }
75    /// # Ok(())
76    /// # }
77    /// ```
78    pub fn safe_path(&self) -> PathBuf {
79        Path::new(&self.name)
80            .components()
81            .filter(|c| matches!(c, Component::Normal(_)))
82            .collect()
83    }
84}
85
86/// Streaming ZIP archive reader with adaptive buffering
87pub struct StreamingZipReader {
88    file: BufReader<File>,
89    entries: Vec<ZipEntry>,
90    #[cfg(feature = "encryption")]
91    password: Option<String>,
92}
93
94impl StreamingZipReader {
95    /// Open a ZIP file and read its central directory with default buffer size
96    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
97        Self::open_with_buffer_size(path, None)
98    }
99
100    /// Open a ZIP file with custom buffer size for optimized reading
101    ///
102    /// Providing a buffer size hint can improve read performance:
103    /// - Small ZIPs (<10MB): 32KB buffer
104    /// - Medium ZIPs (<100MB): 128KB buffer  
105    /// - Large ZIPs (≥100MB): 512KB buffer (default)
106    ///
107    /// # Example
108    /// ```no_run
109    /// # use s_zip::StreamingZipReader;
110    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
111    /// // Optimize for large ZIP files
112    /// let reader = StreamingZipReader::open_with_buffer_size(
113    ///     "large_archive.zip",
114    ///     Some(1024 * 1024) // 1MB buffer for very large files
115    /// )?;
116    /// # Ok(())
117    /// # }
118    /// ```
119    pub fn open_with_buffer_size<P: AsRef<Path>>(
120        path: P,
121        buffer_size: Option<usize>,
122    ) -> Result<Self> {
123        let file = File::open(path)?;
124
125        // Use adaptive buffer size
126        let buf_size = buffer_size.unwrap_or(512 * 1024); // Default 512KB
127        let mut file = BufReader::with_capacity(buf_size, file);
128
129        // Find and read central directory
130        let entries = Self::read_central_directory(&mut file)?;
131
132        Ok(StreamingZipReader {
133            file,
134            entries,
135            #[cfg(feature = "encryption")]
136            password: None,
137        })
138    }
139
140    /// Set password for decrypting encrypted entries
141    #[cfg(feature = "encryption")]
142    pub fn set_password(&mut self, password: impl Into<String>) -> &mut Self {
143        self.password = Some(password.into());
144        self
145    }
146
147    /// Clear password
148    #[cfg(feature = "encryption")]
149    pub fn clear_password(&mut self) -> &mut Self {
150        self.password = None;
151        self
152    }
153
154    /// Get list of all entries in the ZIP
155    pub fn entries(&self) -> &[ZipEntry] {
156        &self.entries
157    }
158
159    /// Find an entry by name
160    pub fn find_entry(&self, name: &str) -> Option<&ZipEntry> {
161        self.entries.iter().find(|e| e.name == name)
162    }
163
164    /// Read an entry's decompressed data into a vector
165    pub fn read_entry(&mut self, entry: &ZipEntry) -> Result<Vec<u8>> {
166        // Seek to local file header
167        self.file.seek(SeekFrom::Start(entry.offset))?;
168
169        // Read and verify local file header
170        let signature = self.read_u32_le()?;
171        if signature != LOCAL_FILE_HEADER_SIGNATURE {
172            return Err(SZipError::InvalidFormat(
173                "Invalid local file header signature".to_string(),
174            ));
175        }
176
177        // Skip version
178        self.file.seek(SeekFrom::Current(2))?;
179
180        // Read flags to check for encryption
181        let flags = self.read_u16_le()?;
182        let is_encrypted = (flags & 0x01) != 0;
183
184        // Read compression method
185        let _compression_method = self.read_u16_le()?;
186
187        // Skip modification time and date, CRC-32
188        self.file.seek(SeekFrom::Current(8))?;
189
190        // Read compressed and uncompressed sizes (already known from central directory)
191        self.file.seek(SeekFrom::Current(8))?;
192
193        // Read filename length and extra field length
194        let filename_len = self.read_u16_le()? as i64;
195        let extra_len = self.read_u16_le()? as usize;
196
197        // Skip filename
198        self.file.seek(SeekFrom::Current(filename_len))?;
199
200        // Check for AES encryption in extra field
201        #[cfg(feature = "encryption")]
202        let encryption_info = if is_encrypted {
203            self.parse_aes_extra_field(extra_len)?
204        } else {
205            // Skip extra field if not encrypted
206            self.file.seek(SeekFrom::Current(extra_len as i64))?;
207            None
208        };
209
210        #[cfg(not(feature = "encryption"))]
211        {
212            if is_encrypted {
213                return Err(SZipError::InvalidFormat(
214                    "Encrypted entry found but encryption feature not enabled".to_string(),
215                ));
216            }
217            // Skip extra field
218            self.file.seek(SeekFrom::Current(extra_len as i64))?;
219        }
220
221        // Calculate actual data size (subtract salt, password verify, and auth code for encrypted entries)
222        #[cfg(feature = "encryption")]
223        let data_size = if let Some((strength, _, _)) = encryption_info {
224            // Subtract salt (already read), password verify (already read), and auth code (10 bytes at end)
225            entry
226                .compressed_size
227                .saturating_sub((strength.salt_size() + 2 + 10) as u64)
228        } else {
229            entry.compressed_size
230        };
231
232        #[cfg(not(feature = "encryption"))]
233        let data_size = entry.compressed_size;
234
235        // Guard against OOM from corrupt/malicious compressed_size values.
236        // Entries larger than 2 GiB must use read_entry_streaming() instead.
237        if data_size > MAX_ENTRY_ALLOC {
238            return Err(SZipError::InvalidFormat(format!(
239                "Entry '{}' is too large to read into memory ({} bytes). \
240                 Use read_entry_streaming() for entries larger than 2 GiB.",
241                entry.name, data_size
242            )));
243        }
244
245        // Now read the compressed data
246        let mut compressed_data = vec![0u8; data_size as usize];
247        self.file.read_exact(&mut compressed_data)?;
248
249        // Read auth code if encrypted
250        #[cfg(feature = "encryption")]
251        let auth_code = if encryption_info.is_some() {
252            let mut ac = vec![0u8; 10];
253            self.file.read_exact(&mut ac)?;
254            Some(ac)
255        } else {
256            None
257        };
258
259        // Decrypt if encrypted (Step 1: Decrypt compressed data)
260        #[cfg(feature = "encryption")]
261        let decryptor_opt = if let Some((strength, salt, pw_verify)) = encryption_info {
262            let password = self.password.as_ref().ok_or_else(|| {
263                SZipError::InvalidFormat("Encrypted entry but no password set".to_string())
264            })?;
265
266            // Create decryptor (password verification happens inside new())
267            let mut decryptor = AesDecryptor::new(password, strength, &salt, &pw_verify)?;
268
269            // Decrypt compressed data in-place
270            decryptor.decrypt(&mut compressed_data)?;
271
272            Some(decryptor)
273        } else {
274            None
275        };
276
277        // Decompress if needed (Step 2: Decompress decrypted data)
278        let data = if entry.compression_method == 8 {
279            // DEFLATE compression
280            let mut decoder = DeflateDecoder::new(&compressed_data[..]);
281            let mut decompressed = Vec::new();
282            decoder.read_to_end(&mut decompressed)?;
283            decompressed
284        } else if entry.compression_method == 0 {
285            // No compression (stored)
286            compressed_data
287        } else if entry.compression_method == 93 {
288            // Zstd compression
289            #[cfg(feature = "zstd-support")]
290            {
291                zstd::decode_all(&compressed_data[..])?
292            }
293            #[cfg(not(feature = "zstd-support"))]
294            {
295                return Err(SZipError::UnsupportedCompression(entry.compression_method));
296            }
297        } else {
298            return Err(SZipError::UnsupportedCompression(entry.compression_method));
299        };
300
301        // Verify HMAC authentication (Step 3: Update HMAC with plaintext and verify)
302        #[cfg(feature = "encryption")]
303        if let Some(mut decryptor) = decryptor_opt {
304            // Update HMAC with decompressed plaintext data
305            decryptor.update_hmac(&data);
306
307            // Verify authentication code
308            if let Some(ac) = auth_code {
309                decryptor.verify_auth_code(&ac)?;
310            }
311        }
312
313        Ok(data)
314    }
315
316    /// Read an entry by name
317    pub fn read_entry_by_name(&mut self, name: &str) -> Result<Vec<u8>> {
318        let entry = self
319            .find_entry(name)
320            .ok_or_else(|| SZipError::EntryNotFound(name.to_string()))?
321            .clone();
322
323        self.read_entry(&entry)
324    }
325
326    /// Get a streaming reader for an entry by name (for large files)
327    /// Returns a reader that decompresses data on-the-fly without loading everything into memory
328    pub fn read_entry_streaming_by_name(&mut self, name: &str) -> Result<Box<dyn Read + '_>> {
329        let entry = self
330            .find_entry(name)
331            .ok_or_else(|| SZipError::EntryNotFound(name.to_string()))?
332            .clone();
333
334        self.read_entry_streaming(&entry)
335    }
336
337    /// Get a streaming reader for an entry (for large files)
338    /// Returns a reader that decompresses data on-the-fly without loading everything into memory
339    ///
340    /// # Errors
341    /// Returns `SZipError::EncryptionError` if the entry is encrypted.
342    /// Use `read_entry()` for encrypted entries, which loads the full entry and
343    /// decrypts it. Streaming decryption is tracked in TODO [P2-3].
344    pub fn read_entry_streaming(&mut self, entry: &ZipEntry) -> Result<Box<dyn Read + '_>> {
345        // Encrypted entries cannot be streamed: we need the full ciphertext to
346        // verify the HMAC auth code before exposing any plaintext.
347        #[cfg(feature = "encryption")]
348        if entry.is_encrypted {
349            return Err(SZipError::EncryptionError(
350                "Streaming read is not supported for encrypted entries. \
351                 Use read_entry() instead, which decrypts and authenticates the full entry."
352                    .to_string(),
353            ));
354        }
355
356        // Seek to local file header
357        self.file.seek(SeekFrom::Start(entry.offset))?;
358
359        // Read and verify local file header
360        let signature = self.read_u32_le()?;
361        if signature != LOCAL_FILE_HEADER_SIGNATURE {
362            return Err(SZipError::InvalidFormat(
363                "Invalid local file header signature".to_string(),
364            ));
365        }
366
367        // Skip version, flags, compression method
368        self.file.seek(SeekFrom::Current(6))?;
369
370        // Skip modification time and date, CRC-32
371        self.file.seek(SeekFrom::Current(8))?;
372
373        // Read compressed and uncompressed sizes
374        self.file.seek(SeekFrom::Current(8))?;
375
376        // Read filename length and extra field length
377        let filename_len = self.read_u16_le()? as i64;
378        let extra_len = self.read_u16_le()? as i64;
379
380        // Skip filename and extra field
381        self.file
382            .seek(SeekFrom::Current(filename_len + extra_len))?;
383
384        // Create a reader limited to compressed data size
385        let limited_reader = (&mut self.file).take(entry.compressed_size);
386
387        // Wrap with decompressor if needed
388        if entry.compression_method == 8 {
389            // DEFLATE compression
390            Ok(Box::new(DeflateDecoder::new(limited_reader)))
391        } else if entry.compression_method == 0 {
392            // No compression (stored)
393            Ok(Box::new(limited_reader))
394        } else if entry.compression_method == 93 {
395            // Zstd compression
396            #[cfg(feature = "zstd-support")]
397            {
398                Ok(Box::new(zstd::Decoder::new(limited_reader)?))
399            }
400            #[cfg(not(feature = "zstd-support"))]
401            {
402                Err(SZipError::UnsupportedCompression(entry.compression_method))
403            }
404        } else {
405            Err(SZipError::UnsupportedCompression(entry.compression_method))
406        }
407    }
408
409    /// Get a streaming reader for an entry by name
410    pub fn read_entry_by_name_streaming(&mut self, name: &str) -> Result<Box<dyn Read + '_>> {
411        let entry = self
412            .find_entry(name)
413            .ok_or_else(|| SZipError::EntryNotFound(name.to_string()))?
414            .clone();
415
416        self.read_entry_streaming(&entry)
417    }
418
419    /// Read the central directory from the ZIP file
420    fn read_central_directory(file: &mut BufReader<File>) -> Result<Vec<ZipEntry>> {
421        // Find end of central directory record
422        let eocd_offset = Self::find_eocd(file)?;
423
424        // Seek to EOCD
425        file.seek(SeekFrom::Start(eocd_offset))?;
426
427        // Read EOCD
428        let signature = Self::read_u32_le_static(file)?;
429        if signature != END_OF_CENTRAL_DIRECTORY_SIGNATURE {
430            return Err(SZipError::InvalidFormat(format!(
431                "Invalid end of central directory signature: 0x{:08x}",
432                signature
433            )));
434        }
435
436        // Skip disk number fields (4 bytes)
437        file.seek(SeekFrom::Current(4))?;
438
439        // Read number of entries on this disk (2 bytes)
440        let _entries_on_disk = Self::read_u16_le_static(file)?;
441
442        // Read total number of entries (2 bytes)
443
444        // These values may be placeholder 0xFFFF/0xFFFFFFFF when ZIP64 is used
445        let total_entries_16 = Self::read_u16_le_static(file)?;
446
447        // Read central directory size (4 bytes)
448        let cd_size_32 = Self::read_u32_le_static(file)?;
449
450        // Read central directory offset (4 bytes)
451        let cd_offset_32 = Self::read_u32_le_static(file)? as u64;
452
453        // Promote to u64 and handle ZIP64 if markers present
454        let mut total_entries = total_entries_16 as usize;
455        let mut cd_offset = cd_offset_32;
456        let _cd_size = cd_size_32 as u64;
457
458        if total_entries_16 == 0xFFFF || cd_size_32 == 0xFFFFFFFF || cd_offset_32 == 0xFFFFFFFF {
459            // Need to find ZIP64 EOCD locator and read ZIP64 EOCD record
460            let (zip64_total_entries, zip64_cd_size, zip64_cd_offset) =
461                Self::read_zip64_eocd(file, eocd_offset)?;
462            total_entries = zip64_total_entries as usize;
463            cd_offset = zip64_cd_offset;
464            // _cd_size can be used if needed (zip64_cd_size)
465            let _ = zip64_cd_size;
466        }
467
468        // Seek to central directory
469        file.seek(SeekFrom::Start(cd_offset))?;
470
471        // Read all central directory entries
472        let mut entries = Vec::with_capacity(total_entries);
473        for _ in 0..total_entries {
474            let signature = Self::read_u32_le_static(file)?;
475            if signature != CENTRAL_DIRECTORY_SIGNATURE {
476                break;
477            }
478
479            // Skip version made by, version needed
480            file.seek(SeekFrom::Current(4))?;
481
482            // Read flags (needed for encryption check)
483            #[cfg_attr(not(feature = "encryption"), allow(unused_variables))]
484            let flags = Self::read_u16_le_static(file)?;
485
486            let compression_method = Self::read_u16_le_static(file)?;
487
488            // Skip modification time, date, CRC-32
489            file.seek(SeekFrom::Current(8))?;
490
491            // Read sizes as 32-bit placeholders (may be 0xFFFFFFFF meaning ZIP64)
492            let compressed_size_32 = Self::read_u32_le_static(file)? as u64;
493            let uncompressed_size_32 = Self::read_u32_le_static(file)? as u64;
494            let filename_len = Self::read_u16_le_static(file)? as usize;
495            let extra_len = Self::read_u16_le_static(file)? as usize;
496            let comment_len = Self::read_u16_le_static(file)? as usize;
497
498            // Skip disk number, internal attributes, external attributes
499            file.seek(SeekFrom::Current(8))?;
500
501            let mut offset = Self::read_u32_le_static(file)? as u64;
502
503            // Read filename
504            let mut filename_buf = vec![0u8; filename_len];
505            file.read_exact(&mut filename_buf)?;
506            let name = String::from_utf8_lossy(&filename_buf).to_string();
507
508            // Read extra field so we can parse ZIP64 extra if present
509            let mut extra_buf = vec![0u8; extra_len];
510            if extra_len > 0 {
511                file.read_exact(&mut extra_buf)?;
512            }
513
514            // If sizes/offsets are 0xFFFFFFFF, parse ZIP64 extra field (0x0001)
515            let mut compressed_size = compressed_size_32;
516            let mut uncompressed_size = uncompressed_size_32;
517
518            if compressed_size_32 == 0xFFFFFFFF
519                || uncompressed_size_32 == 0xFFFFFFFF
520                || offset == 0xFFFFFFFF
521            {
522                // parse extra fields
523                let mut i = 0usize;
524                while i + 4 <= extra_buf.len() {
525                    let id = u16::from_le_bytes([extra_buf[i], extra_buf[i + 1]]);
526                    let data_len =
527                        u16::from_le_bytes([extra_buf[i + 2], extra_buf[i + 3]]) as usize;
528                    i += 4;
529                    if i + data_len > extra_buf.len() {
530                        break;
531                    }
532                    if id == 0x0001 {
533                        // ZIP64 extra field: contains values in order: original size, compressed size, relative header offset, disk start
534                        let mut cursor = 0usize;
535                        // read uncompressed size if placeholder present
536                        if uncompressed_size_32 == 0xFFFFFFFF && cursor + 8 <= data_len {
537                            uncompressed_size = u64::from_le_bytes([
538                                extra_buf[i + cursor],
539                                extra_buf[i + cursor + 1],
540                                extra_buf[i + cursor + 2],
541                                extra_buf[i + cursor + 3],
542                                extra_buf[i + cursor + 4],
543                                extra_buf[i + cursor + 5],
544                                extra_buf[i + cursor + 6],
545                                extra_buf[i + cursor + 7],
546                            ]);
547                            cursor += 8;
548                        }
549                        // read compressed size if placeholder present
550                        if compressed_size_32 == 0xFFFFFFFF && cursor + 8 <= data_len {
551                            compressed_size = u64::from_le_bytes([
552                                extra_buf[i + cursor],
553                                extra_buf[i + cursor + 1],
554                                extra_buf[i + cursor + 2],
555                                extra_buf[i + cursor + 3],
556                                extra_buf[i + cursor + 4],
557                                extra_buf[i + cursor + 5],
558                                extra_buf[i + cursor + 6],
559                                extra_buf[i + cursor + 7],
560                            ]);
561                            cursor += 8;
562                        }
563                        // read offset if placeholder present
564                        if offset == 0xFFFFFFFF && cursor + 8 <= data_len {
565                            offset = u64::from_le_bytes([
566                                extra_buf[i + cursor],
567                                extra_buf[i + cursor + 1],
568                                extra_buf[i + cursor + 2],
569                                extra_buf[i + cursor + 3],
570                                extra_buf[i + cursor + 4],
571                                extra_buf[i + cursor + 5],
572                                extra_buf[i + cursor + 6],
573                                extra_buf[i + cursor + 7],
574                            ]);
575                        }
576                        // we don't need disk start here
577                        break;
578                    }
579                    i += data_len;
580                }
581            }
582
583            // Skip comment
584            if comment_len > 0 {
585                file.seek(SeekFrom::Current(comment_len as i64))?;
586            }
587
588            entries.push(ZipEntry {
589                name,
590                compressed_size,
591                uncompressed_size,
592                compression_method,
593                offset,
594                #[cfg(feature = "encryption")]
595                is_encrypted: (flags & 0x01) != 0,
596            });
597        }
598
599        Ok(entries)
600    }
601
602    /// When EOCD indicates ZIP64 usage, find and read ZIP64 EOCD locator and record
603    fn read_zip64_eocd(file: &mut BufReader<File>, eocd_offset: u64) -> Result<(u64, u64, u64)> {
604        // Search backwards from EOCD for ZIP64 EOCD locator signature (50 4b 06 07)
605        let search_start = eocd_offset.saturating_sub(65557);
606        file.seek(SeekFrom::Start(search_start))?;
607        let mut buffer = Vec::new();
608        file.read_to_end(&mut buffer)?;
609
610        let mut locator_pos: Option<usize> = None;
611        for i in (0..buffer.len().saturating_sub(3)).rev() {
612            if buffer[i] == 0x50
613                && buffer[i + 1] == 0x4b
614                && buffer[i + 2] == 0x06
615                && buffer[i + 3] == 0x07
616            {
617                locator_pos = Some(i);
618                break;
619            }
620        }
621
622        let locator_pos = locator_pos
623            .ok_or_else(|| SZipError::InvalidFormat("ZIP64 EOCD locator not found".to_string()))?;
624
625        // Read locator fields from buffer
626        // locator layout: signature(4), number of the disk with the start of the zip64 eocd(4), relative offset of the zip64 eocd(8), total number of disks(4)
627        let rel_off_bytes = &buffer[locator_pos + 8..locator_pos + 16];
628        let zip64_eocd_offset = u64::from_le_bytes([
629            rel_off_bytes[0],
630            rel_off_bytes[1],
631            rel_off_bytes[2],
632            rel_off_bytes[3],
633            rel_off_bytes[4],
634            rel_off_bytes[5],
635            rel_off_bytes[6],
636            rel_off_bytes[7],
637        ]);
638
639        // Seek to ZIP64 EOCD record
640        file.seek(SeekFrom::Start(zip64_eocd_offset))?;
641
642        let sig = Self::read_u32_le_static(file)?;
643        if sig != ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE {
644            return Err(SZipError::InvalidFormat(format!(
645                "Invalid ZIP64 EOCD signature: 0x{:08x}",
646                sig
647            )));
648        }
649
650        // size of ZIP64 EOCD record (8 bytes)
651        let _size = {
652            let mut buf = [0u8; 8];
653            file.read_exact(&mut buf)?;
654            u64::from_le_bytes(buf)
655        };
656
657        // skip version made by (2), version needed (2), disk number (4), disk where central dir starts (4)
658        file.seek(SeekFrom::Current(12))?;
659
660        // total number of entries on this disk (8)
661        let total_entries = {
662            let mut buf = [0u8; 8];
663            file.read_exact(&mut buf)?;
664            u64::from_le_bytes(buf)
665        };
666
667        // total number of entries (8) - some implementations write both; ignore the second value
668        {
669            let mut buf = [0u8; 8];
670            file.read_exact(&mut buf)?;
671            // ignore u64::from_le_bytes(buf)
672        }
673
674        // central directory size (8)
675        let cd_size = {
676            let mut buf = [0u8; 8];
677            file.read_exact(&mut buf)?;
678            u64::from_le_bytes(buf)
679        };
680
681        // central directory offset (8)
682        let cd_offset = {
683            let mut buf = [0u8; 8];
684            file.read_exact(&mut buf)?;
685            u64::from_le_bytes(buf)
686        };
687
688        Ok((total_entries, cd_size, cd_offset))
689    }
690
691    /// Find the end of central directory record by scanning from the end of the file
692    fn find_eocd(file: &mut BufReader<File>) -> Result<u64> {
693        let file_size = file.seek(SeekFrom::End(0))?;
694
695        // EOCD is at least 22 bytes, search last 65KB (max comment size + EOCD)
696        let search_start = file_size.saturating_sub(65557);
697        file.seek(SeekFrom::Start(search_start))?;
698
699        let mut buffer = Vec::new();
700        file.read_to_end(&mut buffer)?;
701
702        // Search for EOCD signature from the end
703        for i in (0..buffer.len().saturating_sub(3)).rev() {
704            if buffer[i] == 0x50
705                && buffer[i + 1] == 0x4b
706                && buffer[i + 2] == 0x05
707                && buffer[i + 3] == 0x06
708            {
709                return Ok(search_start + i as u64);
710            }
711        }
712
713        Err(SZipError::InvalidFormat(
714            "End of central directory not found".to_string(),
715        ))
716    }
717
718    fn read_u16_le(&mut self) -> Result<u16> {
719        let mut buf = [0u8; 2];
720        self.file.read_exact(&mut buf)?;
721        Ok(u16::from_le_bytes(buf))
722    }
723
724    fn read_u32_le(&mut self) -> Result<u32> {
725        let mut buf = [0u8; 4];
726        self.file.read_exact(&mut buf)?;
727        Ok(u32::from_le_bytes(buf))
728    }
729
730    fn read_u16_le_static(file: &mut BufReader<File>) -> Result<u16> {
731        let mut buf = [0u8; 2];
732        file.read_exact(&mut buf)?;
733        Ok(u16::from_le_bytes(buf))
734    }
735
736    fn read_u32_le_static(file: &mut BufReader<File>) -> Result<u32> {
737        let mut buf = [0u8; 4];
738        file.read_exact(&mut buf)?;
739        Ok(u32::from_le_bytes(buf))
740    }
741
742    /// Parse AES encryption info from extra field
743    #[cfg(feature = "encryption")]
744    #[allow(clippy::type_complexity)]
745    fn parse_aes_extra_field(
746        &mut self,
747        extra_len: usize,
748    ) -> Result<Option<(AesStrength, Vec<u8>, [u8; 2])>> {
749        if extra_len == 0 {
750            return Ok(None);
751        }
752
753        let mut extra_buf = vec![0u8; extra_len];
754        self.file.read_exact(&mut extra_buf)?;
755
756        // Parse extra fields looking for AES extra (0x9901)
757        let mut i = 0usize;
758        while i + 4 <= extra_buf.len() {
759            let id = u16::from_le_bytes([extra_buf[i], extra_buf[i + 1]]);
760            let data_len = u16::from_le_bytes([extra_buf[i + 2], extra_buf[i + 3]]) as usize;
761            i += 4;
762
763            if i + data_len > extra_buf.len() {
764                break;
765            }
766
767            if id == 0x9901 {
768                // WinZip AES encryption extra field
769                // Layout: version(2) + vendor(2) + strength(2) + compression(2) + salt + pwverify(2)
770
771                if data_len < 7 {
772                    return Err(SZipError::InvalidFormat(
773                        "Invalid AES extra field".to_string(),
774                    ));
775                }
776
777                let strength_code = extra_buf[i + 4]; // AES strength is 1 byte, not 2!
778
779                let strength = match strength_code {
780                    0x03 => AesStrength::Aes256,
781                    _ => {
782                        return Err(SZipError::InvalidFormat(format!(
783                            "Unsupported AES strength: {}",
784                            strength_code
785                        )))
786                    }
787                };
788
789                // Read salt and password verification from actual file data (not extra field)
790                // Salt comes after the extra field, before compressed data
791                let salt_size = strength.salt_size();
792
793                let mut salt = vec![0u8; salt_size];
794                self.file.read_exact(&mut salt)?;
795
796                let mut pw_verify = [0u8; 2];
797                self.file.read_exact(&mut pw_verify)?;
798
799                return Ok(Some((strength, salt, pw_verify)));
800            }
801
802            i += data_len;
803        }
804
805        Ok(None)
806    }
807}