Files
wordcraft/crates/formats/src/sheet.rs
T
242e850aaa #3660: refuse a package that declares too many zip entries before zip parses them (#5260)
* #3660: refuse a package that declares too many zip entries before zip parses them

zip::ZipArchive::new parses and keeps every central directory header
the end record declares before its caller can look at len(). A ZIP64
end record can declare one per 46 bytes of the file: a 47 MB package of
one stored file listed 1,000,000 times takes zip 3.0.0 about half a
second and a large transient allocation, and len() then says 1, since
the copies share a name, so the docx reader's MAX_ENTRIES cap never
fires. ODT and spreadsheet packages had no cap at all.

wordcraft_doc::zip_guard::check finds the end records the way zip finds
them (backwards; the ZIP64 record from the locator's offset on; a plain
record when it has no entries or a central header before it) and refuses
a package declaring more than the reader's limit. The docx package
reader, the ODT reader, the spreadsheet reader (mail-merge data) and the
template-title probe call it first, with 20,000 entries as the limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* formats: keep the extensions doc line on EXTENSIONS

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 04:58:18 -05:00

691 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spreadsheets as mail-merge recipient lists (#334): the sheet names of an Excel workbook
//! (`.xlsx` / `.xlsm`, ECMA-376 Part 1 §18 SpreadsheetML; legacy `.xls` BIFF8 in [`xls`], #778)
//! or an OpenDocument spreadsheet (`.ods`, OASIS ODF 1.3 §9.1), and one sheet's cells as rows of
//! display strings.
//!
//! Values: SpreadsheetML shared strings (rich-text runs joined, phonetic runs skipped), inline
//! strings, formula results (the cached `<v>`), booleans (`TRUE`/`FALSE`), errors as their text
//! and numbers. A number whose cell format is a date or time (built-in formats 14–22, 27–36,
//! 45–47 and 50–58, or a custom format with `d`/`y`, `h`/`s` outside quotes and brackets) is
//! written in the locale-neutral ISO 8601 form `yyyy-mm-dd`, `yyyy-mm-dd hh:mm[:ss]` or
//! `hh:mm[:ss]` (both the 1900 and 1904 date systems); other numbers keep up to 15 significant
//! digits, as the spreadsheet stores them. OpenDocument cells use `office:value-type`: floats
//! from `office:value`, dates and times from `office:date-value` / `office:time-value` (same ISO
//! forms), booleans as `TRUE`/`FALSE`, everything else (strings, percentages, currencies) as the
//! cell's text. Merged and covered cells keep only the top-left value; blank rows are dropped.
//!
//! Every file is hostile: zip entries are inflated up to [`MAX_ENTRY`] (and [`MAX_TOTAL`] in
//! all), sheets, rows, columns, cells and string lengths are capped, repeat counts are clamped to
//! what is left, and nothing recurses, so XML depth costs nothing.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{Cursor, Read};
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
mod xls;
#[doc(hidden)]
pub use xls::build as xls_fixture;
mod date;
mod ods;
mod xlsx;
pub(crate) use date::civil;
use date::{DateKind, builtin_date, custom_date, serial_date};
#[cfg(test)]
use date::{iso_datetime, iso_time};
use ods::ods_read;
use xlsx::{number, relationships, xlsx_rows, xlsx_sheets};
/// File extensions read as spreadsheets.
pub const EXTENSIONS: &[&str] = &["xlsx", "xlsm", "xls", "ods"];
/// Largest zip entry inflated.
pub const MAX_ENTRY: u64 = 64 << 20;
/// Most bytes inflated from one workbook.
pub const MAX_TOTAL: u64 = 160 << 20;
/// Most sheets listed.
pub const MAX_SHEETS: usize = 4_096;
/// Most non-blank rows kept (as many as a recipient list holds).
pub const MAX_ROWS: usize = 100_000;
/// Most columns kept; cells further right are ignored.
pub const MAX_COLS: usize = 1_000;
/// Most cells kept in all (blank cells between values count).
pub const MAX_CELLS: usize = 2_000_000;
/// Longest cell text in characters (a spreadsheet cell's own limit).
pub const MAX_CELL_CHARS: usize = 32_767;
/// Most bytes of cell text kept from one sheet: a shared string (or a repeated cell) is stored
/// once in the file but copied into every cell that uses it, so a small file could otherwise
/// ask for many gigabytes. Cells past the budget are ignored.
pub const MAX_TEXT_BYTES: usize = 64 << 20;
/// Most shared strings kept.
const MAX_SHARED: usize = 2_000_000;
/// Most cell formats kept.
const MAX_XFS: usize = 65_536;
/// Elements nested deeper than this are skipped.
const MAX_XML_DEPTH: usize = 256;
/// The spreadsheet kinds read here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Xlsx,
/// Excel 97–2003 (BIFF8 in a compound file).
Xls,
Ods,
}
/// Which sheet to read.
#[derive(Clone, Copy, Debug)]
pub enum Pick<'a> {
/// 0-based position in the workbook's sheet list.
Index(usize),
/// A sheet name (exact match first, then ignoring case).
Name(&'a str),
}
/// Whether `ext` (no dot, any case) names a spreadsheet read here.
pub fn is_extension(ext: &str) -> bool {
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
EXTENSIONS.contains(&ext.as_str())
}
/// What kind of spreadsheet `bytes` holds, from the package contents (`None`: not one).
pub fn detect(bytes: &[u8]) -> Option<Kind> {
if xls::is_xls(bytes) {
return Some(Kind::Xls);
}
if !bytes.starts_with(b"PK") {
return None;
}
let mut pkg = Package::open(bytes).ok()?;
pkg.kind()
}
/// The sheet names, in workbook order.
pub fn sheet_names(bytes: &[u8]) -> Result<Vec<String>, String> {
if bytes.starts_with(&xls::MAGIC) {
return xls::sheet_names(bytes);
}
let mut pkg = Package::open(bytes)?;
match pkg.kind().ok_or("not an Excel or OpenDocument spreadsheet")? {
Kind::Xlsx => Ok(xlsx_sheets(&mut pkg)?.into_iter().map(|s| s.name).collect()),
Kind::Ods => Ok(ods_read(&pkg.read("content.xml")?, None)?.0),
Kind::Xls => xls::sheet_names(bytes),
}
}
/// One sheet's non-blank rows of display strings (trailing blank cells trimmed), with the
/// workbook's sheet names.
pub fn read_rows(bytes: &[u8], pick: Pick<'_>) -> Result<(Vec<String>, Vec<Vec<String>>), String> {
if bytes.starts_with(&xls::MAGIC) {
return xls::read_rows(bytes, pick);
}
let mut pkg = Package::open(bytes)?;
match pkg.kind().ok_or("not an Excel or OpenDocument spreadsheet")? {
Kind::Xlsx => {
let sheets = xlsx_sheets(&mut pkg)?;
let names: Vec<String> = sheets.iter().map(|s| s.name.clone()).collect();
let i = resolve(&names, pick)?;
let sheet = sheets.get(i).ok_or("no such sheet")?;
let rows = xlsx_rows(&mut pkg, &sheet.path)?;
Ok((names, rows))
}
Kind::Xls => xls::read_rows(bytes, pick),
Kind::Ods => {
let content = pkg.read("content.xml")?;
// Names first, so a name can be resolved to its position.
let (names, _) = ods_read(&content, None)?;
let i = resolve(&names, pick)?;
let (_, rows) = ods_read(&content, Some(i))?;
Ok((names, rows))
}
}
}
/// Field names from a header row: trimmed, blanks named `Column N` (N = 1-based column) and
/// repeats numbered (`Name`, `Name 2`), ignoring case as merge fields do.
pub fn field_names(row: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::with_capacity(row.len());
// The names given so far, ASCII-lowercased, and per base name the next number to try: a set
// lookup per candidate, and each base resumes where it stopped, instead of rescanning every
// name from `2` for every repeat (cubic on a row of equal headers).
let mut taken: HashSet<String> = HashSet::with_capacity(row.len());
let mut next: HashMap<String, usize> = HashMap::new();
for (i, h) in row.iter().enumerate() {
let base = match h.trim() {
"" => format!("Column {}", i + 1),
t => t.to_string(),
};
let mut name = base.clone();
if taken.contains(&name.to_ascii_lowercase()) {
let k = next.entry(base.to_ascii_lowercase()).or_insert(2);
loop {
name = format!("{base} {k}");
*k = k.saturating_add(1);
if !taken.contains(&name.to_ascii_lowercase()) {
break;
}
}
}
taken.insert(name.to_ascii_lowercase());
out.push(name);
}
out
}
fn resolve(names: &[String], pick: Pick<'_>) -> Result<usize, String> {
if names.is_empty() {
return Err("the workbook has no sheets".into());
}
match pick {
Pick::Index(i) if i < names.len() => Ok(i),
Pick::Index(i) => Err(format!("the workbook has {} sheets; there is no sheet {}", names.len(), i.saturating_add(1))),
Pick::Name(n) => names
.iter()
.position(|s| s == n)
.or_else(|| names.iter().position(|s| s.eq_ignore_ascii_case(n.trim())))
.ok_or_else(|| format!("no sheet named \"{n}\"")),
}
}
// ---------------------------------------------------------------------------------------------
// Package
struct Package<'a> {
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
total: u64,
}
impl<'a> Package<'a> {
fn open(bytes: &'a [u8]) -> Result<Self, String> {
wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES)?;
let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a valid spreadsheet package ({e})"))?;
Ok(Package { zip, total: 0 })
}
fn has(&self, name: &str) -> bool {
self.zip.index_for_name(name).is_some()
}
/// Inflate an entry, refusing ones (or a workbook) larger than the caps.
fn read(&mut self, name: &str) -> Result<Vec<u8>, String> {
let f = self.zip.by_name(name).map_err(|_| format!("{name} is missing"))?;
if f.size() > MAX_ENTRY {
return Err(format!("{name} is too large"));
}
let mut out = Vec::new();
f.take(MAX_ENTRY + 1).read_to_end(&mut out).map_err(|e| format!("{name}: {e}"))?;
let len = out.len() as u64;
self.total = self.total.saturating_add(len);
if len > MAX_ENTRY || self.total > MAX_TOTAL {
return Err(format!("{name} is too large"));
}
Ok(out)
}
fn kind(&mut self) -> Option<Kind> {
if self.workbook_path().is_some() {
return Some(Kind::Xlsx);
}
if self.has("content.xml") {
let ods = match self.read("mimetype") {
Ok(m) => String::from_utf8_lossy(&m).trim().starts_with("application/vnd.oasis.opendocument.spreadsheet"),
// No mimetype entry: only a package without a text document is taken as one.
Err(_) => !self.has("word/document.xml"),
};
if ods {
return Some(Kind::Ods);
}
}
None
}
/// The SpreadsheetML workbook part: the package's main part when it is a workbook,
/// otherwise `xl/workbook.xml` when present.
fn workbook_path(&mut self) -> Option<String> {
if let Ok(rels) = self.read("_rels/.rels") {
for r in relationships(&rels, "") {
if r.kind.ends_with("/officeDocument") && r.target.to_ascii_lowercase().contains("workbook") && self.has(&r.target) {
return Some(r.target);
}
}
}
self.has("xl/workbook.xml").then(|| "xl/workbook.xml".to_string())
}
}
// ---------------------------------------------------------------------------------------------
// XML
/// A streamed XML event with namespace prefixes dropped; elements deeper than
/// [`MAX_XML_DEPTH`] (and their text) are skipped.
enum Ev<'e> {
Start(&'e BytesStart<'e>),
End(Vec<u8>),
Text(String),
}
fn walk(xml: &[u8], mut f: impl FnMut(Ev<'_>)) {
let mut r = quick_xml::Reader::from_reader(xml);
let cfg = r.config_mut();
cfg.trim_text(false);
cfg.check_end_names = false;
cfg.allow_unmatched_ends = true;
let mut buf = Vec::new();
let mut depth = 0usize;
loop {
buf.clear();
match r.read_event_into(&mut buf) {
Ok(Event::Start(e)) => {
depth = depth.saturating_add(1);
if depth <= MAX_XML_DEPTH {
f(Ev::Start(&e));
}
}
Ok(Event::Empty(e)) => {
if depth < MAX_XML_DEPTH {
f(Ev::Start(&e));
f(Ev::End(e.local_name().as_ref().to_vec()));
}
}
Ok(Event::End(e)) => {
if depth <= MAX_XML_DEPTH {
f(Ev::End(e.local_name().as_ref().to_vec()));
}
depth = depth.saturating_sub(1);
}
Ok(Event::Text(t)) if depth <= MAX_XML_DEPTH => f(Ev::Text(t.decode().map(|c| c.into_owned()).unwrap_or_default())),
Ok(Event::CData(t)) if depth <= MAX_XML_DEPTH => f(Ev::Text(String::from_utf8_lossy(&t).into_owned())),
Ok(Event::GeneralRef(g)) if depth <= MAX_XML_DEPTH => {
let s = match &g[..] {
b"amp" => "&".to_string(),
b"lt" => "<".to_string(),
b"gt" => ">".to_string(),
b"quot" => "\"".to_string(),
b"apos" => "'".to_string(),
_ => g.resolve_char_ref().ok().flatten().map(|c| c.to_string()).unwrap_or_default(),
};
f(Ev::Text(s));
}
Ok(Event::Eof) | Err(_) => break,
Ok(_) => {}
}
}
}
/// An attribute's value by local name.
fn attr(e: &BytesStart<'_>, name: &[u8]) -> Option<String> {
e.attributes().with_checks(false).flatten().take(64).find(|a| a.key.local_name().as_ref() == name).map(|a| {
a.normalized_value(XmlVersion::Explicit1_0).map(|c| c.into_owned()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).into_owned())
})
}
/// `s` + `t`, keeping `s` at most [`MAX_CELL_CHARS`] characters.
fn push_capped(s: &mut String, t: &str) {
let have = s.chars().count();
if have < MAX_CELL_CHARS {
s.extend(t.chars().take(MAX_CELL_CHARS - have));
}
}
/// Running total of the cell text kept, against [`MAX_TEXT_BYTES`].
#[derive(Default, Clone, Copy)]
struct TextBudget(usize);
impl TextBudget {
/// Spend `bytes`; false (and nothing spent) when they don't fit.
fn take(&mut self, bytes: usize) -> bool {
match self.0.checked_add(bytes) {
Some(t) if t <= MAX_TEXT_BYTES => {
self.0 = t;
true
}
_ => false,
}
}
}
/// Rows collected under the caps: blank rows are dropped, rows past [`MAX_ROWS`], cells past
/// [`MAX_CELLS`] and rows past [`MAX_TEXT_BYTES`] of text are ignored.
#[derive(Default)]
struct Grid {
rows: Vec<Vec<String>>,
cells: usize,
text: TextBudget,
/// A row has been left out for its text (and logged, once per sheet).
refused: bool,
}
impl Grid {
fn full(&self) -> bool {
self.rows.len() >= MAX_ROWS || self.cells >= MAX_CELLS || self.text.0 >= MAX_TEXT_BYTES
}
/// Note that a row is left out because its text is over what is left of [`MAX_TEXT_BYTES`]:
/// logged once per sheet, not once per row.
fn refuse(&mut self) {
if !self.refused {
self.refused = true;
log::warn!("sheet: rows with more text than the {MAX_TEXT_BYTES} byte budget has left are left out of the recipient list");
}
}
/// Keep `row` (trailing blanks trimmed) `times` times, as far as the caps allow.
fn push(&mut self, mut row: Vec<String>, times: u64) {
while row.last().is_some_and(String::is_empty) {
row.pop();
}
if row.is_empty() {
return;
}
let mut left = times;
while left > 0 && !self.full() {
// What fits is measured on `row` itself; only a copy that will be kept is made.
let keep = row.len().min(MAX_CELLS.saturating_sub(self.cells));
let r = row.get(..keep).unwrap_or_default();
if !self.text.take(r.iter().map(String::len).sum()) {
self.refuse();
return;
}
self.cells = self.cells.saturating_add(r.len());
self.rows.push(r.to_vec());
left -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn a_package_declaring_too_many_entries_is_refused() {
// #3660: refused before `zip` reserves and parses them.
let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(crate::MAX_ZIP_ENTRIES + 1);
assert!(sheet_names(&bomb).is_err_and(|e| e.contains("declares")));
assert!(crate::odt::parse(&bomb).is_err_and(|e| e.contains("declares")));
}
#[test]
fn negative_zero_formats_as_zero() {
assert_eq!(number(-0.0), "0");
}
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, body) in entries {
z.start_file(*name, o).unwrap();
z.write_all(body.as_bytes()).unwrap();
}
z.finish().unwrap().into_inner()
}
const RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#;
fn xlsx(sheet1: &str) -> Vec<u8> {
let wb = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><workbookPr/><sheets><sheet name="People" sheetId="1" r:id="rId1"/><sheet name="Chart" sheetId="2" r:id="rId9"/><sheet name="Notes" sheetId="3" r:id="rId2"/></sheets></workbook>"#;
let wb_rels = r#"<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="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="/xl/worksheets/sheet2.xml"/><Relationship Id="rId9" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chartsheet" Target="chartsheets/sheet1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let sst = r#"<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><si><t>Name</t></si><si><t>Born</t></si><si><r><t>Ada </t></r><r><rPr><b/></rPr><t xml:space="preserve">Lovelace</t></r><rPh sb="0" eb="1"><t>ignored</t></rPh></si><si><t>Name</t></si></sst>"#;
let styles = r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><numFmts><numFmt numFmtId="164" formatCode="dd/mm/yyyy;@"/><numFmt numFmtId="165" formatCode="&quot;Day&quot; 0.00"/></numFmts><cellStyleXfs><xf numFmtId="14"/></cellStyleXfs><cellXfs><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="164"/><xf numFmtId="165"/><xf numFmtId="22"/></cellXfs></styleSheet>"#;
let sheet2 = r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>x</t></is></c></row></sheetData></worksheet>"#;
zip(&[
("_rels/.rels", RELS),
("xl/workbook.xml", wb),
("xl/_rels/workbook.xml.rels", wb_rels),
("xl/sharedStrings.xml", sst),
("xl/styles.xml", styles),
("xl/worksheets/sheet1.xml", sheet1),
("xl/worksheets/sheet2.xml", sheet2),
])
}
/// #1287: one long shared string in thousands of cells stays under the text budget.
#[test]
fn xlsx_shared_strings_cannot_blow_up_memory() {
let wb = r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>"#;
let wb_rels = r#"<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/sharedStrings" Target="sharedStrings.xml"/></Relationships>"#;
let sst =
format!(r#"<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><si><t>{}</t></si></sst>"#, "x".repeat(MAX_CELL_CHARS));
let mut sheet = String::from("<worksheet><sheetData>");
for r in 1..=4_000 {
sheet.push_str(&format!(r#"<row r="{r}"><c r="A{r}" t="s"><v>0</v></c></row>"#));
}
sheet.push_str("</sheetData></worksheet>");
let book = zip(&[
("_rels/.rels", RELS),
("xl/workbook.xml", wb),
("xl/_rels/workbook.xml.rels", wb_rels),
("xl/sharedStrings.xml", &sst),
("xl/worksheets/sheet1.xml", &sheet),
]);
let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap();
let total: usize = rows.iter().flatten().map(String::len).sum();
assert!(total <= MAX_TEXT_BYTES, "{total} bytes of cell text");
assert!(rows.len() > 1_000, "the budget still keeps a real list: {}", rows.len());
}
#[test]
fn xlsx_shared_strings_dates_numbers_and_gaps() {
let sheet = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c><c r="D1" t="s"><v>3</v></c><c r="E1" t="s"><v>3</v></c></row>
<row r="3"><c r="A3" t="s"><v>2</v></c><c r="B3" s="1"><v>45356</v></c><c r="D3"><v>0.30000000000000004</v></c><c r="E3" t="b"><v>1</v></c></row>
<row r="4"><c r="A4" t="inlineStr"><is><t>Alan</t></is></c><c r="B4" s="2"><v>1</v></c><c r="C4" s="3"><v>12</v></c><c r="D4" t="str"><f>A4&amp;"!"</f><v>Alan!</v></c><c r="E4" s="4"><v>45356.5</v></c></row>
<row r="5"><c r="ZZZZ5"><v>1</v></c><c r="Z5" t="s"><v>999</v></c></row>
</sheetData><mergeCells count="1"><mergeCell ref="A4:B4"/></mergeCells></worksheet>"#;
let book = xlsx(sheet);
assert_eq!(detect(&book), Some(Kind::Xlsx));
assert_eq!(sheet_names(&book).unwrap(), ["People", "Notes"], "chart sheets aren't listed");
let (names, rows) = read_rows(&book, Pick::Index(0)).unwrap();
assert_eq!(names.len(), 2);
assert_eq!(rows.len(), 3, "blank rows (2, and 5 with nothing readable) are dropped: {rows:?}");
assert_eq!(rows[0], ["Name", "Born", "", "Name", "Name"]);
assert_eq!(field_names(&rows[0]), ["Name", "Born", "Column 3", "Name 2", "Name 3"]);
assert_eq!(rows[1], ["Ada Lovelace", "2024-03-05", "", "0.3", "TRUE"]);
assert_eq!(rows[2], ["Alan", "1900-01-01", "12", "Alan!", "2024-03-05 12:00"]);
let (_, notes) = read_rows(&book, Pick::Name("notes")).unwrap();
assert_eq!(notes, [["x"]]);
assert!(read_rows(&book, Pick::Index(5)).is_err());
assert!(read_rows(&book, Pick::Name("Missing")).is_err());
}
#[test]
fn ods_repeats_types_and_covered_cells() {
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:spreadsheet>
<table:table table:name="First"><table:table-row><table:table-cell office:value-type="string"><text:p>only</text:p></table:table-cell></table:table-row></table:table>
<table:table table:name="Guests">
<table:table-row table:number-rows-repeated="3"><table:table-cell table:number-columns-repeated="1024"/></table:table-row>
<table:table-row><table:table-cell office:value-type="string"><text:p>Name</text:p></table:table-cell><table:table-cell table:number-columns-repeated="2"/><table:table-cell office:value-type="string"><text:p>When</text:p></table:table-cell><table:table-cell office:value-type="string" table:number-columns-repeated="2"><text:p>Tag</text:p></table:table-cell><table:table-cell table:number-columns-repeated="16378"/></table:table-row>
<table:table-row table:number-rows-repeated="2"><table:table-cell office:value-type="string" table:number-columns-spanned="2"><text:p>Ada<text:s text:c="2"/>L</text:p><text:p>x</text:p><office:annotation><text:p>note</text:p></office:annotation></table:table-cell><table:covered-table-cell office:value-type="string"><text:p>hidden</text:p></table:covered-table-cell><table:table-cell office:value-type="float" office:value="3.50"><text:p>3,5</text:p></table:table-cell><table:table-cell office:value-type="date" office:date-value="2024-03-05T00:00:00"/><table:table-cell office:value-type="boolean" office:boolean-value="false"/><table:table-cell office:value-type="time" office:time-value="PT13H45M00S"/></table:table-row>
<table:table-row table:number-rows-repeated="1048570"><table:table-cell table:number-columns-repeated="1024"/></table:table-row>
</table:table></office:spreadsheet></office:body></office:document-content>"#;
let book = zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content)]);
assert_eq!(detect(&book), Some(Kind::Ods));
assert_eq!(sheet_names(&book).unwrap(), ["First", "Guests"]);
let (_, rows) = read_rows(&book, Pick::Name("Guests")).unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0], ["Name", "", "", "When", "Tag", "Tag"]);
assert_eq!(rows[1], ["Ada L\nx", "", "3.5", "2024-03-05", "FALSE", "13:45"]);
assert_eq!(rows[1], rows[2]);
}
#[test]
fn hostile_workbooks_are_refused_or_capped_without_panicking() {
// Huge repeat counts: rows and cells stay within the caps.
let row = r#"<table:table-row table:number-rows-repeated="4000000000"><table:table-cell office:value-type="string" table:number-columns-repeated="4000000000"><text:p>x</text:p></table:table-cell></table:table-row>"#;
let content = format!(
r#"<office:document-content xmlns:office="o" xmlns:table="t" xmlns:text="x"><office:body><office:spreadsheet><table:table table:name="S">{row}</table:table></office:spreadsheet></office:body></office:document-content>"#
);
let book = zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", &content)]);
let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap();
assert!(rows.len() <= MAX_ROWS && rows.iter().all(|r| r.len() <= MAX_COLS));
assert!(rows.iter().map(Vec::len).sum::<usize>() <= MAX_CELLS);
// A zip bomb: a tiny entry that inflates past the entry cap is refused.
let big = "<a>".to_string() + &"x".repeat((MAX_ENTRY + 10) as usize) + "</a>";
let bomb = zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", &big)]);
assert!(bomb.len() < 1 << 20, "the bomb is small: {}", bomb.len());
assert!(read_rows(&bomb, Pick::Index(0)).unwrap_err().contains("too large"));
// Huge cell references, long strings and junk.
let long = "y".repeat(MAX_CELL_CHARS + 50);
let sheet = format!(
r#"<worksheet><sheetData><row r="99999999999999999999"><c r="XFD1"><v>1</v></c><c r="A1" t="inlineStr"><is><t>{long}</t></is></c><c r="B1" s="99999999999999999999" t="s"><v>-1</v></c><c r="C1" s="1"><v>1e308</v></c></row></sheetData></worksheet>"#
);
let (_, rows) = read_rows(&xlsx(&sheet), Pick::Index(0)).unwrap();
assert_eq!(rows[0][0].chars().count(), MAX_CELL_CHARS);
assert_eq!(rows[0], [long[..MAX_CELL_CHARS].to_string(), String::new(), "1E308".to_string()]);
for junk in [&b""[..], b"PK\x03\x04junk", b"not a zip", &zip(&[("content.xml", "<<<")])] {
let _ = sheet_names(junk);
let _ = read_rows(junk, Pick::Index(0));
}
}
#[test]
fn dates_and_formats() {
assert_eq!(custom_date("[$-409]mmmm d, yyyy;@"), Some(DateKind::Date));
assert_eq!(custom_date("h:mm AM/PM"), Some(DateKind::Time));
assert_eq!(custom_date("\"days\" 0.00"), None);
assert_eq!(custom_date("General"), None);
assert_eq!(custom_date("[Red]#,##0.00"), None);
assert_eq!(serial_date(61.0, false, DateKind::Date).as_deref(), Some("1900-03-01"));
assert_eq!(serial_date(0.0, true, DateKind::Date).as_deref(), Some("1904-01-01"));
assert_eq!(serial_date(0.75, false, DateKind::Time).as_deref(), Some("18:00"));
assert_eq!(serial_date(-1.0, false, DateKind::Date), None);
assert_eq!(number(1234567.0), "1234567");
assert_eq!(number(0.1 + 0.2), "0.3");
assert_eq!(iso_datetime("2024-03-05T13:45:30-05:00").as_deref(), Some("2024-03-05 13:45:30"));
assert_eq!(iso_datetime("2024-03-5T13:45:00").as_deref(), Some("2024-03-05 13:45"));
assert_eq!(iso_datetime("2024-3-1").as_deref(), Some("2024-03-01"));
assert_eq!(iso_datetime("2024-03-05Z").as_deref(), Some("2024-03-05"));
}
#[test]
fn civil_is_exact_beyond_chronos_range() {
assert_eq!(civil(0), (1970, 1, 1));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of
// the epoch; values from the pre-chrono proleptic Gregorian formula.
assert_eq!(civil(95_026_602), (262_144, 1, 1));
assert_eq!(civil(99_000_000), (273_022, 10, 18));
assert_eq!(civil(-96_465_964), (-262_145, 3, 1));
assert_eq!(civil(-99_000_000), (-269_083, 3, 17));
// The extremes must not overflow or panic (i128 intermediates).
let _ = (civil(i64::MAX), civil(i64::MIN));
}
#[test]
fn test_sheet_extended_value_parsing() {
assert_eq!(iso_time("13:45:00").as_deref(), Some("13:45"));
assert_eq!(iso_time("13:45:30.500").as_deref(), Some("13:45:30"));
assert_eq!(iso_time("13:45:30Z").as_deref(), Some("13:45:30"));
assert_eq!(iso_time("PT13H45M").as_deref(), Some("13:45"));
// ODS with string-value and percentage without text:p, and case-insensitive float
let content = r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
<office:body><office:spreadsheet><table:table table:name="Sheet1">
<table:table-row>
<table:table-cell office:value-type="STRING" office:string-value="Hello"/>
<table:table-cell office:value-type="percentage" office:value="0.15"/>
<table:table-cell office:value-type="FLOAT" office:value="42"/>
<table:table-cell office:value-type="time" office:time-value="14:30:00"/>
</table:table-row>
</table:table></office:spreadsheet></office:body></office:document-content>"#;
let book = zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", content)]);
let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0], vec!["Hello", "0.15", "42", "14:30"]);
// XLSX with case-insensitive cell types
let sheet = r#"<worksheet><sheetData><row r="1">
<c r="A1" t="B"><v>1</v></c>
<c r="B1" t="INLINESTR"><is><t>World</t></is></c>
<c r="C1" t="S"><v>0</v></c>
</row></sheetData></worksheet>"#;
let (_, rows) = read_rows(&xlsx(sheet), Pick::Index(0)).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0], vec!["TRUE", "World", "Name"]);
}
#[test]
fn repeated_headers_are_numbered_in_linear_time() {
// Every repeat used to restart at `Name 2` and compare each candidate with every name
// so far: cubic in the number of equal headers.
let row = vec!["Name".to_string(); 5_000];
let t = std::time::Instant::now();
let names = field_names(&row);
// Milliseconds; the old numbering ran past 6 minutes.
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
assert_eq!(names.len(), 5_000);
assert_eq!(names[0], "Name");
assert_eq!(names[4_999], "Name 5000");
// Names a header already has stay collision-free, ignoring case.
let row: Vec<String> = ["Name", "Name 2", "Name", "name", "", "Column 5", "NAME 2"].iter().map(|s| s.to_string()).collect();
assert_eq!(field_names(&row), ["Name", "Name 2", "Name 3", "name 4", "Column 5", "Column 5 2", "NAME 2 2"]);
}
fn ods(rows: &str) -> Vec<u8> {
let content = format!(
r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:spreadsheet><table:table table:name="Sheet1">{rows}</table:table></office:spreadsheet></office:body></office:document-content>"#
);
zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", &content)])
}
#[test]
fn ods_string_attributes_are_capped_before_repeating() {
// An `office:string-value` is a cell's text: it obeys MAX_CELL_CHARS like `<text:p>`
// does, so repeating it across the columns can't build a huge row first.
let long = "v".repeat(MAX_CELL_CHARS * 4);
let book = ods(&format!(
r#"<table:table-row><table:table-cell office:value-type="string" office:string-value="{long}" table:number-columns-repeated="3"/></table:table-row>"#
));
let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].len(), 3);
assert!(rows[0].iter().all(|c| c.chars().count() == MAX_CELL_CHARS));
// A row whose repeated text alone is over the text budget is dropped as it is built
// (it was built in full, then cloned, then refused); the rows after it still count.
let wide = "€".repeat(MAX_CELL_CHARS);
let book = ods(&format!(
r#"<table:table-row><table:table-cell office:value-type="string" office:string-value="{wide}" table:number-columns-repeated="1000"/></table:table-row><table:table-row><table:table-cell><text:p>next</text:p></table:table-cell></table:table-row>"#
));
let ((_, rows), warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap());
assert_eq!(rows, vec![vec!["next".to_string()]]);
// The row left out is said to be (review of #3873).
assert_eq!(warned.iter().filter(|w| w.contains("are left out of the recipient list")).count(), 1, "{warned:?}");
}
#[test]
fn rows_over_the_text_budget_are_logged_once_per_sheet() {
let wide = "€".repeat(MAX_CELL_CHARS);
let over = format!(
r#"<table:table-row><table:table-cell office:value-type="string" office:string-value="{wide}" table:number-columns-repeated="1000"/></table:table-row>"#
);
let book = ods(&format!("{over}{over}{over}<table:table-row><table:table-cell><text:p>next</text:p></table:table-cell></table:table-row>"));
let ((_, rows), warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap());
assert_eq!(rows, vec![vec!["next".to_string()]]);
assert_eq!(warned.iter().filter(|w| w.contains("recipient list")).count(), 1, "one warning, not one per row: {warned:?}");
let book = ods("<table:table-row><table:table-cell><text:p>fine</text:p></table:table-cell></table:table-row>");
let (_, warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap());
assert!(warned.is_empty(), "{warned:?}");
}
#[test]
fn ods_repeated_text_is_budgeted_for_the_cells_the_grid_keeps() {
// One cell left in the grid: of a long value repeated a thousand times only the first
// copy is kept, and it fits the text budget, so the row isn't refused for the others.
let wide = "€".repeat(MAX_CELL_CHARS);
let full = MAX_CELLS / MAX_COLS - 1;
let book = ods(&format!(
r#"<table:table-row table:number-rows-repeated="{full}"><table:table-cell office:value-type="string" office:string-value="x" table:number-columns-repeated="{MAX_COLS}"/></table:table-row><table:table-row><table:table-cell office:value-type="string" office:string-value="x" table:number-columns-repeated="{}"/></table:table-row><table:table-row><table:table-cell office:value-type="string" office:string-value="{wide}" table:number-columns-repeated="1000"/></table:table-row>"#,
MAX_COLS - 1
));
let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap();
assert_eq!(rows.len(), full + 2);
assert_eq!(rows.last(), Some(&vec![wide]));
assert_eq!(rows.iter().map(Vec::len).sum::<usize>(), MAX_CELLS);
}
}