diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 47d99a94a7..45bde36846 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -486,6 +486,7 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { let pages = pdfcraft_model::pages(doc); let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); + let listed = listed_annotations(doc); let names = sub(doc, &cat, b"Names").unwrap_or_default(); let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); // A graph nested too deeply to walk counts as holding everything, so the removal runs and @@ -528,9 +529,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) + owned(Hidden::LinksActionsScripts) + annots.iter().filter(|a| primary_action_only(a)).count() + // fields no page lists (a listed widget is counted with the annotations above) + field_dicts(doc) .into_iter() - .filter(|&r| doc.get(r).as_dict().is_some_and(|d| !d.contains(b"Subtype") && primary_action_only(d))) + .filter(|r| !listed.contains(r) && doc.get(*r).as_dict().is_some_and(primary_action_only)) .count() + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) } @@ -601,9 +603,18 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [<< /Type /Annot /Subtype /Screen /Rect [10 10 50 50] /A << /S /JavaScript /JS (app.alert\\(1\\)) >> >>] >>" + .to_vec(), + stream("", b"BT ET"), + ]; + let mut doc = pdf(objs); + let counts: HashMap = scan(&doc).into_iter().collect(); + assert!(counts[&Hidden::LinksActionsScripts] > 0, "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + assert!(!bytes.windows(10).any(|w| w == b"JavaScript"), "the direct annotation's action survived the save"); +} + +/// A widget field no page lists, with `/Subtype`, still counts for the actions scan when it holds +/// an action, as removal clears it. +#[test] +fn an_unlisted_widget_field_action_is_counted() { + use crate::sanitize::{Hidden, scan}; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Widget /FT /Btn /T (b) /Rect [0 0 10 10] /A << /S /JavaScript /JS (x) >> >>".to_vec(), + ]; + let doc = pdf(objs); + let counts: HashMap = scan(&doc).into_iter().collect(); + assert!(counts[&Hidden::LinksActionsScripts] > 0, "{counts:?}"); +}