Skip to main content

sim_lib_doc_core/
zip_package.rs

1//! Bounded ZIP package extraction for office codecs.
2
3use std::collections::BTreeMap;
4use std::io::{Cursor, Read};
5
6use zip::ZipArchive;
7
8use crate::OfficeError;
9
10/// Limits applied while extracting an office ZIP container.
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub struct ZipLimits {
13    /// Maximum ZIP entries, including directories.
14    pub max_entries: usize,
15    /// Maximum decompressed bytes retained for one file entry.
16    pub max_entry_bytes: u64,
17    /// Maximum decompressed bytes retained across all file entries.
18    pub max_total_bytes: u64,
19    /// Maximum declared uncompressed-to-compressed ratio for one file entry.
20    pub max_ratio: u64,
21}
22
23impl ZipLimits {
24    /// Production limits for ordinary office documents.
25    #[must_use]
26    pub const fn office() -> Self {
27        Self {
28            max_entries: 4_096,
29            max_entry_bytes: 64 * 1024 * 1024,
30            max_total_bytes: 256 * 1024 * 1024,
31            max_ratio: 200,
32        }
33    }
34}
35
36/// Reads a ZIP archive into normalized entry names and bounded byte buffers.
37pub fn read_zip_entries(
38    bytes: &[u8],
39    limits: &ZipLimits,
40) -> Result<BTreeMap<String, Vec<u8>>, OfficeError> {
41    let mut archive = ZipArchive::new(Cursor::new(bytes)).map_err(zip_error)?;
42    if archive.len() > limits.max_entries {
43        return Err(package_too_large(
44            "entry count",
45            format!(
46                "archive declares {} entries; limit is {}",
47                archive.len(),
48                limits.max_entries
49            ),
50        ));
51    }
52
53    let mut total = 0_u64;
54    let mut entries = BTreeMap::new();
55    for index in 0..archive.len() {
56        let mut file = archive.by_index(index).map_err(zip_error)?;
57        if file.is_dir() {
58            continue;
59        }
60        let name = file.name().replace('\\', "/");
61        let declared = file.size();
62        let compressed = file.compressed_size();
63        reject_declared_entry_size(&name, declared, limits)?;
64        reject_compression_ratio(&name, declared, compressed, limits)?;
65        reject_declared_total(&name, total, declared, limits)?;
66
67        let mut data = Vec::new();
68        let read_limit = limits.max_entry_bytes.saturating_add(1).min(
69            limits
70                .max_total_bytes
71                .saturating_sub(total)
72                .saturating_add(1),
73        );
74        (&mut file)
75            .take(read_limit)
76            .read_to_end(&mut data)
77            .map_err(|err| {
78                OfficeError::Kernel(format!("could not read zip entry {name}: {err}"))
79            })?;
80        let actual = data.len() as u64;
81        if actual > limits.max_entry_bytes {
82            return Err(package_too_large(
83                "entry bytes",
84                format!("entry {name} expands past {} bytes", limits.max_entry_bytes),
85            ));
86        }
87        total = total.checked_add(actual).ok_or_else(|| {
88            package_too_large(
89                "total bytes",
90                format!("entry {name} overflows total byte accounting"),
91            )
92        })?;
93        if total > limits.max_total_bytes {
94            return Err(package_too_large(
95                "total bytes",
96                format!(
97                    "archive expands past {} bytes at entry {name}",
98                    limits.max_total_bytes
99                ),
100            ));
101        }
102        entries.insert(name, data);
103    }
104    Ok(entries)
105}
106
107fn reject_declared_entry_size(
108    name: &str,
109    declared: u64,
110    limits: &ZipLimits,
111) -> Result<(), OfficeError> {
112    if declared > limits.max_entry_bytes {
113        return Err(package_too_large(
114            "entry bytes",
115            format!(
116                "entry {name} declares {declared} bytes; limit is {}",
117                limits.max_entry_bytes
118            ),
119        ));
120    }
121    Ok(())
122}
123
124fn reject_declared_total(
125    name: &str,
126    total: u64,
127    declared: u64,
128    limits: &ZipLimits,
129) -> Result<(), OfficeError> {
130    if total.saturating_add(declared) > limits.max_total_bytes {
131        return Err(package_too_large(
132            "total bytes",
133            format!(
134                "archive declares more than {} bytes at entry {name}",
135                limits.max_total_bytes
136            ),
137        ));
138    }
139    Ok(())
140}
141
142fn reject_compression_ratio(
143    name: &str,
144    declared: u64,
145    compressed: u64,
146    limits: &ZipLimits,
147) -> Result<(), OfficeError> {
148    if declared == 0 {
149        return Ok(());
150    }
151    let ratio_limit = compressed.saturating_mul(limits.max_ratio);
152    if compressed == 0 || declared > ratio_limit {
153        return Err(package_too_large(
154            "compression ratio",
155            format!(
156                "entry {name} declares {declared} bytes from {compressed} compressed bytes; ratio limit is {}",
157                limits.max_ratio
158            ),
159        ));
160    }
161    Ok(())
162}
163
164fn zip_error(error: zip::result::ZipError) -> OfficeError {
165    OfficeError::Kernel(format!("invalid ZIP package: {error}"))
166}
167
168fn package_too_large(limit: &'static str, detail: String) -> OfficeError {
169    OfficeError::PackageTooLarge { limit, detail }
170}