mod container;
mod error;
mod json;
mod model;
mod parse;
mod pipeline;
mod resolve;
pub use container::sanitize::SizeLimits;
pub use error::{Error, Result};
pub use json::{to_json_string, to_json_writer};
pub use model::{
Cell, CellRef, CellValue, DateTimeValue, MergedRegion, ResolvedStyle, Sheet, SheetVisibility,
StyleId, Workbook,
};
use std::fs::File;
use std::io::{Read, Seek};
use std::path::Path;
pub fn parse_workbook(path: impl AsRef<Path>) -> Result<Workbook> {
parse_workbook_with_limits(path, SizeLimits::default())
}
pub fn parse_workbook_with_limits(path: impl AsRef<Path>, limits: SizeLimits) -> Result<Workbook> {
let path = path.as_ref();
let file = File::open(path).map_err(|source| Error::Io {
path: Some(path.to_path_buf()),
source,
})?;
pipeline::run(file, limits).map_err(|err| fill_io_path(err, path))
}
fn fill_io_path(err: Error, path: &Path) -> Error {
match err {
Error::Io { path: None, source } => Error::Io {
path: Some(path.to_path_buf()),
source,
},
other => other,
}
}
pub fn parse_workbook_reader<R: Read + Seek>(reader: R) -> Result<Workbook> {
parse_workbook_reader_with_limits(reader, SizeLimits::default())
}
pub fn parse_workbook_reader_with_limits<R: Read + Seek>(
reader: R,
limits: SizeLimits,
) -> Result<Workbook> {
pipeline::run(reader, limits)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Cursor, Write};
fn build_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut writer = zip::ZipWriter::new(Cursor::new(&mut buf));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (name, data) in entries {
writer.start_file(*name, options).unwrap();
writer.write_all(data).unwrap();
}
writer.finish().unwrap();
}
buf
}
const RELS_XML: &[u8] = br#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>"#;
const WORKBOOK_XML: &[u8] = br#"<?xml version="1.0"?>
<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>"#;
const STYLES_XML: &[u8] = br#"<styleSheet><cellXfs><xf numFmtId="0"/></cellXfs></styleSheet>"#;
const WORKSHEET_XML: &[u8] =
br#"<worksheet><sheetData><row r="1"><c r="A1"><v>42</v></c></row></sheetData></worksheet>"#;
fn minimal_xlsx() -> Vec<u8> {
build_zip(&[
("xl/_rels/workbook.xml.rels", RELS_XML),
("xl/workbook.xml", WORKBOOK_XML),
("xl/styles.xml", STYLES_XML),
("xl/worksheets/sheet1.xml", WORKSHEET_XML),
])
}
#[test]
fn parse_workbook_reads_a_valid_file() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"xlsxparser-test-{}-{}.xlsx",
std::process::id(),
"parse_workbook_reads_a_valid_file"
));
std::fs::write(&path, minimal_xlsx()).unwrap();
let result = parse_workbook(&path);
std::fs::remove_file(&path).ok();
let workbook = result.unwrap();
assert_eq!(workbook.sheets().len(), 1);
}
#[test]
fn parse_workbook_missing_file_returns_io_error_with_path() {
let path = std::env::temp_dir().join("xlsxparser-test-does-not-exist.xlsx");
let err = parse_workbook(&path).unwrap_err();
match err {
Error::Io { path: Some(p), .. } => assert_eq!(p, path),
other => panic!("expected Error::Io {{ path: Some(..), .. }}, got {other:?}"),
}
}
#[test]
fn fill_io_path_rewrites_none_path_only() {
let path = Path::new("book.xlsx");
let with_none = Error::Io {
path: None,
source: std::io::Error::other("boom"),
};
match fill_io_path(with_none, path) {
Error::Io { path: Some(p), .. } => assert_eq!(p, path),
other => panic!("expected Error::Io {{ path: Some(..), .. }}, got {other:?}"),
}
let with_some = Error::Io {
path: Some(std::path::PathBuf::from("already-set.xlsx")),
source: std::io::Error::other("boom"),
};
match fill_io_path(with_some, path) {
Error::Io { path: Some(p), .. } => {
assert_eq!(p, std::path::PathBuf::from("already-set.xlsx"))
}
other => panic!("expected Error::Io {{ path: Some(..), .. }}, got {other:?}"),
}
let other_variant = Error::XmlParse {
path: "xl/worksheets/sheet1.xml".to_string(),
source: Box::new(std::io::Error::other("boom")),
};
assert!(matches!(
fill_io_path(other_variant, path),
Error::XmlParse { .. }
));
}
#[test]
fn parse_workbook_reader_reads_valid_bytes() {
let workbook = parse_workbook_reader(Cursor::new(minimal_xlsx())).unwrap();
assert_eq!(workbook.sheets().len(), 1);
}
#[test]
fn parse_workbook_and_parse_workbook_reader_agree() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"xlsxparser-test-{}-{}.xlsx",
std::process::id(),
"parse_workbook_and_parse_workbook_reader_agree"
));
let bytes = minimal_xlsx();
std::fs::write(&path, &bytes).unwrap();
let from_path = parse_workbook(&path);
std::fs::remove_file(&path).ok();
let from_path = from_path.unwrap();
let from_reader = parse_workbook_reader(Cursor::new(bytes)).unwrap();
assert_eq!(from_path.sheets().len(), from_reader.sheets().len());
assert_eq!(from_path.sheets()[0].name, from_reader.sheets()[0].name);
assert_eq!(
from_path.sheets()[0].get(CellRef { row: 1, col: 1 }),
from_reader.sheets()[0].get(CellRef { row: 1, col: 1 })
);
}
#[test]
fn with_limits_variants_match_the_default_cap_functions() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"xlsxparser-test-{}-{}.xlsx",
std::process::id(),
"with_limits_variants_match_the_default_cap_functions"
));
let bytes = minimal_xlsx();
std::fs::write(&path, &bytes).unwrap();
let default_from_path = parse_workbook(&path).unwrap();
let explicit_from_path = parse_workbook_with_limits(&path, SizeLimits::default()).unwrap();
std::fs::remove_file(&path).ok();
let default_from_reader = parse_workbook_reader(Cursor::new(bytes.clone())).unwrap();
let explicit_from_reader =
parse_workbook_reader_with_limits(Cursor::new(bytes), SizeLimits::default()).unwrap();
assert_eq!(
default_from_path.sheets()[0].name,
explicit_from_path.sheets()[0].name
);
assert_eq!(
default_from_reader.sheets()[0].name,
explicit_from_reader.sheets()[0].name
);
}
#[test]
fn caller_supplied_size_limits_are_honored_by_the_public_api() {
parse_workbook_reader(Cursor::new(minimal_xlsx())).unwrap();
let tiny_limits = SizeLimits {
max_entry_size: 1,
max_total_size: SizeLimits::default().max_total_size,
};
let err = parse_workbook_reader_with_limits(Cursor::new(minimal_xlsx()), tiny_limits)
.unwrap_err();
assert!(matches!(err, Error::ZipBombDetected { .. }));
}
#[test]
fn parse_workbook_output_chains_into_to_json_string() {
let workbook = parse_workbook_reader(Cursor::new(minimal_xlsx())).unwrap();
let json = to_json_string(&workbook).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["sheets"][0]["name"], "Sheet1");
}
#[test]
fn corrupt_xlsx_errors_propagate_unchanged() {
let err = parse_workbook_reader(Cursor::new(b"not a zip file".to_vec())).unwrap_err();
assert!(matches!(err, Error::InvalidPackage(_)));
let missing_rels = build_zip(&[("xl/workbook.xml", WORKBOOK_XML)]);
let err = parse_workbook_reader(Cursor::new(missing_rels)).unwrap_err();
assert!(matches!(err, Error::MissingRelationshipPart(_)));
}
#[test]
fn public_types_are_reachable_from_the_crate_root() {
fn assert_reachable<T>() {}
assert_reachable::<crate::Workbook>();
assert_reachable::<crate::Sheet>();
assert_reachable::<crate::Cell>();
assert_reachable::<crate::CellValue>();
assert_reachable::<crate::CellRef>();
assert_reachable::<crate::SheetVisibility>();
assert_reachable::<crate::MergedRegion>();
assert_reachable::<crate::ResolvedStyle>();
assert_reachable::<crate::StyleId>();
assert_reachable::<crate::DateTimeValue>();
assert_reachable::<crate::SizeLimits>();
assert_reachable::<crate::Error>();
fn _assert_result_reachable(_: crate::Result<()>) {}
}
}