From 8a6aff26e056ced3bca4a943af6ff3e4a5e22924 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:59:36 +0800 Subject: [PATCH] fix(xfa): bound the text the draw phase lays out, so large text in many instances stops draw_value_text re-measures overflowing text up to six times to shrink it, and a repeating subform draws its text once per instance, with no shared cache. A small file with large text drawn across many instances therefore cost one full text layout per instance per pass, for seconds of work, and finished with a valid layout, so neither the page limit nor the measurement budget stopped it. Each pass over a draw's text now counts its characters against a new draw budget (MAX_DRAW_CHARS), the way the measurement budget bounds container sizing. A real form's draws stay far below it; a template that lays out far more text stops with TooLarge. The budget is reduced under test so the guard is exercised without laying out hundreds of megabytes. Test: large_text_drawn_in_many_instances_spends_the_draw_budget (fifty instances of a 20,000-character draw give TooLarge; one copy lays out); it fails without the cap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP --- crates/xfa/src/layout.rs | 12 ++++++++++++ crates/xfa/src/layout/draw.rs | 10 +++++++++- crates/xfa/src/layout/pages.rs | 12 ++++++++++-- crates/xfa/src/tests.rs | 16 ++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index eab26c6dd4..2eb3d08462 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -43,6 +43,15 @@ const MAX_DEPTH: usize = 64; /// Most size measurements of one layout. Measurements of containers are memoized, so a real /// form stays far below this; it bounds the work a hostile template can cause. const MAX_MEASURES: usize = 2_000_000; +/// Most characters the draw phase lays out in one layout, each pass counted. A draw re-measures +/// overflowing text up to six times to shrink it, and a repeating subform draws its text once +/// per instance, so large text drawn many times would otherwise cost seconds; a real form's +/// draws stay far below this. Reduced under test so the guard is exercised without laying out +/// hundreds of megabytes of text. +#[cfg(not(test))] +const MAX_DRAW_CHARS: usize = 256 << 20; +#[cfg(test)] +const MAX_DRAW_CHARS: usize = 1 << 20; /// Most initial instances of one repeating subform. const MAX_INSTANCES: usize = 50; /// Largest picture taken from the data (decoded bytes), as for template pictures. @@ -149,6 +158,8 @@ struct Layouter<'t> { measured: RefCell>, /// Measurements made so far (see [`MAX_MEASURES`]). measures: Cell, + /// Characters the draw phase has laid out, each pass counted (see [`MAX_DRAW_CHARS`]). + drawn_chars: Cell, /// The fields and draws of the block being placed: (first item, one past the last, top, /// bottom) on the last page. A page break keeps each whole (see `split_block`). leaves: Vec<(usize, usize, f64, f64)>, @@ -199,6 +210,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result image_bytes: 0, measured: RefCell::new(HashMap::new()), measures: Cell::new(0), + drawn_chars: Cell::new(0), leaves: Vec::new(), frames: Vec::new(), }; diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 02659c812e..f1ad5c45d9 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -229,11 +229,19 @@ impl Layouter<'_> { _ => return Ok(()), }; let face = Face::of(font); + // Each pass over the text — a re-measure that shrinks it, or the final layout — counts + // its characters against the draw budget, so large text drawn in many instances stops. + let cost = rich.plain().chars().count().max(1); // Text that doesn't fit the height it was given (the template's face is narrower than // the standard one drawn in its place, so it wraps to more lines) is drawn smaller, // down to 70%, rather than over what lies below. - let fits = |k: f64| text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0; + let fits = |k: f64| { + self.drawn_chars.set(self.drawn_chars.get().saturating_add(cost)); + text::measure_height(&rich, rect.w / k, para, &face, &|id, max| self.embed(id, max)) <= rect.h / k + 1.0 + }; let scale = if rect.h < 4.0 || fits(1.0) { 1.0 } else { [0.95, 0.9, 0.85, 0.8, 0.75].into_iter().find(|k| fits(*k)).unwrap_or(0.7) }; + self.drawn_chars.set(self.drawn_chars.get().saturating_add(cost)); + self.check_drawn_chars()?; let mut block = text::layout(&rich, Rect::new(rect.x, rect.y, rect.w / scale, rect.h / scale), para, &face, &|id, max| self.embed(id, max)); if scale < 1.0 { for s in &mut block.spans { diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index dac17c1169..c37cf83bd2 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -2,8 +2,8 @@ use super::EMBED_COPIED; use super::items::{Item, Page, UNDERLINE_DROP}; use super::{ - Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect, - SomIndices, XfaError, text, + Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_DRAW_CHARS, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, + Rc, Rect, SomIndices, XfaError, text, }; impl Layouter<'_> { @@ -39,6 +39,14 @@ impl Layouter<'_> { Ok(()) } + /// `TooLarge` once the draw phase has laid out far more text than a real form does. + pub(super) fn check_drawn_chars(&self) -> Result<(), XfaError> { + if self.drawn_chars.get() > MAX_DRAW_CHARS { + return Err(XfaError::TooLarge("the form draws far more text than the reader lays out".into())); + } + Ok(()) + } + pub(super) fn push(&mut self, item: Item) -> Result<(), XfaError> { self.check_measures()?; self.items += 1; diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 6d93e1bc99..b360fa08f7 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -678,6 +678,22 @@ fn empty_repeating_subforms_spend_the_layout_budget() { assert!(layout_xml(&tpl).is_ok()); } +#[test] +fn large_text_drawn_in_many_instances_spends_the_draw_budget() { + // A draw whose text overflows its fixed height is re-measured up to six times to shrink it, + // and a repeating subform draws it once per instance. Large text drawn in many instances + // used to cost one full text layout per instance per pass, for seconds of work on a small + // file; each pass now counts its characters against the draw budget. + let words = "word ".repeat(4000); // 20,000 characters + let draw = format!("{words}"); + let many = + format!(""); + assert!(matches!(layout_xml(&many), Err(XfaError::TooLarge(_))), "large text in many instances must be refused"); + // One copy of the same draw lays out: the budget does not reject an ordinary form. + let one = format!(""); + assert!(layout_xml(&one).is_ok(), "a single large draw must lay out"); +} + #[test] fn a_packet_stream_listed_many_times_is_read_once() { let mut doc = Document::open(Arc::new(shell(""))).unwrap();