From 6c958c2539f500b19a796060064fcd2277e9c4e5 Mon Sep 17 00:00:00 2001 From: abhinavmir Date: Thu, 6 Aug 2026 14:03:33 -0700 Subject: [PATCH] fix: parse each package part once, and bound the total An EPUB whose reading-order entries all point at the package document parsed that document once per entry, and the document grows with the entry count: a 35 KB file took 27 seconds and a 69 KB one did not finish. Part bytes were cached but parsed trees were not, and the per-part node cap resets on every parse, so nothing bounded the product. Packages now cache parsed trees and hand callers a shared `Rc`, so a part referenced many times parses once. The cache holds up to `max_cached_xml_nodes` (~86 MiB at the measured DOM cost), which covers the parts documents actually repeat; a part too large for it is served uncached and still re-parses, so a running node total across the package bounds that case at `max_document_xml_nodes`. The 35 KB file now converts in 0.03s and the 69 KB one in 0.02s. Repeated slide references in a presentation take about half the time they did. Output is byte-identical across the fixture corpus. --- src/formats/docx/content.rs | 4 +- src/formats/pptx/mod.rs | 6 +- src/package/archive.rs | 90 ++++++++++++++++-- src/package/limits.rs | 19 ++++ src/package/xml.rs | 34 +++++-- tests/fixtures/malformed/selfref--errors.epub | Bin 0 -> 35049 bytes tests/gen_fixtures.py | 21 ++++ ...hots__malformed__selfref--errors.epub.snap | 5 + 8 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/malformed/selfref--errors.epub create mode 100644 tests/snapshots/snapshots__malformed__selfref--errors.epub.snap diff --git a/src/formats/docx/content.rs b/src/formats/docx/content.rs index ab03109..e1df1cc 100644 --- a/src/formats/docx/content.rs +++ b/src/formats/docx/content.rs @@ -633,7 +633,7 @@ impl<'a, 'b, 'e> InlineWalker<'a, 'b, 'e> { let Some((part, bytes)) = self.ctx.rel_part(rel_id)? else { return Ok(Vec::new()); }; - match crate::package::xml::parse_xml(&bytes) { + match self.ctx.pkg.borrow_mut().parse_part(&part, &bytes) { Ok(root) => Ok(crate::shared::drawingml::chart_blocks(&root)), Err(e) if e.is_fatal() => Err(e), Err(e) => { @@ -648,7 +648,7 @@ impl<'a, 'b, 'e> InlineWalker<'a, 'b, 'e> { let Some((part, bytes)) = self.ctx.rel_part(rel_id)? else { return Ok(Vec::new()); }; - match crate::package::xml::parse_xml(&bytes) { + match self.ctx.pkg.borrow_mut().parse_part(&part, &bytes) { Ok(root) => Ok(crate::shared::drawingml::diagram_blocks(&root)), Err(e) if e.is_fatal() => Err(e), Err(e) => { diff --git a/src/formats/pptx/mod.rs b/src/formats/pptx/mod.rs index 800c26e..bee49ec 100644 --- a/src/formats/pptx/mod.rs +++ b/src/formats/pptx/mod.rs @@ -13,7 +13,7 @@ use crate::model::{ use crate::package::relationships::{ RelTarget, Relationships, TargetMode, read_rels, rel_target_bytes, rel_type, rels_part_for, }; -use crate::package::xml::{Element, ns, parse_xml}; +use crate::package::xml::{Element, ns}; use crate::package::{Package, archive::probe_ole, path}; use crate::shared::assets::AssetSink; use crate::shared::delta::rebase_emphasis; @@ -580,7 +580,7 @@ fn parse_graphic_frame( && let Some(rid) = chart_ref.attr_qualified(ns::R, "id") && let Some((part, bytes)) = ctx.rel_part(rid)? { - match parse_xml(&bytes) { + match ctx.pkg.borrow_mut().parse_part(&part, &bytes) { Ok(root) => blocks.extend(crate::shared::drawingml::chart_blocks(&root)), Err(e) if e.is_fatal() => return Err(e), Err(e) => log::warn!("skipping corrupt chart part {part}: {e}"), @@ -591,7 +591,7 @@ fn parse_graphic_frame( && let Some(rid) = rel_ids.attr_qualified(ns::R, "dm") && let Some((part, bytes)) = ctx.rel_part(rid)? { - match parse_xml(&bytes) { + match ctx.pkg.borrow_mut().parse_part(&part, &bytes) { Ok(root) => blocks.extend(crate::shared::drawingml::diagram_blocks(&root)), Err(e) if e.is_fatal() => return Err(e), Err(e) => log::warn!("skipping corrupt diagram part {part}: {e}"), diff --git a/src/package/archive.rs b/src/package/archive.rs index ce6637b..fe97c0c 100644 --- a/src/package/archive.rs +++ b/src/package/archive.rs @@ -2,7 +2,7 @@ use crate::error::ConvertError; use crate::package::limits; -use crate::package::xml::{Element, parse_xml}; +use crate::package::xml::{Element, parse_xml_counted}; use std::collections::HashMap; use std::io::{Cursor, Read}; use std::rc::Rc; @@ -17,6 +17,19 @@ pub struct Package<'a> { /// reference one part many times). Bounded by `MAX_TOTAL_BYTES`. Buffers /// are shared (`Rc`), so a cache hit never copies the bytes. cache: HashMap>, + /// Parsed part trees by normalized name, shared (`Rc`) with callers: a + /// part referenced many times parses once. Holds up to + /// `MAX_CACHED_XML_NODES`; past that a part is returned uncached and + /// re-parses on its next reference. + trees: HashMap>, + /// Nodes currently held in `trees`, against `MAX_CACHED_XML_NODES`. + cached_nodes: u64, + /// XML nodes parsed out of this package so far, counting each parse + /// separately. The tree cache removes the repeats a document actually + /// has; this bounds the ones it cannot hold, so a package pointing at an + /// uncacheable part many times still terminates. Bounded by + /// `MAX_DOCUMENT_XML_NODES`. + parsed_nodes: u64, } impl<'a> Package<'a> { @@ -29,7 +42,14 @@ impl<'a> Package<'a> { detail: format!("archive contains {} entries", zip.len()), }); } - Ok(Package { zip, total_read: 0, cache: HashMap::new() }) + Ok(Package { + zip, + total_read: 0, + cache: HashMap::new(), + trees: HashMap::new(), + cached_nodes: 0, + parsed_nodes: 0, + }) } /// Read a part's bytes. `Ok(None)` means the part is absent (a valid @@ -116,11 +136,11 @@ impl<'a> Package<'a> { /// Read and parse an optional XML part under the unified recovery policy: /// absent -> `Ok(None)`; unreadable or corrupt -> skipped with a log; /// fatal resource-limit errors always propagate. - pub fn optional_xml_part(&mut self, name: &str) -> Result, ConvertError> { + pub fn optional_xml_part(&mut self, name: &str) -> Result>, ConvertError> { let Some(bytes) = self.optional_part(name)? else { return Ok(None); }; - match parse_xml(&bytes) { + match self.parse_part(name, &bytes) { Ok(tree) => Ok(Some(tree)), Err(e) if e.is_fatal() => Err(e), Err(e) => { @@ -132,9 +152,28 @@ impl<'a> Package<'a> { /// Read and parse an XML part that must exist and parse for any /// meaningful output. - pub fn required_xml_part(&mut self, name: &str) -> Result { + pub fn required_xml_part(&mut self, name: &str) -> Result, ConvertError> { let bytes = self.required_part(name)?; - parse_xml(&bytes) + self.parse_part(name, &bytes) + } + + /// Parse one of this package's parts, serving a part already parsed from + /// the tree cache. Parts reached by relationship (charts, diagrams) come + /// through here with the bytes the relationship resolved to, so that they + /// share the cache and the node total with the rest of the package. + pub fn parse_part(&mut self, name: &str, bytes: &[u8]) -> Result, ConvertError> { + let name = name.trim_start_matches('/'); + if let Some(tree) = self.trees.get(name) { + return Ok(Rc::clone(tree)); + } + let before = self.parsed_nodes; + let tree = Rc::new(parse_xml_counted(bytes, &mut self.parsed_nodes)?); + let nodes = self.parsed_nodes - before; + if self.cached_nodes + nodes <= limits::MAX_CACHED_XML_NODES { + self.cached_nodes += nodes; + self.trees.insert(name.to_string(), Rc::clone(&tree)); + } + Ok(tree) } } @@ -179,6 +218,45 @@ mod tests { assert_eq!(pkg.total_read, 4096, "repeated reads must not re-charge the budget"); } + #[test] + fn a_part_referenced_repeatedly_is_parsed_once() { + let data = one_part_zip("a.xml", b""); + let mut pkg = Package::open(&data).unwrap(); + let first = pkg.required_xml_part("a.xml").unwrap(); + let per_parse = pkg.parsed_nodes; + assert!(per_parse > 0); + for _ in 0..64 { + let again = pkg.required_xml_part("a.xml").unwrap(); + assert!(Rc::ptr_eq(&first, &again), "a repeat reference must share the parsed tree"); + } + assert_eq!(pkg.parsed_nodes, per_parse, "a cached tree must not re-parse"); + // The leading slash OPC part URIs carry must not miss the cache. + assert!(Rc::ptr_eq(&first, &pkg.required_xml_part("/a.xml").unwrap())); + } + + #[test] + fn a_part_too_large_to_cache_re_parses_and_hits_the_document_total() { + let data = one_part_zip("a.xml", b""); + let mut pkg = Package::open(&data).unwrap(); + // A package whose cache is full of earlier parts: this one is served + // uncached, so every reference to it parses again. The per-part cap + // resets on each of those parses and never fires; the running total + // is what stops it. + pkg.cached_nodes = limits::MAX_CACHED_XML_NODES; + pkg.required_xml_part("a.xml").unwrap(); + assert!(pkg.trees.is_empty(), "a full cache must not grow past its budget"); + let per_parse = pkg.parsed_nodes; + pkg.required_xml_part("a.xml").unwrap(); + assert_eq!(pkg.parsed_nodes, per_parse * 2, "an uncached repeat must be charged again"); + + pkg.parsed_nodes = limits::MAX_DOCUMENT_XML_NODES; + let err = pkg.required_xml_part("a.xml").unwrap_err(); + assert!( + matches!(err, ConvertError::ResourceLimit { limit: "max_document_xml_nodes", .. }), + "expected max_document_xml_nodes, got: {err}" + ); + } + #[test] fn total_budget_exhaustion_reports_max_total_bytes() { let mut w = zip::ZipWriter::new(Cursor::new(Vec::new())); diff --git a/src/package/limits.rs b/src/package/limits.rs index f6dba39..d06e5fe 100644 --- a/src/package/limits.rs +++ b/src/package/limits.rs @@ -23,6 +23,25 @@ pub const MAX_XML_DEPTH: usize = 256; /// memory test) so a saturating part stays around the archive budget. pub const MAX_XML_NODES: usize = 2_000_000; +/// Maximum XML nodes parsed across one document, summed over every part and +/// every time a part is parsed. The per-part cap above resets on each parse, +/// so a package that references one part many times (an EPUB reading order, +/// a presentation's slide list) can multiply it without bound; this is the +/// ceiling on that product. Sized against `MAX_TOTAL_BYTES`: XML runs about +/// 30-60 bytes per node, so a document that reads its whole decompression +/// budget as markup lands here, and one that parses more than that is +/// re-reading parts rather than covering new ones. +pub const MAX_DOCUMENT_XML_NODES: u64 = 16_000_000; + +/// Maximum XML nodes held in a package's parsed-tree cache, which is what +/// keeps a part referenced many times from being parsed many times. At the +/// measured DOM cost (~430 bytes/node) this is ~86 MiB, and it is far above +/// the parts documents actually repeat: style tables, slide layouts and +/// masters, and chapter files all sit in the thousands of nodes. A part too +/// large to cache re-parses per reference and is bounded by the document +/// total above. +pub const MAX_CACHED_XML_NODES: u64 = 200_000; + /// Maximum content-bearing cells a repeat expansion may produce per table. pub const MAX_EXPANSION: u64 = 4_000_000; diff --git a/src/package/xml.rs b/src/package/xml.rs index 42af1d8..49cd292 100644 --- a/src/package/xml.rs +++ b/src/package/xml.rs @@ -192,8 +192,10 @@ impl<'a> Iterator for DescendantNodes<'a> { /// Parse an XML part into a synthetic root element containing the top-level /// nodes. Encoding comes from the BOM or XML declaration; the part is /// transcoded to UTF-8 before parsing so namespace resolution sees one -/// consistent encoding. -pub fn parse_xml(bytes: &[u8]) -> Result { +/// consistent encoding. Nodes are charged against `document_nodes` as well +/// as the per-part cap, so that a package re-parsing one part for every +/// reference to it stays bounded. +pub fn parse_xml_counted(bytes: &[u8], document_nodes: &mut u64) -> Result { let utf8 = to_utf8(bytes); let mut reader = NsReader::from_reader(utf8.as_ref()); reader.config_mut().check_end_names = false; @@ -202,7 +204,7 @@ pub fn parse_xml(bytes: &[u8]) -> Result { let mut root = Element { ns: None, local: String::new(), attrs: Vec::new(), children: Vec::new() }; let mut stack: Vec = Vec::new(); - let mut nodes: usize = 0; + let mut nodes = NodeCount { part: 0, document: document_nodes }; let mut recovered = false; loop { @@ -310,14 +312,34 @@ fn declared_encoding(head: &[u8]) -> Option { Some(rest[..end].to_string()) } -fn bump_nodes(nodes: &mut usize) -> Result<(), ConvertError> { - *nodes += 1; - if *nodes > limits::MAX_XML_NODES { +/// [`parse_xml_counted`] against a throwaway document total. +#[cfg(test)] +pub fn parse_xml(bytes: &[u8]) -> Result { + parse_xml_counted(bytes, &mut 0) +} + +/// Node counters for one parse: the part's own count, and the running total +/// for the document the part belongs to. +struct NodeCount<'a> { + part: usize, + document: &'a mut u64, +} + +fn bump_nodes(nodes: &mut NodeCount<'_>) -> Result<(), ConvertError> { + nodes.part += 1; + if nodes.part > limits::MAX_XML_NODES { return Err(ConvertError::ResourceLimit { limit: "max_xml_nodes", detail: format!("part exceeds {} xml nodes", limits::MAX_XML_NODES), }); } + *nodes.document += 1; + if *nodes.document > limits::MAX_DOCUMENT_XML_NODES { + return Err(ConvertError::ResourceLimit { + limit: "max_document_xml_nodes", + detail: format!("document exceeds {} parsed xml nodes", limits::MAX_DOCUMENT_XML_NODES), + }); + } Ok(()) } diff --git a/tests/fixtures/malformed/selfref--errors.epub b/tests/fixtures/malformed/selfref--errors.epub new file mode 100644 index 0000000000000000000000000000000000000000..10fc0453542047c40cfec32510d439b0fa988290 GIT binary patch literal 35049 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 literal 0 HcmV?d00001 diff --git a/tests/gen_fixtures.py b/tests/gen_fixtures.py index 21552bf..1774a27 100644 --- a/tests/gen_fixtures.py +++ b/tests/gen_fixtures.py @@ -1739,6 +1739,27 @@ def malformed(): # structurally unusable -> the labelled raw-recovery path must run. raw = ppt.read_bytes().replace(b"\xf5\x0f", b"\xf5\xff") (m / "brokenpersist--recovers.ppt").write_bytes(raw) + # Every reading-order entry resolves to the package document itself, so + # the part read once per entry is the one that grows with the entry + # count. Parsing it per entry is quadratic; the package's parsed-tree + # cache is what keeps this linear. No entry yields a chapter, so the book + # itself is unreadable. + n = 6400 + items = "".join( + f'' + for i in range(n)) + refs = "".join(f'' for i in range(n)) + write_zip(m / "selfref--errors.epub", [ + ("META-INF/container.xml", + '' + '' + ''), + ("c.opf", + '' + '' + f'{items}{refs}'), + ], mimetype_first="application/epub+zip") def abuse(): diff --git a/tests/snapshots/snapshots__malformed__selfref--errors.epub.snap b/tests/snapshots/snapshots__malformed__selfref--errors.epub.snap new file mode 100644 index 0000000..8a3eb67 --- /dev/null +++ b/tests/snapshots/snapshots__malformed__selfref--errors.epub.snap @@ -0,0 +1,5 @@ +--- +source: tests/snapshots.rs +expression: output +--- +ERROR: malformed document: no chapter in the book could be read