Skip to main content

pdf_xfa/
dynamic.rs

1use std::collections::HashMap;
2
3use crate::error::{Result, XfaError};
4use crate::javascript_policy::{self, JavaScriptEntryPoint};
5use crate::js_runtime::{
6    activity_allowed_for_sandbox, NullRuntime, RuntimeMetadata, SandboxError, XfaJsRuntime,
7};
8use formcalc_interpreter::{
9    interpreter::Interpreter, lexer::tokenize, parser, som_bridge::SomResolver,
10    value::Value as FormCalcValue,
11};
12use xfa_dom_resolver::som::{parse_som, SomExpression, SomIndex, SomRoot, SomSelector};
13use xfa_layout_engine::form::{
14    EventScript, FormNodeId, FormNodeType, FormTree, GroupKind, Presence, ScriptLanguage,
15};
16
17// XFA Spec 3.3 §9.3 — Dynamic Forms Re-Layout: after script execution the
18// layout processor must re-run layout.  The spec does not prescribe a fixed
19// pass limit; Adobe typically converges in 2-3 passes.  Our limit of 3 is a
20// pragmatic cap that matches observed Adobe behavior.
21const MAX_SCRIPT_PASSES: usize = 3;
22
23/// Controls only the pre-flight handling of parsed JavaScript-bearing XFA
24/// event hooks. JavaScript execution remains denied by `javascript_policy`.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum JsExecutionMode {
27    /// Abort before script execution when any JavaScript or unsupported script
28    /// language is present. This preserves the original policy-gate behavior.
29    Strict,
30    /// Skip JavaScript and unsupported-language scripts, then continue running
31    /// FormCalc and the layout pipeline. Skipped scripts are reported in the
32    /// returned outcome.
33    #[default]
34    BestEffortStatic,
35    /// **M3-B Phase B opt-in.** Route JavaScript scripts through the sandboxed
36    /// runtime adapter (`crate::js_runtime`). Requires the Cargo feature
37    /// `xfa-js-sandboxed` to be compiled in for any script to actually
38    /// execute; without the feature the runtime returns
39    /// [`SandboxError::NotCompiledIn`] and the dispatch path falls back to
40    /// the same skip behaviour as [`Self::BestEffortStatic`] while incrementing
41    /// `js_runtime_errors` so callers can observe the dead-code state.
42    /// Phase B registers no host bindings; see
43    /// `benchmarks/runs/M3B_HOST_BINDINGS_MINIMUM_SET.md` for the Phase C
44    /// roadmap.
45    SandboxedRuntime,
46}
47/// OutputQuality.
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum OutputQuality {
51    /// Exact.
52    #[default]
53    Exact,
54    /// BestEffort.
55    BestEffort,
56    /// **M3-B Phase B.** All JavaScript scripts on the document executed
57    /// inside the sandbox without runtime / timeout / OOM errors.
58    Sandboxed,
59}
60
61impl OutputQuality {
62    /// as_str.
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Self::Exact => "exact",
66            Self::BestEffort => "best_effort",
67            Self::Sandboxed => "sandboxed",
68        }
69    }
70}
71/// DynamicScriptOutcome.
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct DynamicScriptOutcome {
75    /// changes.
76    pub changes: usize,
77    /// js_present.
78    pub js_present: bool,
79    /// js_skipped.
80    pub js_skipped: usize,
81    /// other_skipped.
82    pub other_skipped: usize,
83    /// formcalc_run.
84    pub formcalc_run: usize,
85    /// formcalc_errors.
86    pub formcalc_errors: usize,
87    /// output_quality.
88    pub output_quality: OutputQuality,
89    /// **M3-B Phase B.** Scripts that ran to completion in the sandboxed
90    /// runtime. Always 0 when mode != [`JsExecutionMode::SandboxedRuntime`]
91    /// or when the `xfa-js-sandboxed` feature is not compiled in.
92    pub js_executed: usize,
93    /// **M3-B Phase B.** Sandbox errors that did not fall under timeout / OOM
94    /// (parse error, throw, missing host binding in Phase B, FFI panic). The
95    /// dispatch path treats these as a script skip; the parent flatten never
96    /// aborts because of them (S-17 fail-open).
97    pub js_runtime_errors: usize,
98    /// **M3-B Phase B.** Per-script time-budget exhaustions.
99    pub js_timeouts: usize,
100    /// **M3-B Phase B.** Per-document memory-budget exhaustions.
101    pub js_oom: usize,
102    /// **M3-B Phase C.** Host-binding invocations.
103    pub js_host_calls: usize,
104    /// **M3-B Phase C.** Successful host-side `field.rawValue` writes.
105    pub js_mutations: usize,
106    /// **M3-B Phase D.** Successful host-side instanceManager writes.
107    pub js_instance_writes: usize,
108    /// **M3-B Phase D-β.** Successful host-side listbox clearItems / addItem writes.
109    pub js_list_writes: usize,
110    /// **M3-B Phase C.** Binding-level failures.
111    pub js_binding_errors: usize,
112    /// **M3-B Phase C.** SOM resolution misses / failures.
113    pub js_resolve_failures: usize,
114    /// **M3-B Phase D-γ.** Successful DataDom reads (children / value / child-by-name).
115    pub js_data_reads: usize,
116}
117
118impl Default for DynamicScriptOutcome {
119    fn default() -> Self {
120        Self {
121            changes: 0,
122            js_present: false,
123            js_skipped: 0,
124            other_skipped: 0,
125            formcalc_run: 0,
126            formcalc_errors: 0,
127            output_quality: OutputQuality::Exact,
128            js_executed: 0,
129            js_runtime_errors: 0,
130            js_timeouts: 0,
131            js_oom: 0,
132            js_host_calls: 0,
133            js_mutations: 0,
134            js_instance_writes: 0,
135            js_list_writes: 0,
136            js_binding_errors: 0,
137            js_resolve_failures: 0,
138            js_data_reads: 0,
139        }
140    }
141}
142
143/// Snapshot of field values and presence states, used for rollback.
144/// NOTE: This rollback mechanism is our own heuristic — the XFA spec does not
145/// define a rollback model.  It protects against scripts that blank out all
146/// fields (broken SOM resolution, etc.).
147struct FormSnapshot {
148    field_values: Vec<(usize, String)>,
149    presences: Vec<(usize, Presence)>,
150    populated_count: usize,
151}
152
153fn snapshot_form(form: &FormTree) -> FormSnapshot {
154    let mut field_values = Vec::new();
155    let mut presences = Vec::new();
156    let mut populated_count = 0usize;
157    for (idx, node) in form.nodes.iter().enumerate() {
158        if let FormNodeType::Field { value } = &node.node_type {
159            field_values.push((idx, value.clone()));
160            if !value.trim().is_empty() {
161                populated_count += 1;
162            }
163        }
164        presences.push((idx, form.metadata[idx].presence));
165    }
166    FormSnapshot {
167        field_values,
168        presences,
169        populated_count,
170    }
171}
172
173fn restore_snapshot(form: &mut FormTree, snapshot: &FormSnapshot) {
174    for (idx, value) in &snapshot.field_values {
175        if let FormNodeType::Field { value: fv } = &mut form.nodes[*idx].node_type {
176            *fv = value.clone();
177        }
178    }
179    for (idx, presence) in &snapshot.presences {
180        form.metadata[*idx].presence = *presence;
181    }
182}
183
184fn should_rollback(
185    form: &FormTree,
186    snapshot: &FormSnapshot,
187    errors: usize,
188    successes: usize,
189) -> bool {
190    if errors > 0 && errors > successes {
191        return true;
192    }
193    if snapshot.populated_count >= 2 {
194        let mut now_empty = 0usize;
195        for (idx, old_value) in &snapshot.field_values {
196            if old_value.trim().is_empty() {
197                continue;
198            }
199            if let FormNodeType::Field { value } = &form.nodes[*idx].node_type {
200                if value.trim().is_empty() {
201                    now_empty += 1;
202                }
203            }
204        }
205        if now_empty * 2 > snapshot.populated_count {
206            return true;
207        }
208    }
209    false
210}
211/// apply_dynamic_scripts.
212// XFA Spec 3.3 §9.3 — Dynamic Forms: after data binding, scripts run in
213// two phases: (1) initialize events fire once, (2) calculate events may
214// iterate until stable (convergence) or MAX_SCRIPT_PASSES is reached.
215// The spec (§14.3.2) defines the event model; our implementation runs
216// initialize then calculate, matching Adobe's processing order.
217//
218// NOTE: §10.6 Rule 3 states the merge-completion order as:
219//   value calcs → property calcs → validations → initialize events.
220// Our order (initialize first) differs from the spec but matches Adobe's
221// observed behavior on our 20K test corpus (97%+ SSIM). §28.2 (p1231)
222// documents Adobe's event execution insert-at-position-2 algorithm.
223//
224// The default JavaScript handling is best-effort static flattening: parsed
225// JavaScript and unsupported-language scripts are skipped and reported, while
226// FormCalc continues to run. Use `apply_dynamic_scripts_with_mode(..., Strict)`
227// when callers need the legacy whole-form JavaScript policy gate.
228pub fn apply_dynamic_scripts(
229    form: &mut FormTree,
230    root_id: FormNodeId,
231) -> Result<DynamicScriptOutcome> {
232    apply_dynamic_scripts_with_mode(form, root_id, JsExecutionMode::default())
233}
234/// apply_dynamic_scripts_with_mode.
235pub fn apply_dynamic_scripts_with_mode(
236    form: &mut FormTree,
237    root_id: FormNodeId,
238    mode: JsExecutionMode,
239) -> Result<DynamicScriptOutcome> {
240    // M3-B Phase C-α (2026-05-03): when the caller asks for SandboxedRuntime
241    // and the `xfa-js-sandboxed` feature is compiled in, instantiate the
242    // real QuickJS-backed adapter. Without the feature, fall back to
243    // NullRuntime which surfaces `SandboxError::NotCompiledIn` per script
244    // (existing Phase B fallback semantics).
245    #[cfg(feature = "xfa-js-sandboxed")]
246    {
247        if mode == JsExecutionMode::SandboxedRuntime {
248            match crate::js_runtime::QuickJsRuntime::new() {
249                Ok(mut rt) => {
250                    return apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut rt);
251                }
252                Err(_e) => {
253                    // QuickJS init failure is rare; fall through to NullRuntime
254                    // so the dispatch path can still record per-script errors
255                    // and the flatten succeeds in best-effort mode.
256                }
257            }
258        }
259    }
260    apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut NullRuntime::new())
261}
262
263/// Phase B entry point that lets the caller inject a sandboxed runtime
264/// adapter. When `mode == JsExecutionMode::SandboxedRuntime` the supplied
265/// `runtime` is consulted for every JavaScript script whose `<event activity>`
266/// is in [`crate::js_runtime::SANDBOX_ACTIVITY_ALLOWLIST`]. UI / submission
267/// activities skip the runtime entirely and are recorded as `js_skipped`,
268/// matching `BestEffortStatic` behaviour for those scripts.
269///
270/// Other modes ignore `runtime` entirely; callers that just need the
271/// existing strict / best-effort behaviour should use
272/// [`apply_dynamic_scripts_with_mode`] (which routes through
273/// [`crate::js_runtime::NullRuntime`]).
274pub fn apply_dynamic_scripts_with_runtime(
275    form: &mut FormTree,
276    root_id: FormNodeId,
277    mode: JsExecutionMode,
278    runtime: &mut dyn XfaJsRuntime,
279) -> Result<DynamicScriptOutcome> {
280    let parents = build_parent_map(form, root_id);
281    let all_scripts: Vec<(FormNodeId, Vec<EventScript>)> = form
282        .nodes
283        .iter()
284        .enumerate()
285        .filter_map(|(idx, _)| {
286            let node_id = FormNodeId(idx);
287            let scripts = form.meta(node_id).event_scripts.clone();
288            (!scripts.is_empty()).then_some((node_id, scripts))
289        })
290        .collect();
291
292    let has_unsupported_script = all_scripts.iter().any(|(_, node_scripts)| {
293        node_scripts
294            .iter()
295            .any(|script| script.language != ScriptLanguage::FormCalc)
296    });
297
298    if mode == JsExecutionMode::Strict && has_unsupported_script {
299        return Err(javascript_policy::reject_execution(
300            JavaScriptEntryPoint::XfaEventHook,
301        ));
302    }
303
304    let mut js_skipped = 0usize;
305    let mut other_skipped = 0usize;
306    let mut sandbox_metadata = RuntimeMetadata::default();
307    let mut scripts = Vec::new();
308    let sandbox_active = mode == JsExecutionMode::SandboxedRuntime;
309    let snapshot = snapshot_form(form);
310
311    if sandbox_active {
312        // Best-effort init / reset; init failures are non-fatal — the
313        // dispatch path will record them as runtime_errors per script.
314        let _ = runtime.init();
315        let _ = runtime.reset_for_new_document();
316        let _ = runtime.set_form_handle(form as *mut FormTree, root_id);
317    }
318
319    for (node_id, node_scripts) in all_scripts {
320        let mut formcalc_scripts = Vec::new();
321        for script in node_scripts {
322            match script.language {
323                ScriptLanguage::FormCalc => formcalc_scripts.push(script),
324                ScriptLanguage::JavaScript => {
325                    if sandbox_active && activity_allowed_for_sandbox(script.activity.as_deref()) {
326                        let _ = runtime.reset_per_script(node_id, script.activity.as_deref());
327                        match runtime.execute_script(script.activity.as_deref(), &script.script) {
328                            Ok(_outcome) => {
329                                // Counter increment lives on `take_metadata()`.
330                            }
331                            Err(SandboxError::Timeout) => js_skipped += 1,
332                            Err(SandboxError::OutOfMemory) => js_skipped += 1,
333                            Err(e) => {
334                                // M3-B Phase C-α: surface the per-script error
335                                // class via `log::debug!` for normal builds and
336                                // also via stderr when `XFA_JS_DEBUG=1` is set
337                                // (operator triage aid; xfa-cli does not init
338                                // a logger that respects RUST_LOG).
339                                log::debug!(
340                                    "sandbox script error on activity={:?}: {}",
341                                    script.activity.as_deref(),
342                                    e
343                                );
344                                if std::env::var("XFA_JS_DEBUG").ok().as_deref() == Some("1") {
345                                    eprintln!(
346                                        "XFA_JS_DEBUG sandbox script error on activity={:?}: {}",
347                                        script.activity.as_deref(),
348                                        e
349                                    );
350                                }
351                                js_skipped += 1;
352                            }
353                        }
354                    } else {
355                        js_skipped += 1;
356                    }
357                }
358                ScriptLanguage::Other => other_skipped += 1,
359            }
360        }
361        if !formcalc_scripts.is_empty() {
362            scripts.push((node_id, formcalc_scripts));
363        }
364    }
365
366    if sandbox_active {
367        let _ = runtime.set_form_handle(std::ptr::null_mut(), root_id);
368        sandbox_metadata = runtime.take_metadata();
369    }
370
371    let mut stats = ScriptStats::default();
372
373    let mut changes = sandbox_metadata
374        .mutations
375        .saturating_add(sandbox_metadata.instance_writes)
376        + run_script_phase(
377            form,
378            root_id,
379            &parents,
380            &scripts,
381            ScriptPhase::Initialize,
382            1,
383            &mut stats,
384        )?
385        + run_script_phase(
386            form,
387            root_id,
388            &parents,
389            &scripts,
390            ScriptPhase::Calculate,
391            MAX_SCRIPT_PASSES,
392            &mut stats,
393        )?;
394
395    let sandbox_rollback_errors = sandbox_metadata
396        .runtime_errors
397        .saturating_add(sandbox_metadata.timeouts)
398        .saturating_add(sandbox_metadata.oom)
399        .saturating_add(sandbox_metadata.binding_errors);
400    let rollback_errors = stats.errors.saturating_add(sandbox_rollback_errors);
401    let rollback_successes = stats.successes.saturating_add(sandbox_metadata.executed);
402
403    if should_rollback(form, &snapshot, rollback_errors, rollback_successes) {
404        restore_snapshot(form, &snapshot);
405        changes = 0;
406    }
407
408    let js_seen_count = js_skipped + sandbox_metadata.executed;
409    let js_present = js_seen_count > 0;
410    let output_quality = if sandbox_active && js_present && sandbox_metadata.is_clean() {
411        OutputQuality::Sandboxed
412    } else if (sandbox_active && js_present) || js_skipped > 0 || other_skipped > 0 {
413        OutputQuality::BestEffort
414    } else {
415        OutputQuality::Exact
416    };
417
418    Ok(DynamicScriptOutcome {
419        changes,
420        js_present,
421        js_skipped,
422        other_skipped,
423        formcalc_run: stats.formcalc_run,
424        formcalc_errors: stats.formcalc_errors,
425        output_quality,
426        js_executed: sandbox_metadata.executed,
427        js_runtime_errors: sandbox_metadata.runtime_errors,
428        js_timeouts: sandbox_metadata.timeouts,
429        js_oom: sandbox_metadata.oom,
430        js_host_calls: sandbox_metadata.host_calls,
431        js_mutations: sandbox_metadata.mutations,
432        js_instance_writes: sandbox_metadata.instance_writes,
433        js_list_writes: sandbox_metadata.list_writes,
434        js_binding_errors: sandbox_metadata.binding_errors,
435        js_resolve_failures: sandbox_metadata.resolve_failures,
436        js_data_reads: sandbox_metadata.data_reads,
437    })
438}
439
440fn has_hidden_ancestor(
441    form: &FormTree,
442    parents: &HashMap<FormNodeId, FormNodeId>,
443    node_id: FormNodeId,
444) -> bool {
445    let mut cursor = parents.get(&node_id).copied();
446    while let Some(ancestor) = cursor {
447        if form.meta(ancestor).presence.is_not_visible() {
448            return true;
449        }
450        cursor = parents.get(&ancestor).copied();
451    }
452    false
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456enum ScriptPhase {
457    Initialize,
458    Calculate,
459}
460
461#[derive(Default)]
462struct ScriptStats {
463    errors: usize,
464    successes: usize,
465    formcalc_run: usize,
466    formcalc_errors: usize,
467}
468
469#[derive(Debug)]
470struct ScriptResult {
471    changes: usize,
472    error: bool,
473}
474
475fn run_script_phase(
476    form: &mut FormTree,
477    root_id: FormNodeId,
478    parents: &HashMap<FormNodeId, FormNodeId>,
479    scripts: &[(FormNodeId, Vec<EventScript>)],
480    phase: ScriptPhase,
481    max_passes: usize,
482    stats: &mut ScriptStats,
483) -> Result<usize> {
484    let mut total_changes = 0;
485
486    for _ in 0..max_passes {
487        let mut pass_changes = 0;
488
489        for (node_id, node_scripts) in scripts {
490            if has_hidden_ancestor(form, parents, *node_id) {
491                continue;
492            }
493
494            for script in node_scripts
495                .iter()
496                .filter(|script| should_run_script(script, phase))
497            {
498                let result = execute_event_script(form, root_id, parents, *node_id, script, phase)?;
499                stats.formcalc_run += 1;
500                if result.error {
501                    stats.errors += 1;
502                    stats.formcalc_errors += 1;
503                } else {
504                    stats.successes += 1;
505                }
506                pass_changes += result.changes;
507            }
508        }
509
510        total_changes += pass_changes;
511        if pass_changes == 0 {
512            break;
513        }
514    }
515
516    Ok(total_changes)
517}
518
519fn should_run_script(script: &EventScript, phase: ScriptPhase) -> bool {
520    match phase {
521        ScriptPhase::Initialize => script.activity.as_deref() == Some("initialize"),
522        ScriptPhase::Calculate => script.activity.as_deref() == Some("calculate"),
523    }
524}
525
526fn execute_event_script(
527    form: &mut FormTree,
528    root_id: FormNodeId,
529    parents: &HashMap<FormNodeId, FormNodeId>,
530    current_id: FormNodeId,
531    script: &EventScript,
532    phase: ScriptPhase,
533) -> Result<ScriptResult> {
534    match script.language {
535        ScriptLanguage::FormCalc => Ok(execute_formcalc_script(
536            form, root_id, parents, current_id, script, phase,
537        )),
538        ScriptLanguage::JavaScript => Err(javascript_policy::reject_execution(
539            JavaScriptEntryPoint::XfaEventHook,
540        )),
541        ScriptLanguage::Other => Err(XfaError::UnsupportedFeature("script language".to_string())),
542    }
543}
544
545fn execute_formcalc_script(
546    form: &mut FormTree,
547    root_id: FormNodeId,
548    parents: &HashMap<FormNodeId, FormNodeId>,
549    current_id: FormNodeId,
550    script: &EventScript,
551    phase: ScriptPhase,
552) -> ScriptResult {
553    let Ok(tokens) = tokenize(&script.script) else {
554        return ScriptResult {
555            changes: 0,
556            error: true,
557        };
558    };
559    let Ok(ast) = parser::parse(tokens) else {
560        return ScriptResult {
561            changes: 0,
562            error: true,
563        };
564    };
565
566    let mut interpreter = Interpreter::new();
567    let mut resolver = FormTreeSomResolver::new(form, root_id, parents, current_id);
568    let Ok(result) = interpreter.exec_with_resolver(&ast, &mut resolver) else {
569        return ScriptResult {
570            changes: resolver.changes,
571            error: true,
572        };
573    };
574
575    if matches!(phase, ScriptPhase::Calculate) {
576        resolver.changes += write_formcalc_value(
577            resolver.form,
578            current_id,
579            ResolvedProperty::RawValue,
580            result,
581        );
582    }
583
584    ScriptResult {
585        changes: resolver.changes,
586        error: false,
587    }
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq)]
591enum ResolvedProperty {
592    RawValue,
593    Presence,
594    SomExpression,
595}
596
597#[derive(Debug, Clone, Copy, PartialEq, Eq)]
598struct ResolvedTarget {
599    node_id: FormNodeId,
600    property: ResolvedProperty,
601}
602
603struct FormTreeSomResolver<'a> {
604    form: &'a mut FormTree,
605    root_id: FormNodeId,
606    parents: &'a HashMap<FormNodeId, FormNodeId>,
607    current_id: FormNodeId,
608    changes: usize,
609}
610
611impl<'a> FormTreeSomResolver<'a> {
612    fn new(
613        form: &'a mut FormTree,
614        root_id: FormNodeId,
615        parents: &'a HashMap<FormNodeId, FormNodeId>,
616        current_id: FormNodeId,
617    ) -> Self {
618        Self {
619            form,
620            root_id,
621            parents,
622            current_id,
623            changes: 0,
624        }
625    }
626
627    fn resolve_target(&self, path: &str) -> Option<ResolvedTarget> {
628        let trimmed = path.trim();
629        if trimmed.is_empty() {
630            return None;
631        }
632
633        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
634            return Some(ResolvedTarget {
635                node_id: self.current_id,
636                property: parse_property_name(trimmed)?,
637            });
638        }
639
640        let (expr, property) = split_property_path(trimmed)?;
641        let node_id = self.resolve_expression(&expr)?.into_iter().next()?;
642        Some(ResolvedTarget { node_id, property })
643    }
644
645    fn count_targets(&self, path: &str) -> usize {
646        let trimmed = path.trim();
647        if trimmed.is_empty() {
648            return 0;
649        }
650        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
651            return 1;
652        }
653        let Some((expr, _property)) = split_property_path(trimmed) else {
654            return 0;
655        };
656        self.resolve_expression(&expr)
657            .map_or(0, |nodes| nodes.len())
658    }
659
660    fn resolve_expression(&self, expr: &SomExpression) -> Option<Vec<FormNodeId>> {
661        match expr.root {
662            SomRoot::Data | SomRoot::Record | SomRoot::Template => None,
663            SomRoot::CurrentContainer => {
664                if expr.segments.is_empty() {
665                    Some(vec![self.current_id])
666                } else {
667                    Some(self.follow_absolute(vec![self.current_id], &expr.segments))
668                }
669            }
670            SomRoot::Form => {
671                if expr.segments.is_empty() {
672                    Some(vec![self.root_id])
673                } else {
674                    Some(self.follow_absolute(vec![self.root_id], &expr.segments))
675                }
676            }
677            SomRoot::Xfa => {
678                let segments = strip_xfa_form_prefix(&expr.segments);
679                if segments.is_empty() {
680                    Some(vec![self.root_id])
681                } else {
682                    Some(self.follow_absolute(vec![self.root_id], segments))
683                }
684            }
685            SomRoot::Unqualified => {
686                if expr.segments.is_empty() {
687                    Some(vec![self.current_id])
688                } else {
689                    Some(self.follow_unqualified(&expr.segments))
690                }
691            }
692        }
693    }
694
695    fn follow_absolute(
696        &self,
697        mut current: Vec<FormNodeId>,
698        segments: &[xfa_dom_resolver::som::SomSegment],
699    ) -> Vec<FormNodeId> {
700        for (idx, segment) in segments.iter().enumerate() {
701            let allow_self = idx == 0;
702            current = current
703                .into_iter()
704                .flat_map(|node_id| self.step_from_node(node_id, segment, allow_self))
705                .collect();
706            if current.is_empty() {
707                break;
708            }
709        }
710        current
711    }
712
713    fn follow_unqualified(
714        &self,
715        segments: &[xfa_dom_resolver::som::SomSegment],
716    ) -> Vec<FormNodeId> {
717        let Some((first, rest)) = segments.split_first() else {
718            return vec![self.current_id];
719        };
720
721        let mut scope = Some(self.current_id);
722        while let Some(scope_id) = scope {
723            let anchors: Vec<_> = descendants_inclusive(self.form, scope_id)
724                .into_iter()
725                .filter(|node_id| self.node_matches_segment(*node_id, first))
726                .collect();
727            let matched = self.follow_remaining(anchors, rest);
728            if !matched.is_empty() {
729                return matched;
730            }
731            scope = self.parents.get(&scope_id).copied();
732        }
733
734        let anchors: Vec<_> = descendants_inclusive(self.form, self.root_id)
735            .into_iter()
736            .filter(|node_id| self.node_matches_segment(*node_id, first))
737            .collect();
738        self.follow_remaining(anchors, rest)
739    }
740
741    fn follow_remaining(
742        &self,
743        mut current: Vec<FormNodeId>,
744        segments: &[xfa_dom_resolver::som::SomSegment],
745    ) -> Vec<FormNodeId> {
746        for segment in segments {
747            current = current
748                .into_iter()
749                .flat_map(|node_id| self.step_from_node(node_id, segment, false))
750                .collect();
751            if current.is_empty() {
752                break;
753            }
754        }
755        current
756    }
757
758    fn step_from_node(
759        &self,
760        node_id: FormNodeId,
761        segment: &xfa_dom_resolver::som::SomSegment,
762        allow_self: bool,
763    ) -> Vec<FormNodeId> {
764        // XFA-F3-06: `..` (parent) navigation — a segment whose name is an
765        // empty string (produced by the `.` separator after `..` in the raw path)
766        // or literally ".." navigates to the parent node.
767        if let SomSelector::Name(name) = &segment.selector {
768            if name == ".." {
769                // Navigate to parent
770                if let Some(&parent_id) = self.parents.get(&node_id) {
771                    return apply_index_to_single(parent_id, segment.index);
772                }
773                return Vec::new();
774            }
775        }
776
777        if allow_self && self.node_matches_selector(node_id, &segment.selector) {
778            return apply_index_to_single(node_id, segment.index);
779        }
780
781        let matches: Vec<_> = self
782            .form
783            .get(node_id)
784            .children
785            .iter()
786            .copied()
787            .filter(|child_id| self.node_matches_selector(*child_id, &segment.selector))
788            .collect();
789
790        apply_index(matches, segment.index)
791    }
792
793    fn node_matches_segment(
794        &self,
795        node_id: FormNodeId,
796        segment: &xfa_dom_resolver::som::SomSegment,
797    ) -> bool {
798        if !self.node_matches_selector(node_id, &segment.selector) {
799            return false;
800        }
801
802        match segment.index {
803            SomIndex::All => true,
804            SomIndex::None => self.sibling_position(node_id, &segment.selector) == Some(0),
805            SomIndex::Specific(idx) => {
806                self.sibling_position(node_id, &segment.selector) == Some(idx)
807            }
808        }
809    }
810
811    fn sibling_position(&self, node_id: FormNodeId, selector: &SomSelector) -> Option<usize> {
812        let Some(parent_id) = self.parents.get(&node_id).copied() else {
813            return self.node_matches_selector(node_id, selector).then_some(0);
814        };
815
816        self.form
817            .get(parent_id)
818            .children
819            .iter()
820            .copied()
821            .filter(|candidate| self.node_matches_selector(*candidate, selector))
822            .position(|candidate| candidate == node_id)
823    }
824
825    fn node_matches_selector(&self, node_id: FormNodeId, selector: &SomSelector) -> bool {
826        match selector {
827            SomSelector::Name(name) => self.form.get(node_id).name == *name,
828            SomSelector::Class(class_name) => self.node_matches_class(node_id, class_name),
829            SomSelector::AllChildren => true,
830        }
831    }
832
833    fn node_matches_class(&self, node_id: FormNodeId, class_name: &str) -> bool {
834        let class_name = class_name.to_ascii_lowercase();
835        match class_name.as_str() {
836            "subform" => matches!(
837                self.form.get(node_id).node_type,
838                FormNodeType::Root | FormNodeType::Subform
839            ),
840            "pageset" => {
841                matches!(self.form.get(node_id).node_type, FormNodeType::PageSet)
842            }
843            "pagearea" => matches!(
844                self.form.get(node_id).node_type,
845                FormNodeType::PageArea { .. }
846            ),
847            "field" => matches!(self.form.get(node_id).node_type, FormNodeType::Field { .. }),
848            "draw" => matches!(
849                self.form.get(node_id).node_type,
850                FormNodeType::Draw(_) | FormNodeType::Image { .. }
851            ),
852            "exclgroup" => self.form.meta(node_id).group_kind == GroupKind::ExclusiveChoice,
853            _ => false,
854        }
855    }
856}
857
858impl SomResolver for FormTreeSomResolver<'_> {
859    fn resolve_path(
860        &mut self,
861        path: &str,
862    ) -> formcalc_interpreter::error::Result<Option<FormCalcValue>> {
863        let Some(target) = self.resolve_target(path) else {
864            // XFA-F3-06: log a warning instead of silently returning None so
865            // that SOM path failures are diagnosable.
866            if !path.trim().is_empty() {
867                log::warn!("SOM bridge: path not resolved: {:?}", path.trim());
868            }
869            return Ok(None);
870        };
871        Ok(Some(read_formcalc_value(
872            self.form,
873            self.root_id,
874            self.parents,
875            target,
876        )))
877    }
878
879    fn assign_path(
880        &mut self,
881        path: &str,
882        value: FormCalcValue,
883    ) -> formcalc_interpreter::error::Result<bool> {
884        let Some(target) = self.resolve_target(path) else {
885            // XFA-F3-06: descriptive warning on assignment failure.
886            if !path.trim().is_empty() {
887                log::warn!("SOM bridge: assignment target not found: {:?}", path.trim());
888            }
889            return Ok(false);
890        };
891        self.changes += write_formcalc_value(self.form, target.node_id, target.property, value);
892        Ok(true)
893    }
894
895    fn count_path_matches(&mut self, path: &str) -> formcalc_interpreter::error::Result<usize> {
896        Ok(self.count_targets(path))
897    }
898}
899
900fn split_property_path(path: &str) -> Option<(SomExpression, ResolvedProperty)> {
901    let normalized = if let Some(rest) = path.strip_prefix("this.") {
902        format!("$.{rest}")
903    } else if path == "this" {
904        "$".to_string()
905    } else {
906        path.to_string()
907    };
908
909    let mut expr = parse_som(&normalized).ok()?;
910    let property = if let Some(last) = expr.segments.last() {
911        match &last.selector {
912            SomSelector::Name(name) => {
913                parse_property_name(name).unwrap_or(ResolvedProperty::RawValue)
914            }
915            _ => ResolvedProperty::RawValue,
916        }
917    } else {
918        ResolvedProperty::RawValue
919    };
920
921    if matches!(
922        expr.segments.last().map(|segment| &segment.selector),
923        Some(SomSelector::Name(name)) if parse_property_name(name).is_some()
924    ) {
925        expr.segments.pop();
926    }
927
928    Some((expr, property))
929}
930
931fn parse_property_name(name: &str) -> Option<ResolvedProperty> {
932    match name {
933        "rawValue" => Some(ResolvedProperty::RawValue),
934        "presence" => Some(ResolvedProperty::Presence),
935        "somExpression" => Some(ResolvedProperty::SomExpression),
936        _ => None,
937    }
938}
939
940fn strip_xfa_form_prefix(
941    segments: &[xfa_dom_resolver::som::SomSegment],
942) -> &[xfa_dom_resolver::som::SomSegment] {
943    match segments.first() {
944        Some(segment)
945            if matches!(&segment.selector, SomSelector::Name(name) if name == "form")
946                && matches!(segment.index, SomIndex::None) =>
947        {
948            &segments[1..]
949        }
950        _ => segments,
951    }
952}
953
954fn apply_index(matches: Vec<FormNodeId>, index: SomIndex) -> Vec<FormNodeId> {
955    match index {
956        SomIndex::None => matches.into_iter().take(1).collect(),
957        SomIndex::Specific(idx) => matches.get(idx).copied().into_iter().collect(),
958        SomIndex::All => matches,
959    }
960}
961
962fn apply_index_to_single(node_id: FormNodeId, index: SomIndex) -> Vec<FormNodeId> {
963    match index {
964        SomIndex::None | SomIndex::Specific(0) | SomIndex::All => vec![node_id],
965        SomIndex::Specific(_) => Vec::new(),
966    }
967}
968
969fn read_formcalc_value(
970    form: &FormTree,
971    root_id: FormNodeId,
972    parents: &HashMap<FormNodeId, FormNodeId>,
973    target: ResolvedTarget,
974) -> FormCalcValue {
975    match target.property {
976        ResolvedProperty::RawValue => get_formcalc_raw_value(form, target.node_id),
977        ResolvedProperty::Presence => FormCalcValue::String(
978            match form.meta(target.node_id).presence {
979                Presence::Visible => "visible",
980                Presence::Hidden => "hidden",
981                Presence::Invisible => "invisible",
982                Presence::Inactive => "inactive",
983            }
984            .to_string(),
985        ),
986        ResolvedProperty::SomExpression => {
987            FormCalcValue::String(build_som_expression(form, root_id, parents, target.node_id))
988        }
989    }
990}
991
992fn get_formcalc_raw_value(form: &FormTree, node_id: FormNodeId) -> FormCalcValue {
993    match &form.get(node_id).node_type {
994        FormNodeType::Field { value } => string_to_formcalc_value(value),
995        _ if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice => {
996            for &child_id in &form.get(node_id).children {
997                if let FormNodeType::Field { value } = &form.get(child_id).node_type {
998                    if !value.is_empty() {
999                        let selected = form.meta(child_id).item_value.as_deref().unwrap_or(value);
1000                        return string_to_formcalc_value(selected);
1001                    }
1002                }
1003            }
1004            FormCalcValue::Null
1005        }
1006        _ => FormCalcValue::Null,
1007    }
1008}
1009
1010fn write_formcalc_value(
1011    form: &mut FormTree,
1012    node_id: FormNodeId,
1013    property: ResolvedProperty,
1014    value: FormCalcValue,
1015) -> usize {
1016    match property {
1017        ResolvedProperty::RawValue => set_raw_value(form, node_id, formcalc_to_script_value(value)),
1018        ResolvedProperty::Presence => {
1019            set_presence(form, node_id, ScriptValue::String(value.to_string_val()))
1020        }
1021        ResolvedProperty::SomExpression => 0,
1022    }
1023}
1024
1025fn string_to_formcalc_value(value: &str) -> FormCalcValue {
1026    let trimmed = value.trim();
1027    if trimmed.is_empty() {
1028        FormCalcValue::Null
1029    } else if let Ok(number) = trimmed.parse::<f64>() {
1030        FormCalcValue::Number(number)
1031    } else {
1032        FormCalcValue::String(value.to_string())
1033    }
1034}
1035
1036fn formcalc_to_script_value(value: FormCalcValue) -> ScriptValue {
1037    match value {
1038        FormCalcValue::Null => ScriptValue::Null,
1039        FormCalcValue::Number(number) => ScriptValue::String(normalize_number(number)),
1040        FormCalcValue::String(value) => ScriptValue::String(value),
1041    }
1042}
1043
1044fn build_som_expression(
1045    form: &FormTree,
1046    root_id: FormNodeId,
1047    parents: &HashMap<FormNodeId, FormNodeId>,
1048    node_id: FormNodeId,
1049) -> String {
1050    let mut parts = Vec::new();
1051    let mut cursor = Some(node_id);
1052    while let Some(current) = cursor {
1053        let node = form.get(current);
1054        if !node.name.is_empty() {
1055            let index = if let Some(parent_id) = parents.get(&current).copied() {
1056                form.get(parent_id)
1057                    .children
1058                    .iter()
1059                    .copied()
1060                    .filter(|sibling_id| form.get(*sibling_id).name == node.name)
1061                    .position(|sibling_id| sibling_id == current)
1062                    .unwrap_or(0)
1063            } else {
1064                0
1065            };
1066            parts.push(format!("{}[{index}]", node.name));
1067        }
1068        if current == root_id {
1069            break;
1070        }
1071        cursor = parents.get(&current).copied();
1072    }
1073    parts.reverse();
1074
1075    if parts.is_empty() {
1076        "$form".to_string()
1077    } else {
1078        format!("$form.{}", parts.join("."))
1079    }
1080}
1081
1082fn descendants_inclusive(form: &FormTree, root_id: FormNodeId) -> Vec<FormNodeId> {
1083    let mut out = Vec::new();
1084    collect_descendants(form, root_id, &mut out);
1085    out
1086}
1087
1088fn collect_descendants(form: &FormTree, node_id: FormNodeId, out: &mut Vec<FormNodeId>) {
1089    out.push(node_id);
1090    for &child_id in &form.get(node_id).children {
1091        collect_descendants(form, child_id, out);
1092    }
1093}
1094
1095fn build_parent_map(form: &FormTree, root_id: FormNodeId) -> HashMap<FormNodeId, FormNodeId> {
1096    let mut parents = HashMap::new();
1097    populate_parent_map(form, root_id, &mut parents);
1098    parents
1099}
1100
1101fn populate_parent_map(
1102    form: &FormTree,
1103    node_id: FormNodeId,
1104    parents: &mut HashMap<FormNodeId, FormNodeId>,
1105) {
1106    for &child_id in &form.get(node_id).children {
1107        parents.insert(child_id, node_id);
1108        populate_parent_map(form, child_id, parents);
1109    }
1110}
1111
1112fn set_raw_value(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
1113    let value = match value {
1114        ScriptValue::Null => String::new(),
1115        ScriptValue::String(value) => value,
1116    };
1117
1118    if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice {
1119        let mut changes = 0;
1120        for &child_id in &form.get(node_id).children.clone() {
1121            let item_value = form.meta(child_id).item_value.clone();
1122            let next = if item_value.as_deref() == Some(value.as_str()) {
1123                value.clone()
1124            } else {
1125                String::new()
1126            };
1127            if let FormNodeType::Field { value: field_value } =
1128                &mut form.get_mut(child_id).node_type
1129            {
1130                if *field_value != next {
1131                    *field_value = next;
1132                    changes += 1;
1133                }
1134            }
1135        }
1136        return changes;
1137    }
1138
1139    if let FormNodeType::Field { value: field_value } = &mut form.get_mut(node_id).node_type {
1140        if *field_value != value {
1141            *field_value = value;
1142            return 1;
1143        }
1144    }
1145
1146    0
1147}
1148
1149fn set_presence(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
1150    let value = match value {
1151        ScriptValue::Null => return 0,
1152        ScriptValue::String(value) => value,
1153    };
1154    let normalized = value.trim().to_ascii_lowercase();
1155    let new_presence = match normalized.as_str() {
1156        "visible" | "open" => Presence::Visible,
1157        "hidden" => Presence::Hidden,
1158        "invisible" => Presence::Invisible,
1159        "inactive" => Presence::Inactive,
1160        _ => return 0,
1161    };
1162
1163    let meta = form.meta_mut(node_id);
1164    if meta.presence == new_presence {
1165        return 0;
1166    }
1167    meta.presence = new_presence;
1168    1
1169}
1170
1171fn normalize_number(number: f64) -> String {
1172    if number.fract().abs() < f64::EPSILON {
1173        (number as i64).to_string()
1174    } else {
1175        number.to_string()
1176    }
1177}
1178
1179#[derive(Debug, Clone, PartialEq, Eq)]
1180enum ScriptValue {
1181    Null,
1182    String(String),
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187    use super::*;
1188    use xfa_layout_engine::form::{
1189        FieldKind, FormNode, FormNodeMeta, FormNodeStyle, GroupKind, Occur,
1190    };
1191    use xfa_layout_engine::text::FontMetrics;
1192    use xfa_layout_engine::types::{BoxModel, LayoutStrategy};
1193
1194    fn add_node(tree: &mut FormTree, name: &str, node_type: FormNodeType) -> FormNodeId {
1195        tree.add_node(FormNode {
1196            name: name.to_string(),
1197            node_type,
1198            box_model: BoxModel::default(),
1199            layout: LayoutStrategy::TopToBottom,
1200            children: Vec::new(),
1201            occur: Occur::once(),
1202            font: FontMetrics::default(),
1203            calculate: None,
1204            validate: None,
1205            column_widths: Vec::new(),
1206            col_span: 1,
1207        })
1208    }
1209
1210    fn empty_meta() -> FormNodeMeta {
1211        FormNodeMeta {
1212            field_kind: FieldKind::Text,
1213            group_kind: GroupKind::None,
1214            style: FormNodeStyle::default(),
1215            ..Default::default()
1216        }
1217    }
1218
1219    fn formcalc_script(script: &str, activity: &str) -> EventScript {
1220        EventScript::formcalc(script, Some(activity))
1221    }
1222
1223    fn javascript_script(script: &str, activity: &str) -> EventScript {
1224        EventScript::javascript(script, Some(activity))
1225    }
1226
1227    fn other_script(script: &str, activity: &str) -> EventScript {
1228        EventScript::new(
1229            script.to_string(),
1230            ScriptLanguage::Other,
1231            Some(activity.to_string()),
1232            None,
1233            None,
1234        )
1235    }
1236
1237    fn script_policy_fixture(include_js: bool) -> (FormTree, FormNodeId, FormNodeId) {
1238        let mut tree = FormTree::new();
1239        let root = add_node(&mut tree, "root", FormNodeType::Root);
1240        let js_hook = add_node(
1241            &mut tree,
1242            "JsHook",
1243            FormNodeType::Field {
1244                value: String::new(),
1245            },
1246        );
1247        let runner = add_node(
1248            &mut tree,
1249            "Runner",
1250            FormNodeType::Field {
1251                value: String::new(),
1252            },
1253        );
1254        let target = add_node(
1255            &mut tree,
1256            "Target",
1257            FormNodeType::Field {
1258                value: String::new(),
1259            },
1260        );
1261
1262        tree.get_mut(root).children = vec![js_hook, runner, target];
1263        if include_js {
1264            tree.meta_mut(js_hook).event_scripts = vec![javascript_script(
1265                "xfa.host.messageBox('skip');",
1266                "initialize",
1267            )];
1268        }
1269        tree.meta_mut(runner).event_scripts =
1270            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];
1271
1272        (tree, root, target)
1273    }
1274
1275    fn other_language_policy_fixture() -> (FormTree, FormNodeId, FormNodeId) {
1276        let mut tree = FormTree::new();
1277        let root = add_node(&mut tree, "root", FormNodeType::Root);
1278        let other = add_node(&mut tree, "OtherHook", FormNodeType::Subform);
1279        let runner = add_node(&mut tree, "Runner", FormNodeType::Subform);
1280        let target = add_node(
1281            &mut tree,
1282            "Target",
1283            FormNodeType::Field {
1284                value: String::new(),
1285            },
1286        );
1287
1288        tree.get_mut(root).children = vec![other, runner, target];
1289        tree.meta_mut(other).event_scripts = vec![other_script("MsgBox \"skip\"", "initialize")];
1290        tree.meta_mut(runner).event_scripts =
1291            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];
1292
1293        (tree, root, target)
1294    }
1295
1296    fn field_value(tree: &FormTree, node_id: FormNodeId) -> &str {
1297        match &tree.get(node_id).node_type {
1298            FormNodeType::Field { value } => value,
1299            _ => panic!("expected field"),
1300        }
1301    }
1302
1303    #[test]
1304    fn change_event_toggles_relative_hidden_subform() {
1305        let mut tree = FormTree::new();
1306        let root = add_node(&mut tree, "root", FormNodeType::Root);
1307        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
1308        let group = add_node(&mut tree, "Choice", FormNodeType::Subform);
1309        let option1 = add_node(
1310            &mut tree,
1311            "Option1",
1312            FormNodeType::Field {
1313                value: "1".to_string(),
1314            },
1315        );
1316        let option2 = add_node(
1317            &mut tree,
1318            "Option2",
1319            FormNodeType::Field {
1320                value: String::new(),
1321            },
1322        );
1323        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
1324
1325        tree.get_mut(root).children = vec![section];
1326        tree.get_mut(section).children = vec![group, details];
1327        tree.get_mut(group).children = vec![option1, option2];
1328
1329        tree.meta_mut(group).group_kind = GroupKind::ExclusiveChoice;
1330        tree.meta_mut(group).event_scripts = vec![formcalc_script(
1331            r#"
1332Details.presence = "hidden"
1333if (this.rawValue == 1) then
1334  Details.presence = "visible"
1335endif
1336"#,
1337            "initialize",
1338        )];
1339        tree.meta_mut(option1).item_value = Some("1".into());
1340        tree.meta_mut(option2).item_value = Some("2".into());
1341        tree.meta_mut(details).presence = Presence::Hidden;
1342
1343        apply_dynamic_scripts(&mut tree, root).unwrap();
1344
1345        assert_eq!(tree.meta(details).presence, Presence::Visible);
1346    }
1347
1348    #[test]
1349    fn calculate_script_on_hidden_block_uses_sibling_values() {
1350        let mut tree = FormTree::new();
1351        let root = add_node(&mut tree, "root", FormNodeType::Root);
1352        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
1353        let option1 = add_node(
1354            &mut tree,
1355            "Opt1",
1356            FormNodeType::Field {
1357                value: "1".to_string(),
1358            },
1359        );
1360        let option2 = add_node(
1361            &mut tree,
1362            "Opt2",
1363            FormNodeType::Field {
1364                value: String::new(),
1365            },
1366        );
1367        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
1368
1369        tree.get_mut(root).children = vec![section];
1370        tree.get_mut(section).children = vec![option1, option2, details];
1371        tree.meta_mut(details).presence = Presence::Hidden;
1372        tree.meta_mut(details).event_scripts = vec![formcalc_script(
1373            r#"
1374this.presence = "hidden"
1375if ((Opt1.rawValue == 1) or (Opt2.rawValue == 1)) then
1376  this.presence = "visible"
1377endif
1378"#,
1379            "calculate",
1380        )];
1381
1382        apply_dynamic_scripts(&mut tree, root).unwrap();
1383
1384        assert_eq!(tree.meta(details).presence, Presence::Visible);
1385    }
1386
1387    #[test]
1388    fn multi_pass_scripts_propagate_raw_values() {
1389        let mut tree = FormTree::new();
1390        let root = add_node(&mut tree, "root", FormNodeType::Root);
1391        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
1392        let controller = add_node(
1393            &mut tree,
1394            "Controller",
1395            FormNodeType::Field {
1396                value: "1".to_string(),
1397            },
1398        );
1399        let target = add_node(
1400            &mut tree,
1401            "Target",
1402            FormNodeType::Field {
1403                value: String::new(),
1404            },
1405        );
1406        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
1407
1408        tree.get_mut(root).children = vec![section];
1409        tree.get_mut(section).children = vec![controller, target, details];
1410
1411        tree.meta_mut(controller).event_scripts = vec![formcalc_script(
1412            r#"
1413if (this.rawValue == 1) then
1414  Target.rawValue = 1
1415endif
1416"#,
1417            "calculate",
1418        )];
1419        tree.meta_mut(details).presence = Presence::Hidden;
1420        tree.meta_mut(details).event_scripts = vec![formcalc_script(
1421            r#"
1422this.presence = "hidden"
1423if (Target.rawValue == 1) then
1424  this.presence = "visible"
1425endif
1426"#,
1427            "calculate",
1428        )];
1429
1430        apply_dynamic_scripts(&mut tree, root).unwrap();
1431
1432        if let FormNodeType::Field { value } = &tree.get(target).node_type {
1433            assert_eq!(value, "1");
1434        } else {
1435            panic!("expected field");
1436        }
1437        assert_eq!(tree.meta(details).presence, Presence::Visible);
1438    }
1439
1440    // ─── #1097: FormCalc SOM bridge hardening ────────────────────────────────
1441
1442    /// SOM path `form1.#subform[0].field1.rawValue` resolves correctly on a
1443    /// simple form tree.
1444    #[test]
1445    fn som_path_resolves_on_simple_form_tree() {
1446        let mut tree = FormTree::new();
1447        let root = add_node(&mut tree, "root", FormNodeType::Root);
1448        let form1 = add_node(&mut tree, "form1", FormNodeType::Subform);
1449        let subform = add_node(&mut tree, "subform1", FormNodeType::Subform);
1450        let field1 = add_node(
1451            &mut tree,
1452            "field1",
1453            FormNodeType::Field {
1454                value: "hello".to_string(),
1455            },
1456        );
1457
1458        tree.get_mut(root).children = vec![form1];
1459        tree.get_mut(form1).children = vec![subform];
1460        tree.get_mut(subform).children = vec![field1];
1461
1462        // Use a calculate script to read the value via absolute SOM path
1463        tree.meta_mut(root).event_scripts = vec![formcalc_script(
1464            "form1.subform1.field1.rawValue",
1465            "calculate",
1466        )];
1467
1468        let parents = super::build_parent_map(&tree, root);
1469        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);
1470        let target = resolver.resolve_target("form1.subform1.field1.rawValue");
1471        assert!(target.is_some(), "SOM path must resolve to a node");
1472        let target = target.unwrap();
1473        let val = super::read_formcalc_value(&tree, root, &parents, target);
1474        match val {
1475            formcalc_interpreter::value::Value::String(s) => assert_eq!(s, "hello"),
1476            formcalc_interpreter::value::Value::Number(n) => {
1477                // number coercion: not expected here
1478                panic!("expected string, got number {n}")
1479            }
1480            _ => panic!("expected string value"),
1481        }
1482    }
1483
1484    /// An invalid SOM path returns `None` (descriptive non-panic failure).
1485    #[test]
1486    fn invalid_som_path_returns_none_not_panic() {
1487        let mut tree = FormTree::new();
1488        let root = add_node(&mut tree, "root", FormNodeType::Root);
1489        let parents = super::build_parent_map(&tree, root);
1490        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);
1491
1492        // This should not panic — it should return None
1493        let result = resolver.resolve_target("nonexistent.deep.path.rawValue");
1494        assert!(
1495            result.is_none(),
1496            "invalid SOM path must return None, not panic"
1497        );
1498    }
1499
1500    #[test]
1501    fn best_effort_skips_javascript_and_runs_formcalc() {
1502        let (mut tree, root, target) = script_policy_fixture(true);
1503
1504        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
1505
1506        assert_eq!(field_value(&tree, target), "ran");
1507        assert!(outcome.js_present);
1508        assert_eq!(outcome.js_skipped, 1);
1509        assert_eq!(outcome.other_skipped, 0);
1510        assert_eq!(outcome.formcalc_run, 1);
1511        assert_eq!(outcome.formcalc_errors, 0);
1512        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);
1513    }
1514
1515    #[test]
1516    fn strict_mode_preserves_javascript_reject() {
1517        let (mut tree, root, target) = script_policy_fixture(true);
1518
1519        let err =
1520            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
1521
1522        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
1523        assert_eq!(field_value(&tree, target), "");
1524    }
1525
1526    #[test]
1527    fn formcalc_only_reports_exact_quality() {
1528        let (mut tree, root, target) = script_policy_fixture(false);
1529
1530        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
1531
1532        assert_eq!(field_value(&tree, target), "ran");
1533        assert!(!outcome.js_present);
1534        assert_eq!(outcome.js_skipped, 0);
1535        assert_eq!(outcome.other_skipped, 0);
1536        assert_eq!(outcome.formcalc_run, 1);
1537        assert_eq!(outcome.formcalc_errors, 0);
1538        assert_eq!(outcome.output_quality, OutputQuality::Exact);
1539    }
1540
1541    #[test]
1542    fn other_language_scripts_skip_in_best_effort_and_reject_in_strict() {
1543        let (mut tree, root, target) = other_language_policy_fixture();
1544        let (mut strict_tree, strict_root, _) = other_language_policy_fixture();
1545
1546        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
1547        assert_eq!(field_value(&tree, target), "ran");
1548        assert_eq!(outcome.js_skipped, 0);
1549        assert_eq!(outcome.other_skipped, 1);
1550        assert_eq!(outcome.formcalc_run, 1);
1551        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);
1552
1553        let err =
1554            apply_dynamic_scripts_with_mode(&mut strict_tree, strict_root, JsExecutionMode::Strict)
1555                .unwrap_err();
1556        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
1557    }
1558
1559    #[test]
1560    fn javascript_direct_executor_call_is_still_denied() {
1561        let mut tree = FormTree::new();
1562        let root = add_node(&mut tree, "root", FormNodeType::Root);
1563        let trigger = add_node(
1564            &mut tree,
1565            "Trigger",
1566            FormNodeType::Field {
1567                value: String::new(),
1568            },
1569        );
1570        tree.get_mut(root).children = vec![trigger];
1571
1572        let parents = build_parent_map(&tree, root);
1573        let script = javascript_script("xfa.host.messageBox('deny');", "initialize");
1574        let err = execute_event_script(
1575            &mut tree,
1576            root,
1577            &parents,
1578            trigger,
1579            &script,
1580            ScriptPhase::Initialize,
1581        )
1582        .unwrap_err();
1583
1584        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
1585    }
1586
1587    #[test]
1588    fn javascript_resolve_node_call_is_explicitly_denied() {
1589        let mut tree = FormTree::new();
1590        let root = add_node(&mut tree, "root", FormNodeType::Root);
1591        let form = add_node(&mut tree, "formulier1", FormNodeType::Subform);
1592        let admin = add_node(&mut tree, "ADMIN", FormNodeType::Subform);
1593        let lock = add_node(
1594            &mut tree,
1595            "LockForm_AD",
1596            FormNodeType::Field {
1597                value: "1".to_string(),
1598            },
1599        );
1600        let reset = add_node(
1601            &mut tree,
1602            "Reset",
1603            FormNodeType::Field {
1604                value: "1".to_string(),
1605            },
1606        );
1607
1608        tree.get_mut(root).children = vec![form];
1609        tree.get_mut(form).children = vec![admin, reset];
1610        tree.get_mut(admin).children = vec![lock];
1611        tree.meta_mut(reset).event_scripts = vec![javascript_script(
1612            r#"xfa.resolveNode("formulier1.ADMIN.LockForm_AD").rawValue = 0;"#,
1613            "initialize",
1614        )];
1615
1616        let err =
1617            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
1618        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
1619
1620        if let FormNodeType::Field { value } = &tree.get(lock).node_type {
1621            assert_eq!(value, "1");
1622        } else {
1623            panic!("expected field");
1624        }
1625    }
1626
1627    #[test]
1628    fn javascript_utils_hide_if_empty_is_explicitly_denied() {
1629        let mut tree = FormTree::new();
1630        let root = add_node(&mut tree, "root", FormNodeType::Root);
1631        let empty = add_node(
1632            &mut tree,
1633            "EmptyField",
1634            FormNodeType::Field {
1635                value: String::new(),
1636            },
1637        );
1638        tree.get_mut(root).children = vec![empty];
1639        tree.meta_mut(empty).event_scripts =
1640            vec![javascript_script("Utils.hideIfEmpty(this);", "initialize")];
1641
1642        let err =
1643            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
1644        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
1645
1646        assert!(!tree.meta(empty).presence.is_not_visible());
1647    }
1648
1649    #[test]
1650    fn malformed_javascript_payload_is_explicitly_denied_without_panic() {
1651        let mut tree = FormTree::new();
1652        let root = add_node(&mut tree, "root", FormNodeType::Root);
1653        let container = add_node(&mut tree, "Container", FormNodeType::Subform);
1654        let empty = add_node(
1655            &mut tree,
1656            "EmptyField",
1657            FormNodeType::Field {
1658                value: String::new(),
1659            },
1660        );
1661
1662        tree.get_mut(root).children = vec![container];
1663        tree.get_mut(container).children = vec![empty];
1664        tree.meta_mut(empty).event_scripts = vec![javascript_script(
1665            "\0}{{not.valid.javascript(",
1666            "initialize",
1667        )];
1668
1669        let err =
1670            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
1671        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
1672
1673        assert!(!tree.meta(container).presence.is_not_visible());
1674    }
1675
1676    #[test]
1677    fn default_meta_helper_is_constructible() {
1678        let meta = empty_meta();
1679        assert_eq!(meta.group_kind, GroupKind::None);
1680    }
1681
1682    #[test]
1683    fn calculate_event_applies_formcalc_return_value() {
1684        let mut tree = FormTree::new();
1685        let root = add_node(&mut tree, "root", FormNodeType::Root);
1686        let total = add_node(
1687            &mut tree,
1688            "Total",
1689            FormNodeType::Field {
1690                value: String::new(),
1691            },
1692        );
1693
1694        tree.get_mut(root).children = vec![total];
1695        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];
1696
1697        apply_dynamic_scripts(&mut tree, root).unwrap();
1698
1699        match &tree.get(total).node_type {
1700            FormNodeType::Field { value } => assert_eq!(value, "42"),
1701            _ => panic!("expected field"),
1702        }
1703    }
1704
1705    #[test]
1706    fn calculate_event_resolves_bare_field_names_as_raw_values() {
1707        let mut tree = FormTree::new();
1708        let root = add_node(&mut tree, "root", FormNodeType::Root);
1709        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
1710        let number1 = add_node(
1711            &mut tree,
1712            "Number1",
1713            FormNodeType::Field {
1714                value: "40".to_string(),
1715            },
1716        );
1717        let number2 = add_node(
1718            &mut tree,
1719            "Number2",
1720            FormNodeType::Field {
1721                value: "2".to_string(),
1722            },
1723        );
1724        let total = add_node(
1725            &mut tree,
1726            "Total",
1727            FormNodeType::Field {
1728                value: String::new(),
1729            },
1730        );
1731
1732        tree.get_mut(root).children = vec![section];
1733        tree.get_mut(section).children = vec![number1, number2, total];
1734        tree.meta_mut(total).event_scripts =
1735            vec![formcalc_script("Number1 + Number2", "calculate")];
1736
1737        apply_dynamic_scripts(&mut tree, root).unwrap();
1738
1739        match &tree.get(total).node_type {
1740            FormNodeType::Field { value } => assert_eq!(value, "42"),
1741            _ => panic!("expected field"),
1742        }
1743    }
1744
1745    #[test]
1746    fn click_events_are_skipped_during_flatten() {
1747        let mut tree = FormTree::new();
1748        let root = add_node(&mut tree, "root", FormNodeType::Root);
1749        let trigger = add_node(
1750            &mut tree,
1751            "Trigger",
1752            FormNodeType::Field {
1753                value: "1".to_string(),
1754            },
1755        );
1756        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
1757
1758        tree.get_mut(root).children = vec![trigger, details];
1759        tree.meta_mut(details).presence = Presence::Hidden;
1760        tree.meta_mut(trigger).event_scripts = vec![formcalc_script(
1761            r#"
1762Details.presence = "visible"
1763"#,
1764            "click",
1765        )];
1766
1767        apply_dynamic_scripts(&mut tree, root).unwrap();
1768
1769        assert_eq!(tree.meta(details).presence, Presence::Hidden);
1770    }
1771
1772    #[test]
1773    fn rollback_when_scripts_mostly_error() {
1774        // Set up a form with fields that have values.  Attach scripts that
1775        // will fail to parse so that errors > successes.  After
1776        // apply_dynamic_scripts the field values must be unchanged.
1777        let mut tree = FormTree::new();
1778        let root = add_node(&mut tree, "root", FormNodeType::Root);
1779        let field_a = add_node(
1780            &mut tree,
1781            "FieldA",
1782            FormNodeType::Field {
1783                value: "hello".to_string(),
1784            },
1785        );
1786        let field_b = add_node(
1787            &mut tree,
1788            "FieldB",
1789            FormNodeType::Field {
1790                value: "world".to_string(),
1791            },
1792        );
1793
1794        tree.get_mut(root).children = vec![field_a, field_b];
1795
1796        // Two scripts that fail parsing (invalid FormCalc), zero successes.
1797        tree.meta_mut(field_a).event_scripts = vec![formcalc_script("@@INVALID@@", "initialize")];
1798        tree.meta_mut(field_b).event_scripts =
1799            vec![formcalc_script("@@ALSO_BROKEN@@", "initialize")];
1800
1801        apply_dynamic_scripts(&mut tree, root).unwrap();
1802
1803        // Fields should retain their original values (rollback).
1804        match &tree.get(field_a).node_type {
1805            FormNodeType::Field { value } => assert_eq!(value, "hello"),
1806            _ => panic!("expected field"),
1807        }
1808        match &tree.get(field_b).node_type {
1809            FormNodeType::Field { value } => assert_eq!(value, "world"),
1810            _ => panic!("expected field"),
1811        }
1812    }
1813
1814    #[test]
1815    fn rollback_when_populated_fields_go_empty() {
1816        // Calculate scripts returning Null clear field values. When >50%
1817        // of populated fields go empty, the rollback heuristic fires.
1818        let mut tree = FormTree::new();
1819        let root = add_node(&mut tree, "root", FormNodeType::Root);
1820        let field_a = add_node(
1821            &mut tree,
1822            "FieldA",
1823            FormNodeType::Field {
1824                value: "keep".to_string(),
1825            },
1826        );
1827        let field_b = add_node(
1828            &mut tree,
1829            "FieldB",
1830            FormNodeType::Field {
1831                value: "also_keep".to_string(),
1832            },
1833        );
1834
1835        tree.get_mut(root).children = vec![field_a, field_b];
1836
1837        // Calculate scripts whose return value (Null) is written to the field,
1838        // blanking it.  The expression `Null()` is not a real FormCalc builtin,
1839        // but `0` would set the field to "0" (not empty).  Instead we use the
1840        // snapshot/rollback logic directly.
1841        // We test the heuristic by manually setting up the condition.
1842        let snapshot = super::snapshot_form(&tree);
1843
1844        // Simulate scripts clearing both fields.
1845        if let FormNodeType::Field { value } = &mut tree.get_mut(field_a).node_type {
1846            *value = String::new();
1847        }
1848        if let FormNodeType::Field { value } = &mut tree.get_mut(field_b).node_type {
1849            *value = String::new();
1850        }
1851
1852        assert!(super::should_rollback(&tree, &snapshot, 0, 2));
1853
1854        super::restore_snapshot(&mut tree, &snapshot);
1855
1856        match &tree.get(field_a).node_type {
1857            FormNodeType::Field { value } => assert_eq!(value, "keep"),
1858            _ => panic!("expected field"),
1859        }
1860        match &tree.get(field_b).node_type {
1861            FormNodeType::Field { value } => assert_eq!(value, "also_keep"),
1862            _ => panic!("expected field"),
1863        }
1864    }
1865
1866    #[test]
1867    fn no_rollback_when_scripts_succeed() {
1868        // A single working script with no errors → no rollback, change persists.
1869        let mut tree = FormTree::new();
1870        let root = add_node(&mut tree, "root", FormNodeType::Root);
1871        let total = add_node(
1872            &mut tree,
1873            "Total",
1874            FormNodeType::Field {
1875                value: String::new(),
1876            },
1877        );
1878
1879        tree.get_mut(root).children = vec![total];
1880        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];
1881
1882        apply_dynamic_scripts(&mut tree, root).unwrap();
1883
1884        match &tree.get(total).node_type {
1885            FormNodeType::Field { value } => assert_eq!(value, "42"),
1886            _ => panic!("expected field"),
1887        }
1888    }
1889}