Skip to main content

pdf_xfa/
javascript_policy.rs

1//! Central JavaScript handling policy for PDF/XFA hardening paths.
2
3use std::collections::HashSet;
4
5use lopdf::{Document, Object, ObjectId};
6
7use crate::error::XfaError;
8
9/// JavaScript may be parsed and inspected so hardening code can locate and
10/// remove active content, but this crate must never execute document-supplied
11/// JavaScript. PDF/XFA inputs are untrusted and JavaScript actions can reach
12/// viewer APIs, network/file operations, and mutable form state. Keeping parse,
13/// execution, and flatten behavior as explicit policy values prevents hidden
14/// no-op fallbacks from becoming accidental execution paths in future work.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum JavaScriptPolicy {
17    /// JavaScript syntax/payloads may be parsed or inspected for audit/strip.
18    AllowParse,
19    /// JavaScript execution is denied for all document-supplied entrypoints.
20    DenyExecution,
21    /// JavaScript-bearing actions are stripped from flattened/hardened output.
22    StripOnFlatten,
23}
24/// ALLOW_PARSE.
25pub const ALLOW_PARSE: JavaScriptPolicy = JavaScriptPolicy::AllowParse;
26/// DENY_EXECUTION.
27pub const DENY_EXECUTION: JavaScriptPolicy = JavaScriptPolicy::DenyExecution;
28/// STRIP_ON_FLATTEN.
29pub const STRIP_ON_FLATTEN: JavaScriptPolicy = JavaScriptPolicy::StripOnFlatten;
30/// JavaScriptEntryPoint.
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum JavaScriptEntryPoint {
34    /// PdfOpenAction.
35    PdfOpenAction,
36    /// AnnotationAdditionalAction.
37    AnnotationAdditionalAction,
38    /// FieldAction.
39    FieldAction,
40    /// XfaEventHook.
41    XfaEventHook,
42}
43
44impl JavaScriptEntryPoint {
45    /// as_str.
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::PdfOpenAction => "PDF /OpenAction JavaScript",
49            Self::AnnotationAdditionalAction => "annotation /AA JavaScript",
50            Self::FieldAction => "field /A JavaScript",
51            Self::XfaEventHook => "XFA event JavaScript",
52        }
53    }
54}
55/// parse_policy.
56pub fn parse_policy() -> JavaScriptPolicy {
57    ALLOW_PARSE
58}
59/// execution_policy.
60pub fn execution_policy(_entrypoint: JavaScriptEntryPoint) -> JavaScriptPolicy {
61    DENY_EXECUTION
62}
63/// flatten_policy.
64pub fn flatten_policy(_entrypoint: JavaScriptEntryPoint) -> JavaScriptPolicy {
65    STRIP_ON_FLATTEN
66}
67/// reject_execution.
68pub fn reject_execution(entrypoint: JavaScriptEntryPoint) -> XfaError {
69    debug_assert_eq!(execution_policy(entrypoint), DENY_EXECUTION);
70    XfaError::UnsupportedFeature("javascript".to_string())
71}
72/// execution_denied_message.
73pub fn execution_denied_message(entrypoint: JavaScriptEntryPoint) -> String {
74    format!(
75        "{} denied by policy: JavaScript is parsed for inspection but never executed",
76        entrypoint.as_str()
77    )
78}
79/// template_mentions_javascript.
80pub fn template_mentions_javascript(template_xml: &str) -> bool {
81    let lower = template_xml.to_ascii_lowercase();
82    lower.contains("text/javascript")
83        || lower.contains("application/javascript")
84        || lower.contains("application/x-javascript")
85        || lower.contains("/x-javascript")
86}
87/// is_javascript_action_dict.
88pub fn is_javascript_action_dict(dict: &lopdf::Dictionary) -> bool {
89    matches!(
90        dict.get(b"S").ok(),
91        Some(Object::Name(name)) if name == b"JavaScript"
92    )
93}
94/// catalog_has_javascript_open_action.
95pub fn catalog_has_javascript_open_action(doc: &Document) -> bool {
96    let Some(catalog_id) = catalog_id(doc) else {
97        return false;
98    };
99    let Some(Object::Dictionary(catalog)) = doc.objects.get(&catalog_id) else {
100        return false;
101    };
102    catalog
103        .get(b"OpenAction")
104        .ok()
105        .is_some_and(|action| object_is_javascript_action(doc, action))
106}
107/// dict_has_javascript_additional_actions.
108pub fn dict_has_javascript_additional_actions(doc: &Document, dict: &lopdf::Dictionary) -> bool {
109    dict.get(b"AA")
110        .ok()
111        .is_some_and(|aa| additional_actions_contain_javascript(doc, aa))
112}
113/// dict_has_javascript_field_action.
114pub fn dict_has_javascript_field_action(doc: &Document, dict: &lopdf::Dictionary) -> bool {
115    dict.get(b"A")
116        .ok()
117        .is_some_and(|action| object_is_javascript_action(doc, action))
118}
119
120/// Strip JavaScript-bearing PDF actions from XFA flattened output.
121///
122/// Returns the number of executable entrypoints neutralized. This is a
123/// hardening count, not a complete object garbage-collection report.
124pub fn strip_javascript_for_flatten(doc: &mut Document) -> usize {
125    debug_assert_eq!(
126        flatten_policy(JavaScriptEntryPoint::PdfOpenAction),
127        STRIP_ON_FLATTEN
128    );
129
130    let mut count = 0;
131    count += strip_javascript_name_tree(doc);
132
133    if let Some(catalog_id) = catalog_id(doc) {
134        let remove_open_action = doc
135            .objects
136            .get(&catalog_id)
137            .and_then(|object| match object {
138                Object::Dictionary(catalog) => catalog.get(b"OpenAction").ok(),
139                _ => None,
140            })
141            .is_some_and(|action| object_is_javascript_action(doc, action));
142
143        if remove_open_action {
144            if let Some(Object::Dictionary(catalog)) = doc.objects.get_mut(&catalog_id) {
145                catalog.remove(b"OpenAction");
146                count += 1;
147            }
148        }
149    }
150
151    // Two-phase strip: detection runs read-only against the original
152    // document state, then mutation applies the collected decisions. A
153    // single-pass loop is order-dependent because mutating a JS-action
154    // object first can hide it from later `dict_has_javascript_field_action`
155    // / `dict_has_javascript_additional_actions` checks on dictionaries
156    // that reference it indirectly, leaving `/A` or `/AA` keys behind
157    // even though the original graph was JS-bearing. Splitting detection
158    // and mutation makes the outcome deterministic regardless of the
159    // underlying object iteration order.
160    let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
161    let mut decisions: Vec<(ObjectId, StripDecision)> = Vec::new();
162    for id in ids {
163        let decision = match doc.objects.get(&id) {
164            Some(Object::Dictionary(dict)) => StripDecision {
165                is_js_action: is_javascript_action_dict(dict),
166                has_js_field_action: dict_has_javascript_field_action(doc, dict),
167                has_js_aa: dict_has_javascript_additional_actions(doc, dict),
168            },
169            _ => StripDecision::default(),
170        };
171        if decision.has_any() {
172            decisions.push((id, decision));
173        }
174    }
175
176    for (id, decision) in decisions {
177        if let Some(Object::Dictionary(dict)) = doc.objects.get_mut(&id) {
178            if decision.is_js_action {
179                dict.remove(b"JS");
180                dict.remove(b"S");
181                count += 1;
182            }
183            if decision.has_js_field_action {
184                dict.remove(b"A");
185                count += 1;
186            }
187            if decision.has_js_aa {
188                dict.remove(b"AA");
189                count += 1;
190            }
191        }
192    }
193
194    count
195}
196
197#[derive(Default, Clone, Copy)]
198struct StripDecision {
199    is_js_action: bool,
200    has_js_field_action: bool,
201    has_js_aa: bool,
202}
203
204impl StripDecision {
205    fn has_any(&self) -> bool {
206        self.is_js_action || self.has_js_field_action || self.has_js_aa
207    }
208}
209
210fn strip_javascript_name_tree(doc: &mut Document) -> usize {
211    let Some(catalog_id) = catalog_id(doc) else {
212        return 0;
213    };
214
215    let names_id = doc
216        .objects
217        .get(&catalog_id)
218        .and_then(|object| match object {
219            Object::Dictionary(catalog) => match catalog.get(b"Names").ok() {
220                Some(Object::Reference(id)) => Some(*id),
221                _ => None,
222            },
223            _ => None,
224        });
225
226    let mut count = 0;
227    if let Some(names_id) = names_id {
228        if let Some(Object::Dictionary(names)) = doc.objects.get_mut(&names_id) {
229            if names.has(b"JavaScript") {
230                names.remove(b"JavaScript");
231                count += 1;
232            }
233        }
234    }
235
236    let has_inline_js = doc.objects.get(&catalog_id).is_some_and(|object| {
237        matches!(object, Object::Dictionary(catalog) if matches!(
238            catalog.get(b"Names").ok(),
239            Some(Object::Dictionary(names)) if names.has(b"JavaScript")
240        ))
241    });
242
243    if has_inline_js {
244        if let Some(Object::Dictionary(catalog)) = doc.objects.get_mut(&catalog_id) {
245            if let Ok(Object::Dictionary(names)) = catalog.get_mut(b"Names") {
246                names.remove(b"JavaScript");
247                count += 1;
248            }
249        }
250    }
251
252    count
253}
254
255fn additional_actions_contain_javascript(doc: &Document, aa: &Object) -> bool {
256    ActionGraphWalk::default().additional_actions_contain_javascript(doc, aa, 0)
257}
258
259fn object_is_javascript_action(doc: &Document, action: &Object) -> bool {
260    ActionGraphWalk::default().object_is_javascript_action(doc, action, 0)
261}
262
263const MAX_ACTION_GRAPH_DEPTH: usize = 64;
264const MAX_ACTION_GRAPH_REFERENCES: usize = 128;
265
266#[derive(Default)]
267struct ActionGraphWalk {
268    visiting: HashSet<ObjectId>,
269    resolved_references: usize,
270}
271
272impl ActionGraphWalk {
273    fn additional_actions_contain_javascript(
274        &mut self,
275        doc: &Document,
276        aa: &Object,
277        depth: usize,
278    ) -> bool {
279        if depth > MAX_ACTION_GRAPH_DEPTH {
280            return true;
281        }
282
283        match aa {
284            Object::Dictionary(dict) => dict
285                .iter()
286                .any(|(_, action)| self.object_is_javascript_action(doc, action, depth + 1)),
287            Object::Reference(id) => {
288                self.referenced_additional_actions_contain_javascript(doc, *id, depth + 1)
289            }
290            _ => false,
291        }
292    }
293
294    fn referenced_additional_actions_contain_javascript(
295        &mut self,
296        doc: &Document,
297        id: ObjectId,
298        depth: usize,
299    ) -> bool {
300        if !self.enter_reference(id) {
301            return true;
302        }
303        let contains_js = doc.objects.get(&id).is_some_and(|object| {
304            self.additional_actions_contain_javascript(doc, object, depth + 1)
305        });
306        self.visiting.remove(&id);
307        contains_js
308    }
309
310    fn object_is_javascript_action(
311        &mut self,
312        doc: &Document,
313        action: &Object,
314        depth: usize,
315    ) -> bool {
316        if depth > MAX_ACTION_GRAPH_DEPTH {
317            return true;
318        }
319
320        match action {
321            Object::Dictionary(dict) => self.action_dict_contains_javascript(doc, dict, depth + 1),
322            Object::Reference(id) => {
323                self.referenced_action_contains_javascript(doc, *id, depth + 1)
324            }
325            Object::Array(actions) => actions
326                .iter()
327                .any(|action| self.object_is_javascript_action(doc, action, depth + 1)),
328            _ => false,
329        }
330    }
331
332    fn referenced_action_contains_javascript(
333        &mut self,
334        doc: &Document,
335        id: ObjectId,
336        depth: usize,
337    ) -> bool {
338        if !self.enter_reference(id) {
339            return true;
340        }
341        let contains_js = doc
342            .objects
343            .get(&id)
344            .is_some_and(|object| self.object_is_javascript_action(doc, object, depth + 1));
345        self.visiting.remove(&id);
346        contains_js
347    }
348
349    fn action_dict_contains_javascript(
350        &mut self,
351        doc: &Document,
352        dict: &lopdf::Dictionary,
353        depth: usize,
354    ) -> bool {
355        if is_javascript_action_dict(dict) {
356            return true;
357        }
358        dict.get(b"Next")
359            .ok()
360            .is_some_and(|next| self.object_is_javascript_action(doc, next, depth + 1))
361    }
362
363    fn enter_reference(&mut self, id: ObjectId) -> bool {
364        if self.resolved_references >= MAX_ACTION_GRAPH_REFERENCES {
365            return false;
366        }
367        if self.visiting.contains(&id) {
368            return false;
369        }
370        self.resolved_references += 1;
371        self.visiting.insert(id)
372    }
373}
374
375fn catalog_id(doc: &Document) -> Option<ObjectId> {
376    match doc.trailer.get(b"Root").ok()? {
377        Object::Reference(id) => Some(*id),
378        _ => None,
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use lopdf::{dictionary, Document, Object};
386
387    fn basic_doc_with_catalog(catalog: lopdf::Dictionary) -> Document {
388        let mut doc = Document::with_version("1.4");
389        let pages_id = doc.add_object(Object::Dictionary(dictionary! {
390            "Type" => Object::Name(b"Pages".to_vec()),
391            "Count" => Object::Integer(0),
392            "Kids" => Object::Array(vec![]),
393        }));
394        let mut catalog = catalog;
395        catalog.set("Type", Object::Name(b"Catalog".to_vec()));
396        catalog.set("Pages", Object::Reference(pages_id));
397        let catalog_id = doc.add_object(Object::Dictionary(catalog));
398        doc.trailer.set("Root", Object::Reference(catalog_id));
399        doc
400    }
401
402    fn js_action(source: &[u8]) -> Object {
403        Object::Dictionary(dictionary! {
404            "S" => Object::Name(b"JavaScript".to_vec()),
405            "JS" => Object::String(source.to_vec(), lopdf::StringFormat::Literal),
406        })
407    }
408
409    fn hide_action_dict(next: Option<Object>) -> lopdf::Dictionary {
410        let mut dict = dictionary! {
411            "S" => Object::Name(b"Hide".to_vec()),
412        };
413        if let Some(next) = next {
414            dict.set("Next", next);
415        }
416        dict
417    }
418
419    fn hide_action(next: Option<Object>) -> Object {
420        Object::Dictionary(hide_action_dict(next))
421    }
422
423    fn set_catalog_open_action(doc: &mut Document, action: Object) {
424        let catalog_id = catalog_id(doc).expect("catalog id");
425        let Some(Object::Dictionary(catalog)) = doc.objects.get_mut(&catalog_id) else {
426            panic!("catalog dictionary");
427        };
428        catalog.set("OpenAction", action);
429    }
430
431    fn catalog_has_open_action(doc: &Document) -> bool {
432        let catalog_id = catalog_id(doc).expect("catalog id");
433        matches!(
434            doc.objects.get(&catalog_id),
435            Some(Object::Dictionary(catalog)) if catalog.has(b"OpenAction")
436        )
437    }
438
439    #[test]
440    fn policy_constants_are_explicit() {
441        assert_eq!(parse_policy(), ALLOW_PARSE);
442        assert_eq!(
443            execution_policy(JavaScriptEntryPoint::XfaEventHook),
444            DENY_EXECUTION
445        );
446        assert_eq!(
447            flatten_policy(JavaScriptEntryPoint::AnnotationAdditionalAction),
448            STRIP_ON_FLATTEN
449        );
450    }
451
452    #[test]
453    fn reject_execution_returns_unsupported_javascript() {
454        let err = reject_execution(JavaScriptEntryPoint::XfaEventHook);
455        assert_eq!(format!("{err}"), "unsupported feature: javascript");
456    }
457
458    #[test]
459    fn detects_open_action_javascript() {
460        let doc = basic_doc_with_catalog(dictionary! {
461            "OpenAction" => js_action(b"app.alert('x')"),
462        });
463
464        assert!(catalog_has_javascript_open_action(&doc));
465    }
466
467    #[test]
468    fn strips_open_action_javascript_on_flatten() {
469        let mut doc = basic_doc_with_catalog(dictionary! {
470            "OpenAction" => js_action(b"app.alert('x')"),
471        });
472
473        assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
474        assert!(!catalog_has_javascript_open_action(&doc));
475    }
476
477    #[test]
478    fn strips_open_action_when_next_dict_contains_javascript() {
479        let mut doc = basic_doc_with_catalog(dictionary! {
480            "OpenAction" => hide_action(Some(js_action(b"app.alert('next')"))),
481        });
482
483        assert!(catalog_has_javascript_open_action(&doc));
484        assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
485        assert!(!catalog_has_open_action(&doc));
486    }
487
488    #[test]
489    fn strips_open_action_when_next_array_contains_javascript() {
490        let mut doc = basic_doc_with_catalog(dictionary! {
491            "OpenAction" => hide_action(Some(Object::Array(vec![
492                hide_action(None),
493                js_action(b"app.alert('array')"),
494            ]))),
495        });
496
497        assert!(catalog_has_javascript_open_action(&doc));
498        assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
499        assert!(!catalog_has_open_action(&doc));
500    }
501
502    #[test]
503    fn cyclic_next_chain_is_fail_closed_without_looping() {
504        let mut doc = basic_doc_with_catalog(dictionary! {});
505        let action_a_id = doc.new_object_id();
506        let action_b_id = doc.new_object_id();
507        doc.objects.insert(
508            action_a_id,
509            Object::Dictionary(hide_action_dict(Some(Object::Reference(action_b_id)))),
510        );
511        doc.objects.insert(
512            action_b_id,
513            Object::Dictionary(hide_action_dict(Some(Object::Reference(action_a_id)))),
514        );
515        set_catalog_open_action(&mut doc, Object::Reference(action_a_id));
516
517        assert!(catalog_has_javascript_open_action(&doc));
518        assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
519        assert!(!catalog_has_open_action(&doc));
520    }
521
522    #[test]
523    fn detects_annotation_additional_action_javascript() {
524        let doc = basic_doc_with_catalog(dictionary! {});
525        let annot = dictionary! {
526            "Type" => Object::Name(b"Annot".to_vec()),
527            "Subtype" => Object::Name(b"Widget".to_vec()),
528            "AA" => Object::Dictionary(dictionary! {
529                "E" => js_action(b"app.alert('enter')"),
530            }),
531        };
532
533        assert!(dict_has_javascript_additional_actions(&doc, &annot));
534    }
535
536    #[test]
537    fn strips_additional_action_entry_with_next_chain_javascript() {
538        let mut doc = basic_doc_with_catalog(dictionary! {});
539        let action_id = doc.add_object(hide_action(Some(js_action(
540            b"app.alert('additional action')",
541        ))));
542        let annot_id = doc.add_object(Object::Dictionary(dictionary! {
543            "Type" => Object::Name(b"Annot".to_vec()),
544            "Subtype" => Object::Name(b"Widget".to_vec()),
545            "AA" => Object::Dictionary(dictionary! {
546                "U" => Object::Reference(action_id),
547            }),
548        }));
549
550        let annot = match doc.objects.get(&annot_id) {
551            Some(Object::Dictionary(annot)) => annot,
552            _ => panic!("annotation dictionary"),
553        };
554        assert!(dict_has_javascript_additional_actions(&doc, annot));
555
556        assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
557        let annot = match doc.objects.get(&annot_id) {
558            Some(Object::Dictionary(annot)) => annot,
559            _ => panic!("annotation dictionary"),
560        };
561        assert!(!annot.has(b"AA"));
562    }
563
564    #[test]
565    fn detects_field_action_javascript() {
566        let doc = basic_doc_with_catalog(dictionary! {});
567        let field = dictionary! {
568            "FT" => Object::Name(b"Tx".to_vec()),
569            "A" => js_action(b"app.alert('field')"),
570        };
571
572        assert!(dict_has_javascript_field_action(&doc, &field));
573    }
574
575    #[test]
576    fn strips_field_action_with_next_chain_javascript() {
577        let mut doc = basic_doc_with_catalog(dictionary! {});
578        let field_id = doc.add_object(Object::Dictionary(dictionary! {
579            "FT" => Object::Name(b"Tx".to_vec()),
580            "A" => hide_action(Some(js_action(b"app.alert('field next')"))),
581        }));
582
583        assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
584        let field = match doc.objects.get(&field_id) {
585            Some(Object::Dictionary(field)) => field,
586            _ => panic!("field dictionary"),
587        };
588        assert!(!field.has(b"A"));
589    }
590
591    #[test]
592    fn pure_non_javascript_action_chain_is_preserved() {
593        let mut doc = basic_doc_with_catalog(dictionary! {
594            "OpenAction" => hide_action(Some(hide_action(Some(hide_action(None))))),
595        });
596
597        assert!(!catalog_has_javascript_open_action(&doc));
598        assert_eq!(strip_javascript_for_flatten(&mut doc), 0);
599        assert!(catalog_has_open_action(&doc));
600    }
601
602    #[test]
603    fn malformed_javascript_payload_is_never_parsed_for_execution() {
604        let mut doc = basic_doc_with_catalog(dictionary! {
605            "OpenAction" => js_action(b"\0}\xff{not valid js"),
606        });
607
608        assert!(catalog_has_javascript_open_action(&doc));
609        assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
610    }
611
612    #[test]
613    fn indirect_field_action_to_javascript_is_always_stripped() {
614        // Codex P2 regression-guard: a field whose `/A` is an indirect
615        // reference to a JavaScript action object must always be stripped,
616        // even when the underlying object iteration order would visit the
617        // JS-action object before the field dict that points to it. The
618        // single-pass loop used to mutate the JS-action first and then
619        // see no JS in the field's referenced action graph; the two-phase
620        // detect-then-mutate split keeps the result deterministic.
621        let mut doc = basic_doc_with_catalog(dictionary! {});
622        let js_action_id = doc.add_object(js_action(b"app.alert('indirect')"));
623        let field_id = doc.add_object(Object::Dictionary(dictionary! {
624            "FT" => Object::Name(b"Tx".to_vec()),
625            "A" => Object::Reference(js_action_id),
626        }));
627
628        let count = strip_javascript_for_flatten(&mut doc);
629        // Field /A removal + JS action /JS+/S removal = 2 strip events.
630        assert_eq!(
631            count, 2,
632            "both field /A and JS action body must be stripped"
633        );
634
635        let field = match doc.objects.get(&field_id) {
636            Some(Object::Dictionary(field)) => field,
637            _ => panic!("field dictionary"),
638        };
639        assert!(
640            !field.has(b"A"),
641            "field /A pointing indirectly to JS action must be stripped"
642        );
643
644        let js_obj = match doc.objects.get(&js_action_id) {
645            Some(Object::Dictionary(action)) => action,
646            _ => panic!("js action dictionary"),
647        };
648        assert!(
649            !js_obj.has(b"JS"),
650            "indirect JS action /JS payload must be stripped"
651        );
652        assert!(
653            !js_obj.has(b"S"),
654            "indirect JS action /S name must be stripped"
655        );
656    }
657
658    #[test]
659    fn strip_outcome_is_independent_of_object_insertion_order() {
660        // Build the same logical graph (field /A → indirect JS action) twice,
661        // but allocate the objects in opposite orders so HashMap iteration
662        // hits them in different sequence. The two-phase strip must produce
663        // identical post-state in both arrangements.
664        let mut doc_action_first = basic_doc_with_catalog(dictionary! {});
665        let action_id_a = doc_action_first.add_object(js_action(b"app.alert('A')"));
666        let field_id_a = doc_action_first.add_object(Object::Dictionary(dictionary! {
667            "FT" => Object::Name(b"Tx".to_vec()),
668            "A" => Object::Reference(action_id_a),
669        }));
670
671        let mut doc_field_first = basic_doc_with_catalog(dictionary! {});
672        let field_id_b = doc_field_first.add_object(Object::Dictionary(dictionary! {
673            "FT" => Object::Name(b"Tx".to_vec()),
674            "A" => Object::Reference(lopdf::ObjectId::default()), // placeholder
675        }));
676        let action_id_b = doc_field_first.add_object(js_action(b"app.alert('A')"));
677        // Patch the placeholder so the field really points at the action.
678        if let Some(Object::Dictionary(d)) = doc_field_first.objects.get_mut(&field_id_b) {
679            d.set("A", Object::Reference(action_id_b));
680        }
681
682        let count_a = strip_javascript_for_flatten(&mut doc_action_first);
683        let count_b = strip_javascript_for_flatten(&mut doc_field_first);
684        assert_eq!(count_a, count_b, "strip count must match across orderings");
685
686        for (doc, fid, aid) in [
687            (&doc_action_first, field_id_a, action_id_a),
688            (&doc_field_first, field_id_b, action_id_b),
689        ] {
690            let field = match doc.objects.get(&fid) {
691                Some(Object::Dictionary(f)) => f,
692                _ => panic!("field"),
693            };
694            assert!(
695                !field.has(b"A"),
696                "field /A must be stripped in both orderings"
697            );
698            let act = match doc.objects.get(&aid) {
699                Some(Object::Dictionary(a)) => a,
700                _ => panic!("action"),
701            };
702            assert!(
703                !act.has(b"JS"),
704                "action /JS must be stripped in both orderings"
705            );
706            assert!(
707                !act.has(b"S"),
708                "action /S must be stripped in both orderings"
709            );
710        }
711    }
712
713    #[test]
714    fn non_javascript_action_chain_with_indirect_targets_is_preserved() {
715        // Pure non-JS chain reachable through indirect references must not
716        // be stripped by the two-phase pass. Confirms that only JS-bearing
717        // graphs are removed, not innocent action chains.
718        let mut doc = basic_doc_with_catalog(dictionary! {});
719        let inner_hide_id = doc.add_object(hide_action(None));
720        let outer_hide = hide_action(Some(Object::Reference(inner_hide_id)));
721        let field_id = doc.add_object(Object::Dictionary(dictionary! {
722            "FT" => Object::Name(b"Tx".to_vec()),
723            "A" => outer_hide,
724        }));
725
726        assert_eq!(
727            strip_javascript_for_flatten(&mut doc),
728            0,
729            "non-JS action chain must be preserved"
730        );
731
732        let field = match doc.objects.get(&field_id) {
733            Some(Object::Dictionary(f)) => f,
734            _ => panic!("field"),
735        };
736        assert!(field.has(b"A"), "non-JS /A must remain on field");
737        let inner = match doc.objects.get(&inner_hide_id) {
738            Some(Object::Dictionary(a)) => a,
739            _ => panic!("inner hide action"),
740        };
741        assert!(inner.has(b"S"), "non-JS action /S must remain");
742    }
743}