Skip to main content

s_zip/
format.rs

1//! Shared ZIP format constants, types, and pure parsing helpers.
2//!
3//! Both `reader` and `async_reader` import from here to avoid duplicating ~600
4//! lines of identical ZIP parsing logic.  None of the functions in this module
5//! perform I/O — they only operate on already-read byte slices so they can be
6//! used in both the sync and async code paths without any adaptation.
7
8use std::path::{Component, Path, PathBuf};
9
10// ── Signatures ────────────────────────────────────────────────────────────────
11
12/// ZIP local file header signature (`PK\x03\x04`)
13pub const LOCAL_FILE_HEADER_SIGNATURE: u32 = 0x04034b50;
14
15/// ZIP central directory entry signature (`PK\x01\x02`)
16pub const CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x02014b50;
17
18/// ZIP end-of-central-directory signature (`PK\x05\x06`)
19pub const END_OF_CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x06054b50;
20
21/// ZIP64 end-of-central-directory record signature (`PK\x06\x06`)
22pub const ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x06064b50;
23
24// ── Limits ────────────────────────────────────────────────────────────────────
25
26/// Maximum single-entry allocation (2 GiB).
27///
28/// Prevents OOM when reading a corrupt or maliciously crafted ZIP that
29/// advertises a huge `compressed_size` (e.g. `u64::MAX`) in its central
30/// directory.  Entries genuinely larger than this threshold must use the
31/// streaming API (`read_entry_streaming`).
32pub const MAX_ENTRY_ALLOC: u64 = 2 * 1024 * 1024 * 1024; // 2 GiB
33
34// ── Entry ─────────────────────────────────────────────────────────────────────
35
36/// Entry in a ZIP central directory.
37///
38/// Shared between the sync (`reader`) and async (`async_reader`) modules.
39#[derive(Debug, Clone)]
40pub struct ZipEntry {
41    pub name: String,
42    pub compressed_size: u64,
43    pub uncompressed_size: u64,
44    pub compression_method: u16,
45    /// Offset of the local file header from the start of the archive.
46    pub offset: u64,
47    /// CRC-32 checksum from the central directory.
48    pub crc32: u32,
49    /// `true` when general-purpose bit 0 (encryption flag) is set in the
50    /// central directory.  The sync reader gates this behind
51    /// `#[cfg(feature = "encryption")]` at the usage sites; the async reader
52    /// always exposes it.
53    pub is_encrypted: bool,
54}
55
56impl ZipEntry {
57    /// Return a sanitized extraction path safe against zip-slip attacks.
58    ///
59    /// Strips leading `/`, `\`, `..`, and Windows drive prefixes.
60    ///
61    /// Always use this method when extracting entries to disk.  Never use
62    /// `entry.name` directly as a filesystem path.
63    ///
64    /// # Example
65    /// ```no_run
66    /// # use s_zip::ZipEntry;
67    /// // A malicious entry "../../../etc/passwd" becomes "etc/passwd"
68    /// ```
69    pub fn safe_path(&self) -> PathBuf {
70        Path::new(&self.name)
71            .components()
72            .filter(|c| matches!(c, Component::Normal(_)))
73            .collect()
74    }
75}
76
77// ── Pure parsing helpers ──────────────────────────────────────────────────────
78
79/// Scan `buffer` (which starts at byte `search_start` in the file) for the
80/// end-of-central-directory signature and return its absolute file offset.
81///
82/// The search starts from the **end** of the buffer (most ZIPs have no
83/// comment, so EOCD is right at the end) and works backwards.
84///
85/// Returns `None` if the signature is not found.
86#[inline]
87pub fn find_eocd_in_buffer(buffer: &[u8], search_start: u64) -> Option<u64> {
88    for i in (0..buffer.len().saturating_sub(3)).rev() {
89        if buffer[i] == 0x50
90            && buffer[i + 1] == 0x4b
91            && buffer[i + 2] == 0x05
92            && buffer[i + 3] == 0x06
93        {
94            return Some(search_start + i as u64);
95        }
96    }
97    None
98}
99
100/// Scan `buffer` for the ZIP64 EOCD locator signature (`PK\x06\x07`) and
101/// return the absolute file offset of the ZIP64 EOCD *record* encoded inside
102/// the locator.
103///
104/// `buffer` must include the region between the start of the file (or a
105/// reasonable backward search window) and the EOCD record.
106///
107/// Returns `None` if the locator is not found or the buffer is too short.
108#[inline]
109pub fn find_zip64_eocd_offset(buffer: &[u8]) -> Option<u64> {
110    for i in (0..buffer.len().saturating_sub(3)).rev() {
111        if buffer[i] == 0x50
112            && buffer[i + 1] == 0x4b
113            && buffer[i + 2] == 0x06
114            && buffer[i + 3] == 0x07
115        {
116            // locator layout (after sig): disk_with_zip64_eocd(4), zip64_eocd_offset(8), total_disks(4)
117            if i + 16 > buffer.len() {
118                return None;
119            }
120            let rel_off_bytes = &buffer[i + 8..i + 16];
121            let offset = u64::from_le_bytes([
122                rel_off_bytes[0],
123                rel_off_bytes[1],
124                rel_off_bytes[2],
125                rel_off_bytes[3],
126                rel_off_bytes[4],
127                rel_off_bytes[5],
128                rel_off_bytes[6],
129                rel_off_bytes[7],
130            ]);
131            return Some(offset);
132        }
133    }
134    None
135}
136
137/// Parse a ZIP64 extra field (tag `0x0001`) out of `extra_buf` and return
138/// updated `(uncompressed_size, compressed_size, offset)`.
139///
140/// Only values whose 32-bit central-directory placeholder equals `0xFFFFFFFF`
141/// are replaced; the others are returned unchanged.
142///
143/// If no ZIP64 extra field is found the input values are returned as-is.
144#[inline]
145pub fn parse_zip64_extra_field(
146    extra_buf: &[u8],
147    compressed_size_32: u64,
148    uncompressed_size_32: u64,
149    offset_32: u64,
150) -> (u64, u64, u64) {
151    let mut compressed_size = compressed_size_32;
152    let mut uncompressed_size = uncompressed_size_32;
153    let mut offset = offset_32;
154
155    let mut i = 0usize;
156    while i + 4 <= extra_buf.len() {
157        let id = u16::from_le_bytes([extra_buf[i], extra_buf[i + 1]]);
158        let data_len = u16::from_le_bytes([extra_buf[i + 2], extra_buf[i + 3]]) as usize;
159        i += 4;
160        if i + data_len > extra_buf.len() {
161            break;
162        }
163        if id == 0x0001 {
164            // ZIP64 extra field: values present in this order — original size,
165            // compressed size, relative header offset, disk start — but only
166            // when the corresponding 32-bit field holds the placeholder 0xFFFFFFFF.
167            let mut cursor = 0usize;
168            if uncompressed_size_32 == 0xFFFFFFFF && cursor + 8 <= data_len {
169                uncompressed_size =
170                    u64::from_le_bytes(extra_buf[i + cursor..i + cursor + 8].try_into().unwrap());
171                cursor += 8;
172            }
173            if compressed_size_32 == 0xFFFFFFFF && cursor + 8 <= data_len {
174                compressed_size =
175                    u64::from_le_bytes(extra_buf[i + cursor..i + cursor + 8].try_into().unwrap());
176                cursor += 8;
177            }
178            if offset_32 == 0xFFFFFFFF && cursor + 8 <= data_len {
179                offset =
180                    u64::from_le_bytes(extra_buf[i + cursor..i + cursor + 8].try_into().unwrap());
181            }
182            break;
183        }
184        i += data_len;
185    }
186
187    (uncompressed_size, compressed_size, offset)
188}
189
190/// Parse WinZip AES extra field (ID `0x9901`) from `extra_buf`.
191///
192/// Returns `Some((strength_code, data_offset))` where `strength_code` is the
193/// single byte encoding the AES key length (0x01 = AES-128, 0x02 = AES-192,
194/// 0x03 = AES-256) and `data_offset` is the byte position immediately after
195/// the extra field (where the caller's further parsing stops — the salt and
196/// password-verification bytes are read from the *file data*, not the extra
197/// field itself).
198///
199/// Returns `None` if no AES extra field is found.
200#[inline]
201pub fn parse_aes_extra_field_buf(extra_buf: &[u8]) -> Option<u8> {
202    let mut i = 0usize;
203    while i + 4 <= extra_buf.len() {
204        let id = u16::from_le_bytes([extra_buf[i], extra_buf[i + 1]]);
205        let data_len = u16::from_le_bytes([extra_buf[i + 2], extra_buf[i + 3]]) as usize;
206        i += 4;
207        if i + data_len > extra_buf.len() {
208            break;
209        }
210        if id == 0x9901 {
211            // WinZip AES extra field: version(2)+vendor(2)+strength(1)+compression(2) = 7 bytes
212            if data_len >= 7 {
213                return Some(extra_buf[i + 4]);
214            }
215        }
216        i += data_len;
217    }
218    None
219}
220
221// ── Tests ─────────────────────────────────────────────────────────────────────
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_find_eocd_in_buffer_found() {
229        // Minimal valid EOCD: signature + 18 zeros
230        let mut buf = vec![0u8; 40];
231        buf[10] = 0x50;
232        buf[11] = 0x4b;
233        buf[12] = 0x05;
234        buf[13] = 0x06;
235        let offset = find_eocd_in_buffer(&buf, 1000).unwrap();
236        assert_eq!(offset, 1010);
237    }
238
239    #[test]
240    fn test_find_eocd_in_buffer_not_found() {
241        let buf = vec![0u8; 40];
242        assert!(find_eocd_in_buffer(&buf, 0).is_none());
243    }
244
245    #[test]
246    fn test_parse_zip64_extra_no_placeholder() {
247        // No 0xFFFFFFFF placeholders → values unchanged
248        let extra = [];
249        let (u, c, o) = parse_zip64_extra_field(&extra, 100, 200, 300);
250        assert_eq!((u, c, o), (200, 100, 300));
251    }
252
253    #[test]
254    fn test_parse_zip64_extra_with_zip64_field() {
255        // Build a ZIP64 extra field with all three values
256        let unc: u64 = 0xDEAD_BEEF_0000_0001;
257        let com: u64 = 0xDEAD_BEEF_0000_0002;
258        let off: u64 = 0xDEAD_BEEF_0000_0003;
259
260        let mut extra = Vec::new();
261        extra.extend_from_slice(&0x0001u16.to_le_bytes()); // tag
262        extra.extend_from_slice(&24u16.to_le_bytes()); // data len = 3 * 8
263        extra.extend_from_slice(&unc.to_le_bytes());
264        extra.extend_from_slice(&com.to_le_bytes());
265        extra.extend_from_slice(&off.to_le_bytes());
266
267        let (u, c, o) = parse_zip64_extra_field(&extra, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF);
268        assert_eq!(u, unc);
269        assert_eq!(c, com);
270        assert_eq!(o, off);
271    }
272
273    #[test]
274    fn test_safe_path_strips_dotdot() {
275        let entry = ZipEntry {
276            name: "../../etc/passwd".to_string(),
277            compressed_size: 0,
278            uncompressed_size: 0,
279            compression_method: 0,
280            offset: 0,
281            crc32: 0,
282            is_encrypted: false,
283        };
284        let p = entry.safe_path();
285        assert_eq!(p, PathBuf::from("etc/passwd"));
286    }
287}