Skip to main content

xlsxparser/
lib.rs

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