DOCX: a malformed main document part is an error, not a truncated document (#3599)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
fluorine
2026-10-22 14:40:26 -04:00
co-authored by Claude Sonnet 5.5
parent 00bc6a4b02
commit 7dbb79ad12
4 changed files with 42 additions and 2 deletions
+8
View File
@@ -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<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse_strict(b).map(Some),
None => Ok(None),
}
}
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
match self.get(name) {
Some(b) => xml::parse(b).map(Some),
+1 -1
View File
@@ -129,7 +129,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
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" {
+14 -1
View File
@@ -263,6 +263,16 @@ pub fn parse(bytes: &[u8]) -> Result<El, DocxError> {
/// [`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<El, DocxError> {
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<El, DocxError> {
parse_with(bytes, &[], true)
}
fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Result<El, DocxError> {
// 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<El, Docx
c.trim_text_start = false;
c.trim_text_end = false;
c.expand_empty_elements = false;
c.check_end_names = false;
c.check_end_names = strict;
}
let mut stack: Vec<El> = Vec::new();
let mut root: Option<El> = None;
@@ -389,6 +399,9 @@ pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result<El, Docx
break;
}
}
if strict && !stack.is_empty() {
return Err(DocxError::Xml("an element is not closed".into()));
}
// Unclosed elements at EOF: close them (lenient).
while let Some(el) = stack.pop() {
attach(&mut stack, &mut root, el);
+19
View File
@@ -68,6 +68,25 @@ fn missing_or_wrong_main_part() {
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "ok");
}
fn body_zip(body: &str) -> Vec<u8> {
doc_zip(format!("<w:document {W_NS}><w:body>{body}</w:body></w:document>").as_bytes())
}
#[test]
fn a_malformed_main_part_is_an_error_not_a_truncated_document() {
let cases = [
("a less-than sign in a run", "<w:p><w:r><w:t>before a <- b after</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>"),
("a paragraph left open", "<w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>second</w:t></w:r><w:p><w:r><w:t>third</w:t></w:r></w:p>"),
("a mismatched end tag", "<w:p><w:r><w:t>one</w:t></w:p></w:r><w:p><w:r><w:t>two</w:t></w:r></w:p>"),
];
for (what, body) in cases {
assert!(matches!(read(&body_zip(body)), Err(DocxError::Xml(_))), "{what}");
}
let whole = "<w:p><w:r><w:t>a &lt;- b</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p>";
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!("<w:document {W_NS}><w:body>");