From d9edcdd1dada7dd8f817922b5e472ba1a3ae97c4 Mon Sep 17 00:00:00 2001 From: XuJian <265865031+xujiantop-crypto@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:35:53 +0800 Subject: [PATCH 1/2] fix(xml): reject delimiters that silently truncate text --- src/formats/docx/mod.rs | 33 +++++++++++++++++++++++++++++++++ src/package/xml.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs index eee5317e..e9995459 100644 --- a/src/formats/docx/mod.rs +++ b/src/formats/docx/mod.rs @@ -143,6 +143,39 @@ mod tests { const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + #[test] + fn bare_less_than_returns_an_error_instead_of_truncated_markdown() { + for paragraph in ["Pump A < pump B in flow rate.", "< First paragraph."] { + let document = format!( + r#" + Before. + {paragraph} + After. + "# + ); + let bytes = docx_parts(&[("word/document.xml", &document)]); + assert!(matches!( + crate::to_markdown_bytes(&bytes, crate::Format::Docx), + Err(ConvertError::Malformed { .. }) + )); + } + } + + #[test] + fn escaped_less_than_keeps_later_paragraphs() { + let document = format!( + r#" + Pump A < pump B. + After. + "# + ); + let bytes = docx_parts(&[("word/document.xml", &document)]); + assert_eq!( + crate::to_markdown_bytes(&bytes, crate::Format::Docx).unwrap(), + "Pump A < pump B.\n\nAfter.\n" + ); + } + fn find_image(blocks: &[Block]) -> Option<&Inline> { blocks.iter().find_map(|b| match b { Block::Paragraph(inlines) => inlines.iter().find(|i| matches!(i, Inline::Image { .. })), diff --git a/src/package/xml.rs b/src/package/xml.rs index 32ff65d8..3aba225d 100644 --- a/src/package/xml.rs +++ b/src/package/xml.rs @@ -218,6 +218,7 @@ pub fn parse_xml(bytes: &[u8]) -> Result { .map_err(|e| ConvertError::malformed(format!("unparseable xml: {e}")))?; match event { Event::Start(e) => { + check_start_delimiters(&e)?; if stack.len() >= limits::MAX_XML_DEPTH { return Err(ConvertError::ResourceLimit { limit: "max_xml_depth", @@ -228,6 +229,7 @@ pub fn parse_xml(bytes: &[u8]) -> Result { stack.push(start_to_element(&e, &reader, &mut interner)); } Event::Empty(e) => { + check_start_delimiters(&e)?; bump_nodes(&mut nodes)?; let elem = start_to_element(&e, &reader, &mut interner); attach(&mut stack, &mut root, Node::Elem(elem)); @@ -278,6 +280,15 @@ pub fn parse_xml(bytes: &[u8]) -> Result { Ok(root) } +fn check_start_delimiters(e: &BytesStart<'_>) -> Result<(), ConvertError> { + // quick-xml can consume a bare '<' in text together with the next end + // tag as one start event. Recovering that tree silently loses content. + if e.name().as_ref().is_empty() || e.contains(&b'<') { + return Err(ConvertError::malformed("unparseable xml: invalid start-tag delimiter")); + } + Ok(()) +} + /// Transcode an XML part to UTF-8 based on its BOM or encoding declaration. fn to_utf8(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { use std::borrow::Cow; @@ -593,6 +604,29 @@ mod tests { assert_eq!(r.text(), "\u{4f60}\u{597d}"); } + #[test] + fn bare_less_than_in_text_is_not_silently_recovered() { + for xml in [ + "Pump A < pump B", + "< First paragraph.", + "Pump A ", + "< >later", + "< />", + "", + ] { + assert!( + matches!(parse_xml(xml.as_bytes()), Err(ConvertError::Malformed { .. })), + "{xml}" + ); + } + } + + #[test] + fn escaped_less_than_and_cdata_keep_all_text() { + let root = parse_xml(b"before < middle ").unwrap(); + assert_eq!(root.text(), "before < middle < after"); + } + #[test] fn unclosed_elements_recovered() { let root = parse_xml(b"text").unwrap(); From b778a373f166a49f08927511ba93a613d6b2b1ff Mon Sep 17 00:00:00 2001 From: XuJian <265865031+xujiantop-crypto@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:15:10 +0800 Subject: [PATCH 2/2] fix(xml): reject raw delimiters inside end tags --- src/formats/docx/mod.rs | 6 +++++- src/package/xml.rs | 41 ++++++++++++++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs index e9995459..8fe0e6a5 100644 --- a/src/formats/docx/mod.rs +++ b/src/formats/docx/mod.rs @@ -145,7 +145,11 @@ mod tests { #[test] fn bare_less_than_returns_an_error_instead_of_truncated_markdown() { - for paragraph in ["Pump A < pump B in flow rate.", "< First paragraph."] { + for paragraph in [ + "Pump A < pump B in flow rate.", + "< First paragraph.", + "Pump A Before. diff --git a/src/package/xml.rs b/src/package/xml.rs index 3aba225d..eb875abb 100644 --- a/src/package/xml.rs +++ b/src/package/xml.rs @@ -200,6 +200,10 @@ impl<'a> Iterator for DescendantNodes<'a> { /// 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. +/// +/// Unclosed or mismatched elements are recovered while keeping available +/// text. Empty start-tag names and extra raw `<` bytes swallowed by tag +/// events are rejected to avoid silently discarding content. pub fn parse_xml(bytes: &[u8]) -> Result { let utf8 = to_utf8(bytes); let mut reader = NsReader::from_reader(utf8.as_ref()); @@ -234,15 +238,22 @@ pub fn parse_xml(bytes: &[u8]) -> Result { let elem = start_to_element(&e, &reader, &mut interner); attach(&mut stack, &mut root, Node::Elem(elem)); } - Event::End(end) => match stack.pop() { - Some(elem) => { - if elem.local.as_bytes() != end.local_name().as_ref() { - recovered = true; + Event::End(end) => { + if end.name().as_ref().contains(&b'<') { + return Err(ConvertError::malformed( + "unparseable xml: invalid end-tag delimiter", + )); + } + match stack.pop() { + Some(elem) => { + if elem.local.as_bytes() != end.local_name().as_ref() { + recovered = true; + } + attach(&mut stack, &mut root, Node::Elem(elem)); } - attach(&mut stack, &mut root, Node::Elem(elem)); + None => recovered = true, } - None => recovered = true, - }, + } Event::Text(e) => { let text = match e.decode() { Ok(t) => t.into_owned(), @@ -627,6 +638,16 @@ mod tests { assert_eq!(root.text(), "before < middle < after"); } + #[test] + fn bare_less_than_in_end_tag_is_not_silently_recovered() { + for xml in ["Pump A ", "Pump A "] { + assert!( + matches!(parse_xml(xml.as_bytes()), Err(ConvertError::Malformed { .. })), + "{xml}" + ); + } + } + #[test] fn unclosed_elements_recovered() { let root = parse_xml(b"text").unwrap(); @@ -635,6 +656,12 @@ mod tests { assert_eq!(a.text(), "text"); } + #[test] + fn mismatched_elements_keep_all_text() { + let root = parse_xml(b"beforeinsideafter").unwrap(); + assert_eq!(root.text(), "beforeinsideafter"); + } + #[test] fn depth_limit_is_hard_error() { let mut xml = Vec::new();