M12: XFA prototypes: a work budget for merging, and a SOM search past its budget fails the expansion

A SOM look-up cut short by its budget was cached as "not found", so later
references warned that they named nothing and the template was only partly
resolved. It now stops the expansion: the template is read unresolved with
one warning, as the README says.

Merging looked at every child of every prototype layer, also those that
write nothing (proto, unchosen ui/value alternatives), so a prototype with
many proto children used by many elements did quadratic work bounded only
by output. Merging now charges children, attributes and namespaces against
a 64 million budget before looking at them, and leaves out the skipped
classes before indexing them; namespace look-ups use a set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBVyQEwtmEw3mS1MoAiwrK
This commit is contained in:
mtlcl410
2026-10-30 07:26:03 +08:00
co-authored by Claude Opus 5.5
parent 8e85e0b7ab
commit a51eb0ea0c
3 changed files with 89 additions and 10 deletions
+5 -3
View File
@@ -36,9 +36,11 @@ that warning and keeps the existing layout and accepted widget values.
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, and the written template may grow to 4 times its size
(at least 1 MB, at most 64 MB); past a budget the template is read unresolved. Each case is a
warning.
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.
- **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
+56 -7
View File
@@ -39,6 +39,14 @@ const MIN_OUTPUT: usize = 1 << 20;
const GROWTH: usize = 4;
/// 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
/// reference looks through its prototype's children again, also those that write nothing
/// (`proto`, and the `ui` and `value` alternatives not chosen), so the output budget alone does
/// not bound this. Real forms cost one unit for every 5.5 to 36 bytes written (the largest
/// measured, a 14 MB template, costs 2.5 million), so writing as much as the 64 MB output
/// budget allows costs some 12 million: this keeps a margin of five over that, and stops a
/// hostile template after about a second.
const MAX_WORK: usize = 64_000_000;
/// The template written again with its prototype references resolved (`None` when it has
/// none, or when resolving them would pass a budget: the template is then read as it is), and
@@ -59,6 +67,7 @@ pub(crate) fn expand(template: Node) -> (Option<String>, Vec<String>) {
ids: None,
soms: HashMap::new(),
search: MAX_SEARCH,
work: MAX_WORK,
active: HashMap::new(),
missing: false,
external: false,
@@ -100,6 +109,8 @@ struct Writer<'a, 'd> {
soms: HashMap<&'a str, Option<Node<'a, 'd>>>,
/// Elements left to look at for SOM references.
search: usize,
/// Children, attributes and namespaces left to look at while merging layers.
work: usize,
/// The elements being written around the current one: inheriting from one is a loop.
active: HashMap<NodeId, u32>,
missing: bool,
@@ -165,6 +176,8 @@ impl<'a, 'd> Writer<'a, 'd> {
}
};
match found {
// A budget ran out while looking: the template is read unresolved, with that warning.
_ if self.failed.is_some() => None,
Some(t) if t == n => {
self.looped = true;
None
@@ -197,7 +210,11 @@ impl<'a, 'd> Writer<'a, 'd> {
return *found;
}
let found = self.resolve(expr);
self.soms.insert(expr, found);
// A look-up the budget cut short has not found nothing: it is not cached, and the
// template is read unresolved (`step` set `failed`).
if self.failed.is_none() {
self.soms.insert(expr, found);
}
found
}
@@ -229,7 +246,11 @@ impl<'a, 'd> Writer<'a, 'd> {
return;
}
for c in at.children().filter(Node::is_element) {
if self.search == 0 || out.len() > index {
if out.len() > index || self.failed.is_some() {
return;
}
if self.search == 0 {
self.failed = Some(format!("need more than {MAX_SEARCH} elements looked at to resolve their SOM references"));
return;
}
self.search -= 1;
@@ -264,6 +285,21 @@ impl<'a, 'd> Writer<'a, 'd> {
}
}
/// Take `n` from the work budget; past it, expanding stops.
fn charge(&mut self, n: usize) -> bool {
match self.work.checked_sub(n) {
Some(left) => {
self.work = left;
true
}
None => {
self.work = 0;
self.failed = Some(format!("need more than {MAX_WORK} children, attributes and namespaces looked at to merge them"));
false
}
}
}
/// Write the element that `stack` makes together: `stack[0]` over `stack[1]` over …, each
/// an element of the same class. `scope` holds the namespaces already declared around it.
fn element(&mut self, stack: &[Node<'a, 'd>], scope: &[(Option<&'a str>, &'a str)], depth: usize) {
@@ -286,6 +322,16 @@ impl<'a, 'd> Writer<'a, 'd> {
break;
}
}
// Charge what merging looks at before looking: each layer's children (counted no
// further than the budget left), attributes and namespaces, and the scope around it.
let mut cost = scope.len().saturating_add(layers.len());
for l in &layers {
let children = l.children().take(self.work.saturating_add(1)).count();
cost = cost.saturating_add(children).saturating_add(l.attributes().len()).saturating_add(l.namespaces().len());
}
if self.failed.is_some() || !self.charge(cost) {
return;
}
let Some(&top) = layers.first() else { return };
for l in &layers {
*self.active.entry(l.id()).or_insert(0) += 1;
@@ -298,10 +344,11 @@ impl<'a, 'd> Writer<'a, 'd> {
self.out.push_str(&qname);
// Namespaces in scope in any layer that the elements written around this one lack.
let mut inner: Vec<(Option<&'a str>, &'a str)> = scope.to_vec();
let mut declared: HashSet<Option<&'a str>> = scope.iter().map(|(n, _)| *n).collect();
for layer in &layers {
for ns in layer.namespaces() {
let (name, uri) = (ns.name(), ns.uri());
if name == Some("xml") || inner.iter().any(|(n, _)| *n == name) {
if name == Some("xml") || !declared.insert(name) {
continue;
}
match name {
@@ -345,11 +392,14 @@ impl<'a, 'd> Writer<'a, 'd> {
} else {
None
};
// Prototypes take no part in the form; of the alternatives one is kept. A class left
// out is left out whole, so it need not be counted either.
let left_out = |c: &Node| c.tag_name().name() == "proto" || alternative(tag, c) && chosen != Some(c.tag_name().name());
// The deeper layers' children by (class, position among that class).
let index = |l: &Node<'a, 'd>| {
let mut counts: HashMap<&str, usize> = HashMap::new();
let mut by: HashMap<(&'a str, usize), Node<'a, 'd>> = HashMap::new();
for c in l.children().filter(Node::is_element) {
for c in l.children().filter(|c| c.is_element() && !left_out(c)) {
let k = counts.entry(c.tag_name().name()).or_insert(0);
by.insert((c.tag_name().name(), *k), c);
*k += 1;
@@ -364,7 +414,7 @@ impl<'a, 'd> Writer<'a, 'd> {
if c.is_text() && i == text_layer {
escape(c.text().unwrap_or_default(), false, &mut self.out);
}
if !c.is_element() {
if !c.is_element() || left_out(&c) {
continue;
}
let ctag = c.tag_name().name();
@@ -373,8 +423,7 @@ impl<'a, 'd> Writer<'a, 'd> {
*k += 1;
*k - 1
};
// Prototypes take no part in the form; of the alternatives one is kept.
if ctag == "proto" || alternative(tag, &c) && chosen != Some(ctag) || !done.insert((ctag, n)) {
if !done.insert((ctag, n)) {
continue;
}
let stack: Vec<Node<'a, 'd>> = std::iter::once(c).chain(below.iter().skip(i).filter_map(|b| b.get(&(ctag, n)).copied())).collect();
+28
View File
@@ -227,3 +227,31 @@ fn hostile_references_neither_panic_nor_run_away() {
assert!(started.elapsed().as_secs() < 10);
assert!(warnings.iter().any(|w| w.contains("larger than")), "{warnings:?}");
}
#[test]
fn a_som_search_past_its_budget_leaves_the_template_unresolved_with_one_warning() {
// 300 references, each to a style 10,000 elements into the sheet: 3 million elements looked
// at, past the 2 million the look-ups share. The first ones resolve before the budget runs
// out, so the template is either resolved in full or not at all.
let styles: String = (0..10_000).map(|i| format!(r#"<field name="s{i}" w="1mm"/>"#)).collect();
let body: String =
(0..300).map(|i| format!(r##"<field name="f{i}" usehref="#som($template.#subform.designer__stylesheet.s{})"/>"##, 9_999 - i)).collect();
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
assert!(warnings.iter().any(|w| w.contains("SOM references") && w.contains("read without resolving")), "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("names nothing")), "an interrupted search is not a missing target: {warnings:?}");
assert_eq!(field(&t, "f0").common.w, None, "read as written");
}
#[test]
fn prototype_children_that_write_nothing_still_count_against_a_budget() {
// A prototype with 20,000 `proto` children, used by 20,000 fields: the children write
// nothing, but merging would look at 400 million of them.
let protos = "<proto/>".repeat(20_000);
let styles = format!(r#"<field id="P" w="1mm">{protos}</field>"#);
let body = r##"<field use="#P"/>"##.repeat(20_000).replacen("<field ", r#"<field name="first" "#, 1);
let started = std::time::Instant::now();
let (t, warnings) = parse(&form(&styles, &body)).unwrap();
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
assert!(warnings.iter().any(|w| w.contains("children, attributes and namespaces") && w.contains("read without resolving")), "{warnings:?}");
assert_eq!(field(&t, "first").common.w, None, "read as written");
}