Skip to main content

ravenrs_sheet/xlsx/
package.rs

1//! OPC package access: reading zip parts with decompression limits, relationships and part-name
2//! resolution; writing a zip package deterministically.
3
4use std::collections::HashMap;
5use std::io::{Cursor, Read, Write};
6
7use crate::xlsx::IoError;
8use crate::xlsx::xml::{self, El};
9
10/// Largest decompressed part accepted.
11pub const MAX_PART: u64 = 512 * 1024 * 1024;
12/// Largest total decompressed size read from one package.
13pub const MAX_TOTAL: u64 = 1024 * 1024 * 1024;
14/// Most entries accepted in the zip directory.
15pub const MAX_ENTRIES: usize = 200_000;
16
17pub struct Package<'a> {
18    zip: zip::ZipArchive<Cursor<&'a [u8]>>,
19    /// Lower-case normalized part name → zip index.
20    names: HashMap<String, usize>,
21    total: u64,
22}
23
24/// A relationship.
25#[derive(Clone, Debug)]
26pub struct Rel {
27    pub id: String,
28    /// Last path segment of the relationship type (`worksheet`, `styles`, …): the same for the
29    /// transitional and strict namespaces.
30    pub kind: String,
31    /// Resolved part name (no leading `/`), or the raw target for external relationships.
32    pub target: String,
33    pub external: bool,
34}
35
36pub fn normalize(name: &str) -> String {
37    name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
38}
39
40impl<'a> Package<'a> {
41    pub fn open(bytes: &'a [u8]) -> Result<Package<'a>, IoError> {
42        let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| IoError::Zip(e.to_string()))?;
43        if zip.len() > MAX_ENTRIES {
44            return Err(IoError::TooLarge(format!("{} zip entries", zip.len())));
45        }
46        let mut names = HashMap::new();
47        for i in 0..zip.len() {
48            // A name that is not UTF-8 cannot be a part anything refers to.
49            if let Some(Ok(n)) = zip.name_for_index(i) {
50                names.entry(normalize(&n)).or_insert(i);
51            }
52        }
53        Ok(Package { zip, names, total: 0 })
54    }
55
56    /// Every part's (normalized) name, sorted.
57    #[cfg(test)]
58    pub fn names(&self) -> Vec<String> {
59        let mut v: Vec<String> = self.names.keys().cloned().collect();
60        v.sort();
61        v
62    }
63
64    pub fn has(&self, part: &str) -> bool {
65        self.names.contains_key(&normalize(part))
66    }
67
68    /// Reads a part's bytes; `Ok(None)` when it doesn't exist.
69    pub fn read(&mut self, part: &str) -> Result<Option<Vec<u8>>, IoError> {
70        self.read_limited(part, MAX_PART)
71    }
72
73    /// Reads a part the reader can do without (the caller warns and goes on when it fails). A read
74    /// that fails partway is charged this part's whole allowance (`maximum`, and the byte that
75    /// detects an overrun) instead of ending the package's: the decompression work stays bounded,
76    /// and the parts read after it still open.
77    pub fn read_optional_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
78        let before = self.total;
79        let read = self.read_limited(part, maximum);
80        if let Err(IoError::Zip(_)) = &read {
81            self.total = before.saturating_add(maximum.min(MAX_PART).saturating_add(1)).min(MAX_TOTAL);
82        }
83        read
84    }
85
86    /// Reads a small metadata part without allowing it to expand to the general part limit.
87    pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
88        let maximum = maximum.min(MAX_PART);
89        let Some(&idx) = self.names.get(&normalize(part)) else {
90            return Ok(None);
91        };
92        let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
93        if f.size() > maximum {
94            return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size())));
95        }
96        let remaining = MAX_TOTAL.saturating_sub(self.total);
97        let limit = maximum.min(remaining);
98        let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
99        let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| {
100            // A read that fails partway has already spent decompression work and buffered data
101            // the package budget never sees: count the allowance as spent, so a loop over many
102            // broken parts can't decompress it over and over.
103            self.total = MAX_TOTAL;
104            IoError::Zip(format!("{part}: {e}"))
105        });
106        if out.len() as u64 > limit {
107            // The allowance was spent getting here; make the exhaustion terminal instead of
108            // letting later reads pay the same decompression cost again.
109            self.total = MAX_TOTAL;
110            return Err(IoError::TooLarge(if remaining < maximum {
111                "package expands to more than 1 GB".into()
112            } else {
113                format!("{part} expands to more than {maximum} bytes")
114            }));
115        }
116        read?;
117        self.total += out.len() as u64;
118        Ok(Some(out))
119    }
120
121    pub fn read_xml(&mut self, part: &str) -> Result<Option<El>, IoError> {
122        match self.read(part)? {
123            Some(b) => xml::parse(&b).map(Some).map_err(|e| prefix(part, e)),
124            None => Ok(None),
125        }
126    }
127
128    /// Relationships of a part (`xl/workbook.xml` → `xl/_rels/workbook.xml.rels`). Missing or
129    /// broken rels parts give an empty list.
130    pub fn rels(&mut self, part: &str) -> Result<Vec<Rel>, IoError> {
131        let (dir, file) = split_dir(part);
132        let rels_name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
133        let root = match self.read(&rels_name)? {
134            Some(b) => match xml::parse(&b) {
135                Ok(r) => r,
136                Err(_) => return Ok(vec![]),
137            },
138            None => return Ok(vec![]),
139        };
140        Ok(root
141            .kids("Relationship")
142            .filter_map(|r| {
143                let id = r.attr("Id")?.to_string();
144                let ty = r.attr("Type").unwrap_or("");
145                let kind = ty.rsplit('/').next().unwrap_or("").to_string();
146                let target_raw = r.attr("Target")?;
147                let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
148                let target = if external { target_raw.to_string() } else { resolve(dir, target_raw) };
149                Some(Rel { id, kind, target, external })
150            })
151            .collect())
152    }
153}
154
155/// The first part whose declared unpacked size is over [`MAX_PART`], with that size.
156pub fn oversized_part(bytes: &[u8]) -> Option<(String, u64)> {
157    let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).ok()?;
158    (0..zip.len().min(MAX_ENTRIES)).find_map(|i| {
159        let f = zip.by_index_raw(i).ok()?;
160        (f.size() > MAX_PART).then(|| (f.name().map_or_else(|_| String::from_utf8_lossy(f.name_raw()).into_owned(), |n| n.into_owned()), f.size()))
161    })
162}
163
164fn prefix(part: &str, e: IoError) -> IoError {
165    match e {
166        IoError::Xml(m) => IoError::Xml(format!("{part}: {m}")),
167        o => o,
168    }
169}
170
171/// `xl/worksheets/sheet1.xml` → (`xl/worksheets`, `sheet1.xml`).
172pub fn split_dir(part: &str) -> (&str, &str) {
173    let p = part.trim_start_matches('/');
174    match p.rfind('/') {
175        Some(i) => (p.get(..i).unwrap_or(""), p.get(i + 1..).unwrap_or("")),
176        None => ("", p),
177    }
178}
179
180/// Resolves a relationship target against the source part's directory.
181pub fn resolve(dir: &str, target: &str) -> String {
182    let target = target.replace('\\', "/");
183    let target = target.split('#').next().unwrap_or("").to_string();
184    let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { dir.split('/').filter(|s| !s.is_empty()).collect() };
185    for s in target.split('/') {
186        match s {
187            "" | "." => {}
188            ".." => {
189                segs.pop();
190            }
191            s => segs.push(s),
192        }
193    }
194    segs.join("/")
195}
196
197// ---------------------------------------------------------------- writing
198
199pub struct ZipOut {
200    zip: zip::ZipWriter<Cursor<Vec<u8>>>,
201}
202
203impl ZipOut {
204    pub fn new() -> ZipOut {
205        ZipOut { zip: zip::ZipWriter::new(Cursor::new(Vec::new())) }
206    }
207    pub fn add(&mut self, name: &str, data: &[u8]) -> Result<(), IoError> {
208        let opts = zip::write::SimpleFileOptions::default()
209            .compression_method(zip::CompressionMethod::Deflated)
210            .compression_level(Some(6))
211            .last_modified_time(zip::DateTime::default())
212            .large_file(data.len() as u64 >= u32::MAX as u64);
213        self.zip.start_file(name, opts).map_err(|e| IoError::Zip(e.to_string()))?;
214        self.zip.write_all(data).map_err(|e| IoError::Zip(e.to_string()))?;
215        Ok(())
216    }
217    pub fn finish(self) -> Result<Vec<u8>, IoError> {
218        self.zip.finish().map(|c| c.into_inner()).map_err(|e| IoError::Zip(e.to_string()))
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn resolving() {
228        assert_eq!(resolve("xl", "worksheets/sheet1.xml"), "xl/worksheets/sheet1.xml");
229        assert_eq!(resolve("xl/worksheets", "../drawings/d1.xml"), "xl/drawings/d1.xml");
230        assert_eq!(resolve("xl/worksheets", "/xl/tables/t.xml"), "xl/tables/t.xml");
231        assert_eq!(resolve("", "xl/workbook.xml"), "xl/workbook.xml");
232        assert_eq!(resolve("a", "../../../x"), "x");
233        assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
234        assert_eq!(split_dir("x.xml"), ("", "x.xml"));
235    }
236
237    /// An over-budget read must spend the package's allowance once and for all: loops over many
238    /// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the
239    /// remaining allowance again and again.
240    #[test]
241    fn a_failed_part_ends_the_package_budget() {
242        let z = crate::xlsx::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]);
243        let mut pkg = Package::open(&z).unwrap();
244        assert!(pkg.read("a.xml").unwrap().is_some());
245        // Nothing left to spend within the per-part limit either.
246        pkg.total = MAX_TOTAL - 1;
247        assert!(pkg.read("b.xml").is_err());
248        assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged");
249        // Oversized repeated attempts stay cheap errors now: no allowance is left to pay for.
250        assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_))));
251    }
252
253    /// A part the reader can do without, damaged inside an intact archive, costs its own
254    /// allowance: the parts after it are still read.
255    #[test]
256    fn a_damaged_optional_part_costs_only_its_allowance() {
257        use std::io::Write as _;
258        let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
259        let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
260        w.start_file("a.xml", stored).unwrap();
261        w.write_all(b"<a>damaged</a>").unwrap();
262        w.start_file("b.xml", zip::write::SimpleFileOptions::default()).unwrap();
263        w.write_all(b"hello").unwrap();
264        let mut z = w.finish().unwrap().into_inner();
265        let at = z.windows(7).position(|x| x == b"damaged").unwrap();
266        z[at] ^= 0x20;
267        let mut pkg = Package::open(&z).unwrap();
268        assert!(matches!(pkg.read_optional_limited("a.xml", 1024), Err(IoError::Zip(_))));
269        assert_eq!(pkg.total, 1025, "the failed read is charged its allowance");
270        assert_eq!(pkg.read("b.xml").unwrap().as_deref(), Some(&b"hello"[..]));
271        // An ordinary read of it still ends the package's budget.
272        assert!(pkg.read("a.xml").is_err());
273        assert_eq!(pkg.total, MAX_TOTAL);
274    }
275}