From 5a5e97b0a64892dac87b52d6a3d1f4bdb410d575 Mon Sep 17 00:00:00 2001 From: natocool2 Date: Tue, 13 Oct 2026 09:07:47 +0100 Subject: [PATCH] Bump quick-xml to 0.41 (RUSTSEC-2026-0194, RUSTSEC-2026-0195) The .docx/.xlsx text import iterates attributes with quick-xml's default duplicate check, so one crafted start tag with N attributes cost O(N^2) (RUSTSEC-2026-0194, fixed in quick-xml 0.41). Bumping also clears RUSTSEC-2026-0195 (NsReader namespace allocation), which this workspace does not reach (no NsReader). unescape_value() is deprecated in 0.41 (clippy -D warnings); normalized_value(Implicit1_0) is what the deprecated method now does. Regression test: an element with 100,000 attributes parses in < 5 s. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 17 ++++------------- Cargo.toml | 2 +- crates/idml/src/xml.rs | 5 ++++- crates/textimport/src/docx.rs | 22 ++++++++++++++++++++-- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef525c84..07ae0c03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -983,7 +983,7 @@ dependencies = [ "hayro-syntax", "kurbo", "log", - "quick-xml 0.38.4", + "quick-xml", "regex", "serde", "serde_json", @@ -1046,7 +1046,7 @@ dependencies = [ "designcraft-compose", "designcraft-doc", "designcraft-geom", - "quick-xml 0.38.4", + "quick-xml", "thiserror 2.0.21", "zip", ] @@ -1120,7 +1120,7 @@ name = "designcraft-textimport" version = "0.7.1" dependencies = [ "designcraft-doc", - "quick-xml 0.38.4", + "quick-xml", "zip", ] @@ -3505,15 +3505,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - [[package]] name = "quick-xml" version = "0.41.0" @@ -4989,7 +4980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] diff --git a/Cargo.toml b/Cargo.toml index b08a82fc..6653e320 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,7 +64,7 @@ flo_curves = "0.8" qrcodegen = "1.8" krilla = { version = "0.8", features = ["pdf"] } hayro = "0.7" -quick-xml = "0.38" +quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # ui diff --git a/crates/idml/src/xml.rs b/crates/idml/src/xml.rs index cb09249a..97aab239 100644 --- a/crates/idml/src/xml.rs +++ b/crates/idml/src/xml.rs @@ -283,7 +283,10 @@ fn start_el(e: &quick_xml::events::BytesStart) -> Result { for a in e.attributes().with_checks(false) { let a = a.map_err(|e| e.to_string())?; let k = String::from_utf8_lossy(a.key.as_ref()).to_string(); - let v = a.unescape_value().map(|v| v.to_string()).unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string()); + let v = a + .normalized_value(quick_xml::XmlVersion::Implicit1_0) + .map(|v| v.to_string()) + .unwrap_or_else(|_| String::from_utf8_lossy(&a.value).to_string()); el.attrs.push((k, v)); } Ok(el) diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs index 67a2b71a..1b4f28cb 100644 --- a/crates/textimport/src/docx.rs +++ b/crates/textimport/src/docx.rs @@ -67,7 +67,9 @@ pub(crate) fn parse(xml: &str) -> Result { attrs: e .attributes() .flatten() - .map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default())) + .map(|a| { + (local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default()) + }) .collect(), kids: vec![], }; @@ -79,7 +81,9 @@ pub(crate) fn parse(xml: &str) -> Result { attrs: e .attributes() .flatten() - .map(|a| (local(a.key.as_ref()), a.unescape_value().map(|v| v.to_string()).unwrap_or_default())) + .map(|a| { + (local(a.key.as_ref()), a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map(|v| v.to_string()).unwrap_or_default()) + }) .collect(), kids: vec![], }; @@ -510,4 +514,18 @@ mod tests { i.story.check().unwrap(); assert_eq!(i.story.text, "Kept text. MOVED HERE. Plain INSERTED\nEnd."); } + + #[test] + fn an_element_with_many_attributes_parses_in_linear_time() { + // RUSTSEC-2026-0194: before quick-xml 0.41 the duplicate-attribute check compared each name with every + // earlier one on the element, so a crafted .docx/.xlsx with one huge tag hung the import (O(N²)). + let attrs: String = (0..100_000).map(|i| format!(" w:a{i}=\"{i}\"")).collect(); + let xml = format!(""); + let started = std::time::Instant::now(); + let doc = parse(&xml).unwrap(); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "parse took {:?}", started.elapsed()); + let p = doc.child("body").and_then(|b| b.child("p")).unwrap(); + assert_eq!(p.attr("a0"), Some("0")); + assert_eq!(p.attr("a99999"), Some("99999")); + } }