diff --git a/crates/xfa/README.md b/crates/xfa/README.md index c48058385e..e4d5c29c6a 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -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 diff --git a/crates/xfa/src/proto.rs b/crates/xfa/src/proto.rs index 98fa8ce1d5..4c639c2d88 100644 --- a/crates/xfa/src/proto.rs +++ b/crates/xfa/src/proto.rs @@ -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, Vec) { 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>>, /// 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, 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> = 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> = std::iter::once(c).chain(below.iter().skip(i).filter_map(|b| b.get(&(ctag, n)).copied())).collect(); diff --git a/crates/xfa/src/proto/tests.rs b/crates/xfa/src/proto/tests.rs index c104828d9f..aa143f22e2 100644 --- a/crates/xfa/src/proto/tests.rs +++ b/crates/xfa/src/proto/tests.rs @@ -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#""#)).collect(); + let body: String = + (0..300).map(|i| format!(r##""##, 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 = "".repeat(20_000); + let styles = format!(r#"{protos}"#); + let body = r##""##.repeat(20_000).replacen("