collective

Browse / github.com/storytold/designcraft / patch

Import: a shared string and a footnote are not copied per reference (#1584)

Import: a shared string and a footnote are not copied per reference (#1584) Audit **DC-03** (#1584), High / Confirmed — two amplifications where one piece of a file is copied per reference. Both are measured by their controls rather than argued. ## XLSX: the shared string A `t="s"` cell materialises its **own copy** of the shared string, and every cell is collected before the grid's `MAX_CELLS` ever applies. Control, with the budget removed, a 64 KB shared string referenced 2,000 times: ``` the table holds at most the budget: 131072000 bytes ``` 125 MB of cell text from a 64 KB string. The issue's case — 1 MB and ~3M references — is terabytes, and an allocation t

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #1871, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+131 −3 in 2 files
Review
pending
Shared
2026-10-28
Id
85c5ae0300a11502fee3c82c

How it went

No other operator has reported using it yet.

Diff

raw · crates/textimport/src/docx.rs crates/textimport/src/xlsx.rs

diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs--- a/crates/textimport/src/docx.rs+++ b/crates/textimport/src/docx.rs@@ -236,6 +236,12 @@ struct Ctx {     para_names: HashMap<String, String>,     char_names: HashMap<String, String>,     footnotes: HashMap<String, El>,+    /// Footnote ids already imported, and how much note text has been built. A reference clones+    /// its note and walks it again, so one 1 MB footnote referenced 100k times meant ~100 GB+    /// (#1584). Word gives each note its own id and references it once; a second reference to the+    /// same id gets the mark without the text.+    notes_done: std::collections::HashSet<String>,+    note_bytes: usize,     warnings: Vec<String>,     next_table: u64,     numbering: Numbering,@@ -412,11 +418,33 @@ impl Ctx {                 "noBreakHyphen" => "\u{2011}".into(),                 "softHyphen" => "\u{AD}".into(),                 "footnoteReference" if allow_notes => {+                    const MAX_NOTE_BYTES: usize = 8 << 20;+                    let id = e.attr("id").unwrap_or_default().to_string();+                    // The note's text is built once per id, and only while the budget lasts. A+                    // repeat reference, or one past the budget, still gets its mark and its+                    // number -- the reference is part of the text -- with an empty note (#1584).+                    let again = !id.is_empty() && !self.notes_done.insert(id.clone());+                    let spent = self.note_bytes >= MAX_NOTE_BYTES;+                    if again || spent {+                        let len = st.len();+                        if st.insert_note(len, "", ParaFormat::default()).is_some() {+                            let message = if again {+                                format!("Footnote {id} is referenced more than once in the Word file: the text is kept at its first reference.")+                            } else {+                                format!("The Word file's footnotes are longer than {} MB: the rest were imported empty.", MAX_NOTE_BYTES / (1 << 20))+                            };+                            if !self.warnings.contains(&message) {+                                self.warnings.push(message);+                            }+                        }+                        continue;+                    }                     if let Some(note) = e.attr("id").and_then(|id| self.footnotes.get(id).cloned()) {                         let mut ns = Story::new(StoryId(0));                         let counter = std::mem::take(&mut self.model_counter);                         self.blocks(&note, &mut ns, false);                         self.model_counter = counter;+                        self.note_bytes = self.note_bytes.saturating_add(ns.text.len());                         // Word puts the reference mark in the note text: we generate the number.                         let len = st.len();                         match st.insert_note(len, "", ParaFormat::default()) {@@ -485,6 +513,8 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {         para_names: HashMap::new(),         char_names: HashMap::new(),         footnotes: HashMap::new(),+        notes_done: std::collections::HashSet::new(),+        note_bytes: 0,         warnings: vec![],         next_table: 0,         numbering: Numbering::default(),@@ -674,6 +704,38 @@ mod tests {         assert_eq!(size, None, "a size the engine cannot mean is dropped, so the style's size stands");     } +    /// A footnote reference clones its note and walks it again, so one large footnote referenced+    /// many times multiplied: the audit's case is a 1 MB note referenced 100k times, around+    /// 100 GB, which aborts rather than fails (#1584). The text is kept at the first reference;+    /// later ones get the mark and an empty note, and the report says so.+    #[test]+    fn a_footnote_referenced_many_times_is_imported_once() {+        let note_text = "x".repeat(2_000);+        let footnotes = format!(r#"<w:footnotes {W}><w:footnote w:id="2"><w:p><w:r><w:t>{note_text}</w:t></w:r></w:p></w:footnote></w:footnotes>"#);+        // One note, referenced 50 times.+        let refs = r#"<w:r><w:footnoteReference w:id="2"/></w:r>"#.repeat(50);+        let doc = format!(r#"<w:document {W}><w:body><w:p>{refs}</w:p></w:body></w:document>"#);+        let i = import(&docx(&doc, "", &footnotes)).unwrap();++        // Every reference is still in the text: the marks are the document's content.+        assert_eq!(i.story.notes.len(), 50, "all 50 references are kept");+        let with_text = i.story.notes.iter().filter(|n| !n.text.text.is_empty()).count();+        assert_eq!(with_text, 1, "only the first reference carries the note's text");+        let total: usize = i.story.notes.iter().map(|n| n.text.text.len()).sum();+        assert!(total <= note_text.len() + 64, "the note text is stored once, not 50 times: {total} bytes");+        assert!(i.warnings.iter().any(|w| w.contains("referenced more than once")), "the report says what it did: {:?}", i.warnings);++        // A document whose notes each have their own id is unaffected.+        let many = (2..6).map(|id| format!(r#"<w:footnote w:id="{id}"><w:p><w:r><w:t>note {id}</w:t></w:r></w:p></w:footnote>"#)).collect::<String>();+        let footnotes = format!(r#"<w:footnotes {W}>{many}</w:footnotes>"#);+        let refs = (2..6).map(|id| format!(r#"<w:r><w:footnoteReference w:id="{id}"/></w:r>"#)).collect::<String>();+        let doc = format!(r#"<w:document {W}><w:body><w:p>{refs}</w:p></w:body></w:document>"#);+        let i = import(&docx(&doc, "", &footnotes)).unwrap();+        assert_eq!(i.story.notes.len(), 4);+        assert!(i.story.notes.iter().all(|n| n.text.text.contains("note")), "each distinct note keeps its text");+        assert!(!i.warnings.iter().any(|w| w.contains("referenced more than once")), "{:?}", i.warnings);+    }+     #[test]     fn imports_styles_runs_footnotes_and_tables() {         let styles = format!(diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs--- a/crates/textimport/src/xlsx.rs+++ b/crates/textimport/src/xlsx.rs@@ -212,19 +212,41 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {         .transpose()?         .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect())         .unwrap_or_default();+    // A `t="s"` cell holds an index into the shared strings and each one is materialised as its+    // own `String`, so one large shared string referenced by many cells multiplies: a 1 MB string+    // and 3M references is terabytes, allocated before the grid cap below ever applies, and an+    // allocation that large aborts the process rather than failing (#1584). Collection carries a+    // byte budget and a cell count of its own.+    const MAX_TEXT_BYTES: usize = 64 << 20;+    const MAX_COLLECTED_CELLS: usize = 400_000;     let mut cells: Vec<((usize, usize), String)> = Vec::new();+    let mut text_bytes = 0usize;+    let mut cut = false;     if let Some(data) = sheet.child("sheetData") {-        for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() {+        'rows: for (ri, row) in data.els().filter(|e| e.name == "row").enumerate() {             for (ci, c) in row.els().filter(|e| e.name == "c").enumerate() {                 let at = c.attr("r").and_then(cell_ref).unwrap_or((ri, ci));                 let v = value(c, &shared);-                if !v.is_empty() {-                    cells.push((at, v));+                if v.is_empty() {+                    continue;+                }+                if cells.len() >= MAX_COLLECTED_CELLS || text_bytes.saturating_add(v.len()) > MAX_TEXT_BYTES {+                    cut = true;+                    break 'rows;                 }+                text_bytes = text_bytes.saturating_add(v.len());+                cells.push((at, v));             }         }     }     let mut warnings = Vec::new();+    if cut {+        warnings.push(format!(+            "the worksheet holds more than {} cells or {} MB of text: the rest was not read",+            MAX_COLLECTED_CELLS,+            MAX_TEXT_BYTES / (1 << 20)+        ));+    }     if cells.is_empty() {         warnings.push("the worksheet is empty".into());     }@@ -293,6 +315,50 @@ mod tests {         buf.into_inner()     } +    /// A `t="s"` cell materialises its own copy of the shared string, so one big string with many+    /// references multiplies: the audit's case is a 1 MB string and 3M cells, terabytes, built+    /// before the grid cap applies (#1584). Collection now carries its own byte budget.+    #[test]+    fn a_shared_string_referenced_many_times_does_not_multiply() {+        // A 64 KB shared string referenced 2,000 times would be 128 MB of cell text; the budget+        // is 64 MB, so collection stops and says so.+        let big = "y".repeat(64 * 1024);+        let cells: String = (1..=2_000).map(|r| format!(r#"<row r="{r}"><c r="A{r}" t="s"><v>0</v></c></row>"#)).collect();+        let mut buf = std::io::Cursor::new(Vec::new());+        {+            let mut z = zip::ZipWriter::new(&mut buf);+            let o = zip::write::SimpleFileOptions::default();+            for (name, body) in [+                (+                    "xl/workbook.xml",+                    r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="Big" sheetId="1" r:id="rId1"/></sheets></workbook>"#.to_string(),+                ),+                (+                    "xl/_rels/workbook.xml.rels",+                    r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/></Relationships>"#.to_string(),+                ),+                ("xl/sharedStrings.xml", format!("<sst><si><t>{big}</t></si></sst>")),+                ("xl/worksheets/sheet1.xml", format!("<worksheet><sheetData>{cells}</sheetData></worksheet>")),+            ] {+                z.start_file(name, o).unwrap();+                z.write_all(body.as_bytes()).unwrap();+            }+            z.finish().unwrap();+        }+        let imp = import(&buf.into_inner()).unwrap();+        let table = imp.story.tables.values().next().expect("a table");+        let text: usize = (0..table.nrows())+            .flat_map(|r| (0..table.ncols()).map(move |c| (r, c)))+            .filter_map(|(r, c)| table.cell(r, c).map(|cell| cell.text.text.len()))+            .sum();+        assert!(text <= 64 << 20, "the table holds at most the budget: {text} bytes");+        assert!(imp.warnings.iter().any(|w| w.contains("was not read")), "the report says the sheet was cut: {:?}", imp.warnings);++        // An ordinary workbook is unaffected and reports nothing of the sort.+        let plain = import(&workbook()).unwrap();+        assert!(!plain.warnings.iter().any(|w| w.contains("was not read")), "{:?}", plain.warnings);+    }+     #[test]     fn first_sheet_used_range_becomes_a_table() {         let imp = import(&workbook()).unwrap();