From 1503682b573cc2354269fd69faef876002a44ca4 Mon Sep 17 00:00:00 2001 From: lowtide-lab <54317404+lowtide-lab@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:54:30 +0100 Subject: [PATCH] fix(xfa): the signed field appearance search counts against the kids read limit --- crates/xfa/src/pdf.rs | 46 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 6be47b7dba..c18749ad09 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -146,7 +146,7 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, let kids = kids.as_deref().and_then(Object::as_array).map(Vec::as_slice).unwrap_or_default(); if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) { // The appearance is on the field when it is its own widget, or on its one widget. - let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); + let appearance = d.get(b"AP").cloned().or_else(|| walk.find_map(kids, |k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); } if depth < MAX_FIELD_DEPTH { @@ -168,6 +168,22 @@ struct FieldQueue { } impl FieldQueue { + /// The first of `kids` for which `f` gives a value. The entries looked at count against + /// [`MAX_KIDS_READ`] like those queued: signed fields sharing one long `/Kids` would + /// otherwise each search all of it. Past the limit nothing more is looked at. + fn find_map(&mut self, kids: &[Object], mut f: impl FnMut(&Object) -> Option) -> Option { + for k in kids { + self.read += 1; + if self.read > MAX_KIDS_READ { + return None; + } + if let Some(v) = f(k) { + return Some(v); + } + } + None + } + /// Queue the fields `kids` lists, each the first time it is listed. Fields and kids are /// indirect references (ISO 32000-2 ยง12.7.3.1); anything else is skipped. A field listed /// again, by this list or another, under any generation, takes no room, and at most @@ -1057,6 +1073,34 @@ mod tests { assert!(names.contains("c.plain")); } + #[test] + fn signed_fields_sharing_one_long_kids_array_search_it_within_the_read_limit() { + // 1,000 signed fields share one `/Kids` array of 100,000 nulls and a widget with an + // appearance at its end. Each searched the whole array for that appearance: with a + // million nulls, a billion lookups (39 s). The search counts against `MAX_KIDS_READ`, so + // the fields read once it is spent keep their value but get no appearance. + let mut doc = Document::new_empty(); + let mut widget = Dict::new(); + widget.set(b"AP".to_vec(), Object::Dict(Dict::new())); + let mut shared = vec![Object::Null; 100_000]; + shared.push(Object::Ref(doc.add(Object::Dict(widget)))); + let kids = doc.add(Object::Array(shared)); + let fields = (0..1_000) + .map(|i| { + let mut d = Dict::new(); + d.set(b"T".to_vec(), Object::String(PdfString::literal(format!("s{i}").as_str()))); + d.set(b"FT".to_vec(), Object::Name(b"Sig".to_vec())); + d.set(b"V".to_vec(), Object::Dict(Dict::new())); + d.set(b"Kids".to_vec(), Object::Ref(kids)); + Object::Ref(doc.add(Object::Dict(d))) + }) + .collect(); + let (signed, _) = existing(&doc, fields); + assert_eq!(signed.len(), 1_000); + let with_appearance = signed.values().filter(|s| s.appearance.is_some()).count(); + assert!((1..=10).contains(&with_appearance), "{with_appearance} appearances found"); + } + /// Field `c` with two kids: `plain`, and `sig`, a signed signature field. fn signed_parent(doc: &mut Document) -> ObjRef { let plain = doc.add(field("plain", Object::Array(Vec::new())));