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 pump B in flow rate.",
+ ] {
let document = format!(
r#"
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 B", "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();