#3692: a row's gridAfter is bounded like the DOCX reader's

Audit finding 35: Table::cols() added a deserialized row's grid_after
(u32) unchecked, and layout_table sized its column vectors from it, so a
small native JSON document could ask for a 16 GB grid (and overflow the
sum on 32-bit wasm). Row::grid_after() now clamps to MAX_COLS as
first_grid() does and the importer does; cols() sums with saturating
adds; layout reads the bounded value. Test:
tests_budget::a_huge_grid_after_is_bounded_like_the_docx_readers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-25 21:01:25 -04:00
committed by Dylan M. Taylor
co-authored by Claude Opus 5.5
parent d81b43b336
commit 4963e30eed
4 changed files with 36 additions and 3 deletions
+8 -1
View File
@@ -48,6 +48,12 @@ impl Row {
pub fn first_grid(&self) -> usize {
(self.props.grid_before as usize).min(MAX_COLS)
}
/// Grid columns `w:gridAfter` leaves empty after the row's last cell, bounded as the DOCX
/// reader bounds them (a native document can say anything).
pub fn grid_after(&self) -> usize {
(self.props.grid_after as usize).min(MAX_COLS)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
@@ -97,7 +103,8 @@ impl Table {
}
pub fn cols(&self) -> usize {
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
let row_cols =
|r: &Row| r.first_grid().saturating_add(r.cells.iter().map(Cell::span).fold(0, usize::saturating_add)).saturating_add(r.grid_after());
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
}
+2
View File
@@ -4967,6 +4967,8 @@ mod tests_autospace;
#[cfg(test)]
mod tests_bidi;
#[cfg(test)]
mod tests_budget;
#[cfg(test)]
mod tests_complex;
#[cfg(test)]
mod tests_grid;
+2 -2
View File
@@ -537,7 +537,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default();
// A row that skips grid columns (`w:gridBefore`/`w:gridAfter`) has its edge at its own
// first and last cell.
let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.props.grid_after as usize));
let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.grid_after()));
let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default();
let mut borders = Borders {
top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between),
@@ -1170,7 +1170,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
/// columns, width in points). Column widths make room for them like for an empty cell.
fn row_gaps(row: &Row, end: usize) -> impl Iterator<Item = (usize, usize, f32)> {
let p = &row.props;
[(0, row.first_grid(), p.w_before), (end, p.grid_after as usize, p.w_after)]
[(0, row.first_grid(), p.w_before), (end, row.grid_after(), p.w_after)]
.into_iter()
.filter(|&(_, n, _)| n > 0)
.filter_map(|(g, n, w)| w.filter(|w| w.is_finite() && *w > 0.0).map(|w| (g, n, w.min(1584.0))))
+24
View File
@@ -0,0 +1,24 @@
//! Hostile documents whose layout work or memory would otherwise grow far past their size: each
//! is bounded by a cap, and real documents stay well under it.
use super::*;
use wordcraft_doc::Block;
use wordcraft_doc::table::{MAX_COLS, Table};
fn lay(doc: &Document) -> DocLayout {
let mut c = LayoutCache::new();
layout(doc, &mut c, &LayoutOptions::default())
}
#[test]
fn a_huge_grid_after_is_bounded_like_the_docx_readers() {
// A native document can say a row leaves four billion columns empty after its cells.
let mut t = Table::new(1, 2, 468.0);
t.rows[0].props.grid_after = u32::MAX;
t.grid.clear();
assert!(t.cols() <= 2 + MAX_COLS, "{} columns", t.cols());
let mut d = Document::from_text("");
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
let l = lay(&d);
assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Cell { row: 0, cell: 1, .. })));
}