From 7dbb79ad1213b29cf8e1e52ab0feb24dbdd76804 Mon Sep 17 00:00:00 2001 From: fluorine Date: Thu, 22 Oct 2026 14:40:26 -0400 Subject: [PATCH] DOCX: a malformed main document part is an error, not a truncated document (#3599) Co-Authored-By: Claude Sonnet 5.5 --- crates/docx/src/package.rs | 8 ++++++++ crates/docx/src/read/mod.rs | 2 +- crates/docx/src/xml.rs | 15 ++++++++++++++- crates/docx/tests/malformed.rs | 19 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index debef3cb5..f98a3a709 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -258,6 +258,14 @@ impl Package { self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v) } + /// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read. + pub fn xml_strict(&self, name: &str) -> Result, DocxError> { + match self.get(name) { + Some(b) => xml::parse_strict(b).map(Some), + None => Ok(None), + } + } + pub fn xml(&self, name: &str) -> Result, DocxError> { match self.get(name) { Some(b) => xml::parse(b).map(Some), diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 79bed9f35..8bdb65933 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { pub(crate) fn read_opened(pkg: &Package) -> Result { let root_rels = pkg.rels(""); let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string()); - let Some(root) = pkg.xml(&main)? else { + let Some(root) = pkg.xml_strict(&main)? else { return Err(DocxError::MissingPart(main)); }; if root.name != "w:document" { diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 1ebb497a8..c517f3669 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -263,6 +263,16 @@ pub fn parse(bytes: &[u8]) -> Result { /// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a /// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows. pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result { + parse_with(bytes, extra, false) +} + +/// [`parse`] for a part whose loss would be silent data loss (the main document): a mismatched end +/// tag or an element left open at the end of the data is an error, not a cue to stop reading. +pub fn parse_strict(bytes: &[u8]) -> Result { + parse_with(bytes, &[], true) +} + +fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result { // UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped. let transcoded = utf16_to_utf8(bytes); let bytes = match &transcoded { @@ -275,7 +285,7 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result = Vec::new(); let mut root: Option = None; @@ -389,6 +399,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result Vec { + doc_zip(format!("{body}").as_bytes()) +} + +#[test] +fn a_malformed_main_part_is_an_error_not_a_truncated_document() { + let cases = [ + ("a less-than sign in a run", "before a <- b afterlast"), + ("a paragraph left open", "firstsecondthird"), + ("a mismatched end tag", "onetwo"), + ]; + for (what, body) in cases { + assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}"); + } + let whole = "a <- blast"; + let d = read(&body_zip(whole)).unwrap(); + assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a <- b\nlast", "well-formed text still reads whole"); +} + #[test] fn deep_nesting_is_rejected() { let mut x = format!("");