From 4f57accf7340538962f712519550e6103cdd268a Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:11:43 +0530 Subject: [PATCH 1/5] Take zip 9.2 and tokio 1.54, testing the footer property directly zip 9.2 no longer rejects the method-99 central record the admission test relied on, so extraction now succeeds with the outer document's text. The test asserted zip's old strictness, not the property it names: its nested archive held only nested.xml, which DOCX never reads. Make the nested archive a readable DOCX with its own text, and assert that text never comes out, whether or not the corrupt record is refused. Supersedes #58 (lockfile from dependabot's update). --- Cargo.lock | 37 ++++++++++++++++++++++--------- src/intake/zip_admission_tests.rs | 14 ++++++++++-- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c426742..fc86848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,7 +424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1328,7 +1328,7 @@ dependencies = [ "getrandom 0.2.17", "libc", "untrusted", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -1347,7 +1347,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1604,7 +1604,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1761,9 +1761,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "pin-project-lite", @@ -1868,9 +1868,9 @@ dependencies = [ [[package]] name = "typed-path" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typenum" @@ -2071,6 +2071,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -2080,6 +2086,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2279,16 +2294,18 @@ dependencies = [ [[package]] name = "zip" -version = "9.0.5" +version = "9.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" +checksum = "3f5b58784007ff6e5f280ec82c8596686a3b9b5dd5836c63288d4479f84728b9" dependencies = [ "crc32fast", "flate2", + "getrandom 0.4.3", "indexmap", "memchr", "nix", "typed-path", + "windows-sys 0.61.2", "zopfli", ] diff --git a/src/intake/zip_admission_tests.rs b/src/intake/zip_admission_tests.rs index 90de4c6..4adbf34 100644 --- a/src/intake/zip_admission_tests.rs +++ b/src/intake/zip_admission_tests.rs @@ -183,7 +183,9 @@ fn central_directory_metadata_and_zip64_extensions_have_fixed_caps() { #[test] fn payload_footer_cannot_be_retried_when_primary_directory_is_invalid() { - let nested = fixture(&[("nested.xml", b"unchecked")]); + // The nested archive is itself a readable DOCX, so a fallback to its + // footer would surface its text rather than merely fail. + let nested = fixture(&[("word/document.xml", b"unchecked")]); let bytes = fixture(&[ ("nested.zip", &nested), ("word/document.xml", b"visible"), @@ -200,7 +202,15 @@ fn payload_footer_cannot_be_retried_when_primary_directory_is_invalid() { // footers; the embedded ZIP must not become a second admission candidate. corrupt[central + 10..central + 12].copy_from_slice(&99u16.to_le_bytes()); zip_preflight(&corrupt).unwrap(); - assert!(extract_docx(&corrupt).is_err()); + // zip 9.0 refuses the corrupt record and zip 9.2 tolerates it on a member + // DOCX never reads; either way only the outer document may be read. + if let Ok(document) = extract_docx(&corrupt) { + assert!( + document.sections.iter().all(|s| !s.text.contains("unchecked")), + "{:?}", + document.sections + ); + } } #[test] From 0220fced3995eca5782297d8e4b181b34b137db6 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:13:00 +0530 Subject: [PATCH 2/5] Port the Office XML readers to quick-xml 0.42 quick-xml 0.42 stores names, text and attribute values as str and drops decode(). Compare names as str, read text and references through their str deref, and normalize attribute values without a decoder. decode() did no EOL normalization, and neither does the deref, so the extracted text is unchanged. Supersedes #29. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/intake/mod.rs | 48 +++++++++++++++++--------------------- src/intake/office_order.rs | 11 +++------ src/markdown/ooxml.rs | 14 ++++------- 5 files changed, 31 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc86848..1886ade 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1695,7 +1695,7 @@ dependencies = [ "pdf-extract", "png 0.17.16", "ppt-rs", - "quick-xml 0.41.0", + "quick-xml 0.42.0", "serde_json", "tinydocs-bus", "zip", diff --git a/Cargo.toml b/Cargo.toml index ef06ec2..0c1ce38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ pdf-extract = { version = "0.12", optional = true } # Bounded OOXML intake reads only selected XML parts, never extracts files. zip = { version = "9", default-features = false, features = ["deflate"], optional = true } # Streaming XML events avoid building attacker-controlled DOM trees. -quick-xml = { version = "0.41", optional = true } +quick-xml = { version = "0.42", optional = true } # Workbook row conversion for document-memory Markdown; implementation only. calamine = { version = "0.38", optional = true } # Pure Rust PDF rasterization, isolated from the default extraction build. diff --git a/src/intake/mod.rs b/src/intake/mod.rs index cb1e0ca..d16f721 100644 --- a/src/intake/mod.rs +++ b/src/intake/mod.rs @@ -234,40 +234,37 @@ fn xml_text(xml: &[u8], shared: &[String], limit: usize) -> Result { return Err(Error::extraction_failed("XML nesting limit exceeded")); } let local = e.local_name(); - if local.as_ref() == b"rPh" && phonetic_depth.is_none() { + if local.as_ref() == "rPh" && phonetic_depth.is_none() { phonetic_depth = Some(depth); } - in_text = local.as_ref() == b"t" && phonetic_depth.is_none(); + in_text = local.as_ref() == "t" && phonetic_depth.is_none(); if phonetic_depth.is_none() { match local.as_ref() { - b"br" | b"cr" => output.append("\n"), - b"tab" => output.append("\t"), + "br" | "cr" => output.append("\n"), + "tab" => output.append("\t"), _ => {} } } - value = local.as_ref() == b"v"; - if local.as_ref() == b"c" { + value = local.as_ref() == "v"; + if local.as_ref() == "c" { shared_cell = false; for attr in e.attributes() { let attr = attr.map_err(failed)?; - if attr.key.as_ref() == b"t" && attr.value.as_ref() == b"s" { + if attr.key.as_ref() == "t" && attr.value == "s" { shared_cell = true; } } } } Event::Text(e) if in_text || value => { - let decoded = e.decode().map_err(failed)?; - output.append_value(&mut current, value && shared_cell, &decoded)?; + output.append_value(&mut current, value && shared_cell, &e)?; } Event::GeneralRef(e) if in_text || value => { - let name = e.decode().map_err(failed)?; - let decoded = decode_reference(&name)?; + let decoded = decode_reference(&e)?; output.append_value(&mut current, value && shared_cell, &decoded)?; } Event::CData(e) if in_text || value => { - let decoded = e.decode().map_err(failed)?; - output.append_value(&mut current, value && shared_cell, &decoded)?; + output.append_value(&mut current, value && shared_cell, &e)?; } Event::End(e) => { if phonetic_depth == Some(depth) { @@ -277,7 +274,7 @@ fn xml_text(xml: &[u8], shared: &[String], limit: usize) -> Result { .checked_sub(1) .ok_or_else(|| Error::extraction_failed("invalid XML nesting"))?; let local = e.local_name(); - if local.as_ref() == b"t" || local.as_ref() == b"v" { + if local.as_ref() == "t" || local.as_ref() == "v" { if value && shared_cell { let index: usize = current.parse().map_err(failed)?; output.append(shared.get(index).ok_or_else(|| { @@ -288,13 +285,13 @@ fn xml_text(xml: &[u8], shared: &[String], limit: usize) -> Result { in_text = false; value = false; } - if [b"p".as_slice(), b"row", b"c"].contains(&local.as_ref()) { + if ["p", "row", "c"].contains(&local.as_ref()) { output.append("\n"); } } Event::Empty(e) if phonetic_depth.is_none() => match e.local_name().as_ref() { - b"br" | b"cr" => output.append("\n"), - b"tab" => output.append("\t"), + "br" | "cr" => output.append("\n"), + "tab" => output.append("\t"), _ => {} }, Event::DocType(_) => { @@ -338,10 +335,10 @@ fn xml_events(xml: &[u8], mut consume: impl FnMut(&str, Option<&str>) -> Result< return Err(Error::extraction_failed("XML nesting limit exceeded")); } let local = e.local_name(); - if local.as_ref() == b"rPh" && phonetic_depth.is_none() { + if local.as_ref() == "rPh" && phonetic_depth.is_none() { phonetic_depth = Some(depth); } - in_text = local.as_ref() == b"t" && phonetic_depth.is_none(); + in_text = local.as_ref() == "t" && phonetic_depth.is_none(); } Event::End(e) => { if phonetic_depth == Some(depth) { @@ -350,17 +347,14 @@ fn xml_events(xml: &[u8], mut consume: impl FnMut(&str, Option<&str>) -> Result< depth = depth .checked_sub(1) .ok_or_else(|| Error::extraction_failed("invalid XML nesting"))?; - consume( - std::str::from_utf8(e.local_name().as_ref()).map_err(failed)?, - None, - )?; + consume(e.local_name().as_ref(), None)?; in_text = false; } - Event::Text(e) if in_text => consume("", Some(&e.decode().map_err(failed)?))?, - Event::CData(e) if in_text => consume("", Some(&e.decode().map_err(failed)?))?, - Event::Empty(e) if e.local_name().as_ref() == b"si" => consume("si", None)?, + Event::Text(e) if in_text => consume("", Some(&e))?, + Event::CData(e) if in_text => consume("", Some(&e))?, + Event::Empty(e) if e.local_name().as_ref() == "si" => consume("si", None)?, Event::GeneralRef(e) if in_text => { - consume("", Some(&decode_reference(&e.decode().map_err(failed)?)?))?; + consume("", Some(&decode_reference(&e)?))?; } Event::DocType(_) => { return Err(Error::extraction_failed("XML DTDs are not supported")); diff --git a/src/intake/office_order.rs b/src/intake/office_order.rs index e00937c..f053921 100644 --- a/src/intake/office_order.rs +++ b/src/intake/office_order.rs @@ -145,7 +145,7 @@ fn elements( match event { Event::Start(element) | Event::Empty(element) => { let local = element.local_name(); - let name = std::str::from_utf8(local.as_ref()).map_err(failed)?; + let name = local.as_ref(); if name.len() > 256 || parents.len() >= 128 { return Err(invalid("XML nesting or name limit exceeded")); } @@ -160,14 +160,9 @@ fn elements( return Err(invalid("XML metadata limit exceeded")); } attrs.push(( - std::str::from_utf8(attribute.key.as_ref()) - .map_err(failed)? - .to_owned(), + attribute.key.as_ref().to_owned(), attribute - .decoded_and_normalized_value( - quick_xml::XmlVersion::Implicit1_0, - reader.decoder(), - ) + .normalized_value(quick_xml::XmlVersion::Implicit1_0) .map_err(failed)? .into_owned(), )); diff --git a/src/markdown/ooxml.rs b/src/markdown/ooxml.rs index 041920f..de19955 100644 --- a/src/markdown/ooxml.rs +++ b/src/markdown/ooxml.rs @@ -129,24 +129,20 @@ fn xml_text(xml: &str, paragraph_tag: &str, text_tag: &str) -> String { let mut in_text = false; loop { match reader.read_event() { - Ok(Event::Start(tag)) if tag.name().as_ref() == text_tag.as_bytes() => in_text = true, - Ok(Event::End(tag)) if tag.name().as_ref() == text_tag.as_bytes() => in_text = false, - Ok(Event::End(tag)) if tag.name().as_ref() == paragraph_tag.as_bytes() => { + Ok(Event::Start(tag)) if tag.name().as_ref() == text_tag => in_text = true, + Ok(Event::End(tag)) if tag.name().as_ref() == text_tag => in_text = false, + Ok(Event::End(tag)) if tag.name().as_ref() == paragraph_tag => { out.push_str("\n\n"); } Ok(Event::Text(text)) if in_text => { - out.push_str(&text.decode().unwrap_or_default()); + out.push_str(&text); } // The parser reports each `&` / `&` as its own event; // dropping them would turn "Q&A" into "QA". Ok(Event::GeneralRef(reference)) if in_text => { if let Ok(Some(ch)) = reference.resolve_char_ref() { out.push(ch); - } else if let Some(entity) = reference - .decode() - .ok() - .and_then(|name| quick_xml::escape::resolve_predefined_entity(&name)) - { + } else if let Some(entity) = quick_xml::escape::resolve_predefined_entity(&reference) { out.push_str(entity); } } From 73aa99ab13793ee0763ead27bef4cf7fe4485e7f Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 15:13:06 +0530 Subject: [PATCH 3/5] Take png 0.18 in the render tests png 0.18 returns output_buffer_size() as an Option, None only when the size overflows usize. The test fixture is 80x120, so unwrap it. Supersedes #28. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/pdf_render/mod_tests.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1886ade..0374aed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1693,7 +1693,7 @@ dependencies = [ "docx-rs", "hayro", "pdf-extract", - "png 0.17.16", + "png 0.18.1", "ppt-rs", "quick-xml 0.42.0", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0c1ce38..a2aa61e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ hayro = { version = "0.5", optional = true } [dev-dependencies] # Decode raster fixtures to assert scanned-page pixels survive rendering. -png = "0.17" +png = "0.18" # `.docx` output is a zip container; the tests re-open the produced bytes and # assert on the OOXML parts inside. zip = { version = "9", default-features = false, features = ["deflate"] } diff --git a/src/pdf_render/mod_tests.rs b/src/pdf_render/mod_tests.rs index 6956393..cad2f4e 100644 --- a/src/pdf_render/mod_tests.rs +++ b/src/pdf_render/mod_tests.rs @@ -33,7 +33,7 @@ fn renders_only_requested_pages_in_order_with_png_dimensions() { assert_eq!(images.pages[1].page, 1); let decoder = png::Decoder::new(std::io::Cursor::new(&images.pages[0].bytes)); let mut reader = decoder.read_info().unwrap(); - let mut pixels = vec![0; reader.output_buffer_size()]; + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let frame = reader.next_frame(&mut pixels).unwrap(); assert!( pixels[..frame.buffer_size()] From 3ff26dda8571e13b0565f582a153b9c3f76ddeb2 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 16:58:37 +0530 Subject: [PATCH 4/5] Format the zip and quick-xml ports --- src/intake/zip_admission_tests.rs | 5 ++++- src/markdown/ooxml.rs | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/intake/zip_admission_tests.rs b/src/intake/zip_admission_tests.rs index 4adbf34..9b5dd31 100644 --- a/src/intake/zip_admission_tests.rs +++ b/src/intake/zip_admission_tests.rs @@ -206,7 +206,10 @@ fn payload_footer_cannot_be_retried_when_primary_directory_is_invalid() { // DOCX never reads; either way only the outer document may be read. if let Ok(document) = extract_docx(&corrupt) { assert!( - document.sections.iter().all(|s| !s.text.contains("unchecked")), + document + .sections + .iter() + .all(|s| !s.text.contains("unchecked")), "{:?}", document.sections ); diff --git a/src/markdown/ooxml.rs b/src/markdown/ooxml.rs index de19955..cee539f 100644 --- a/src/markdown/ooxml.rs +++ b/src/markdown/ooxml.rs @@ -142,7 +142,9 @@ fn xml_text(xml: &str, paragraph_tag: &str, text_tag: &str) -> String { Ok(Event::GeneralRef(reference)) if in_text => { if let Ok(Some(ch)) = reference.resolve_char_ref() { out.push(ch); - } else if let Some(entity) = quick_xml::escape::resolve_predefined_entity(&reference) { + } else if let Some(entity) = + quick_xml::escape::resolve_predefined_entity(&reference) + { out.push_str(entity); } } From 1fbbbc0e2d1a110aa177e45818af33b5a4345ec9 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Wed, 11 Nov 2026 17:38:34 +0530 Subject: [PATCH 5/5] Assert the footer test's outcome exactly The test only checked the text when extraction succeeded, so an error would have passed silently. Under zip 9.2 extraction succeeds, so require exactly the outer document's single section. --- src/intake/zip_admission_tests.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/intake/zip_admission_tests.rs b/src/intake/zip_admission_tests.rs index 9b5dd31..b2f3a38 100644 --- a/src/intake/zip_admission_tests.rs +++ b/src/intake/zip_admission_tests.rs @@ -202,18 +202,11 @@ fn payload_footer_cannot_be_retried_when_primary_directory_is_invalid() { // footers; the embedded ZIP must not become a second admission candidate. corrupt[central + 10..central + 12].copy_from_slice(&99u16.to_le_bytes()); zip_preflight(&corrupt).unwrap(); - // zip 9.0 refuses the corrupt record and zip 9.2 tolerates it on a member - // DOCX never reads; either way only the outer document may be read. - if let Ok(document) = extract_docx(&corrupt) { - assert!( - document - .sections - .iter() - .all(|s| !s.text.contains("unchecked")), - "{:?}", - document.sections - ); - } + // zip 9.2 tolerates the corrupt record on a member DOCX never reads, so + // the outer document is extracted; the nested one must never be read. + let document = extract_docx(&corrupt).unwrap(); + assert_eq!(document.sections.len(), 1, "{:?}", document.sections); + assert_eq!(document.sections[0].text, "visible"); } #[test]