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}