From 7ba0ee58041c164890ba29e435c57d97fc017e5d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:04:11 +0800 Subject: [PATCH] M12: XFA prototypes: resolve each element's reference once, cap SOM expressions at 1,024 bytes, and test expansion limits A prototype shared by many elements was reached again from each of them and its own reference read again every time, so a long reference (1 MB) on a prototype used by 60,000 fields cost tens of gigabytes of hashing while the written template stayed small. Each element's reference is now resolved once. A SOM expression longer than 1,024 bytes names nothing (the longest in 198 local Designer templates is 109 bytes). Tests: a "billion laughs" template (ten levels, each prototype referencing the level below ten times) fails as a whole within the output cap and the form is read as written; a long Designer-style form resolves in full with no warning; the shared long reference; the SOM length limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/xfa/README.md | 15 ++++--- crates/xfa/src/proto.rs | 22 +++++++++ crates/xfa/src/proto/tests.rs | 84 +++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/crates/xfa/README.md b/crates/xfa/README.md index e4d5c29c6a..7cbdc378d6 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -35,12 +35,15 @@ that warning and keeps the existing layout and accepted widget values. by class and position, a matched prototype child being the prototype of the element's own; a `ui` widget or `value` kind and rich text are taken from the nearest layer that has one. References are `#id`, `#som(…)` (or `.#…`) and, for `use`, a SOM expression. Loops are cut - where they close, chains merge at most 32 layers, nesting stops at 256 elements, SOM look-ups - share a budget of 2 million elements, merging looks at no more than 64 million children, - attributes and namespaces in all (prototype children that write nothing, such as `proto`, - count too), and the written template may grow to 4 times its size (at least 1 MB, at most - 64 MB). Past the nesting, look-up, merge or growth budget the whole template is read - unresolved, with one warning saying which; a cut loop or chain is a warning too. + where they close, chains merge at most 32 layers, nesting stops at 256 elements, a SOM + expression longer than 1,024 bytes names nothing, SOM look-ups share a budget of 2 million + elements, each element's reference is resolved once however many elements reach it, + merging looks at no more than 64 million children, attributes and namespaces in all + (prototype children that write nothing, such as `proto`, count too), and the written + template may grow to 4 times its size (at least 1 MB, at most 64 MB). Past the nesting, + look-up, merge or growth budget expanding fails as a whole: no partly written template is + used, the template is read as it is written (unresolved), with one warning saying which + budget. A cut loop or chain is a warning too. - **Layout** (`layout`): positioned, `tb`, `lr-tb`/`rl-tb`, `table` and `row` layouts; a container without a width is as wide as its content; flow across content areas and pages with page masters chosen by occurrence; a table's header row repeats after a break; presence diff --git a/crates/xfa/src/proto.rs b/crates/xfa/src/proto.rs index 9dc92fe2bc..91d672f2ea 100644 --- a/crates/xfa/src/proto.rs +++ b/crates/xfa/src/proto.rs @@ -37,6 +37,9 @@ const MAX_LAYERS: usize = 32; const MAX_OUTPUT: usize = 64 << 20; const MIN_OUTPUT: usize = 1 << 20; const GROWTH: usize = 4; +/// Longest SOM expression evaluated, in bytes. Designer writes some tens of bytes +/// (`$template.#subform.designer__stylesheet.Heading[1]`); a longer one names nothing. +const MAX_SOM: usize = 1_024; /// Most elements looked at to resolve all SOM references together. const MAX_SEARCH: usize = 2_000_000; /// Most children, attributes and namespaces looked at to merge all the layers together. Each @@ -67,6 +70,7 @@ pub(crate) fn expand(template: Node) -> (Option, Vec) { limit, failed: None, ids: None, + refs: HashMap::new(), soms: HashMap::new(), search: MAX_SEARCH, work: MAX_WORK, @@ -111,6 +115,9 @@ struct Writer<'a, 'd> { failed: Option, /// The template's elements by `id`, built on the first `#id` reference. ids: Option>>, + /// The prototype each element names, once resolved: a prototype shared by many elements + /// is reached again from each of them, and its reference (of any length) is read once. + refs: HashMap>>, /// SOM references already resolved, by their text. soms: HashMap<&'a str, Option>>, /// Elements left to look at for SOM references. @@ -158,6 +165,18 @@ fn alternative(tag: &str, c: &Node) -> bool { impl<'a, 'd> Writer<'a, 'd> { /// The element `n` names as its prototype. `usehref` wins over `use` when both are set. fn reference(&mut self, n: Node<'a, 'd>) -> Option> { + if let Some(found) = self.refs.get(&n.id()) { + return *found; + } + let found = self.find_reference(n); + // A look-up a budget cut short is not kept: expanding has failed. + if self.failed.is_none() { + self.refs.insert(n.id(), found); + } + found + } + + fn find_reference(&mut self, n: Node<'a, 'd>) -> Option> { let set = |name: &str| n.attribute(name).map(str::trim).filter(|v| !v.is_empty()); let found = if let Some(href) = set("usehref") { match href.split_once('#') { @@ -212,6 +231,9 @@ impl<'a, 'd> Writer<'a, 'd> { /// A SOM expression from the template down, such as `$template.#subform.styles.Heading[1]`. fn som(&mut self, expr: &'a str) -> Option> { + if expr.len() > MAX_SOM { + return None; + } if let Some(found) = self.soms.get(expr) { return *found; } diff --git a/crates/xfa/src/proto/tests.rs b/crates/xfa/src/proto/tests.rs index f659a616ce..eb03c505a2 100644 --- a/crates/xfa/src/proto/tests.rs +++ b/crates/xfa/src/proto/tests.rs @@ -315,3 +315,87 @@ fn inherited_attributes_keep_their_namespace_where_the_prefix_is_bound_again() { assert_eq!(f.attribute(("http://www.w3.org/XML/1998/namespace", "lang")), Some("en"), "{out}"); assert_eq!(f.attribute("w"), Some("1in"), "{out}"); } + +/// `expand` on the template of `xml`, timed. +fn expand_timed(xml: &str) -> (Option, Vec, std::time::Duration) { + let doc = roxmltree::Document::parse(xml).unwrap(); + let template = doc.descendants().find(|n| n.tag_name().name() == "template").unwrap(); + let started = std::time::Instant::now(); + let (out, warnings) = crate::proto::expand(template); + (out, warnings, started.elapsed()) +} + +#[test] +fn billion_laughs_prototypes_fail_whole_within_the_output_cap() { + // Ten levels of prototypes, each holding ten references to the level below: a template of + // under 3 kB that would write 10^10 fields. Expanding fails as a whole, quickly, and + // nothing of it is used (no truncated template): the form is read as written. + let mut styles = String::from(r#""#); + for level in (1..10).rev() { + let refs = format!(r##""##, level + 1).repeat(10); + styles.push_str(&format!(r#"{refs}"#)); + } + let xml = form(&styles, r##""##); + assert!(xml.len() < 3_000, "{}", xml.len()); + let (out, warnings, took) = expand_timed(&xml); + assert!(took.as_secs() < 5, "{took:?}"); + assert!(out.is_none(), "a failed expansion writes nothing for the parser to read"); + assert!(warnings.iter().any(|w| w.contains("larger than 1 MB") && w.contains("read without resolving")), "{warnings:?}"); + let (t, _) = parse(&xml).unwrap(); + let Some(Node::Subform(bomb)) = nodes(&t).first() else { panic!() }; + assert!(bomb.children.is_empty(), "read as written: {bomb:?}"); + assert!(close(field(&t, "plain").common.w, 72.0)); +} + +#[test] +fn a_designer_style_sheet_on_a_long_form_resolves_in_full() { + // 3,000 fields and draws on Designer's style sheet, each through `#som(…)`: well inside + // every budget, so all of them take their style and nothing is warned about. + let styles: String = (0..40).map(|i| format!(r#""#, i + 1)).collect(); + let body: String = (0..3_000) + .map(|i| format!(r##""##, i % 40)) + .collect(); + let xml = form(&styles, &body); + let (out, warnings, took) = expand_timed(&xml); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(out.is_some() && took.as_secs() < 5, "{took:?}"); + let (t, warnings) = parse(&xml).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + for i in [0usize, 39, 1_234, 2_999] { + let f = field(&t, &format!("f{i}")); + assert!(close(f.common.h, (i % 40 + 1) as f64 * MM), "f{i}: {:?}", f.common); + assert_eq!(f.common.font.size, Some(9.0), "f{i}"); + } +} + +#[test] +fn a_long_reference_shared_by_many_elements_is_resolved_once() { + // One prototype whose own reference is a 1 MB `use` id, used by 60,000 fields: the output + // stays small, so only reading that reference again for every field (60 GB of text) could + // make this slow. A reference is resolved once per element. + let long = "x".repeat(1 << 20); + let styles = format!(r#""#); + let body = r##""##.repeat(60_000).replacen(""#; + let body = format!( + r##""## + ); + let (t, warnings) = parse(&form(styles, &body)).unwrap(); + assert!(warnings.iter().any(|w| w.contains("names nothing")), "{warnings:?}"); + assert_eq!(field(&t, "long").common.h, None); + assert!(close(field(&t, "short").common.h, 2.0 * MM)); +}