1use std::io::{Cursor, Read};
18
19use serde::Deserialize;
20
21use super::ConvertError;
22
23pub const CONTAINER_ZIP_ID: &str = "container-zip";
26pub const CONTAINER_ZIP_VERSION: &str = "1.0.0";
28
29#[derive(Debug, Clone, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct ContainerLimits {
34 pub max_depth: usize,
36 pub max_children: usize,
38 pub max_expanded_bytes: u64,
41 pub max_member_size: u64,
43}
44
45impl Default for ContainerLimits {
46 fn default() -> Self {
47 ContainerLimits {
48 max_depth: 4,
49 max_children: 1000,
50 max_expanded_bytes: 1_073_741_824,
51 max_member_size: 268_435_456,
52 }
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct MemberMeta {
59 pub name: String,
61 pub safe_path: Option<String>,
65 pub declared_size: u64,
67 pub is_dir: bool,
69 pub is_symlink: bool,
71 pub encrypted: bool,
73}
74
75pub enum Inflated {
77 Ok(Vec<u8>),
79 TooLarge(u64),
82 Error(String),
84}
85
86pub struct ZipContainer<'a> {
88 archive: zip::ZipArchive<Cursor<&'a [u8]>>,
89}
90
91fn open_error(detail: impl std::fmt::Display) -> ConvertError {
92 ConvertError {
93 code: "container-unreadable",
94 message: format!("zip central directory unreadable: {detail}"),
95 }
96}
97
98impl<'a> ZipContainer<'a> {
99 pub fn open(bytes: &'a [u8]) -> Result<Self, ConvertError> {
101 let archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(open_error)?;
102 Ok(ZipContainer { archive })
103 }
104
105 pub fn len(&self) -> usize {
107 self.archive.len()
108 }
109
110 pub fn is_empty(&self) -> bool {
112 self.archive.is_empty()
113 }
114
115 pub fn listing(&mut self) -> Result<String, ConvertError> {
119 let mut out = String::new();
120 for index in 0..self.archive.len() {
121 let entry = self.archive.by_index(index).map_err(open_error)?;
122 out.push_str(&escape_listing_name(entry.name()));
123 out.push('\t');
124 out.push_str(&entry.size().to_string());
125 out.push('\n');
126 }
127 Ok(out)
128 }
129
130 pub fn meta(&mut self, index: usize) -> Result<MemberMeta, ConvertError> {
133 let entry = self.archive.by_index(index).map_err(open_error)?;
134 let safe_path = classify_member_name(entry.name());
135 Ok(MemberMeta {
136 name: entry.name().to_string(),
137 safe_path,
138 declared_size: entry.size(),
139 is_dir: entry.is_dir(),
140 is_symlink: entry.is_symlink(),
141 encrypted: entry.encrypted(),
142 })
143 }
144
145 pub fn inflate(&mut self, index: usize, cap: u64) -> Inflated {
149 let entry = match self.archive.by_index(index) {
150 Ok(entry) => entry,
151 Err(e) => return Inflated::Error(e.to_string()),
152 };
153 let mut buffer = Vec::new();
154 match entry.take(cap + 1).read_to_end(&mut buffer) {
155 Ok(_) => {
156 if buffer.len() as u64 > cap {
157 Inflated::TooLarge(buffer.len() as u64)
158 } else {
159 Inflated::Ok(buffer)
160 }
161 }
162 Err(e) => Inflated::Error(e.to_string()),
163 }
164 }
165}
166
167pub fn classify_member_name(raw: &str) -> Option<String> {
176 if raw.is_empty() {
177 return None;
178 }
179 if raw.chars().any(|c| c.is_control()) {
183 return None;
184 }
185 if raw.starts_with('/') || raw.starts_with('\\') {
187 return None;
188 }
189 let drive = raw.as_bytes();
191 if drive.len() >= 2 && drive[0].is_ascii_alphabetic() && drive[1] == b':' {
192 return None;
193 }
194 let mut segments = Vec::new();
195 for segment in raw.split(['/', '\\']) {
196 if segment.is_empty() || segment == "." || segment == ".." {
197 return None;
198 }
199 segments.push(segment);
200 }
201 if segments.is_empty() {
202 return None;
203 }
204 Some(segments.join("/"))
205}
206
207fn escape_listing_name(name: &str) -> String {
211 let mut out = String::with_capacity(name.len());
212 for c in name.chars() {
213 match c {
214 '\n' => out.push_str("\\n"),
215 '\r' => out.push_str("\\r"),
216 '\t' => out.push_str("\\t"),
217 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
218 c => out.push(c),
219 }
220 }
221 out
222}
223
224pub fn preflight_entry_count(bytes: &[u8]) -> Option<u64> {
236 const EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
237 const LOCATOR_SIG: [u8; 4] = [0x50, 0x4b, 0x06, 0x07];
238 const ZIP64_EOCD_SIG: [u8; 4] = [0x50, 0x4b, 0x06, 0x06];
239 let scan_start = bytes.len().saturating_sub(22 + 0xFFFF);
241 let window = &bytes[scan_start..];
242 let mut eocd = None;
243 let upper = window.len().saturating_sub(4);
244 for start in (0..=upper).rev() {
245 if window[start..start + 4] == EOCD_SIG {
246 eocd = Some(scan_start + start);
247 break;
248 }
249 }
250 let eocd = eocd?;
251 if eocd + 22 > bytes.len() {
252 return None;
253 }
254 let total = u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]) as u64;
255 if total != 0xFFFF {
256 return Some(total);
257 }
258 if eocd < 20 {
260 return Some(total);
261 }
262 let locator = eocd - 20;
263 if bytes[locator..locator + 4] != LOCATOR_SIG {
264 return Some(total);
265 }
266 let z64_offset = u64::from_le_bytes(bytes[locator + 8..locator + 16].try_into().ok()?) as usize;
267 if z64_offset + 40 > bytes.len() || bytes[z64_offset..z64_offset + 4] != ZIP64_EOCD_SIG {
268 return Some(total);
269 }
270 Some(u64::from_le_bytes(
271 bytes[z64_offset + 32..z64_offset + 40].try_into().ok()?,
272 ))
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use std::io::Write;
279 use zip::write::SimpleFileOptions;
280
281 fn zip_with(entries: &[(&str, &[u8])]) -> Vec<u8> {
282 let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
283 for (name, bytes) in entries {
284 writer
285 .start_file(*name, SimpleFileOptions::default())
286 .unwrap();
287 writer.write_all(bytes).unwrap();
288 }
289 writer.finish().unwrap().into_inner()
290 }
291
292 #[test]
293 fn listing_names_every_entry_with_its_declared_size() {
294 let bytes = zip_with(&[("a.txt", b"hello"), ("dir/b.txt", b"hi")]);
295 let Ok(mut container) = ZipContainer::open(&bytes) else {
296 panic!("open failed")
297 };
298 assert_eq!(container.len(), 2);
299 let listing = container.listing().unwrap();
300 assert_eq!(listing, "a.txt\t5\ndir/b.txt\t2\n");
301 }
302
303 #[test]
304 fn a_normal_member_has_a_safe_path() {
305 let bytes = zip_with(&[("inner/report.txt", b"x")]);
306 let Ok(mut container) = ZipContainer::open(&bytes) else {
307 panic!("open failed")
308 };
309 let meta = container.meta(0).unwrap();
310 assert_eq!(meta.safe_path.as_deref(), Some("inner/report.txt"));
311 assert!(!meta.is_dir && !meta.is_symlink && !meta.encrypted);
312 }
313
314 #[test]
315 fn a_traversal_member_has_no_safe_path() {
316 let bytes = zip_with(&[("../escape.txt", b"x")]);
317 let Ok(mut container) = ZipContainer::open(&bytes) else {
318 panic!("open failed")
319 };
320 let meta = container.meta(0).unwrap();
321 assert_eq!(meta.safe_path, None);
322 }
323
324 #[test]
325 fn inflation_stops_one_byte_past_the_cap() {
326 let big = [b'x'; 100];
327 let bytes = zip_with(&[("big.txt", &big)]);
328 let Ok(mut container) = ZipContainer::open(&bytes) else {
329 panic!("open failed")
330 };
331 match container.inflate(0, 10) {
332 Inflated::TooLarge(n) => assert_eq!(n, 11),
333 _ => panic!("expected TooLarge"),
334 }
335 match container.inflate(0, 1000) {
336 Inflated::Ok(data) => assert_eq!(data.len(), 100),
337 _ => panic!("expected Ok"),
338 }
339 }
340
341 #[test]
342 fn member_name_validation_refuses_unsafe_shapes() {
343 assert_eq!(
344 classify_member_name("inner/report.txt").as_deref(),
345 Some("inner/report.txt")
346 );
347 assert_eq!(classify_member_name("a\\b.txt").as_deref(), Some("a/b.txt"));
348 assert_eq!(classify_member_name("../escape.txt"), None);
349 assert_eq!(classify_member_name("/abs.txt"), None);
350 assert_eq!(classify_member_name("C:\\win.txt"), None);
351 assert_eq!(classify_member_name("line1\nline2.txt"), None);
352 assert_eq!(classify_member_name("tab\there.txt"), None);
353 assert_eq!(classify_member_name(""), None);
354 assert_eq!(classify_member_name("dir/"), None);
355 }
356
357 #[test]
358 fn listing_escapes_control_characters_in_names() {
359 let bytes = zip_with(&[("a\tb.txt", b"x"), ("c\nd.txt", b"y")]);
360 let Ok(mut container) = ZipContainer::open(&bytes) else {
361 panic!("open failed")
362 };
363 let listing = container.listing().unwrap();
364 assert!(listing.contains("a\\tb.txt\t1\n"), "{listing:?}");
365 assert!(listing.contains("c\\nd.txt\t1\n"), "{listing:?}");
366 assert_eq!(listing.lines().count(), 2);
369 }
370
371 #[test]
372 fn preflight_reads_the_entry_count_from_the_eocd() {
373 let bytes = zip_with(&[("a.txt", b"1"), ("b.txt", b"2"), ("c.txt", b"3")]);
374 assert_eq!(preflight_entry_count(&bytes), Some(3));
375 assert_eq!(preflight_entry_count(b"not a zip"), None);
376 }
377
378 #[test]
379 fn a_non_zip_fails_to_open() {
380 let err = match ZipContainer::open(b"not a zip at all") {
381 Ok(_) => panic!("expected an open error"),
382 Err(e) => e,
383 };
384 assert_eq!(err.code, "container-unreadable");
385 }
386}