mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
fix(forms): name tree nodes sharing one long /Kids array no longer exhaust memory (#5121)
This commit is contained in:
1 parent
1ec71c1bf0
commit
ab7af72347
2 files changed
+143
-20
No files matched your search
@@ -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)?;
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in new issue
Block a user