fix(forms): script streams share one decode budget per reading

Each field action and document-script entry decoded its /JS stream
with a fresh 1 GiB allowance and kept its own copy, so many fields or
name-tree pairs naming one compressed stream retained gigabytes. A
reading of the fields, one field's actions, and the document scripts
each share a 64 MiB DecodeBudget for their script streams.
This commit is contained in:
aiongg
2026-10-22 16:57:55 +00:00
parent 92ab0e932d
commit bb8ea65ffd
4 changed files with 81 additions and 21 deletions
+4 -3
View File
@@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec<String> {
}
}
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option<FieldAction> {
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option<FieldAction> {
let a = doc.resolve(o);
let d = a.as_dict()?;
Some(match d.name(b"S")? {
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()),
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()),
b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()),
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()),
b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()),
@@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAc
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut scripts = crate::script_budget();
for t in Trigger::ALL {
let o = match t.key() {
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
Some(k) => aa.get(k).cloned(),
};
// An action chain (/Next) counts as its first action here.
if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) {
if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) {
out.push((t, a));
}
}
+35 -17
View File
@@ -14,7 +14,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString};
mod actions;
pub mod af;
@@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object {
const MAX_LAYER_STATE: usize = 1024;
/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget).
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::ButtonAction> {
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option<af::ButtonAction> {
// A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action):
// clicking it picks a picture, as `buttonImportIcon()` does.
if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
@@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::But
let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false)));
af::ButtonAction::SetLayers { changes, preserve_rb }
}
b"JavaScript" => af::button_script(&script(doc, &action)?),
b"JavaScript" => af::button_script(&script(doc, &action, scripts)?),
_ => return None,
})
}
@@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
(!parts.is_empty()).then(|| parts.join("."))
}
/// The JavaScript of an action dictionary (`/JS` string or stream).
fn script(doc: &Document, action: &Object) -> Option<String> {
/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode
/// in all, however many actions name the same stream.
const MAX_SCRIPT_BYTES: usize = 64 << 20;
/// A budget for the script streams of one reading of the form.
fn script_budget() -> DecodeBudget {
DecodeBudget::new(MAX_SCRIPT_BYTES, 0)
}
/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against
/// `budget`, and is `None` once it is spent.
fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option<String> {
let a = doc.resolve(action);
let js = a.as_dict()?.get(b"JS")?.clone();
match &*doc.resolve(&js) {
Object::String(s) => Some(s.to_text()),
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only
/// widget's when the field has none).
fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions {
let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| {
(widgets.len() == 1)
.then(|| doc.get(widgets[0]))
@@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
});
let mut out = af::Actions::default();
let Some(aa) = aa else { return out };
let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a));
let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts));
if let Some(f) = js(b"F") {
match af::parse_format(&f) {
Some(fm) => out.format = fm,
@@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
None => Inherited::default(),
};
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut seen = Seen { fields: std::collections::HashSet::new(), scripts: script_budget() };
if let Some(af) = &af {
for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
if let Some(r) = f.as_ref() {
@@ -604,7 +614,7 @@ fn adopt_page_fields(
pages: &[ObjRef],
base: &Inherited,
page_of: &std::collections::HashMap<ObjRef, usize>,
seen: &mut std::collections::HashSet<ObjRef>,
seen: &mut Seen,
out: &mut Vec<Field>,
) {
for p in pages {
@@ -613,7 +623,7 @@ fn adopt_page_fields(
let Some(annots) = annots.as_array() else { continue };
for e in annots {
let Some(r) = e.as_ref() else { continue };
if seen.contains(&r) {
if seen.fields.contains(&r) {
continue;
}
let obj = doc.get(r);
@@ -624,7 +634,7 @@ fn adopt_page_fields(
if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") {
continue;
}
if let Some(root) = unlisted_root(doc, r, seen) {
if let Some(root) = unlisted_root(doc, r, &seen.fields) {
walk(doc, root, base, page_of, seen, out, 0);
}
}
@@ -756,16 +766,23 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
/// What one reading of the form's fields has used: the field dictionaries read, and the script
/// budget their actions share.
struct Seen {
fields: std::collections::HashSet<ObjRef>,
scripts: DecodeBudget,
}
fn walk(
doc: &Document,
r: ObjRef,
parent: &Inherited,
page_of: &std::collections::HashMap<ObjRef, usize>,
seen: &mut std::collections::HashSet<ObjRef>,
seen: &mut Seen,
out: &mut Vec<Field>,
depth: usize,
) {
if depth > 64 || !seen.insert(r) {
if depth > 64 || !seen.fields.insert(r) {
return;
}
let obj = doc.get(r);
@@ -808,9 +825,10 @@ fn walk(
return;
}
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
let actions = actions_of(doc, d, &widget_refs);
let button =
(inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten();
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
.then(|| button_action(doc, d, &widget_refs, &mut seen.scripts))
.flatten();
let ff = inh.ff.unwrap_or(0);
let kind = match inh.ft.as_deref() {
Some(b"Tx") => FieldKind::Text,
+2 -1
View File
@@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() };
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.
let mut stack = vec![tree];
@@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
Object::String(s) => s.to_text(),
_ => continue,
};
if let Some(js) = crate::script(doc, v) {
if let Some(js) = crate::script(doc, v, &mut scripts) {
out.push((key, js));
}
}
+40
View File
@@ -2981,3 +2981,43 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
assert_eq!(walk.next(), None);
assert_eq!(page_refs(&doc), [page]);
}
/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream.
fn repeated_script_references() -> Document {
use pdfcraft_cos::Stream;
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 js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20]));
let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))]));
let fields: Vec<Object> = (0..300)
.map(|i| {
Object::Ref(doc.add(dict(vec![
(b"FT", Object::Name(b"Tx".to_vec())),
(b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))),
(b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())),
(b"AA", dict(vec![(b"C", Object::Ref(action))])),
])))
})
.collect();
let names: Vec<Object> =
(0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect();
let catalog = doc.add(dict(vec![
(b"Type", Object::Name(b"Catalog".to_vec())),
(b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])),
(b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])),
]));
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
doc
}
#[test]
fn repeated_script_references_share_one_decode_budget() {
let doc = repeated_script_references();
let limit = MAX_SCRIPT_BYTES;
let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum();
assert!(named > 0 && named <= limit, "{named} bytes of document scripts");
let all = fields(&doc);
assert_eq!(all.len(), 300);
let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum();
assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts");
}