fix(forms): name tree nodes sharing one long /Kids array no longer exhaust memory (#5121)

This commit is contained in:
Unhumanje authored and GitHub committed 2026-11-09 10:11:18 +08:00
1 parent 1ec71c1bf0
commit ab7af72347
2 files changed
+143 -20

No files matched your search

+52 -20
View File
@@ -198,6 +198,9 @@ pub fn document_scripts(doc: &Document) -> Vec<String> {
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<u32> = 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<u32> = HashSet::new();
let mut stack = vec![tree];
let mut seen: HashSet<ObjRef> = 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<ObjRef> = 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)?;
+91
View File
@@ -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<Object> = (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<String> = (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<String> = (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`).