Skip to main content

pdf_xfa/
javascript_policy.rs

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