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) <noreply@anthropic.com>
This commit is contained in:
natocool2
2026-10-13 09:07:47 +01:00
co-authored by Claude Opus 5.5
parent e862e0a613
commit 5a5e97b0a6
4 changed files with 29 additions and 17 deletions
Generated
+4 -13
View File
@@ -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",
]
+1 -1
View File
@@ -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
+4 -1
View File
@@ -283,7 +283,10 @@ fn start_el(e: &quick_xml::events::BytesStart) -> Result<El, String> {
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)
+20 -2
View File
@@ -67,7 +67,9 @@ pub(crate) fn parse(xml: &str) -> Result<El, ImportError> {
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<El, ImportError> {
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!("<w:document><w:body><w:p{attrs}/></w:body></w:document>");
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"));
}
}