Skip to main content

xlsxparser/
lib.rs

1//! `xlsxparser` — a lightweight, high-performance `.xlsx` (OOXML) parser
2//! library, purpose-built for the kind of files common in Japanese business
3//! systems: sheets with an extreme number of rows/columns ("grid-paper
4//! Excel") and heavy use of merged cells.
5//!
6//! ```no_run
7//! let workbook = xlsxparser::parse_workbook("book.xlsx")?;
8//! let json = xlsxparser::to_json_string(&workbook)?;
9//! # Ok::<(), xlsxparser::Error>(())
10//! ```
11//!
12//! # Security: CSV / formula injection
13//!
14//! Cell string values (including formula-computed result strings, `t="str"`)
15//! pass through into [`CellValue::Text`] and the JSON output unchanged, with
16//! no sanitization at any stage — this is safe as JSON output (`serde_json`
17//! escapes correctly) but not necessarily as CSV or another spreadsheet
18//! format. Callers who re-export parsed values into CSV or `.xlsx` are
19//! responsible for their own formula-injection mitigations (e.g. escaping a
20//! value that starts with `=`, `+`, `-`, or `@`), since a `.xlsx` input is
21//! untrusted and this library performs no rewriting of cell content.
22
23mod container;
24mod error;
25mod json;
26mod model;
27mod parse;
28mod pipeline;
29mod resolve;
30
31pub use container::sanitize::SizeLimits;
32pub use error::{Error, Result};
33pub use json::{to_json_string, to_json_writer};
34pub use model::{
35    Cell, CellRef, CellValue, DateTimeValue, MergedRegion, ResolvedStyle, Sheet, SheetVisibility,
36    StyleId, Workbook,
37};
38
39use std::fs::File;
40use std::io::{Read, Seek};
41use std::path::Path;
42
43/// Parses `.xlsx` from a file path — the most common public entry point.
44/// Uses the default Zip Bomb size cap (`SizeLimits::default()`). To specify
45/// the cap explicitly, use [`parse_workbook_with_limits`].
46pub fn parse_workbook(path: impl AsRef<Path>) -> Result<Workbook> {
47    parse_workbook_with_limits(path, SizeLimits::default())
48}
49
50/// [`parse_workbook`], plus letting the caller specify the Zip Bomb size cap
51/// explicitly. `parse_workbook` is a thin wrapper that simply delegates
52/// here with `SizeLimits::default()`; the actual logic — opening a
53/// `std::fs::File` and delegating to the internal pipeline — lives only in
54/// this function. Beyond a failure of `File::open` itself, any I/O error
55/// arising during ZIP extraction or XML streaming with `path` left unset
56/// (`None`) is backfilled with the file path this function already knows
57/// before being returned.
58pub fn parse_workbook_with_limits(path: impl AsRef<Path>, limits: SizeLimits) -> Result<Workbook> {
59    let path = path.as_ref();
60    let file = File::open(path).map_err(|source| Error::Io {
61        path: Some(path.to_path_buf()),
62        source,
63    })?;
64    pipeline::run(file, limits).map_err(|err| fill_io_path(err, path))
65}
66
67/// Backfills the file path `parse_workbook_with_limits` already knows into
68/// an `Error::Io { path: None, .. }` propagated from the pipeline. Any other
69/// variant is returned unchanged. `Error::XmlParse` /
70/// `Error::MissingRequiredElement` also carry a `path` field, but theirs
71/// names a part within the OPC package (e.g. `"xl/worksheets/sheet1.xml"`)
72/// — a different meaning from a filesystem path — so they are excluded from
73/// backfilling.
74fn fill_io_path(err: Error, path: &Path) -> Error {
75    match err {
76        Error::Io { path: None, source } => Error::Io {
77            path: Some(path.to_path_buf()),
78            source,
79        },
80        other => other,
81    }
82}
83
84/// Parses `.xlsx` from any `Read + Seek` input (an in-memory buffer, a
85/// fully-read HTTP response body, etc.) — a general-purpose entry point for
86/// callers that don't go through the filesystem. Requiring a seekable input
87/// to read the ZIP central directory simply carries forward
88/// `ZipContainer::open_reader`'s constraint (a purely streaming `Read`-only
89/// input cannot be opened this way). Uses the default Zip Bomb size cap
90/// (`SizeLimits::default()`). To specify the cap explicitly, use
91/// [`parse_workbook_reader_with_limits`].
92pub fn parse_workbook_reader<R: Read + Seek>(reader: R) -> Result<Workbook> {
93    parse_workbook_reader_with_limits(reader, SizeLimits::default())
94}
95
96/// [`parse_workbook_reader`], plus letting the caller specify the Zip Bomb
97/// size cap explicitly. `parse_workbook_reader` is a thin wrapper that
98/// simply delegates here with `SizeLimits::default()`.
99pub fn parse_workbook_reader_with_limits<R: Read + Seek>(
100    reader: R,
101    limits: SizeLimits,
102) -> Result<Workbook> {
103    pipeline::run(reader, limits)
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::io::{Cursor, Write};
110
111    fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
112        let mut buf = Vec::new();
113        {
114            let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf));
115            let options = zip::write::SimpleFileOptions::default()
116                .compression_method(zip::CompressionMethod::Deflated);
117            for (name, data) in entries {
118                writer.start_file(*name, options).unwrap();
119                writer.write_all(data).unwrap();
120            }
121            writer.finish().unwrap();
122        }
123        buf
124    }
125
126    const RELS_XML: &[u8] = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
127  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
128  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
129</Relationships>"#;
130
131    const WORKBOOK_XML: &[u8] = br#"<?xml version="1.0"?>
132<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
133  <sheets>
134    <sheet name="Sheet1" sheetId="1" r:id="rId1"/>
135  </sheets>
136</workbook>"#;
137
138    const STYLES_XML: &[u8] = br#"<styleSheet><cellXfs><xf numFmtId="0"/></cellXfs></styleSheet>"#;
139
140    const WORKSHEET_XML: &[u8] =
141        br#"<worksheet><sheetData><row r="1"><c r="A1"><v>42</v></c></row></sheetData></worksheet>"#;
142
143    fn minimal_xlsx() -> Vec<u8> {
144        build_zip(&[
145            ("xl/_rels/workbook.xml.rels", RELS_XML),
146            ("xl/workbook.xml", WORKBOOK_XML),
147            ("xl/styles.xml", STYLES_XML),
148            ("xl/worksheets/sheet1.xml", WORKSHEET_XML),
149        ])
150    }
151
152    #[test]
153    fn parse_workbook_reads_a_valid_file() {
154        let dir = std::env::temp_dir();
155        let path = dir.join(format!(
156            "xlsxparser-test-{}-{}.xlsx",
157            std::process::id(),
158            "parse_workbook_reads_a_valid_file"
159        ));
160        std::fs::write(&path, minimal_xlsx()).unwrap();
161
162        let result = parse_workbook(&path);
163        std::fs::remove_file(&path).ok();
164
165        let workbook = result.unwrap();
166        assert_eq!(workbook.sheets().len(), 1);
167    }
168
169    #[test]
170    fn parse_workbook_missing_file_returns_io_error_with_path() {
171        let path = std::env::temp_dir().join("xlsxparser-test-does-not-exist.xlsx");
172        let err = parse_workbook(&path).unwrap_err();
173        match err {
174            Error::Io { path: Some(p), .. } => assert_eq!(p, path),
175            other => panic!("expected Error::Io {{ path: Some(..), .. }}, got {other:?}"),
176        }
177    }
178
179    #[test]
180    fn fill_io_path_rewrites_none_path_only() {
181        let path = Path::new("book.xlsx");
182
183        let with_none = Error::Io {
184            path: None,
185            source: std::io::Error::other("boom"),
186        };
187        match fill_io_path(with_none, path) {
188            Error::Io { path: Some(p), .. } => assert_eq!(p, path),
189            other => panic!("expected Error::Io {{ path: Some(..), .. }}, got {other:?}"),
190        }
191
192        let with_some = Error::Io {
193            path: Some(std::path::PathBuf::from("already-set.xlsx")),
194            source: std::io::Error::other("boom"),
195        };
196        match fill_io_path(with_some, path) {
197            Error::Io { path: Some(p), .. } => {
198                assert_eq!(p, std::path::PathBuf::from("already-set.xlsx"))
199            }
200            other => panic!("expected Error::Io {{ path: Some(..), .. }}, got {other:?}"),
201        }
202
203        let other_variant = Error::XmlParse {
204            path: "xl/worksheets/sheet1.xml".to_string(),
205            source: Box::new(std::io::Error::other("boom")),
206        };
207        assert!(matches!(
208            fill_io_path(other_variant, path),
209            Error::XmlParse { .. }
210        ));
211    }
212
213    #[test]
214    fn parse_workbook_reader_reads_valid_bytes() {
215        let workbook = parse_workbook_reader(Cursor::new(minimal_xlsx())).unwrap();
216        assert_eq!(workbook.sheets().len(), 1);
217    }
218
219    #[test]
220    fn parse_workbook_and_parse_workbook_reader_agree() {
221        let dir = std::env::temp_dir();
222        let path = dir.join(format!(
223            "xlsxparser-test-{}-{}.xlsx",
224            std::process::id(),
225            "parse_workbook_and_parse_workbook_reader_agree"
226        ));
227        let bytes = minimal_xlsx();
228        std::fs::write(&path, &bytes).unwrap();
229
230        let from_path = parse_workbook(&path);
231        std::fs::remove_file(&path).ok();
232        let from_path = from_path.unwrap();
233        let from_reader = parse_workbook_reader(Cursor::new(bytes)).unwrap();
234
235        assert_eq!(from_path.sheets().len(), from_reader.sheets().len());
236        assert_eq!(from_path.sheets()[0].name, from_reader.sheets()[0].name);
237        assert_eq!(
238            from_path.sheets()[0].get(CellRef { row: 1, col: 1 }),
239            from_reader.sheets()[0].get(CellRef { row: 1, col: 1 })
240        );
241    }
242
243    #[test]
244    fn with_limits_variants_match_the_default_cap_functions() {
245        let dir = std::env::temp_dir();
246        let path = dir.join(format!(
247            "xlsxparser-test-{}-{}.xlsx",
248            std::process::id(),
249            "with_limits_variants_match_the_default_cap_functions"
250        ));
251        let bytes = minimal_xlsx();
252        std::fs::write(&path, &bytes).unwrap();
253
254        let default_from_path = parse_workbook(&path).unwrap();
255        let explicit_from_path = parse_workbook_with_limits(&path, SizeLimits::default()).unwrap();
256        std::fs::remove_file(&path).ok();
257
258        let default_from_reader = parse_workbook_reader(Cursor::new(bytes.clone())).unwrap();
259        let explicit_from_reader =
260            parse_workbook_reader_with_limits(Cursor::new(bytes), SizeLimits::default()).unwrap();
261
262        assert_eq!(
263            default_from_path.sheets()[0].name,
264            explicit_from_path.sheets()[0].name
265        );
266        assert_eq!(
267            default_from_reader.sheets()[0].name,
268            explicit_from_reader.sheets()[0].name
269        );
270    }
271
272    #[test]
273    fn caller_supplied_size_limits_are_honored_by_the_public_api() {
274        // Succeeds under the default cap...
275        parse_workbook_reader(Cursor::new(minimal_xlsx())).unwrap();
276
277        // ...but a caller-supplied max_entry_size too small to hold even
278        // xl/workbook.xml turns the same input into Error::ZipBombDetected,
279        // proving the public `_with_limits` functions actually forward
280        // `limits` through to the pipeline rather than ignoring it.
281        let tiny_limits = SizeLimits {
282            max_entry_size: 1,
283            max_total_size: SizeLimits::default().max_total_size,
284        };
285        let err = parse_workbook_reader_with_limits(Cursor::new(minimal_xlsx()), tiny_limits)
286            .unwrap_err();
287        assert!(matches!(err, Error::ZipBombDetected { .. }));
288    }
289
290    #[test]
291    fn parse_workbook_output_chains_into_to_json_string() {
292        let workbook = parse_workbook_reader(Cursor::new(minimal_xlsx())).unwrap();
293        let json = to_json_string(&workbook).unwrap();
294        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
295        assert_eq!(parsed["sheets"][0]["name"], "Sheet1");
296    }
297
298    #[test]
299    fn corrupt_xlsx_errors_propagate_unchanged() {
300        let err = parse_workbook_reader(Cursor::new(b"not a zip file".to_vec())).unwrap_err();
301        assert!(matches!(err, Error::InvalidPackage(_)));
302
303        let missing_rels = build_zip(&[("xl/workbook.xml", WORKBOOK_XML)]);
304        let err = parse_workbook_reader(Cursor::new(missing_rels)).unwrap_err();
305        assert!(matches!(err, Error::MissingRelationshipPart(_)));
306    }
307
308    #[test]
309    fn public_types_are_reachable_from_the_crate_root() {
310        // A compile-time check: if any of these names weren't re-exported at
311        // the crate root, this module simply wouldn't compile.
312        fn assert_reachable<T>() {}
313        assert_reachable::<crate::Workbook>();
314        assert_reachable::<crate::Sheet>();
315        assert_reachable::<crate::Cell>();
316        assert_reachable::<crate::CellValue>();
317        assert_reachable::<crate::CellRef>();
318        assert_reachable::<crate::SheetVisibility>();
319        assert_reachable::<crate::MergedRegion>();
320        assert_reachable::<crate::ResolvedStyle>();
321        assert_reachable::<crate::StyleId>();
322        assert_reachable::<crate::DateTimeValue>();
323        assert_reachable::<crate::SizeLimits>();
324        assert_reachable::<crate::Error>();
325        fn _assert_result_reachable(_: crate::Result<()>) {}
326    }
327}