Skip to main content

text_mirror/convert/
container.rs

1//! Zip container expansion.
2//!
3//! A source detected as a plain zip is a container. Its artifact is a
4//! member listing, and each member is extracted and routed back
5//! through the pipeline's dispatch as a child source under a sibling
6//! `.d/` directory. The member loop, recursion, and record writing
7//! live in the pipeline. This module owns the zip reading: the
8//! listing, per-member hygiene, and capped inflation.
9//!
10//! The zip-bomb surface is bounded in two stages. The central
11//! directory is read first, so the child count and the declared sizes
12//! are checked before any inflation. Then each member inflates through
13//! a capped reader that never trusts a declared size as its
14//! allocation budget, and the actual inflated bytes charge a
15//! cumulative counter across the whole expansion.
16
17use std::io::{Cursor, Read};
18
19use serde::Deserialize;
20
21use super::ConvertError;
22
23/// Synthetic converter id recorded for a zip container's listing
24/// artifact. No registry entry claims it, the pipeline synthesizes it.
25pub const CONTAINER_ZIP_ID: &str = "container-zip";
26/// Version of the container listing format.
27pub const CONTAINER_ZIP_VERSION: &str = "1.0.0";
28
29/// Expansion ceilings from the `[containers]` section of
30/// `rules/converters.toml`, versioned with the rest of the rules.
31#[derive(Debug, Clone, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct ContainerLimits {
34    /// Maximum nesting depth. The outer file is depth 0.
35    pub max_depth: usize,
36    /// Maximum members per container.
37    pub max_children: usize,
38    /// Cumulative inflated bytes across one top-level container's
39    /// whole tree, dedup copies included.
40    pub max_expanded_bytes: u64,
41    /// Maximum uncompressed bytes of one member.
42    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/// One member's central-directory metadata, read before any inflation.
57#[derive(Debug, Clone)]
58pub struct MemberMeta {
59    /// The raw entry name as stored in the archive.
60    pub name: String,
61    /// The safe relative path, or `None` when the entry name is
62    /// absolute, escapes with `..`, carries a drive prefix, or is
63    /// otherwise unsafe as a mirror path.
64    pub safe_path: Option<String>,
65    /// Declared uncompressed size from the central directory.
66    pub declared_size: u64,
67    /// True for a directory entry, which is listed but never a child.
68    pub is_dir: bool,
69    /// True for a symlink entry, refused like an unsafe path.
70    pub is_symlink: bool,
71    /// True when the member is encrypted.
72    pub encrypted: bool,
73}
74
75/// The outcome of inflating one member under a byte cap.
76pub enum Inflated {
77    /// The member inflated within the cap.
78    Ok(Vec<u8>),
79    /// The member overran the cap. Carries the bytes inflated before
80    /// the cap tripped, so a cumulative counter can charge them.
81    TooLarge(u64),
82    /// The member could not be read.
83    Error(String),
84}
85
86/// A zip archive opened over borrowed bytes.
87pub 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    /// Opens the archive and reads its central directory.
100    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    /// The member count from the central directory.
106    pub fn len(&self) -> usize {
107        self.archive.len()
108    }
109
110    /// True when the archive holds no members.
111    pub fn is_empty(&self) -> bool {
112        self.archive.is_empty()
113    }
114
115    /// The member listing artifact: one line per entry in archive
116    /// order, the entry name, a tab, and the declared uncompressed
117    /// size.
118    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    /// The metadata for member `index`, read from the central
131    /// directory before any inflation.
132    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    /// Inflates member `index` through a reader capped at `cap` bytes.
146    /// A declared size is never trusted as the allocation budget, so
147    /// the read stops one byte past the cap and reports the overrun.
148    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
167/// Validates a raw member name and returns its safe forward-slash
168/// relative path, or `None` when the name is unsafe as a mirror path.
169///
170/// The raw name is inspected before any normalization, so an absolute,
171/// rooted, or drive-prefixed name is refused rather than rebased, and
172/// a `..` component, a control character, or an empty segment is
173/// refused. This is stricter than the zip crate's `enclosed_name`,
174/// which silently rebases an absolute path into a relative one.
175pub fn classify_member_name(raw: &str) -> Option<String> {
176    if raw.is_empty() {
177        return None;
178    }
179    // A control character in a member name would enter the manifest
180    // key, the mirror path, and the bundle checksum file, one line per
181    // artifact. Refuse it.
182    if raw.chars().any(|c| c.is_control()) {
183        return None;
184    }
185    // Absolute or rooted names.
186    if raw.starts_with('/') || raw.starts_with('\\') {
187        return None;
188    }
189    // A drive prefix such as `C:`.
190    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
207/// Escapes control characters in a member name for the parent listing,
208/// so a newline or tab in a name cannot break the one-line-per-member,
209/// tab-separated format.
210fn 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
224/// Reads the total central-directory entry count from the end-of-
225/// central-directory record with bounded tail reads, before the full
226/// archive is parsed.
227///
228/// The zip crate folds entries into a map keyed by raw name, so its
229/// `len` undercounts an archive of duplicate names, and it parses the
230/// whole directory before any count is available. This reads only the
231/// EOCD, and the ZIP64 EOCD when the 16-bit count saturates, so the
232/// child-count limit can refuse an oversized directory before it is
233/// allocated. `None` means no EOCD was found and the caller falls
234/// back to opening the archive, which reports the error.
235pub 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    // The EOCD is 22 bytes plus a comment of at most 0xFFFF bytes.
240    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    // A saturated 16-bit count points at the ZIP64 records.
259    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        // Every line is a real record line: the escaped names added no
367        // extra newline or tab.
368        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}