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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
This commit is contained in:
mtlcl410andClaude Opus 4.8 committed 2026-11-03 10:00:54 +08:00
1 parent 3936f93a14
commit 8a6aff26e0
4 files changed
+47 -3

No files matched your search

+12
View File
@@ -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<HashMap<MeasureKey, Measured>>,
/// Measurements made so far (see [`MAX_MEASURES`]).
measures: Cell<usize>,
/// Characters the draw phase has laid out, each pass counted (see [`MAX_DRAW_CHARS`]).
drawn_chars: Cell<usize>,
/// 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<Form, XfaError>
image_bytes: 0,
measured: RefCell::new(HashMap::new()),
measures: Cell::new(0),
drawn_chars: Cell::new(0),
leaves: Vec::new(),
frames: Vec::new(),
};
+9 -1
View File
@@ -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 {
+10 -2
View File
@@ -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;
+16
View File
@@ -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!("<draw w=\"1in\" h=\"0.5in\"><value><text>{words}</text></value></draw>");
let many =
format!("<template><subform layout=\"tb\"><subform layout=\"tb\"><occur initial=\"50\" max=\"-1\"/>{draw}</subform></subform></template>");
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!("<template><subform layout=\"tb\">{draw}</subform></template>");
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("<template><subform/></template>"))).unwrap();