collective

Browse / github.com/storytold/designcraft / patch

Tables: a shape a table cannot hold is clamped, not allocated (#1583)

Tables: a shape a table cannot hold is clamped, not allocated (#1583) Audit **DC-02** (#1583), High / Confirmed: `Table::new` allocates `rows × cols` cells up front, and the counts reach it from IDML and DOCX, where they are whatever the file claims. ## Measured, not assumed `size_of::<Cell>()` is **1,360 bytes** here (a `Cell` carries a `Story`). So the reported IDML case — one `<Table>` with 5,000 `<Row/>` and 5,000 `<Column/>`, about 1 KB zipped — asks for 25M cells, **~34 GB**. As the issue says, that does not fail politely: `handle_alloc_error` aborts the process, and `catch_unwind` in `engine/src/guard.rs` cannot catch an abort, so every open document's

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #1868, open)
Base
ed8aabd1dc5a88652a076631c2d46886ca60c031
Size
+112 −8 in 2 files
Review
pending
Shared
2026-10-28
Id
73be79a503134b417d5886fe

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/table.rs crates/idml/src/import.rs

diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs--- a/crates/doc/src/table.rs+++ b/crates/doc/src/table.rs@@ -393,10 +393,44 @@ impl CellRange {     } } +/// Most rows a table holds, as InDesign allows.+pub const MAX_TABLE_ROWS: usize = 10_000;+/// Most columns a table holds, as InDesign allows.+pub const MAX_TABLE_COLS: usize = 1_000;+/// Most cells a table holds, whatever its shape.+///+/// A [`Cell`] is about 1.4 KB (it carries a [`Story`]), so this is roughly 340 MB of cells at the+/// limit. The row and column limits alone are not enough: 10,000 × 1,000 would be 13.6 GB, and an+/// IDML story holding one `<Table>` with 5,000 `<Row/>` and 5,000 `<Column/>` -- about 1 KB+/// zipped -- asked for 25M cells, some 34 GB. That allocation does not fail politely: it aborts+/// the process through `handle_alloc_error`, which no `catch_unwind` can save, so every open+/// document's unsaved work goes with it (#1583).+pub const MAX_TABLE_CELLS: usize = 250_000;++/// The rows and columns a table of `rows × cols` is actually given, inside the limits above.+///+/// Columns are clamped first and rows take what is left of the cell budget, because a table is+/// far more often long than wide: truncating a 5,000-column table to 1,000 columns and then its+/// rows loses less of it than the other way round. Callers that read a file report the+/// difference; the counts are a document's claim, not something to trust.+pub fn clamped_size(rows: usize, cols: usize) -> (usize, usize) {+    let cols = cols.clamp(1, MAX_TABLE_COLS);+    let rows = rows.clamp(1, MAX_TABLE_ROWS.min(MAX_TABLE_CELLS / cols).max(1));+    (rows, cols)+}+ impl Table {     /// A `rows × cols` table (`header` + body + `footer` rows) whose columns share `width`.+    ///+    /// The shape is clamped to [`clamped_size`]: the counts reach this from IDML and DOCX files,+    /// where they are whatever the file says (#1583).     pub fn new(id: u64, body_rows: usize, cols: usize, header: usize, footer: usize, width: f64) -> Table {-        let cols = cols.max(1);+        // Header, body and footer rows share one budget: the total is clamped first, then the+        // header and footer take what they can of it, and the body keeps at least one row.+        let (total, cols) = clamped_size(body_rows.max(1).saturating_add(header).saturating_add(footer), cols);+        let header = header.min(total.saturating_sub(1));+        let footer = footer.min(total.saturating_sub(header).saturating_sub(1));+        let body_rows = total.saturating_sub(header).saturating_sub(footer).max(1);         let mut rows = Vec::new();         rows.extend((0..header).map(|_| Row { kind: RowKind::Header, ..Default::default() }));         rows.extend((0..body_rows.max(1)).map(|_| Row::default()));@@ -1273,6 +1307,63 @@ impl Story {  #[cfg(test)] mod tests {+    /// A table's shape comes out of IDML and DOCX files unchecked, and `Cell` is about 1.4 KB, so+    /// a file claiming 5,000 x 5,000 asked for 25M cells -- some 34 GB. That allocation aborts the+    /// process rather than failing, taking every open document's unsaved work with it (#1583).+    #[test]+    fn a_table_larger_than_the_limits_is_clamped_not_allocated() {+        // The reported shape: 5,000 rows and 5,000 columns.+        let t = Table::new(1, 5_000, 5_000, 0, 0, 500.0);+        assert_eq!(t.columns.len(), MAX_TABLE_COLS, "columns are clamped to InDesign's limit");+        assert!(t.cells.len() <= MAX_TABLE_CELLS, "{} cells is within the budget", t.cells.len());+        assert_eq!(t.rows.len() * t.columns.len(), t.cells.len(), "the grid stays rectangular");+        // What it would have been: 25,000,000 cells at 1,360 bytes each.+        assert!(25_000_000 * std::mem::size_of::<Cell>() > 30_000_000_000, "the size this test is about");+        // Each of these tables is the cell budget, about 340 MB. Freed before the next one is+        // built, so the test's peak is one table and not all of them -- a constrained runner+        // would otherwise hold 680 MB here (noted in review).+        drop(t);++        // A long table keeps its rows while it fits the budget (the budget itself is checked by+        // `clamped_size`, which allocates nothing).+        let long = Table::new(2, 10_000, 5, 0, 0, 500.0);+        assert_eq!((long.rows.len(), long.columns.len()), (10_000, 5), "a long thin table is untouched");+        assert_eq!(clamped_size(10_000, 25), (10_000, 25), "10,000 x 25 is inside the budget");++        // Past the row limit the rows are clamped too.+        let longer = Table::new(3, 50_000, 2, 0, 0, 100.0);+        assert_eq!(longer.rows.len(), MAX_TABLE_ROWS, "rows are clamped to InDesign's limit");++        // Header, body and footer share the budget, and the table still has a body.+        let with_parts = Table::new(4, 5_000, 5_000, 3, 2, 500.0);+        assert!(with_parts.rows.len() <= MAX_TABLE_ROWS, "{} rows", with_parts.rows.len());+        assert!(with_parts.cells.len() <= MAX_TABLE_CELLS, "{} cells", with_parts.cells.len());+        assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Header), "the header survives");+        assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Footer), "the footer survives");+        assert!(with_parts.rows.iter().any(|r| r.kind == RowKind::Body), "and so does a body row");+        drop(with_parts);++        // A header and footer bigger than the whole budget still leave a body row.+        let all_header = Table::new(6, 1, 1, MAX_TABLE_ROWS * 2, MAX_TABLE_ROWS * 2, 100.0);+        assert!(all_header.rows.len() <= MAX_TABLE_ROWS, "{} rows", all_header.rows.len());+        assert!(all_header.rows.iter().any(|r| r.kind == RowKind::Body), "a body row survives");++        // An ordinary table is exactly what it was.+        let plain = Table::new(5, 4, 4, 1, 1, 400.0);+        assert_eq!((plain.rows.len(), plain.columns.len(), plain.cells.len()), (6, 4, 24));+    }++    /// `clamped_size` is the rule the importers report against.+    #[test]+    fn clamped_size_keeps_the_shape_it_can() {+        assert_eq!(clamped_size(4, 4), (4, 4));+        assert_eq!(clamped_size(0, 0), (1, 1), "a table has at least one cell");+        assert_eq!(clamped_size(10, MAX_TABLE_COLS + 500), (10, MAX_TABLE_COLS));+        assert_eq!(clamped_size(MAX_TABLE_ROWS + 1, 1), (MAX_TABLE_ROWS, 1));+        let (r, c) = clamped_size(5_000, 5_000);+        assert!(r * c <= MAX_TABLE_CELLS && c == MAX_TABLE_COLS, "{r} x {c}");+    }+     use super::*;      fn story(t: &str) -> Story {diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs--- a/crates/idml/src/import.rs+++ b/crates/idml/src/import.rs@@ -2196,21 +2196,34 @@ impl<'r> Importer<'r> {         let id = self.alloc();         let rows: Vec<&El> = e.find_all("Row").collect();         let cols: Vec<&El> = e.find_all("Column").collect();-        let nr = rows.len().max(1);-        let nc = cols.len().max(1);+        // A file's row and column counts are its claim, and a table of them is allocated whole: a+        // story holding one `<Table>` with 5,000 of each asked for 25M cells, around 34 GB, which+        // aborts the process instead of failing (#1583). What is read is clamped, and the rest of+        // this function walks the clamped slices -- indexing `t.rows`/`t.columns` by the file's+        // own counts would be out of bounds.+        let (nr, nc) = designcraft_doc::table::clamped_size(rows.len().max(1), cols.len().max(1));+        if nr < rows.len() || nc < cols.len() {+            self.warnings.push(format!(+                "a table of {} rows and {} columns was read as {nr} x {nc}: more than a table can hold",+                rows.len(),+                cols.len()+            ));+        }+        let rows = &rows[..nr.min(rows.len())];+        let cols = &cols[..nc.min(cols.len())];         let mut t = Table::new(id, nr, nc, 0, 0, 0.0);         t.options.direction = if e.get("TableDirection") == Some("RightToLeftDirection") {             designcraft_doc::TextDirection::RightToLeft         } else {             designcraft_doc::TextDirection::LeftToRight         };-        for (r, re) in rows.iter().enumerate() {+        for (row, re) in t.rows.iter_mut().zip(rows.iter()) {             let auto = re.boolean("AutoGrow").unwrap_or(true);-            t.rows[r].mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };-            t.rows[r].height = if auto { re.num("MinimumHeight").unwrap_or(3.0) } else { re.num("SingleRowHeight").unwrap_or(12.0) };+            row.mode = if auto { RowHeightMode::AtLeast } else { RowHeightMode::Exactly };+            row.height = if auto { re.num("MinimumHeight").unwrap_or(3.0) } else { re.num("SingleRowHeight").unwrap_or(12.0) };         }-        for (c, ce) in cols.iter().enumerate() {-            t.columns[c].width = ce.num("SingleColumnWidth").unwrap_or(72.0).max(3.0);+        for (column, ce) in t.columns.iter_mut().zip(cols.iter()) {+            column.width = ce.num("SingleColumnWidth").unwrap_or(72.0).max(3.0);         }         let h = e.num("HeaderRowCount").unwrap_or(0.0) as usize;         let f = e.num("FooterRowCount").unwrap_or(0.0) as usize;