diff --git a/ROADMAP.md b/ROADMAP.md
index 1b28d2b960..f4f1c4d6a1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -234,6 +234,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-03: Security: Paste Special with picture files on the clipboard refuses anything that isn't a regular file (a pipe or device reports no length and can produce endless data), and `cargo xtask stats` no longer follows directory symlinks. Readiness unchanged.
- 2026-11-03: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged.
- 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged.
+- 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged.
- 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged.
- 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged.
- 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged.
diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs
index 53c7fca84f..beeaa1f142 100644
--- a/crates/formats/src/sheet.rs
+++ b/crates/formats/src/sheet.rs
@@ -427,6 +427,27 @@ mod tests {
const RELS: &str = r#""#;
+ #[test]
+ fn workbook_sheets_find_their_relationships_by_index() {
+ // Every sheet searched every relationship; 4,096 sheets over 20,000 relationships.
+ let sheets: String = (0..MAX_SHEETS).map(|i| format!(r#""#)).collect();
+ let wb = format!(
+ r#"{sheets}"#
+ );
+ let mut rels = String::from(r#""#);
+ // A chart sheet sharing a worksheet's id doesn't hide it.
+ rels.push_str(r#""#);
+ for i in 0..20_000 {
+ rels.push_str(&format!(
+ r#""#
+ ));
+ }
+ rels.push_str("");
+ let names = sheet_names(&zip(&[("_rels/.rels", RELS), ("xl/workbook.xml", &wb), ("xl/_rels/workbook.xml.rels", &rels)])).unwrap();
+ assert_eq!(names.len(), MAX_SHEETS);
+ assert_eq!(names.first().map(String::as_str), Some("S0"));
+ }
+
fn xlsx(sheet1: &str) -> Vec {
let wb = r#""#;
let wb_rels = r#""#;
diff --git a/crates/formats/src/sheet/xlsx.rs b/crates/formats/src/sheet/xlsx.rs
index dffa9be7dd..cca2472119 100644
--- a/crates/formats/src/sheet/xlsx.rs
+++ b/crates/formats/src/sheet/xlsx.rs
@@ -83,14 +83,13 @@ fn workbook(pkg: &mut Package<'_>) -> Result {
}
}
});
- let sheets = listed
- .into_iter()
- .filter_map(|(name, id)| {
- // Worksheets only: chart and dialog sheets hold no cells.
- let r = rels.iter().find(|r| r.id == id && r.kind.ends_with("/worksheet"))?;
- Some(SheetRef { name, path: r.target.clone() })
- })
- .collect();
+ // Worksheets only: chart and dialog sheets hold no cells. Indexed by id once (the first
+ // relationship with an id wins), not searched for every sheet.
+ let mut worksheets: HashMap<&str, &str> = HashMap::new();
+ for r in rels.iter().filter(|r| r.kind.ends_with("/worksheet")) {
+ worksheets.entry(r.id.as_str()).or_insert(r.target.as_str());
+ }
+ let sheets = listed.into_iter().filter_map(|(name, id)| Some(SheetRef { name, path: worksheets.get(id.as_str())?.to_string() })).collect();
let part = |suffix: &str| rels.iter().find(|r| r.kind.ends_with(suffix)).map(|r| r.target.clone());
Ok(Workbook { sheets, date1904, shared: part("/sharedStrings"), styles: part("/styles") })
}