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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-01 19:04:11 +08:00
1 parent 16c9141f49
commit 7ba0ee5804
3 files changed
+115 -6

No files matched your search

+9 -6
View File
@@ -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
+22
View File
@@ -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<String>, Vec<String>) {
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<String>,
/// The template's elements by `id`, built on the first `#id` reference.
ids: Option<HashMap<&'a str, Node<'a, 'd>>>,
/// 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<NodeId, Option<Node<'a, 'd>>>,
/// SOM references already resolved, by their text.
soms: HashMap<&'a str, Option<Node<'a, 'd>>>,
/// 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<Node<'a, 'd>> {
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<Node<'a, 'd>> {
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<Node<'a, 'd>> {
if expr.len() > MAX_SOM {
return None;
}
if let Some(found) = self.soms.get(expr) {
return *found;
}
+84
View File
@@ -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<String>, Vec<String>, 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#"<subform id="L10"><field w="1mm"/></subform>"#);
for level in (1..10).rev() {
let refs = format!(r##"<subform use="#L{}"/>"##, level + 1).repeat(10);
styles.push_str(&format!(r#"<subform id="L{level}">{refs}</subform>"#));
}
let xml = form(&styles, r##"<subform name="bomb" use="#L1"/><field name="plain" w="1in"/>"##);
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#"<field name="S{i}" h="{}mm"><font typeface="Arial" size="9pt"/></field>"#, i + 1)).collect();
let body: String = (0..3_000)
.map(|i| format!(r##"<field name="f{i}" w="20mm" usehref=".#som($template.#subform.designer__stylesheet.S{})"/>"##, 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#"<field id="P" w="1mm" use="{long}"/><field id="{long}" h="2mm"/>"#);
let body = r##"<field use="#P"/>"##.repeat(60_000).replacen("<field ", r#"<field name="first" "#, 1);
let xml = form(&styles, &body);
let (out, warnings, took) = expand_timed(&xml);
assert!(took.as_secs() < 3, "{took:?}");
assert!(out.is_some(), "{warnings:?}");
let (t, _) = parse(&xml).unwrap();
let first = field(&t, "first");
assert!(close(first.common.w, MM) && close(first.common.h, 2.0 * MM), "{:?}", first.common);
}
#[test]
fn a_som_expression_past_its_length_limit_is_not_followed() {
// A SOM expression longer than any Designer writes is not evaluated: it is warned about as
// a reference naming nothing, and the rest of the template resolves.
let padded = format!("$template.{}#subform.designer__stylesheet.Q", " ".repeat(5_000));
let styles = r#"<field name="Q" h="2mm"/>"#;
let body = format!(
r##"<field name="long" w="1in" usehref="#som({padded})"/><field name="short" w="1in" usehref="#som($template.#subform.designer__stylesheet.Q)"/>"##
);
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));
}