From ab7af7234772a17edd2fc21c2d91c23482a1cc3a Mon Sep 17 00:00:00 2001 From: Unhumanje <54317404+lowtide-lab@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:11:18 +0100 Subject: [PATCH] fix(forms): name tree nodes sharing one long /Kids array no longer exhaust memory (#5121) --- crates/forms/src/scripting.rs | 72 +++++++++++++++++++-------- crates/forms/src/tests.rs | 91 +++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 93ed2d7c7a..78dcdcff28 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -198,6 +198,9 @@ pub fn document_scripts(doc: &Document) -> Vec { document_scripts_named(doc).into_iter().map(|(_, js)| js).collect() } +/// Most `/Kids` entries the document JavaScript name tree walk reads in all. +const MAX_QUEUED_KIDS: usize = 1 << 20; + /// [`document_scripts`] with their names. pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(root) = doc.root() else { return Vec::new() }; @@ -206,43 +209,72 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); let mut scripts = crate::script_budget(); - // Kids stay unresolved on the stack, and each node is read once: a node listed many times, - // or among its own kids, must not multiply the work. + // Kids are indirect references (ISO 32000-2 ยง7.9.6), queued unresolved, each the first time + // it is listed: a node listed many times, or among its own kids, must not multiply the work, + // and the stack holds only references (a direct object there would be copied whole, once + // for every node listing it). Only the root may be direct. Nodes are told apart by object + // number, as `Document::get` finds them, so `5 0 R` and `5 1 R` are one node. + let mut seen: HashSet = tree.as_ref().map(|r| r.num).into_iter().collect(); + // The `/Names` arrays read, by object number: nodes sharing one would otherwise each list + // all its scripts again. + let mut names_read: HashSet = HashSet::new(); let mut stack = vec![tree]; - let mut seen: HashSet = HashSet::new(); let mut visited = 0; + // Every `/Kids` entry read, over the whole walk. Nodes sharing one long `/Kids` array would + // otherwise read all of it once per node. + let mut queued = 0usize; while let Some(raw) = stack.pop() { - if let Object::Ref(r) = raw - && !seen.insert(r) - { - continue; - } visited += 1; if visited > 10_000 { break; } let node = doc.resolve(&raw); let Some(d) = node.as_dict() else { continue }; - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { - stack.extend(kids); + if let Some(kids) = d.get(b"Kids") { + in_array(doc, kids, |kids| { + // Past the limit nothing more is read, but the nodes already queued still are. + // Pushed in reverse, so the kids are read in the order listed. + let room = MAX_QUEUED_KIDS.saturating_sub(queued); + queued = queued.saturating_add(kids.len().min(room)); + let new: Vec = kids.iter().take(room).filter_map(Object::as_ref).filter(|r| seen.insert(r.num)).collect(); + stack.extend(new.into_iter().rev().map(Object::Ref)); + }); } - if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { - for pair in pairs.chunks(2) { - let [k, v] = pair else { continue }; - let key = match &*doc.resolve(k) { - Object::String(s) => s.to_text(), - _ => continue, - }; - if let Some(js) = crate::script(doc, v, &mut scripts) { - out.push((key, js)); + if let Some(pairs) = d.get(b"Names") + && pairs.as_ref().is_none_or(|r| names_read.insert(r.num)) + { + in_array(doc, pairs, |pairs| { + for pair in pairs.chunks(2) { + let [k, v] = pair else { continue }; + let key = match &*doc.resolve(k) { + Object::String(s) => s.to_text(), + _ => continue, + }; + if let Some(js) = crate::script(doc, v, &mut scripts) { + out.push((key, js)); + } } - } + }); } } out.sort(); out } +/// Call `f` on the array `o` is, or refers to, read in place: resolving a direct array would +/// copy it. +fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) { + match o { + Object::Ref(r) => { + if let Some(a) = doc.get(*r).as_array() { + f(a); + } + } + Object::Array(a) => f(a), + _ => {} + } +} + /// Add (or replace) a document-level JavaScript named `name`. pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> { let root = doc.root().ok_or(FormError::NoForm)?; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 09564c2dee..0dc29632d9 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3609,6 +3609,97 @@ fn document_scripts_read_each_name_tree_node_once() { assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); } +#[test] +fn name_tree_nodes_sharing_one_long_kids_array_queue_it_a_bounded_number_of_times() { + // 10,000 nodes share one /Kids array of 210,001 entries: the leaf, every node, and the leaf + // 200,000 times more. Queuing that array once per node would hold two billion entries. + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let action = doc.add(dict(vec![ + (b"S", Object::Name(b"JavaScript".to_vec())), + (b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))), + ])); + let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))])); + let kids = doc.add(Object::Null); + let nodes: Vec = (0..10_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))])))).collect(); + let mut shared = vec![Object::Ref(leaf); 200_000]; + shared.extend(nodes); + shared.push(Object::Ref(leaf)); + doc.set(kids, Object::Array(shared)); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); +} + +#[test] +fn a_direct_object_in_a_shared_kids_array_is_not_copied_for_every_node() { + // 1,000 nodes share one /Kids array that also holds a direct dictionary with a 2 MiB + // string. Queued with every node's kids, it would be copied 1,000 times (2 GiB) while the + // entry count stayed under its limit. Kids are references, so it is skipped. + let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) }; + let mut doc = Document::new_empty(); + let action = doc.add(dict(vec![ + (b"S", Object::Name(b"JavaScript".to_vec())), + (b"JS", Object::String(pdfcraft_cos::PdfString::literal("var ready = true;"))), + ])); + let leaf = doc.add(dict(vec![(b"Names", Object::Array(vec![Object::String(pdfcraft_cos::PdfString::literal("init")), Object::Ref(action)]))])); + let kids = doc.add(Object::Null); + let big = dict(vec![(b"Pad", Object::String(pdfcraft_cos::PdfString::literal("x".repeat(2 << 20))))]); + let mut shared = vec![big]; + shared.extend((0..1_000).map(|_| Object::Ref(doc.add(dict(vec![(b"Kids", Object::Ref(kids))]))))); + shared.push(Object::Ref(leaf)); + doc.set(kids, Object::Array(shared)); + let catalog = doc.add(dict(vec![ + (b"Type", Object::Name(b"Catalog".to_vec())), + (b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Kids", Object::Ref(kids))]))])), + ])); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog)); + assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]); +} + +#[test] +fn a_name_tree_node_listed_under_many_generations_is_read_once() { + // `Document::get` finds a node by object number alone, so `4 0 R` and `4 1 R` are one node. + // The root lists one under 10,000 generations: read for each, it would list its script + // 10,000 times and use up the walk's 10,000 visits before the node listed after it. + let kids: Vec = (0..10_000).map(|g| format!("4 {g} R")).collect(); + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(), + "<< /Type /Pages /Kids [] /Count 0 >>".into(), + format!("<< /Kids [{} 5 0 R] >>", kids.join(" ")), + "<< /Names [(a) 6 0 R] >>".into(), + "<< /Names [(b) 6 0 R] >>".into(), + "<< /S /JavaScript /JS (var ready = true;) >>".into(), + ]); + let scripts = document_scripts_named(&doc); + assert_eq!(scripts.len(), 2, "{} scripts", scripts.len()); + let js = "var ready = true;".to_string(); + assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]); +} + +#[test] +fn name_tree_nodes_sharing_one_names_array_list_its_scripts_once() { + // 9,999 nodes share one /Names array. Read for every node, its scripts would be listed 9,999 + // times: ten million scripts and 1.3 GB when it names 1,000. + let nodes: Vec = (0..9_999).map(|i| format!("{} 0 R", i + 6)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".to_string(), + "<< /Type /Pages /Kids [] /Count 0 >>".into(), + format!("<< /Kids [{}] >>", nodes.join(" ")), + "[(a) 5 0 R (b) 5 0 R]".into(), + "<< /S /JavaScript /JS (var ready = true;) >>".into(), + ]; + objs.extend(nodes.iter().map(|_| "<< /Names 4 0 R >>".to_string())); + let doc = document(&objs); + let scripts = document_scripts_named(&doc); + assert_eq!(scripts.len(), 2, "{} scripts", scripts.len()); + let js = "var ready = true;".to_string(); + assert_eq!(scripts, [("a".to_string(), js.clone()), ("b".to_string(), js)]); +} + /// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one, /// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list /// box without one, and G only a down appearance (`/D`, no `/N`).