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_with_gate, presave_during_flatten_enabled, NullRuntime,
7    RuntimeMetadata, SandboxError, XfaJsRuntime,
8};
9use formcalc_interpreter::{
10    interpreter::Interpreter, lexer::tokenize, parser, som_bridge::SomResolver,
11    value::Value as FormCalcValue,
12};
13use xfa_dom_resolver::som::{parse_som, SomExpression, SomIndex, SomRoot, SomSelector};
14use xfa_layout_engine::form::{
15    EventScript, FormNodeId, FormNodeType, FormTree, GroupKind, Presence, ScriptLanguage,
16};
17
18// XFA Spec 3.3 §9.3 — Dynamic Forms Re-Layout: after script execution the
19// layout processor must re-run layout.  The spec does not prescribe a fixed
20// pass limit; Adobe typically converges in 2-3 passes.  Our limit of 3 is a
21// pragmatic cap that matches observed Adobe behavior.
22const MAX_SCRIPT_PASSES: usize = 3;
23
24/// Controls only the pre-flight handling of parsed JavaScript-bearing XFA
25/// event hooks. JavaScript execution remains denied by `javascript_policy`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum JsExecutionMode {
28    /// Abort before script execution when any JavaScript or unsupported script
29    /// language is present. This preserves the original policy-gate behavior.
30    Strict,
31    /// Skip JavaScript and unsupported-language scripts, then continue running
32    /// FormCalc and the layout pipeline. Skipped scripts are reported in the
33    /// returned outcome.
34    #[default]
35    BestEffortStatic,
36    /// **M3-B Phase B opt-in.** Route JavaScript scripts through the sandboxed
37    /// runtime adapter (`crate::js_runtime`). Requires the Cargo feature
38    /// `xfa-js-sandboxed` to be compiled in for any script to actually
39    /// execute; without the feature the runtime returns
40    /// [`SandboxError::NotCompiledIn`] and the dispatch path falls back to
41    /// the same skip behaviour as [`Self::BestEffortStatic`] while incrementing
42    /// `js_runtime_errors` so callers can observe the dead-code state.
43    /// Phase B registers no host bindings; see
44    /// `benchmarks/runs/M3B_HOST_BINDINGS_MINIMUM_SET.md` for the Phase C
45    /// roadmap.
46    SandboxedRuntime,
47}
48/// Fidelity level of the flattened output relative to the source XFA data.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub enum OutputQuality {
51    /// All data was bound and rendered without skipping any scripts or content.
52    #[default]
53    Exact,
54    /// Some scripts were skipped (e.g. JavaScript with `BestEffortStatic` mode);
55    /// output may differ from a full Adobe Reader render.
56    BestEffort,
57    /// All JavaScript scripts on the document executed inside the sandbox
58    /// without runtime / timeout / OOM errors (requires `xfa-js-sandboxed` feature).
59    Sandboxed,
60}
61
62impl OutputQuality {
63    /// Return a short lowercase string label suitable for logging and metrics.
64    pub fn as_str(self) -> &'static str {
65        match self {
66            Self::Exact => "exact",
67            Self::BestEffort => "best_effort",
68            Self::Sandboxed => "sandboxed",
69        }
70    }
71}
72/// Aggregate outcome of the dynamic script processing pass.
73///
74/// Returned by [`flatten_xfa_to_pdf_with_metadata`](crate::flatten_xfa_to_pdf_with_metadata)
75/// and embedded in [`FlattenMetadata`](crate::FlattenMetadata).
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct DynamicScriptOutcome {
78    /// Number of form field values that were mutated by scripts.
79    pub changes: usize,
80    /// True when the document contains at least one JavaScript event hook.
81    pub js_present: bool,
82    /// Number of JavaScript scripts that were skipped (not executed).
83    pub js_skipped: usize,
84    /// Number of scripts in unsupported languages (not FormCalc, not JavaScript) skipped.
85    pub other_skipped: usize,
86    /// Number of FormCalc scripts that ran successfully.
87    pub formcalc_run: usize,
88    /// Number of FormCalc scripts that produced an error.
89    pub formcalc_errors: usize,
90    /// Overall output quality after script processing.
91    pub output_quality: OutputQuality,
92    /// **M3-B Phase B.** Scripts that ran to completion in the sandboxed
93    /// runtime. Always 0 when mode != [`JsExecutionMode::SandboxedRuntime`]
94    /// or when the `xfa-js-sandboxed` feature is not compiled in.
95    pub js_executed: usize,
96    /// **M3-B Phase B.** Sandbox errors that did not fall under timeout / OOM
97    /// (parse error, throw, missing host binding in Phase B, FFI panic). The
98    /// dispatch path treats these as a script skip; the parent flatten never
99    /// aborts because of them (S-17 fail-open).
100    pub js_runtime_errors: usize,
101    /// **M3-B Phase B.** Per-script time-budget exhaustions.
102    pub js_timeouts: usize,
103    /// **M3-B Phase B.** Per-document memory-budget exhaustions.
104    pub js_oom: usize,
105    /// **M3-B Phase C.** Host-binding invocations.
106    pub js_host_calls: usize,
107    /// **M3-B Phase C.** Successful host-side `field.rawValue` writes.
108    pub js_mutations: usize,
109    /// **M3-B Phase D.** Successful host-side instanceManager writes.
110    pub js_instance_writes: usize,
111    /// **M3-B Phase D-β.** Successful host-side listbox clearItems / addItem writes.
112    pub js_list_writes: usize,
113    /// **M3-B Phase C.** Binding-level failures.
114    pub js_binding_errors: usize,
115    /// **M3-B Phase C.** SOM resolution misses / failures.
116    pub js_resolve_failures: usize,
117    /// **M3-B Phase D-γ.** Successful DataDom reads (children / value / child-by-name).
118    pub js_data_reads: usize,
119    /// **M3-B Phase E (XFA-JS-HOST-STUBS).** Scripts touched a host capability
120    /// that requires real viewer / user interaction (UI dialogs, signature,
121    /// submit, openList, beep, ...). The stub returned a deterministic safe
122    /// default so the script kept running; this counter records how often
123    /// such a touch happened so callers can distinguish "would-have-been
124    /// interactive" from genuine runtime errors.
125    pub js_unsupported_host_calls: usize,
126    /// **M3-B Phase D-θ.2.** Strict probe calls skipped because
127    /// `parentIds.length == 1 && chain.length == 1` (no same-name
128    /// sibling ambiguity possible). Each skipped call saves one
129    /// `resolveWithFullChainStrict` host round-trip.
130    pub js_probe_skips: usize,
131    /// **D3 (trace-only).** `<variables>` `<script>` objects collected.
132    pub variables_scripts_collected: usize,
133    /// **D3 (trace-only).** `<variables>` `<text>` data items collected.
134    pub variables_data_items_collected: usize,
135    /// **D3 (trace-only).** Script objects / data items whose registration
136    /// bound a namespace (JS-side `setVariables*` returned success).
137    pub script_objects_registered: usize,
138    /// **D3 (trace-only).** Script objects / data items that did NOT register
139    /// (Rust skip or JS-side eval failure). Observability only.
140    pub script_objects_register_failed: usize,
141    /// **D3 (trace-only).** Script objects collected under a nested subform
142    /// scope (registered to `subformVariables` only — not bare-ident visible).
143    pub script_objects_subform_scoped: usize,
144    /// **D4.** Total SOM lookups at the host resolve boundary.
145    pub som_lookups_total: usize,
146    /// **D4.** SOM lookups that resolved.
147    pub som_lookup_successes: usize,
148    /// **D4.** SOM lookups that returned NoMatch.
149    pub som_lookup_failures: usize,
150    /// **D4.** Subform-script names withheld for ambiguity (fail-closed).
151    pub som_lookup_ambiguous: usize,
152    /// **D4.** Subform-scoped script objects exposed to bare-identifier lookup.
153    pub som_subform_scripts_exposed: usize,
154    /// **D4 (trace-only).** `occur`-path SOM references (classified, not resolved).
155    pub som_occur_path_refs: usize,
156    /// **D5.** `node.occur` handle accesses (successes + failures).
157    pub occur_lookups_total: usize,
158    /// **D5.** `node.occur` accesses where the node handle was live.
159    pub occur_lookup_successes: usize,
160    /// **D5.** `node.occur` accesses where the node handle was not live.
161    pub occur_lookup_failures: usize,
162    /// **D5.** Reads of an occur property (`min`/`max`/`initial`).
163    pub occur_property_reads: usize,
164    /// **D5.** Writes to an occur property (captured, not applied).
165    pub occur_property_writes: usize,
166    /// **D5.** Writes specifically to `occur.min`.
167    pub occur_min_writes: usize,
168    /// **D5.** Writes specifically to `occur.max`.
169    pub occur_max_writes: usize,
170    /// **D5.** Occur mutations captured as intent (no layout effect).
171    pub occur_mutations_captured: usize,
172    /// **D5/D6.** Occur mutations APPLIED to layout (D5: 0; D6: >0 under `XFA_OCCUR_APPLY`).
173    pub occur_mutations_applied: usize,
174    /// **D6.** Captured occur mutations not applied (gate off / rollback / unsupported).
175    pub occur_mutations_skipped: usize,
176    /// **D6.** Captured occur mutations skipped because the target is not a
177    /// repeatable container (fail-closed).
178    pub occur_application_ambiguous: usize,
179    /// **D6.** Distinct nodes whose occur was applied.
180    pub occur_application_targets: usize,
181    /// **D7.** Presence retry was active (sandboxed + both flags + no rollback).
182    pub presence_retry_enabled: bool,
183    /// **D7.** Hidden/invisible/inactive nodes considered under occur targets.
184    pub presence_retry_candidates: usize,
185    /// **D7.** Nodes admitted (Hidden/Invisible -> Visible).
186    pub presence_retry_admitted: usize,
187    /// **D7.** Candidates skipped (e.g. Inactive, fail-closed).
188    pub presence_retry_skipped: usize,
189    /// **D7.** Total nodes under admitted subtrees (recovery breadth).
190    pub presence_retry_nodes_under_admitted: usize,
191    /// **D7.** Field/draw nodes under admitted subtrees.
192    pub presence_retry_text_nodes_admitted: usize,
193
194    // ---- Epic A: runtime observability enrichment (XFA_FLATTEN_TRACE / XFA_RUNTIME_DIAG) ----
195    /// **E-1 (XFA_FLATTEN_TRACE).** Per-script lifecycle entries (capped at 500).
196    /// Each entry records the script index, host node id/name, activity, language,
197    /// and outcome (executed|skipped_activity|skipped_mode|error|timeout).
198    pub script_lifecycle: Vec<ScriptLifecycleEntry>,
199
200    /// **E-6 (XFA_FLATTEN_TRACE).** Per-activity tally of JS scripts that were
201    /// skipped (not routed through the sandbox).
202    pub skipped_activities: SkippedActivities,
203
204    /// **E-2 (XFA_RUNTIME_DIAG).** SOM resolution misses logged at the host-binding
205    /// boundary. Capped at 200 entries.
206    pub som_fail_log: Vec<SomFailEntry>,
207
208    /// **E-3 (XFA_RUNTIME_DIAG).** Per-write instanceManager mutation log (capped at 200).
209    pub instance_write_log: Vec<InstanceWriteEntry>,
210
211    /// **E-4 (XFA_RUNTIME_DIAG).** Presence mutations observed during FormCalc
212    /// script execution (capped at 200). JS-side presence writes go through the
213    /// host binding; FormCalc goes through `set_presence` in dynamic.rs — this
214    /// captures the FormCalc path.
215    pub presence_mutation_log: Vec<PresenceMutationEntry>,
216
217    /// **E-5 (XFA_FLATTEN_TRACE).** Number of named subforms hidden by
218    /// `apply_form_dom_presence` because they had no matching form-DOM entry.
219    pub form_dom_match_failures: usize,
220
221    /// **E-5 (XFA_RUNTIME_DIAG).** Per-suppression entries from
222    /// `apply_form_dom_presence` (capped at 200).
223    pub form_dom_match_log: Vec<FormDomMatchEntry>,
224}
225
226// ---- Epic A support types ----
227
228/// E-1: one entry in `script.lifecycle[]`.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct ScriptLifecycleEntry {
231    /// Zero-based index of this script in document execution order.
232    pub script_idx: usize,
233    /// FormNodeId that owns the script.
234    pub node_id: usize,
235    /// `name` attribute of the owning node (may be empty).
236    pub node_name: String,
237    /// `activity` attribute of the event element (e.g. `"initialize"`), or empty.
238    pub activity: String,
239    /// Script language.
240    pub lang: &'static str,
241    /// Execution outcome.
242    pub outcome: &'static str,
243}
244
245/// E-6: per-activity skip tallies for JavaScript scripts.
246#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
247pub struct SkippedActivities {
248    /// Scripts with `activity="initialize"` skipped.
249    pub initialize: usize,
250    /// Scripts with `activity="calculate"` skipped.
251    pub calculate: usize,
252    /// Scripts with `activity="click"` skipped.
253    pub click: usize,
254    /// Scripts with `activity="docReady"` skipped.
255    pub doc_ready: usize,
256    /// Scripts with `activity="layoutReady"` skipped.
257    pub layout_ready: usize,
258    /// Scripts with any other (or absent) activity attribute skipped.
259    pub other: usize,
260}
261
262impl SkippedActivities {
263    fn bump(&mut self, activity: Option<&str>) {
264        match activity {
265            Some("initialize") => self.initialize += 1,
266            Some("calculate") => self.calculate += 1,
267            Some("click") => self.click += 1,
268            Some("docReady") => self.doc_ready += 1,
269            Some("layoutReady") => self.layout_ready += 1,
270            _ => self.other += 1,
271        }
272    }
273}
274
275/// E-2: one SOM-miss entry.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct SomFailEntry {
278    /// The SOM path that failed to resolve.
279    pub path: String,
280    /// Miss category (e.g. `"resolve_miss"`, `"formcalc_miss"`).
281    pub kind: String,
282    /// Script index (matches `ScriptLifecycleEntry::script_idx`).
283    pub script_idx: usize,
284    /// Activity at the time of the miss.
285    pub activity: String,
286}
287
288/// E-3: one instanceManager write entry.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct InstanceWriteEntry {
291    /// Script index.
292    pub script_idx: usize,
293    /// Activity.
294    pub activity: String,
295    /// Parent container node id.
296    pub parent_node_id: usize,
297    /// Parent container node name.
298    pub parent_node_name: String,
299    /// Prototype node name (the repeated child template).
300    pub prototype_node_name: String,
301    /// Instance count before the write.
302    pub old_count: usize,
303    /// Instance count after the write.
304    pub new_count: usize,
305}
306
307/// E-4: one presence-mutation observation.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct PresenceMutationEntry {
310    /// Script index at mutation time (best-effort; may be 0 for FormCalc).
311    pub script_idx: usize,
312    /// Activity at mutation time.
313    pub activity: String,
314    /// Node id.
315    pub node_id: usize,
316    /// Node name.
317    pub node_name: String,
318    /// Presence before the change.
319    pub old_presence: String,
320    /// Presence after the change.
321    pub new_presence: String,
322}
323
324/// E-5: one form-DOM match-failure / suppression entry.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct FormDomMatchEntry {
327    /// Template FormNodeId of the hidden subform.
328    pub template_node_id: usize,
329    /// Name of the suppressed subform.
330    pub template_node_name: String,
331    /// Short reason string.
332    pub reason: String,
333}
334
335impl Default for DynamicScriptOutcome {
336    fn default() -> Self {
337        Self {
338            changes: 0,
339            js_present: false,
340            js_skipped: 0,
341            other_skipped: 0,
342            formcalc_run: 0,
343            formcalc_errors: 0,
344            output_quality: OutputQuality::Exact,
345            js_executed: 0,
346            js_runtime_errors: 0,
347            js_timeouts: 0,
348            js_oom: 0,
349            js_host_calls: 0,
350            js_mutations: 0,
351            js_instance_writes: 0,
352            js_list_writes: 0,
353            js_binding_errors: 0,
354            js_resolve_failures: 0,
355            js_data_reads: 0,
356            js_unsupported_host_calls: 0,
357            js_probe_skips: 0,
358            variables_scripts_collected: 0,
359            variables_data_items_collected: 0,
360            script_objects_registered: 0,
361            script_objects_register_failed: 0,
362            script_objects_subform_scoped: 0,
363            som_lookups_total: 0,
364            som_lookup_successes: 0,
365            som_lookup_failures: 0,
366            som_lookup_ambiguous: 0,
367            som_subform_scripts_exposed: 0,
368            som_occur_path_refs: 0,
369            occur_lookups_total: 0,
370            occur_lookup_successes: 0,
371            occur_lookup_failures: 0,
372            occur_property_reads: 0,
373            occur_property_writes: 0,
374            occur_min_writes: 0,
375            occur_max_writes: 0,
376            occur_mutations_captured: 0,
377            occur_mutations_applied: 0,
378            occur_mutations_skipped: 0,
379            occur_application_ambiguous: 0,
380            occur_application_targets: 0,
381            presence_retry_enabled: false,
382            presence_retry_candidates: 0,
383            presence_retry_admitted: 0,
384            presence_retry_skipped: 0,
385            presence_retry_nodes_under_admitted: 0,
386            presence_retry_text_nodes_admitted: 0,
387            script_lifecycle: Vec::new(),
388            skipped_activities: SkippedActivities::default(),
389            som_fail_log: Vec::new(),
390            instance_write_log: Vec::new(),
391            presence_mutation_log: Vec::new(),
392            form_dom_match_failures: 0,
393            form_dom_match_log: Vec::new(),
394        }
395    }
396}
397
398/// Snapshot of field values, presence states, and structural shape used for
399/// rollback after a failed script pass.
400///
401/// XFA-INST-MGR (2026-05-17): the snapshot now also captures every node's
402/// children list plus the total node count. When `restore_snapshot` runs it
403/// truncates any clones added by `instanceManager.addInstance` /
404/// `setInstances` and restores the original child ordering, so a rollback is
405/// structurally consistent end-to-end — not just at the field-value layer.
406/// This keeps the layout pass from seeing half-applied script mutations when
407/// scripts produced enough errors to invalidate the pass.
408///
409/// NOTE: The rollback policy itself is our own heuristic — the XFA spec does
410/// not define one. It protects against scripts that blank out all fields
411/// (broken SOM resolution, etc.) or that add structural clones we cannot
412/// safely keep after rejecting the pass.
413struct FormSnapshot {
414    field_values: Vec<(usize, String)>,
415    presences: Vec<(usize, Presence)>,
416    /// Per-node children list at snapshot time.  Indexed by `form.nodes`
417    /// position; `children[i]` is the saved `children` vec for node `i`.
418    children: Vec<Vec<FormNodeId>>,
419    /// Total node count at snapshot time.  On rollback `form.nodes` and
420    /// `form.metadata` are truncated back to this length, evicting any
421    /// runtime-created clones from the form tree.
422    node_count: usize,
423    populated_count: usize,
424}
425
426fn snapshot_form(form: &FormTree) -> FormSnapshot {
427    let mut field_values = Vec::new();
428    let mut presences = Vec::new();
429    let mut children = Vec::with_capacity(form.nodes.len());
430    let mut populated_count = 0usize;
431    for (idx, node) in form.nodes.iter().enumerate() {
432        if let FormNodeType::Field { value } = &node.node_type {
433            field_values.push((idx, value.clone()));
434            if !value.trim().is_empty() {
435                populated_count += 1;
436            }
437        }
438        presences.push((idx, form.metadata[idx].presence));
439        children.push(node.children.clone());
440    }
441    FormSnapshot {
442        field_values,
443        presences,
444        children,
445        node_count: form.nodes.len(),
446        populated_count,
447    }
448}
449
450fn restore_snapshot(form: &mut FormTree, snapshot: &FormSnapshot) {
451    for (idx, value) in &snapshot.field_values {
452        if let FormNodeType::Field { value: fv } = &mut form.nodes[*idx].node_type {
453            *fv = value.clone();
454        }
455    }
456    for (idx, presence) in &snapshot.presences {
457        form.metadata[*idx].presence = *presence;
458    }
459    // XFA-INST-MGR: drop any runtime-created clones first, THEN restore the
460    // original children lists.  Truncating must happen before assignment
461    // because the saved children vec may reference indices that the snapshot
462    // already covers (clones never receive an `xfa_id`, so `node_ids` does
463    // not need pruning — see `host::clone_subtree`).
464    if form.nodes.len() > snapshot.node_count {
465        form.nodes.truncate(snapshot.node_count);
466        form.metadata.truncate(snapshot.node_count);
467    }
468    for (idx, saved_children) in snapshot.children.iter().enumerate() {
469        if let Some(node) = form.nodes.get_mut(idx) {
470            node.children = saved_children.clone();
471        }
472    }
473}
474
475fn should_rollback(
476    form: &FormTree,
477    snapshot: &FormSnapshot,
478    errors: usize,
479    successes: usize,
480) -> bool {
481    if errors > 0 && errors > successes {
482        return true;
483    }
484    if snapshot.populated_count >= 2 {
485        let mut now_empty = 0usize;
486        for (idx, old_value) in &snapshot.field_values {
487            if old_value.trim().is_empty() {
488                continue;
489            }
490            if let FormNodeType::Field { value } = &form.nodes[*idx].node_type {
491                if value.trim().is_empty() {
492                    now_empty += 1;
493                }
494            }
495        }
496        if now_empty * 2 > snapshot.populated_count {
497            return true;
498        }
499    }
500    false
501}
502// XFA Spec 3.3 §9.3 — Dynamic Forms: after data binding, scripts run in
503// two phases: (1) initialize events fire once, (2) calculate events may
504// iterate until stable (convergence) or MAX_SCRIPT_PASSES is reached.
505// The spec (§14.3.2) defines the event model; our implementation runs
506// initialize then calculate, matching Adobe's processing order.
507//
508// NOTE: §10.6 Rule 3 states the merge-completion order as:
509//   value calcs → property calcs → validations → initialize events.
510// Our order (initialize first) differs from the spec but matches Adobe's
511// observed behavior on our 20K test corpus (97%+ SSIM). §28.2 (p1231)
512// documents Adobe's event execution insert-at-position-2 algorithm.
513//
514// The default JavaScript handling is best-effort static flattening: parsed
515// JavaScript and unsupported-language scripts are skipped and reported, while
516// FormCalc continues to run. Use `apply_dynamic_scripts_with_mode(..., Strict)`
517// when callers need the legacy whole-form JavaScript policy gate.
518
519/// Convenience entry point: runs the script pipeline with the default
520/// [`JsExecutionMode`] (currently [`JsExecutionMode::BestEffortStatic`]).
521///
522/// Prefer [`apply_dynamic_scripts_with_runtime`] when you need explicit
523/// runtime injection (e.g. in tests or when using the sandboxed runtime).
524#[doc(hidden)]
525pub fn apply_dynamic_scripts(
526    form: &mut FormTree,
527    root_id: FormNodeId,
528) -> Result<DynamicScriptOutcome> {
529    apply_dynamic_scripts_with_mode(form, root_id, JsExecutionMode::default())
530}
531
532/// Runs the script pipeline with an explicit [`JsExecutionMode`], using the
533/// internal [`NullRuntime`] (or the compiled-in QuickJS runtime for
534/// [`JsExecutionMode::SandboxedRuntime`]).
535///
536/// This is an intermediate convenience wrapper. The canonical low-level entry
537/// point is [`apply_dynamic_scripts_with_runtime`], which accepts any
538/// [`XfaJsRuntime`] implementation.
539#[doc(hidden)]
540pub fn apply_dynamic_scripts_with_mode(
541    form: &mut FormTree,
542    root_id: FormNodeId,
543    mode: JsExecutionMode,
544) -> Result<DynamicScriptOutcome> {
545    // M3-B Phase C-α (2026-05-03): when the caller asks for SandboxedRuntime
546    // and the `xfa-js-sandboxed` feature is compiled in, instantiate the
547    // real QuickJS-backed adapter. Without the feature, fall back to
548    // NullRuntime which surfaces `SandboxError::NotCompiledIn` per script
549    // (existing Phase B fallback semantics).
550    #[cfg(feature = "xfa-js-sandboxed")]
551    {
552        if mode == JsExecutionMode::SandboxedRuntime {
553            match crate::js_runtime::QuickJsRuntime::new() {
554                Ok(mut rt) => {
555                    return apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut rt);
556                }
557                Err(_e) => {
558                    // QuickJS init failure is rare; fall through to NullRuntime
559                    // so the dispatch path can still record per-script errors
560                    // and the flatten succeeds in best-effort mode.
561                }
562            }
563        }
564    }
565    apply_dynamic_scripts_with_runtime(form, root_id, mode, &mut NullRuntime::new())
566}
567
568/// D6: opt-in gate for applying captured `occur.min` mutations to layout.
569/// Default OFF. Even in sandboxed mode, occur application only happens when
570/// `XFA_OCCUR_APPLY=1`, so the committed default (and default sandboxed
571/// capture-only) behaviour is unchanged.
572fn occur_apply_enabled() -> bool {
573    std::env::var("XFA_OCCUR_APPLY").ok().as_deref() == Some("1")
574}
575
576/// D7: opt-in gate for presence/visibility retry. Default OFF. Requires
577/// `XFA_PRESENCE_RETRY=1`; the dispatch path additionally requires
578/// `XFA_OCCUR_APPLY=1` (occur application is the prerequisite for the retry).
579fn presence_retry_enabled() -> bool {
580    std::env::var("XFA_PRESENCE_RETRY").ok().as_deref() == Some("1")
581}
582
583/// Epic A: true when `XFA_RUNTIME_DIAG` is set to `"1"`.  Gates the verbose
584/// per-entry log arrays (E-2, E-3, E-4, E-5-log).  Default OFF so normal
585/// flattens are byte-identical and zero-cost.
586pub(crate) fn runtime_diag_enabled() -> bool {
587    std::env::var("XFA_RUNTIME_DIAG").ok().as_deref() == Some("1")
588}
589
590// Epic A E-4: thread-local accumulator for FormCalc presence mutations.
591// Active only during `apply_dynamic_scripts_with_runtime` when
592// `XFA_RUNTIME_DIAG=1`.  Using thread-local avoids threading an extra Vec
593// through `run_script_phase` / `write_formcalc_value` / `set_presence`.
594std::thread_local! {
595    static PRESENCE_MUT_LOG: std::cell::RefCell<Option<Vec<PresenceMutationEntry>>> =
596        const { std::cell::RefCell::new(None) };
597}
598
599/// Push a presence mutation entry into the thread-local log.
600/// Called from `set_presence_inner` when an entry was created.
601fn push_presence_mutation(
602    node_id: usize,
603    node_name: &str,
604    old_presence: &'static str,
605    new_presence: &'static str,
606) {
607    PRESENCE_MUT_LOG.with(|cell| {
608        if let Some(ref mut log) = *cell.borrow_mut() {
609            if log.len() < 200 {
610                log.push(PresenceMutationEntry {
611                    // script_idx/activity are not readily available at this call
612                    // depth — report 0/"" (best-effort per Epic A spec note).
613                    script_idx: 0,
614                    activity: String::new(),
615                    node_id,
616                    node_name: node_name.to_string(),
617                    old_presence: old_presence.to_string(),
618                    new_presence: new_presence.to_string(),
619                });
620            }
621        }
622    });
623}
624
625/// Phase B entry point that lets the caller inject a sandboxed runtime
626/// adapter. When `mode == JsExecutionMode::SandboxedRuntime` the supplied
627/// `runtime` is consulted for every JavaScript script whose `<event activity>`
628/// is in [`crate::js_runtime::SANDBOX_ACTIVITY_ALLOWLIST`]. UI / submission
629/// activities skip the runtime entirely and are recorded as `js_skipped`,
630/// matching `BestEffortStatic` behaviour for those scripts.
631///
632/// Other modes ignore `runtime` entirely; callers that just need the
633/// existing strict / best-effort behaviour should use
634/// [`apply_dynamic_scripts_with_mode`] (which routes through
635/// [`crate::js_runtime::NullRuntime`]).
636pub fn apply_dynamic_scripts_with_runtime(
637    form: &mut FormTree,
638    root_id: FormNodeId,
639    mode: JsExecutionMode,
640    runtime: &mut dyn XfaJsRuntime,
641) -> Result<DynamicScriptOutcome> {
642    let parents = build_parent_map(form, root_id);
643    let all_scripts: Vec<(FormNodeId, Vec<EventScript>)> = form
644        .nodes
645        .iter()
646        .enumerate()
647        .filter_map(|(idx, _)| {
648            let node_id = FormNodeId(idx);
649            let scripts = form.meta(node_id).event_scripts.clone();
650            (!scripts.is_empty()).then_some((node_id, scripts))
651        })
652        .collect();
653
654    let has_unsupported_script = all_scripts.iter().any(|(_, node_scripts)| {
655        node_scripts
656            .iter()
657            .any(|script| script.language != ScriptLanguage::FormCalc)
658    });
659
660    if mode == JsExecutionMode::Strict && has_unsupported_script {
661        return Err(javascript_policy::reject_execution(
662            JavaScriptEntryPoint::XfaEventHook,
663        ));
664    }
665
666    let mut js_skipped = 0usize;
667    let mut other_skipped = 0usize;
668    let mut sandbox_metadata = RuntimeMetadata::default();
669    let mut scripts = Vec::new();
670    let sandbox_active = mode == JsExecutionMode::SandboxedRuntime;
671    let snapshot = snapshot_form(form);
672
673    // D1.B (XFA Product Quality Wave 3 — preSave gated allow). The env-var
674    // gate is read ONCE per flatten so all scripts in this document see the
675    // same decision. Default OFF; flipping requires
676    // `XFA_PRESAVE_DURING_FLATTEN=1`. The host-binding layer is informed via
677    // `set_presave_gate` so dispatch and host stay in lock-step (defence-
678    // in-depth §2 of the policy doc).
679    let presave_gate = sandbox_active && presave_during_flatten_enabled();
680
681    // Epic A E-1/E-6: read gates once; zero cost unless env is set.
682    let trace_enabled = crate::flatten_trace::enabled();
683    let diag_enabled = runtime_diag_enabled();
684
685    // E-1: lifecycle log (cap 500).
686    let mut script_lifecycle: Vec<ScriptLifecycleEntry> = Vec::new();
687    // E-6: per-activity skip tally.
688    let mut skipped_activities = SkippedActivities::default();
689    // Running script index (mirrors host.rs next_script_idx for JS; for
690    // FormCalc scripts we share the same monotonic counter).
691    let mut dispatch_script_idx: usize = 0;
692
693    if sandbox_active {
694        // Best-effort init / reset; init failures are non-fatal — the
695        // dispatch path will record them as runtime_errors per script.
696        let _ = runtime.init();
697        let _ = runtime.reset_for_new_document();
698        let _ = runtime.set_form_handle(form as *mut FormTree, root_id);
699        runtime.set_presave_gate(presave_gate);
700    }
701
702    for (node_id, node_scripts) in all_scripts {
703        let node = form.get(node_id);
704        let node_name = if trace_enabled || diag_enabled {
705            node.name.clone()
706        } else {
707            String::new()
708        };
709        let mut formcalc_scripts = Vec::new();
710        for script in node_scripts {
711            match script.language {
712                ScriptLanguage::FormCalc => formcalc_scripts.push(script),
713                ScriptLanguage::JavaScript => {
714                    let activity_str = script.activity.as_deref().unwrap_or("");
715                    if sandbox_active
716                        && activity_allowed_for_sandbox_with_gate(
717                            script.activity.as_deref(),
718                            presave_gate,
719                        )
720                    {
721                        let this_idx = dispatch_script_idx;
722                        dispatch_script_idx += 1;
723                        let _ = runtime.reset_per_script(node_id, script.activity.as_deref());
724                        let outcome_str = match runtime
725                            .execute_script(script.activity.as_deref(), &script.script)
726                        {
727                            Ok(_) => {
728                                // Counter increment lives on `take_metadata()`.
729                                "executed"
730                            }
731                            Err(SandboxError::Timeout) => {
732                                js_skipped += 1;
733                                "timeout"
734                            }
735                            Err(SandboxError::OutOfMemory) => {
736                                js_skipped += 1;
737                                "error"
738                            }
739                            Err(e) => {
740                                // M3-B Phase C-α: surface the per-script error
741                                // class via `log::debug!` for normal builds and
742                                // also via stderr when `XFA_JS_DEBUG=1` is set
743                                // (operator triage aid; xfa-cli does not init
744                                // a logger that respects RUST_LOG).
745                                log::debug!(
746                                    "sandbox script error on activity={:?}: {}",
747                                    script.activity.as_deref(),
748                                    e
749                                );
750                                if std::env::var("XFA_JS_DEBUG").ok().as_deref() == Some("1") {
751                                    eprintln!(
752                                        "XFA_JS_DEBUG sandbox script error on activity={:?}: {}",
753                                        script.activity.as_deref(),
754                                        e
755                                    );
756                                }
757                                js_skipped += 1;
758                                "error"
759                            }
760                        };
761                        // E-1: record lifecycle entry when tracing is on.
762                        if trace_enabled && script_lifecycle.len() < 500 {
763                            script_lifecycle.push(ScriptLifecycleEntry {
764                                script_idx: this_idx,
765                                node_id: node_id.0,
766                                node_name: node_name.clone(),
767                                activity: activity_str.to_string(),
768                                lang: "javascript",
769                                outcome: outcome_str,
770                            });
771                        }
772                    } else {
773                        js_skipped += 1;
774                        // E-1: record skipped lifecycle entry.
775                        if trace_enabled && script_lifecycle.len() < 500 {
776                            let skip_reason = if sandbox_active {
777                                "skipped_activity"
778                            } else {
779                                "skipped_mode"
780                            };
781                            script_lifecycle.push(ScriptLifecycleEntry {
782                                script_idx: dispatch_script_idx,
783                                node_id: node_id.0,
784                                node_name: node_name.clone(),
785                                activity: activity_str.to_string(),
786                                lang: "javascript",
787                                outcome: skip_reason,
788                            });
789                        }
790                        // E-6: tally per-activity skips.
791                        if trace_enabled {
792                            skipped_activities.bump(script.activity.as_deref());
793                        }
794                        dispatch_script_idx += 1;
795                    }
796                }
797                ScriptLanguage::Other => {
798                    other_skipped += 1;
799                    // E-1: log other-language skips.
800                    if trace_enabled && script_lifecycle.len() < 500 {
801                        script_lifecycle.push(ScriptLifecycleEntry {
802                            script_idx: dispatch_script_idx,
803                            node_id: node_id.0,
804                            node_name: node_name.clone(),
805                            activity: script.activity.as_deref().unwrap_or("").to_string(),
806                            lang: "other",
807                            outcome: "skipped_mode",
808                        });
809                    }
810                    dispatch_script_idx += 1;
811                }
812            }
813        }
814        if !formcalc_scripts.is_empty() {
815            scripts.push((node_id, formcalc_scripts));
816        }
817    }
818
819    let mut captured_occur: Vec<(usize, String, i64)> = Vec::new();
820    // Epic A E-2/E-3: logs drained from host bindings.
821    let mut sandbox_diag = crate::js_runtime::RuntimeDiagLogs::default();
822    if sandbox_active {
823        let _ = runtime.set_form_handle(std::ptr::null_mut(), root_id);
824        sandbox_metadata = runtime.take_metadata();
825        captured_occur = runtime.take_occur_mutations();
826        if diag_enabled {
827            sandbox_diag = runtime.take_diag_logs();
828        }
829    }
830
831    let mut stats = ScriptStats::default();
832
833    // Epic A E-4: arm the thread-local presence mutation log.
834    if diag_enabled {
835        PRESENCE_MUT_LOG.with(|cell| {
836            *cell.borrow_mut() = Some(Vec::new());
837        });
838    }
839
840    let mut changes = sandbox_metadata
841        .mutations
842        .saturating_add(sandbox_metadata.instance_writes)
843        + run_script_phase(
844            form,
845            root_id,
846            &parents,
847            &scripts,
848            ScriptPhase::Initialize,
849            1,
850            &mut stats,
851        )?
852        + run_script_phase(
853            form,
854            root_id,
855            &parents,
856            &scripts,
857            ScriptPhase::Calculate,
858            MAX_SCRIPT_PASSES,
859            &mut stats,
860        )?;
861
862    // E-4: drain and disarm.
863    let presence_mutation_log = if diag_enabled {
864        PRESENCE_MUT_LOG.with(|cell| cell.borrow_mut().take().unwrap_or_default())
865    } else {
866        Vec::new()
867    };
868
869    let sandbox_rollback_errors = sandbox_metadata
870        .runtime_errors
871        .saturating_add(sandbox_metadata.timeouts)
872        .saturating_add(sandbox_metadata.oom)
873        .saturating_add(sandbox_metadata.binding_errors);
874    let rollback_errors = stats.errors.saturating_add(sandbox_rollback_errors);
875    let rollback_successes = stats.successes.saturating_add(sandbox_metadata.executed);
876
877    let rolled_back = should_rollback(form, &snapshot, rollback_errors, rollback_successes);
878    if rolled_back {
879        restore_snapshot(form, &snapshot);
880        changes = 0;
881    }
882
883    // D6: apply captured `occur.min` mutations to the form before layout.
884    // Strictly gated: sandboxed mode + the opt-in `XFA_OCCUR_APPLY=1` flag +
885    // the script pass did NOT roll back. Default flatten and the default
886    // sandboxed (capture-only) behaviour are unchanged. Each captured write is
887    // applied only to a live, repeatable container node (Subform/Area/ExclGroup);
888    // everything else fails closed (counted, not applied). `occur.max` writes
889    // are captured but not applied in D6 (min-only scope).
890    let mut occur_applied_targets: std::collections::HashSet<usize> =
891        std::collections::HashSet::new();
892    if sandbox_active && !rolled_back && occur_apply_enabled() && !captured_occur.is_empty() {
893        for (idx, prop, value) in &captured_occur {
894            if prop != "min" {
895                // occur.max captured but not applied in D6.
896                sandbox_metadata.occur_mutations_skipped =
897                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
898                continue;
899            }
900            if *value < 0 || *idx >= form.nodes.len() {
901                sandbox_metadata.occur_mutations_skipped =
902                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
903                continue;
904            }
905            let nid = FormNodeId(*idx);
906            let is_repeatable = matches!(
907                form.get(nid).node_type,
908                FormNodeType::Subform | FormNodeType::Area | FormNodeType::ExclGroup
909            );
910            if !is_repeatable {
911                // Fail closed: only repeatable containers may have occur applied.
912                sandbox_metadata.occur_application_ambiguous = sandbox_metadata
913                    .occur_application_ambiguous
914                    .saturating_add(1);
915                sandbox_metadata.occur_mutations_skipped =
916                    sandbox_metadata.occur_mutations_skipped.saturating_add(1);
917                continue;
918            }
919            let v = *value as u32;
920            let node = form.get_mut(nid);
921            node.occur.min = v;
922            if node.occur.initial < v {
923                node.occur.initial = v;
924            }
925            if let Some(m) = node.occur.max {
926                if m < v {
927                    node.occur.max = Some(v);
928                }
929            }
930            sandbox_metadata.occur_mutations_applied =
931                sandbox_metadata.occur_mutations_applied.saturating_add(1);
932            occur_applied_targets.insert(*idx);
933        }
934        sandbox_metadata.occur_application_targets = sandbox_metadata
935            .occur_application_targets
936            .saturating_add(occur_applied_targets.len());
937    } else if !captured_occur.is_empty() {
938        // Captured but the apply gate is closed (default capture-only path):
939        // count them as skipped for observability without touching layout.
940        sandbox_metadata.occur_mutations_skipped = sandbox_metadata
941            .occur_mutations_skipped
942            .saturating_add(captured_occur.len());
943    }
944
945    // D7: opt-in presence/visibility retry. Strictly gated: sandboxed +
946    // `XFA_PRESENCE_RETRY=1` + `XFA_OCCUR_APPLY=1` (occur application is the
947    // prerequisite) + no rollback. Admits (`Hidden`/`Invisible` -> `Visible`)
948    // ONLY nodes that are an occur-applied target or a descendant of one
949    // (occur-related, unambiguous). `Inactive` is skipped (fail-closed). Default
950    // and default-sandboxed behaviour are unchanged.
951    let mut pr_candidates = 0usize;
952    let mut pr_admitted = 0usize;
953    let mut pr_skipped = 0usize;
954    let mut pr_nodes_under_admitted = 0usize;
955    let mut pr_text_nodes_admitted = 0usize;
956    let presence_retry_active = sandbox_active
957        && !rolled_back
958        && occur_apply_enabled()
959        && presence_retry_enabled()
960        && !occur_applied_targets.is_empty();
961    if presence_retry_active {
962        let mut visited: std::collections::HashSet<usize> = std::collections::HashSet::new();
963        let mut stack: Vec<FormNodeId> = occur_applied_targets
964            .iter()
965            .map(|&i| FormNodeId(i))
966            .collect();
967        let mut admitted_ids: std::collections::HashSet<usize> = std::collections::HashSet::new();
968        while let Some(nid) = stack.pop() {
969            if nid.0 >= form.nodes.len() || !visited.insert(nid.0) {
970                continue;
971            }
972            let presence = form.meta(nid).presence;
973            if presence == Presence::Hidden || presence == Presence::Invisible {
974                pr_candidates += 1;
975                // Fail-closed safety: never un-hide `Inactive` (intentionally
976                // removed); only `Hidden`/`Invisible` are admitted here.
977                form.meta_mut(nid).presence = Presence::Visible;
978                pr_admitted += 1;
979                admitted_ids.insert(nid.0);
980            } else if presence == Presence::Inactive {
981                pr_candidates += 1;
982                pr_skipped += 1;
983            }
984            for &c in &form.get(nid).children {
985                stack.push(c);
986            }
987        }
988        // Count content under admitted subtrees (observability of recovery).
989        for &aid in &admitted_ids {
990            let mut s = vec![FormNodeId(aid)];
991            let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
992            while let Some(n) = s.pop() {
993                if n.0 >= form.nodes.len() || !seen.insert(n.0) {
994                    continue;
995                }
996                pr_nodes_under_admitted += 1;
997                if let FormNodeType::Draw(_) | FormNodeType::Field { .. } = form.get(n).node_type {
998                    pr_text_nodes_admitted += 1;
999                }
1000                for &c in &form.get(n).children {
1001                    s.push(c);
1002                }
1003            }
1004        }
1005    }
1006
1007    let js_seen_count = js_skipped + sandbox_metadata.executed;
1008    let js_present = js_seen_count > 0;
1009    let output_quality = if sandbox_active && js_present && sandbox_metadata.is_clean() {
1010        OutputQuality::Sandboxed
1011    } else if (sandbox_active && js_present) || js_skipped > 0 || other_skipped > 0 {
1012        OutputQuality::BestEffort
1013    } else {
1014        OutputQuality::Exact
1015    };
1016
1017    Ok(DynamicScriptOutcome {
1018        changes,
1019        js_present,
1020        js_skipped,
1021        other_skipped,
1022        formcalc_run: stats.formcalc_run,
1023        formcalc_errors: stats.formcalc_errors,
1024        output_quality,
1025        js_executed: sandbox_metadata.executed,
1026        js_runtime_errors: sandbox_metadata.runtime_errors,
1027        js_timeouts: sandbox_metadata.timeouts,
1028        js_oom: sandbox_metadata.oom,
1029        js_host_calls: sandbox_metadata.host_calls,
1030        js_mutations: sandbox_metadata.mutations,
1031        js_instance_writes: sandbox_metadata.instance_writes,
1032        js_list_writes: sandbox_metadata.list_writes,
1033        js_binding_errors: sandbox_metadata.binding_errors,
1034        js_resolve_failures: sandbox_metadata.resolve_failures,
1035        js_data_reads: sandbox_metadata.data_reads,
1036        js_unsupported_host_calls: sandbox_metadata.unsupported_host_calls,
1037        js_probe_skips: sandbox_metadata.probe_skips,
1038        variables_scripts_collected: sandbox_metadata.variables_scripts_collected,
1039        variables_data_items_collected: sandbox_metadata.variables_data_items_collected,
1040        script_objects_registered: sandbox_metadata.script_objects_registered,
1041        script_objects_register_failed: sandbox_metadata.script_objects_register_failed,
1042        script_objects_subform_scoped: sandbox_metadata.script_objects_subform_scoped,
1043        som_lookups_total: sandbox_metadata.som_lookups_total,
1044        som_lookup_successes: sandbox_metadata.som_lookup_successes,
1045        som_lookup_failures: sandbox_metadata.som_lookup_failures,
1046        som_lookup_ambiguous: sandbox_metadata.som_lookup_ambiguous,
1047        som_subform_scripts_exposed: sandbox_metadata.som_subform_scripts_exposed,
1048        som_occur_path_refs: sandbox_metadata.som_occur_path_refs,
1049        occur_lookups_total: sandbox_metadata.occur_lookups_total,
1050        occur_lookup_successes: sandbox_metadata.occur_lookup_successes,
1051        occur_lookup_failures: sandbox_metadata.occur_lookup_failures,
1052        occur_property_reads: sandbox_metadata.occur_property_reads,
1053        occur_property_writes: sandbox_metadata.occur_property_writes,
1054        occur_min_writes: sandbox_metadata.occur_min_writes,
1055        occur_max_writes: sandbox_metadata.occur_max_writes,
1056        occur_mutations_captured: sandbox_metadata.occur_mutations_captured,
1057        occur_mutations_applied: sandbox_metadata.occur_mutations_applied,
1058        occur_mutations_skipped: sandbox_metadata.occur_mutations_skipped,
1059        occur_application_ambiguous: sandbox_metadata.occur_application_ambiguous,
1060        occur_application_targets: sandbox_metadata.occur_application_targets,
1061        presence_retry_enabled: presence_retry_active,
1062        presence_retry_candidates: pr_candidates,
1063        presence_retry_admitted: pr_admitted,
1064        presence_retry_skipped: pr_skipped,
1065        presence_retry_nodes_under_admitted: pr_nodes_under_admitted,
1066        presence_retry_text_nodes_admitted: pr_text_nodes_admitted,
1067        // Epic A fields.
1068        script_lifecycle,
1069        skipped_activities,
1070        som_fail_log: sandbox_diag.som_fail_log,
1071        instance_write_log: sandbox_diag.instance_write_log,
1072        presence_mutation_log,
1073        form_dom_match_failures: 0,
1074        form_dom_match_log: Vec::new(),
1075    })
1076}
1077
1078fn has_hidden_ancestor(
1079    form: &FormTree,
1080    parents: &HashMap<FormNodeId, FormNodeId>,
1081    node_id: FormNodeId,
1082) -> bool {
1083    let mut cursor = parents.get(&node_id).copied();
1084    while let Some(ancestor) = cursor {
1085        if form.meta(ancestor).presence.is_not_visible() {
1086            return true;
1087        }
1088        cursor = parents.get(&ancestor).copied();
1089    }
1090    false
1091}
1092
1093#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1094enum ScriptPhase {
1095    Initialize,
1096    Calculate,
1097}
1098
1099#[derive(Default)]
1100struct ScriptStats {
1101    errors: usize,
1102    successes: usize,
1103    formcalc_run: usize,
1104    formcalc_errors: usize,
1105}
1106
1107#[derive(Debug)]
1108struct ScriptResult {
1109    changes: usize,
1110    error: bool,
1111}
1112
1113fn run_script_phase(
1114    form: &mut FormTree,
1115    root_id: FormNodeId,
1116    parents: &HashMap<FormNodeId, FormNodeId>,
1117    scripts: &[(FormNodeId, Vec<EventScript>)],
1118    phase: ScriptPhase,
1119    max_passes: usize,
1120    stats: &mut ScriptStats,
1121) -> Result<usize> {
1122    let mut total_changes = 0;
1123
1124    for _ in 0..max_passes {
1125        let mut pass_changes = 0;
1126
1127        for (node_id, node_scripts) in scripts {
1128            if has_hidden_ancestor(form, parents, *node_id) {
1129                continue;
1130            }
1131
1132            for script in node_scripts
1133                .iter()
1134                .filter(|script| should_run_script(script, phase))
1135            {
1136                let result = execute_event_script(form, root_id, parents, *node_id, script, phase)?;
1137                stats.formcalc_run += 1;
1138                if result.error {
1139                    stats.errors += 1;
1140                    stats.formcalc_errors += 1;
1141                } else {
1142                    stats.successes += 1;
1143                }
1144                pass_changes += result.changes;
1145            }
1146        }
1147
1148        total_changes += pass_changes;
1149        if pass_changes == 0 {
1150            break;
1151        }
1152    }
1153
1154    Ok(total_changes)
1155}
1156
1157fn should_run_script(script: &EventScript, phase: ScriptPhase) -> bool {
1158    match phase {
1159        ScriptPhase::Initialize => script.activity.as_deref() == Some("initialize"),
1160        ScriptPhase::Calculate => script.activity.as_deref() == Some("calculate"),
1161    }
1162}
1163
1164fn execute_event_script(
1165    form: &mut FormTree,
1166    root_id: FormNodeId,
1167    parents: &HashMap<FormNodeId, FormNodeId>,
1168    current_id: FormNodeId,
1169    script: &EventScript,
1170    phase: ScriptPhase,
1171) -> Result<ScriptResult> {
1172    match script.language {
1173        ScriptLanguage::FormCalc => Ok(execute_formcalc_script(
1174            form, root_id, parents, current_id, script, phase,
1175        )),
1176        ScriptLanguage::JavaScript => Err(javascript_policy::reject_execution(
1177            JavaScriptEntryPoint::XfaEventHook,
1178        )),
1179        ScriptLanguage::Other => Err(XfaError::UnsupportedFeature("script language".to_string())),
1180    }
1181}
1182
1183/// Emit a stderr line when `XFA_FORMCALC_DEBUG=1` is set so that residual-scan
1184/// tooling can aggregate FormCalc failures by stage (`lexer` / `parser` /
1185/// `interpreter`) and message. Off by default; emits nothing otherwise.
1186///
1187/// Mirrors the `XFA_JS_DEBUG` opt-in pattern used by the sandbox JS runtime.
1188fn formcalc_debug_emit(stage: &str, message: &str, script: &EventScript) {
1189    if std::env::var("XFA_FORMCALC_DEBUG").ok().as_deref() != Some("1") {
1190        return;
1191    }
1192    let activity = script.activity.as_deref().unwrap_or("?");
1193    // Single-line, double-quoted message so the scan script's regex can parse
1194    // it like `XFA_JS_DEBUG resolve_*` lines. Newlines collapsed defensively.
1195    let one_line = message.replace(['\n', '\r'], " ");
1196    eprintln!("XFA_FORMCALC_DEBUG stage={stage} activity=\"{activity}\" message=\"{one_line}\"");
1197}
1198
1199fn execute_formcalc_script(
1200    form: &mut FormTree,
1201    root_id: FormNodeId,
1202    parents: &HashMap<FormNodeId, FormNodeId>,
1203    current_id: FormNodeId,
1204    script: &EventScript,
1205    phase: ScriptPhase,
1206) -> ScriptResult {
1207    let tokens = match tokenize(&script.script) {
1208        Ok(t) => t,
1209        Err(err) => {
1210            formcalc_debug_emit("lexer", &format!("{err}"), script);
1211            return ScriptResult {
1212                changes: 0,
1213                error: true,
1214            };
1215        }
1216    };
1217    let ast = match parser::parse(tokens) {
1218        Ok(a) => a,
1219        Err(err) => {
1220            formcalc_debug_emit("parser", &format!("{err}"), script);
1221            return ScriptResult {
1222                changes: 0,
1223                error: true,
1224            };
1225        }
1226    };
1227
1228    let mut interpreter = Interpreter::new();
1229    let mut resolver = FormTreeSomResolver::new(form, root_id, parents, current_id);
1230    let result = match interpreter.exec_with_resolver(&ast, &mut resolver) {
1231        Ok(r) => r,
1232        Err(err) => {
1233            formcalc_debug_emit("interpreter", &format!("{err}"), script);
1234            return ScriptResult {
1235                changes: resolver.changes,
1236                error: true,
1237            };
1238        }
1239    };
1240
1241    if matches!(phase, ScriptPhase::Calculate) {
1242        resolver.changes += write_formcalc_value(
1243            resolver.form,
1244            current_id,
1245            ResolvedProperty::RawValue,
1246            result,
1247        );
1248    }
1249
1250    ScriptResult {
1251        changes: resolver.changes,
1252        error: false,
1253    }
1254}
1255
1256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1257enum ResolvedProperty {
1258    RawValue,
1259    Presence,
1260    SomExpression,
1261}
1262
1263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1264struct ResolvedTarget {
1265    node_id: FormNodeId,
1266    property: ResolvedProperty,
1267}
1268
1269struct FormTreeSomResolver<'a> {
1270    form: &'a mut FormTree,
1271    root_id: FormNodeId,
1272    parents: &'a HashMap<FormNodeId, FormNodeId>,
1273    current_id: FormNodeId,
1274    changes: usize,
1275}
1276
1277impl<'a> FormTreeSomResolver<'a> {
1278    fn new(
1279        form: &'a mut FormTree,
1280        root_id: FormNodeId,
1281        parents: &'a HashMap<FormNodeId, FormNodeId>,
1282        current_id: FormNodeId,
1283    ) -> Self {
1284        Self {
1285            form,
1286            root_id,
1287            parents,
1288            current_id,
1289            changes: 0,
1290        }
1291    }
1292
1293    fn resolve_target(&self, path: &str) -> Option<ResolvedTarget> {
1294        let trimmed = path.trim();
1295        if trimmed.is_empty() {
1296            return None;
1297        }
1298
1299        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
1300            return Some(ResolvedTarget {
1301                node_id: self.current_id,
1302                property: parse_property_name(trimmed)?,
1303            });
1304        }
1305
1306        let (expr, property) = split_property_path(trimmed)?;
1307        let node_id = self.resolve_expression(&expr)?.into_iter().next()?;
1308        Some(ResolvedTarget { node_id, property })
1309    }
1310
1311    fn count_targets(&self, path: &str) -> usize {
1312        let trimmed = path.trim();
1313        if trimmed.is_empty() {
1314            return 0;
1315        }
1316        if matches!(trimmed, "rawValue" | "presence" | "somExpression") {
1317            return 1;
1318        }
1319        let Some((expr, _property)) = split_property_path(trimmed) else {
1320            return 0;
1321        };
1322        self.resolve_expression(&expr)
1323            .map_or(0, |nodes| nodes.len())
1324    }
1325
1326    fn resolve_expression(&self, expr: &SomExpression) -> Option<Vec<FormNodeId>> {
1327        match expr.root {
1328            SomRoot::Data | SomRoot::Record | SomRoot::Template => None,
1329            SomRoot::CurrentContainer => {
1330                if expr.segments.is_empty() {
1331                    Some(vec![self.current_id])
1332                } else {
1333                    Some(self.follow_absolute(vec![self.current_id], &expr.segments))
1334                }
1335            }
1336            SomRoot::Form => {
1337                if expr.segments.is_empty() {
1338                    Some(vec![self.root_id])
1339                } else {
1340                    Some(self.follow_absolute(vec![self.root_id], &expr.segments))
1341                }
1342            }
1343            SomRoot::Xfa => {
1344                let segments = strip_xfa_form_prefix(&expr.segments);
1345                if segments.is_empty() {
1346                    Some(vec![self.root_id])
1347                } else {
1348                    Some(self.follow_absolute(vec![self.root_id], segments))
1349                }
1350            }
1351            SomRoot::Unqualified => {
1352                if expr.segments.is_empty() {
1353                    Some(vec![self.current_id])
1354                } else {
1355                    Some(self.follow_unqualified(&expr.segments))
1356                }
1357            }
1358        }
1359    }
1360
1361    fn follow_absolute(
1362        &self,
1363        mut current: Vec<FormNodeId>,
1364        segments: &[xfa_dom_resolver::som::SomSegment],
1365    ) -> Vec<FormNodeId> {
1366        for (idx, segment) in segments.iter().enumerate() {
1367            let allow_self = idx == 0;
1368            current = current
1369                .into_iter()
1370                .flat_map(|node_id| self.step_from_node(node_id, segment, allow_self))
1371                .collect();
1372            if current.is_empty() {
1373                break;
1374            }
1375        }
1376        current
1377    }
1378
1379    fn follow_unqualified(
1380        &self,
1381        segments: &[xfa_dom_resolver::som::SomSegment],
1382    ) -> Vec<FormNodeId> {
1383        let Some((first, rest)) = segments.split_first() else {
1384            return vec![self.current_id];
1385        };
1386
1387        let mut scope = Some(self.current_id);
1388        while let Some(scope_id) = scope {
1389            let anchors: Vec<_> = descendants_inclusive(self.form, scope_id)
1390                .into_iter()
1391                .filter(|node_id| self.node_matches_segment(*node_id, first))
1392                .collect();
1393            let matched = self.follow_remaining(anchors, rest);
1394            if !matched.is_empty() {
1395                return matched;
1396            }
1397            scope = self.parents.get(&scope_id).copied();
1398        }
1399
1400        let anchors: Vec<_> = descendants_inclusive(self.form, self.root_id)
1401            .into_iter()
1402            .filter(|node_id| self.node_matches_segment(*node_id, first))
1403            .collect();
1404        self.follow_remaining(anchors, rest)
1405    }
1406
1407    fn follow_remaining(
1408        &self,
1409        mut current: Vec<FormNodeId>,
1410        segments: &[xfa_dom_resolver::som::SomSegment],
1411    ) -> Vec<FormNodeId> {
1412        for segment in segments {
1413            current = current
1414                .into_iter()
1415                .flat_map(|node_id| self.step_from_node(node_id, segment, false))
1416                .collect();
1417            if current.is_empty() {
1418                break;
1419            }
1420        }
1421        current
1422    }
1423
1424    fn step_from_node(
1425        &self,
1426        node_id: FormNodeId,
1427        segment: &xfa_dom_resolver::som::SomSegment,
1428        allow_self: bool,
1429    ) -> Vec<FormNodeId> {
1430        // XFA-F3-06: `..` (parent) navigation — a segment whose name is an
1431        // empty string (produced by the `.` separator after `..` in the raw path)
1432        // or literally ".." navigates to the parent node.
1433        if let SomSelector::Name(name) = &segment.selector {
1434            if name == ".." {
1435                // Navigate to parent
1436                if let Some(&parent_id) = self.parents.get(&node_id) {
1437                    return apply_index_to_single(parent_id, segment.index);
1438                }
1439                return Vec::new();
1440            }
1441        }
1442
1443        if allow_self && self.node_matches_selector(node_id, &segment.selector) {
1444            return apply_index_to_single(node_id, segment.index);
1445        }
1446
1447        let matches: Vec<_> = self
1448            .form
1449            .get(node_id)
1450            .children
1451            .iter()
1452            .copied()
1453            .filter(|child_id| self.node_matches_selector(*child_id, &segment.selector))
1454            .collect();
1455
1456        apply_index(matches, segment.index)
1457    }
1458
1459    fn node_matches_segment(
1460        &self,
1461        node_id: FormNodeId,
1462        segment: &xfa_dom_resolver::som::SomSegment,
1463    ) -> bool {
1464        if !self.node_matches_selector(node_id, &segment.selector) {
1465            return false;
1466        }
1467
1468        match segment.index {
1469            SomIndex::All => true,
1470            SomIndex::None => self.sibling_position(node_id, &segment.selector) == Some(0),
1471            SomIndex::Specific(idx) => {
1472                self.sibling_position(node_id, &segment.selector) == Some(idx)
1473            }
1474        }
1475    }
1476
1477    fn sibling_position(&self, node_id: FormNodeId, selector: &SomSelector) -> Option<usize> {
1478        let Some(parent_id) = self.parents.get(&node_id).copied() else {
1479            return self.node_matches_selector(node_id, selector).then_some(0);
1480        };
1481
1482        self.form
1483            .get(parent_id)
1484            .children
1485            .iter()
1486            .copied()
1487            .filter(|candidate| self.node_matches_selector(*candidate, selector))
1488            .position(|candidate| candidate == node_id)
1489    }
1490
1491    fn node_matches_selector(&self, node_id: FormNodeId, selector: &SomSelector) -> bool {
1492        match selector {
1493            SomSelector::Name(name) => self.form.get(node_id).name == *name,
1494            SomSelector::Class(class_name) => self.node_matches_class(node_id, class_name),
1495            SomSelector::AllChildren => true,
1496        }
1497    }
1498
1499    fn node_matches_class(&self, node_id: FormNodeId, class_name: &str) -> bool {
1500        let class_name = class_name.to_ascii_lowercase();
1501        match class_name.as_str() {
1502            "subform" => matches!(
1503                self.form.get(node_id).node_type,
1504                FormNodeType::Root | FormNodeType::Subform
1505            ),
1506            "pageset" => {
1507                matches!(self.form.get(node_id).node_type, FormNodeType::PageSet)
1508            }
1509            "pagearea" => matches!(
1510                self.form.get(node_id).node_type,
1511                FormNodeType::PageArea { .. }
1512            ),
1513            "field" => matches!(self.form.get(node_id).node_type, FormNodeType::Field { .. }),
1514            "draw" => matches!(
1515                self.form.get(node_id).node_type,
1516                FormNodeType::Draw(_) | FormNodeType::Image { .. }
1517            ),
1518            "exclgroup" => self.form.meta(node_id).group_kind == GroupKind::ExclusiveChoice,
1519            _ => false,
1520        }
1521    }
1522}
1523
1524impl SomResolver for FormTreeSomResolver<'_> {
1525    fn resolve_path(
1526        &mut self,
1527        path: &str,
1528    ) -> formcalc_interpreter::error::Result<Option<FormCalcValue>> {
1529        let Some(target) = self.resolve_target(path) else {
1530            // XFA-F3-06: log a warning instead of silently returning None so
1531            // that SOM path failures are diagnosable.
1532            if !path.trim().is_empty() {
1533                log::warn!("SOM bridge: path not resolved: {:?}", path.trim());
1534            }
1535            return Ok(None);
1536        };
1537        Ok(Some(read_formcalc_value(
1538            self.form,
1539            self.root_id,
1540            self.parents,
1541            target,
1542        )))
1543    }
1544
1545    fn assign_path(
1546        &mut self,
1547        path: &str,
1548        value: FormCalcValue,
1549    ) -> formcalc_interpreter::error::Result<bool> {
1550        let Some(target) = self.resolve_target(path) else {
1551            // XFA-F3-06: descriptive warning on assignment failure.
1552            if !path.trim().is_empty() {
1553                log::warn!("SOM bridge: assignment target not found: {:?}", path.trim());
1554            }
1555            return Ok(false);
1556        };
1557        self.changes += write_formcalc_value(self.form, target.node_id, target.property, value);
1558        Ok(true)
1559    }
1560
1561    fn count_path_matches(&mut self, path: &str) -> formcalc_interpreter::error::Result<usize> {
1562        Ok(self.count_targets(path))
1563    }
1564}
1565
1566fn split_property_path(path: &str) -> Option<(SomExpression, ResolvedProperty)> {
1567    let normalized = if let Some(rest) = path.strip_prefix("this.") {
1568        format!("$.{rest}")
1569    } else if path == "this" {
1570        "$".to_string()
1571    } else {
1572        path.to_string()
1573    };
1574
1575    let mut expr = parse_som(&normalized).ok()?;
1576    let property = if let Some(last) = expr.segments.last() {
1577        match &last.selector {
1578            SomSelector::Name(name) => {
1579                parse_property_name(name).unwrap_or(ResolvedProperty::RawValue)
1580            }
1581            _ => ResolvedProperty::RawValue,
1582        }
1583    } else {
1584        ResolvedProperty::RawValue
1585    };
1586
1587    if matches!(
1588        expr.segments.last().map(|segment| &segment.selector),
1589        Some(SomSelector::Name(name)) if parse_property_name(name).is_some()
1590    ) {
1591        expr.segments.pop();
1592    }
1593
1594    Some((expr, property))
1595}
1596
1597fn parse_property_name(name: &str) -> Option<ResolvedProperty> {
1598    match name {
1599        "rawValue" => Some(ResolvedProperty::RawValue),
1600        "presence" => Some(ResolvedProperty::Presence),
1601        "somExpression" => Some(ResolvedProperty::SomExpression),
1602        _ => None,
1603    }
1604}
1605
1606fn strip_xfa_form_prefix(
1607    segments: &[xfa_dom_resolver::som::SomSegment],
1608) -> &[xfa_dom_resolver::som::SomSegment] {
1609    match segments.first() {
1610        Some(segment)
1611            if matches!(&segment.selector, SomSelector::Name(name) if name == "form")
1612                && matches!(segment.index, SomIndex::None) =>
1613        {
1614            &segments[1..]
1615        }
1616        _ => segments,
1617    }
1618}
1619
1620fn apply_index(matches: Vec<FormNodeId>, index: SomIndex) -> Vec<FormNodeId> {
1621    match index {
1622        SomIndex::None => matches.into_iter().take(1).collect(),
1623        SomIndex::Specific(idx) => matches.get(idx).copied().into_iter().collect(),
1624        SomIndex::All => matches,
1625    }
1626}
1627
1628fn apply_index_to_single(node_id: FormNodeId, index: SomIndex) -> Vec<FormNodeId> {
1629    match index {
1630        SomIndex::None | SomIndex::Specific(0) | SomIndex::All => vec![node_id],
1631        SomIndex::Specific(_) => Vec::new(),
1632    }
1633}
1634
1635fn read_formcalc_value(
1636    form: &FormTree,
1637    root_id: FormNodeId,
1638    parents: &HashMap<FormNodeId, FormNodeId>,
1639    target: ResolvedTarget,
1640) -> FormCalcValue {
1641    match target.property {
1642        ResolvedProperty::RawValue => get_formcalc_raw_value(form, target.node_id),
1643        ResolvedProperty::Presence => FormCalcValue::String(
1644            match form.meta(target.node_id).presence {
1645                Presence::Visible => "visible",
1646                Presence::Hidden => "hidden",
1647                Presence::Invisible => "invisible",
1648                Presence::Inactive => "inactive",
1649            }
1650            .to_string(),
1651        ),
1652        ResolvedProperty::SomExpression => {
1653            FormCalcValue::String(build_som_expression(form, root_id, parents, target.node_id))
1654        }
1655    }
1656}
1657
1658fn get_formcalc_raw_value(form: &FormTree, node_id: FormNodeId) -> FormCalcValue {
1659    match &form.get(node_id).node_type {
1660        FormNodeType::Field { value } => string_to_formcalc_value(value),
1661        _ if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice => {
1662            for &child_id in &form.get(node_id).children {
1663                if let FormNodeType::Field { value } = &form.get(child_id).node_type {
1664                    if !value.is_empty() {
1665                        let selected = form.meta(child_id).item_value.as_deref().unwrap_or(value);
1666                        return string_to_formcalc_value(selected);
1667                    }
1668                }
1669            }
1670            FormCalcValue::Null
1671        }
1672        _ => FormCalcValue::Null,
1673    }
1674}
1675
1676fn write_formcalc_value(
1677    form: &mut FormTree,
1678    node_id: FormNodeId,
1679    property: ResolvedProperty,
1680    value: FormCalcValue,
1681) -> usize {
1682    match property {
1683        ResolvedProperty::RawValue => set_raw_value(form, node_id, formcalc_to_script_value(value)),
1684        ResolvedProperty::Presence => {
1685            set_presence(form, node_id, ScriptValue::String(value.to_string_val()))
1686        }
1687        ResolvedProperty::SomExpression => 0,
1688    }
1689}
1690
1691fn string_to_formcalc_value(value: &str) -> FormCalcValue {
1692    let trimmed = value.trim();
1693    if trimmed.is_empty() {
1694        FormCalcValue::Null
1695    } else if let Ok(number) = trimmed.parse::<f64>() {
1696        FormCalcValue::Number(number)
1697    } else {
1698        FormCalcValue::String(value.to_string())
1699    }
1700}
1701
1702fn formcalc_to_script_value(value: FormCalcValue) -> ScriptValue {
1703    match value {
1704        FormCalcValue::Null => ScriptValue::Null,
1705        FormCalcValue::Number(number) => ScriptValue::String(normalize_number(number)),
1706        FormCalcValue::String(value) => ScriptValue::String(value),
1707    }
1708}
1709
1710fn build_som_expression(
1711    form: &FormTree,
1712    root_id: FormNodeId,
1713    parents: &HashMap<FormNodeId, FormNodeId>,
1714    node_id: FormNodeId,
1715) -> String {
1716    let mut parts = Vec::new();
1717    let mut cursor = Some(node_id);
1718    while let Some(current) = cursor {
1719        let node = form.get(current);
1720        if !node.name.is_empty() {
1721            let index = if let Some(parent_id) = parents.get(&current).copied() {
1722                form.get(parent_id)
1723                    .children
1724                    .iter()
1725                    .copied()
1726                    .filter(|sibling_id| form.get(*sibling_id).name == node.name)
1727                    .position(|sibling_id| sibling_id == current)
1728                    .unwrap_or(0)
1729            } else {
1730                0
1731            };
1732            parts.push(format!("{}[{index}]", node.name));
1733        }
1734        if current == root_id {
1735            break;
1736        }
1737        cursor = parents.get(&current).copied();
1738    }
1739    parts.reverse();
1740
1741    if parts.is_empty() {
1742        "$form".to_string()
1743    } else {
1744        format!("$form.{}", parts.join("."))
1745    }
1746}
1747
1748fn descendants_inclusive(form: &FormTree, root_id: FormNodeId) -> Vec<FormNodeId> {
1749    let mut out = Vec::new();
1750    collect_descendants(form, root_id, &mut out);
1751    out
1752}
1753
1754fn collect_descendants(form: &FormTree, node_id: FormNodeId, out: &mut Vec<FormNodeId>) {
1755    out.push(node_id);
1756    for &child_id in &form.get(node_id).children {
1757        collect_descendants(form, child_id, out);
1758    }
1759}
1760
1761fn build_parent_map(form: &FormTree, root_id: FormNodeId) -> HashMap<FormNodeId, FormNodeId> {
1762    let mut parents = HashMap::new();
1763    populate_parent_map(form, root_id, &mut parents);
1764    parents
1765}
1766
1767fn populate_parent_map(
1768    form: &FormTree,
1769    node_id: FormNodeId,
1770    parents: &mut HashMap<FormNodeId, FormNodeId>,
1771) {
1772    for &child_id in &form.get(node_id).children {
1773        parents.insert(child_id, node_id);
1774        populate_parent_map(form, child_id, parents);
1775    }
1776}
1777
1778fn set_raw_value(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
1779    let value = match value {
1780        ScriptValue::Null => String::new(),
1781        ScriptValue::String(value) => value,
1782    };
1783
1784    if form.meta(node_id).group_kind == GroupKind::ExclusiveChoice {
1785        let mut changes = 0;
1786        for &child_id in &form.get(node_id).children.clone() {
1787            let item_value = form.meta(child_id).item_value.clone();
1788            let next = if item_value.as_deref() == Some(value.as_str()) {
1789                value.clone()
1790            } else {
1791                String::new()
1792            };
1793            if let FormNodeType::Field { value: field_value } =
1794                &mut form.get_mut(child_id).node_type
1795            {
1796                if *field_value != next {
1797                    *field_value = next;
1798                    changes += 1;
1799                }
1800            }
1801        }
1802        return changes;
1803    }
1804
1805    if let FormNodeType::Field { value: field_value } = &mut form.get_mut(node_id).node_type {
1806        if *field_value != value {
1807            *field_value = value;
1808            return 1;
1809        }
1810    }
1811
1812    0
1813}
1814
1815fn set_presence(form: &mut FormTree, node_id: FormNodeId, value: ScriptValue) -> usize {
1816    set_presence_inner(form, node_id, value).0
1817}
1818
1819/// Returns `(change_count, Option<(old_str, new_str)>)` for E-4 observability.
1820fn set_presence_inner(
1821    form: &mut FormTree,
1822    node_id: FormNodeId,
1823    value: ScriptValue,
1824) -> (usize, Option<(&'static str, &'static str)>) {
1825    let value = match value {
1826        ScriptValue::Null => return (0, None),
1827        ScriptValue::String(value) => value,
1828    };
1829    let normalized = value.trim().to_ascii_lowercase();
1830    let new_presence = match normalized.as_str() {
1831        "visible" | "open" => Presence::Visible,
1832        "hidden" => Presence::Hidden,
1833        "invisible" => Presence::Invisible,
1834        "inactive" => Presence::Inactive,
1835        _ => return (0, None),
1836    };
1837
1838    let meta = form.meta_mut(node_id);
1839    let old_presence = meta.presence;
1840    if old_presence == new_presence {
1841        return (0, None);
1842    }
1843    meta.presence = new_presence;
1844
1845    fn pres_str(p: Presence) -> &'static str {
1846        match p {
1847            Presence::Visible => "visible",
1848            Presence::Hidden => "hidden",
1849            Presence::Invisible => "invisible",
1850            Presence::Inactive => "inactive",
1851        }
1852    }
1853    let mutation = (pres_str(old_presence), pres_str(new_presence));
1854    if runtime_diag_enabled() {
1855        let node_name = form.get(node_id).name.clone();
1856        push_presence_mutation(node_id.0, &node_name, mutation.0, mutation.1);
1857    }
1858    (1, Some(mutation))
1859}
1860
1861fn normalize_number(number: f64) -> String {
1862    if number.fract().abs() < f64::EPSILON {
1863        (number as i64).to_string()
1864    } else {
1865        number.to_string()
1866    }
1867}
1868
1869#[derive(Debug, Clone, PartialEq, Eq)]
1870enum ScriptValue {
1871    Null,
1872    String(String),
1873}
1874
1875#[cfg(test)]
1876mod tests {
1877    use super::*;
1878    use xfa_layout_engine::form::{
1879        FieldKind, FormNode, FormNodeMeta, FormNodeStyle, GroupKind, Occur,
1880    };
1881    use xfa_layout_engine::text::FontMetrics;
1882    use xfa_layout_engine::types::{BoxModel, LayoutStrategy};
1883
1884    fn add_node(tree: &mut FormTree, name: &str, node_type: FormNodeType) -> FormNodeId {
1885        tree.add_node(FormNode {
1886            name: name.to_string(),
1887            node_type,
1888            box_model: BoxModel::default(),
1889            layout: LayoutStrategy::TopToBottom,
1890            children: Vec::new(),
1891            occur: Occur::once(),
1892            font: FontMetrics::default(),
1893            calculate: None,
1894            validate: None,
1895            column_widths: Vec::new(),
1896            col_span: 1,
1897        })
1898    }
1899
1900    fn empty_meta() -> FormNodeMeta {
1901        FormNodeMeta {
1902            field_kind: FieldKind::Text,
1903            group_kind: GroupKind::None,
1904            style: FormNodeStyle::default(),
1905            ..Default::default()
1906        }
1907    }
1908
1909    fn formcalc_script(script: &str, activity: &str) -> EventScript {
1910        EventScript::formcalc(script, Some(activity))
1911    }
1912
1913    fn javascript_script(script: &str, activity: &str) -> EventScript {
1914        EventScript::javascript(script, Some(activity))
1915    }
1916
1917    fn other_script(script: &str, activity: &str) -> EventScript {
1918        EventScript::new(
1919            script.to_string(),
1920            ScriptLanguage::Other,
1921            Some(activity.to_string()),
1922            None,
1923            None,
1924        )
1925    }
1926
1927    fn script_policy_fixture(include_js: bool) -> (FormTree, FormNodeId, FormNodeId) {
1928        let mut tree = FormTree::new();
1929        let root = add_node(&mut tree, "root", FormNodeType::Root);
1930        let js_hook = add_node(
1931            &mut tree,
1932            "JsHook",
1933            FormNodeType::Field {
1934                value: String::new(),
1935            },
1936        );
1937        let runner = add_node(
1938            &mut tree,
1939            "Runner",
1940            FormNodeType::Field {
1941                value: String::new(),
1942            },
1943        );
1944        let target = add_node(
1945            &mut tree,
1946            "Target",
1947            FormNodeType::Field {
1948                value: String::new(),
1949            },
1950        );
1951
1952        tree.get_mut(root).children = vec![js_hook, runner, target];
1953        if include_js {
1954            tree.meta_mut(js_hook).event_scripts = vec![javascript_script(
1955                "xfa.host.messageBox('skip');",
1956                "initialize",
1957            )];
1958        }
1959        tree.meta_mut(runner).event_scripts =
1960            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];
1961
1962        (tree, root, target)
1963    }
1964
1965    fn other_language_policy_fixture() -> (FormTree, FormNodeId, FormNodeId) {
1966        let mut tree = FormTree::new();
1967        let root = add_node(&mut tree, "root", FormNodeType::Root);
1968        let other = add_node(&mut tree, "OtherHook", FormNodeType::Subform);
1969        let runner = add_node(&mut tree, "Runner", FormNodeType::Subform);
1970        let target = add_node(
1971            &mut tree,
1972            "Target",
1973            FormNodeType::Field {
1974                value: String::new(),
1975            },
1976        );
1977
1978        tree.get_mut(root).children = vec![other, runner, target];
1979        tree.meta_mut(other).event_scripts = vec![other_script("MsgBox \"skip\"", "initialize")];
1980        tree.meta_mut(runner).event_scripts =
1981            vec![formcalc_script(r#"Target.rawValue = "ran""#, "initialize")];
1982
1983        (tree, root, target)
1984    }
1985
1986    fn field_value(tree: &FormTree, node_id: FormNodeId) -> &str {
1987        match &tree.get(node_id).node_type {
1988            FormNodeType::Field { value } => value,
1989            _ => panic!("expected field"),
1990        }
1991    }
1992
1993    #[test]
1994    fn change_event_toggles_relative_hidden_subform() {
1995        let mut tree = FormTree::new();
1996        let root = add_node(&mut tree, "root", FormNodeType::Root);
1997        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
1998        let group = add_node(&mut tree, "Choice", FormNodeType::Subform);
1999        let option1 = add_node(
2000            &mut tree,
2001            "Option1",
2002            FormNodeType::Field {
2003                value: "1".to_string(),
2004            },
2005        );
2006        let option2 = add_node(
2007            &mut tree,
2008            "Option2",
2009            FormNodeType::Field {
2010                value: String::new(),
2011            },
2012        );
2013        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2014
2015        tree.get_mut(root).children = vec![section];
2016        tree.get_mut(section).children = vec![group, details];
2017        tree.get_mut(group).children = vec![option1, option2];
2018
2019        tree.meta_mut(group).group_kind = GroupKind::ExclusiveChoice;
2020        tree.meta_mut(group).event_scripts = vec![formcalc_script(
2021            r#"
2022Details.presence = "hidden"
2023if (this.rawValue == 1) then
2024  Details.presence = "visible"
2025endif
2026"#,
2027            "initialize",
2028        )];
2029        tree.meta_mut(option1).item_value = Some("1".into());
2030        tree.meta_mut(option2).item_value = Some("2".into());
2031        tree.meta_mut(details).presence = Presence::Hidden;
2032
2033        apply_dynamic_scripts(&mut tree, root).unwrap();
2034
2035        assert_eq!(tree.meta(details).presence, Presence::Visible);
2036    }
2037
2038    #[test]
2039    fn calculate_script_on_hidden_block_uses_sibling_values() {
2040        let mut tree = FormTree::new();
2041        let root = add_node(&mut tree, "root", FormNodeType::Root);
2042        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
2043        let option1 = add_node(
2044            &mut tree,
2045            "Opt1",
2046            FormNodeType::Field {
2047                value: "1".to_string(),
2048            },
2049        );
2050        let option2 = add_node(
2051            &mut tree,
2052            "Opt2",
2053            FormNodeType::Field {
2054                value: String::new(),
2055            },
2056        );
2057        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2058
2059        tree.get_mut(root).children = vec![section];
2060        tree.get_mut(section).children = vec![option1, option2, details];
2061        tree.meta_mut(details).presence = Presence::Hidden;
2062        tree.meta_mut(details).event_scripts = vec![formcalc_script(
2063            r#"
2064this.presence = "hidden"
2065if ((Opt1.rawValue == 1) or (Opt2.rawValue == 1)) then
2066  this.presence = "visible"
2067endif
2068"#,
2069            "calculate",
2070        )];
2071
2072        apply_dynamic_scripts(&mut tree, root).unwrap();
2073
2074        assert_eq!(tree.meta(details).presence, Presence::Visible);
2075    }
2076
2077    #[test]
2078    fn multi_pass_scripts_propagate_raw_values() {
2079        let mut tree = FormTree::new();
2080        let root = add_node(&mut tree, "root", FormNodeType::Root);
2081        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
2082        let controller = add_node(
2083            &mut tree,
2084            "Controller",
2085            FormNodeType::Field {
2086                value: "1".to_string(),
2087            },
2088        );
2089        let target = add_node(
2090            &mut tree,
2091            "Target",
2092            FormNodeType::Field {
2093                value: String::new(),
2094            },
2095        );
2096        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2097
2098        tree.get_mut(root).children = vec![section];
2099        tree.get_mut(section).children = vec![controller, target, details];
2100
2101        tree.meta_mut(controller).event_scripts = vec![formcalc_script(
2102            r#"
2103if (this.rawValue == 1) then
2104  Target.rawValue = 1
2105endif
2106"#,
2107            "calculate",
2108        )];
2109        tree.meta_mut(details).presence = Presence::Hidden;
2110        tree.meta_mut(details).event_scripts = vec![formcalc_script(
2111            r#"
2112this.presence = "hidden"
2113if (Target.rawValue == 1) then
2114  this.presence = "visible"
2115endif
2116"#,
2117            "calculate",
2118        )];
2119
2120        apply_dynamic_scripts(&mut tree, root).unwrap();
2121
2122        if let FormNodeType::Field { value } = &tree.get(target).node_type {
2123            assert_eq!(value, "1");
2124        } else {
2125            panic!("expected field");
2126        }
2127        assert_eq!(tree.meta(details).presence, Presence::Visible);
2128    }
2129
2130    // ─── #1097: FormCalc SOM bridge hardening ────────────────────────────────
2131
2132    /// SOM path `form1.#subform[0].field1.rawValue` resolves correctly on a
2133    /// simple form tree.
2134    #[test]
2135    fn som_path_resolves_on_simple_form_tree() {
2136        let mut tree = FormTree::new();
2137        let root = add_node(&mut tree, "root", FormNodeType::Root);
2138        let form1 = add_node(&mut tree, "form1", FormNodeType::Subform);
2139        let subform = add_node(&mut tree, "subform1", FormNodeType::Subform);
2140        let field1 = add_node(
2141            &mut tree,
2142            "field1",
2143            FormNodeType::Field {
2144                value: "hello".to_string(),
2145            },
2146        );
2147
2148        tree.get_mut(root).children = vec![form1];
2149        tree.get_mut(form1).children = vec![subform];
2150        tree.get_mut(subform).children = vec![field1];
2151
2152        // Use a calculate script to read the value via absolute SOM path
2153        tree.meta_mut(root).event_scripts = vec![formcalc_script(
2154            "form1.subform1.field1.rawValue",
2155            "calculate",
2156        )];
2157
2158        let parents = super::build_parent_map(&tree, root);
2159        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);
2160        let target = resolver.resolve_target("form1.subform1.field1.rawValue");
2161        assert!(target.is_some(), "SOM path must resolve to a node");
2162        let target = target.unwrap();
2163        let val = super::read_formcalc_value(&tree, root, &parents, target);
2164        match val {
2165            formcalc_interpreter::value::Value::String(s) => assert_eq!(s, "hello"),
2166            formcalc_interpreter::value::Value::Number(n) => {
2167                // number coercion: not expected here
2168                panic!("expected string, got number {n}")
2169            }
2170            _ => panic!("expected string value"),
2171        }
2172    }
2173
2174    /// An invalid SOM path returns `None` (descriptive non-panic failure).
2175    #[test]
2176    fn invalid_som_path_returns_none_not_panic() {
2177        let mut tree = FormTree::new();
2178        let root = add_node(&mut tree, "root", FormNodeType::Root);
2179        let parents = super::build_parent_map(&tree, root);
2180        let resolver = FormTreeSomResolver::new(&mut tree, root, &parents, root);
2181
2182        // This should not panic — it should return None
2183        let result = resolver.resolve_target("nonexistent.deep.path.rawValue");
2184        assert!(
2185            result.is_none(),
2186            "invalid SOM path must return None, not panic"
2187        );
2188    }
2189
2190    #[test]
2191    fn best_effort_skips_javascript_and_runs_formcalc() {
2192        let (mut tree, root, target) = script_policy_fixture(true);
2193
2194        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2195
2196        assert_eq!(field_value(&tree, target), "ran");
2197        assert!(outcome.js_present);
2198        assert_eq!(outcome.js_skipped, 1);
2199        assert_eq!(outcome.other_skipped, 0);
2200        assert_eq!(outcome.formcalc_run, 1);
2201        assert_eq!(outcome.formcalc_errors, 0);
2202        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);
2203    }
2204
2205    #[test]
2206    fn strict_mode_preserves_javascript_reject() {
2207        let (mut tree, root, target) = script_policy_fixture(true);
2208
2209        let err =
2210            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2211
2212        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2213        assert_eq!(field_value(&tree, target), "");
2214    }
2215
2216    #[test]
2217    fn formcalc_only_reports_exact_quality() {
2218        let (mut tree, root, target) = script_policy_fixture(false);
2219
2220        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2221
2222        assert_eq!(field_value(&tree, target), "ran");
2223        assert!(!outcome.js_present);
2224        assert_eq!(outcome.js_skipped, 0);
2225        assert_eq!(outcome.other_skipped, 0);
2226        assert_eq!(outcome.formcalc_run, 1);
2227        assert_eq!(outcome.formcalc_errors, 0);
2228        assert_eq!(outcome.output_quality, OutputQuality::Exact);
2229    }
2230
2231    #[test]
2232    fn other_language_scripts_skip_in_best_effort_and_reject_in_strict() {
2233        let (mut tree, root, target) = other_language_policy_fixture();
2234        let (mut strict_tree, strict_root, _) = other_language_policy_fixture();
2235
2236        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2237        assert_eq!(field_value(&tree, target), "ran");
2238        assert_eq!(outcome.js_skipped, 0);
2239        assert_eq!(outcome.other_skipped, 1);
2240        assert_eq!(outcome.formcalc_run, 1);
2241        assert_eq!(outcome.output_quality, OutputQuality::BestEffort);
2242
2243        let err =
2244            apply_dynamic_scripts_with_mode(&mut strict_tree, strict_root, JsExecutionMode::Strict)
2245                .unwrap_err();
2246        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2247    }
2248
2249    #[test]
2250    fn javascript_direct_executor_call_is_still_denied() {
2251        let mut tree = FormTree::new();
2252        let root = add_node(&mut tree, "root", FormNodeType::Root);
2253        let trigger = add_node(
2254            &mut tree,
2255            "Trigger",
2256            FormNodeType::Field {
2257                value: String::new(),
2258            },
2259        );
2260        tree.get_mut(root).children = vec![trigger];
2261
2262        let parents = build_parent_map(&tree, root);
2263        let script = javascript_script("xfa.host.messageBox('deny');", "initialize");
2264        let err = execute_event_script(
2265            &mut tree,
2266            root,
2267            &parents,
2268            trigger,
2269            &script,
2270            ScriptPhase::Initialize,
2271        )
2272        .unwrap_err();
2273
2274        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2275    }
2276
2277    #[test]
2278    fn javascript_resolve_node_call_is_explicitly_denied() {
2279        let mut tree = FormTree::new();
2280        let root = add_node(&mut tree, "root", FormNodeType::Root);
2281        let form = add_node(&mut tree, "formulier1", FormNodeType::Subform);
2282        let admin = add_node(&mut tree, "ADMIN", FormNodeType::Subform);
2283        let lock = add_node(
2284            &mut tree,
2285            "LockForm_AD",
2286            FormNodeType::Field {
2287                value: "1".to_string(),
2288            },
2289        );
2290        let reset = add_node(
2291            &mut tree,
2292            "Reset",
2293            FormNodeType::Field {
2294                value: "1".to_string(),
2295            },
2296        );
2297
2298        tree.get_mut(root).children = vec![form];
2299        tree.get_mut(form).children = vec![admin, reset];
2300        tree.get_mut(admin).children = vec![lock];
2301        tree.meta_mut(reset).event_scripts = vec![javascript_script(
2302            r#"xfa.resolveNode("formulier1.ADMIN.LockForm_AD").rawValue = 0;"#,
2303            "initialize",
2304        )];
2305
2306        let err =
2307            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2308        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2309
2310        if let FormNodeType::Field { value } = &tree.get(lock).node_type {
2311            assert_eq!(value, "1");
2312        } else {
2313            panic!("expected field");
2314        }
2315    }
2316
2317    #[test]
2318    fn javascript_utils_hide_if_empty_is_explicitly_denied() {
2319        let mut tree = FormTree::new();
2320        let root = add_node(&mut tree, "root", FormNodeType::Root);
2321        let empty = add_node(
2322            &mut tree,
2323            "EmptyField",
2324            FormNodeType::Field {
2325                value: String::new(),
2326            },
2327        );
2328        tree.get_mut(root).children = vec![empty];
2329        tree.meta_mut(empty).event_scripts =
2330            vec![javascript_script("Utils.hideIfEmpty(this);", "initialize")];
2331
2332        let err =
2333            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2334        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2335
2336        assert!(!tree.meta(empty).presence.is_not_visible());
2337    }
2338
2339    #[test]
2340    fn malformed_javascript_payload_is_explicitly_denied_without_panic() {
2341        let mut tree = FormTree::new();
2342        let root = add_node(&mut tree, "root", FormNodeType::Root);
2343        let container = add_node(&mut tree, "Container", FormNodeType::Subform);
2344        let empty = add_node(
2345            &mut tree,
2346            "EmptyField",
2347            FormNodeType::Field {
2348                value: String::new(),
2349            },
2350        );
2351
2352        tree.get_mut(root).children = vec![container];
2353        tree.get_mut(container).children = vec![empty];
2354        tree.meta_mut(empty).event_scripts = vec![javascript_script(
2355            "\0}{{not.valid.javascript(",
2356            "initialize",
2357        )];
2358
2359        let err =
2360            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::Strict).unwrap_err();
2361        assert!(matches!(err, XfaError::UnsupportedFeature(feature) if feature == "javascript"));
2362
2363        assert!(!tree.meta(container).presence.is_not_visible());
2364    }
2365
2366    #[test]
2367    fn default_meta_helper_is_constructible() {
2368        let meta = empty_meta();
2369        assert_eq!(meta.group_kind, GroupKind::None);
2370    }
2371
2372    #[test]
2373    fn calculate_event_applies_formcalc_return_value() {
2374        let mut tree = FormTree::new();
2375        let root = add_node(&mut tree, "root", FormNodeType::Root);
2376        let total = add_node(
2377            &mut tree,
2378            "Total",
2379            FormNodeType::Field {
2380                value: String::new(),
2381            },
2382        );
2383
2384        tree.get_mut(root).children = vec![total];
2385        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];
2386
2387        apply_dynamic_scripts(&mut tree, root).unwrap();
2388
2389        match &tree.get(total).node_type {
2390            FormNodeType::Field { value } => assert_eq!(value, "42"),
2391            _ => panic!("expected field"),
2392        }
2393    }
2394
2395    #[test]
2396    fn calculate_event_resolves_bare_field_names_as_raw_values() {
2397        let mut tree = FormTree::new();
2398        let root = add_node(&mut tree, "root", FormNodeType::Root);
2399        let section = add_node(&mut tree, "Section", FormNodeType::Subform);
2400        let number1 = add_node(
2401            &mut tree,
2402            "Number1",
2403            FormNodeType::Field {
2404                value: "40".to_string(),
2405            },
2406        );
2407        let number2 = add_node(
2408            &mut tree,
2409            "Number2",
2410            FormNodeType::Field {
2411                value: "2".to_string(),
2412            },
2413        );
2414        let total = add_node(
2415            &mut tree,
2416            "Total",
2417            FormNodeType::Field {
2418                value: String::new(),
2419            },
2420        );
2421
2422        tree.get_mut(root).children = vec![section];
2423        tree.get_mut(section).children = vec![number1, number2, total];
2424        tree.meta_mut(total).event_scripts =
2425            vec![formcalc_script("Number1 + Number2", "calculate")];
2426
2427        apply_dynamic_scripts(&mut tree, root).unwrap();
2428
2429        match &tree.get(total).node_type {
2430            FormNodeType::Field { value } => assert_eq!(value, "42"),
2431            _ => panic!("expected field"),
2432        }
2433    }
2434
2435    #[test]
2436    fn click_events_are_skipped_during_flatten() {
2437        let mut tree = FormTree::new();
2438        let root = add_node(&mut tree, "root", FormNodeType::Root);
2439        let trigger = add_node(
2440            &mut tree,
2441            "Trigger",
2442            FormNodeType::Field {
2443                value: "1".to_string(),
2444            },
2445        );
2446        let details = add_node(&mut tree, "Details", FormNodeType::Subform);
2447
2448        tree.get_mut(root).children = vec![trigger, details];
2449        tree.meta_mut(details).presence = Presence::Hidden;
2450        tree.meta_mut(trigger).event_scripts = vec![formcalc_script(
2451            r#"
2452Details.presence = "visible"
2453"#,
2454            "click",
2455        )];
2456
2457        apply_dynamic_scripts(&mut tree, root).unwrap();
2458
2459        assert_eq!(tree.meta(details).presence, Presence::Hidden);
2460    }
2461
2462    #[test]
2463    fn rollback_when_scripts_mostly_error() {
2464        // Set up a form with fields that have values.  Attach scripts that
2465        // will fail to parse so that errors > successes.  After
2466        // apply_dynamic_scripts the field values must be unchanged.
2467        let mut tree = FormTree::new();
2468        let root = add_node(&mut tree, "root", FormNodeType::Root);
2469        let field_a = add_node(
2470            &mut tree,
2471            "FieldA",
2472            FormNodeType::Field {
2473                value: "hello".to_string(),
2474            },
2475        );
2476        let field_b = add_node(
2477            &mut tree,
2478            "FieldB",
2479            FormNodeType::Field {
2480                value: "world".to_string(),
2481            },
2482        );
2483
2484        tree.get_mut(root).children = vec![field_a, field_b];
2485
2486        // Two scripts that fail parsing (invalid FormCalc), zero successes.
2487        tree.meta_mut(field_a).event_scripts = vec![formcalc_script("@@INVALID@@", "initialize")];
2488        tree.meta_mut(field_b).event_scripts =
2489            vec![formcalc_script("@@ALSO_BROKEN@@", "initialize")];
2490
2491        apply_dynamic_scripts(&mut tree, root).unwrap();
2492
2493        // Fields should retain their original values (rollback).
2494        match &tree.get(field_a).node_type {
2495            FormNodeType::Field { value } => assert_eq!(value, "hello"),
2496            _ => panic!("expected field"),
2497        }
2498        match &tree.get(field_b).node_type {
2499            FormNodeType::Field { value } => assert_eq!(value, "world"),
2500            _ => panic!("expected field"),
2501        }
2502    }
2503
2504    #[test]
2505    fn rollback_when_populated_fields_go_empty() {
2506        // Calculate scripts returning Null clear field values. When >50%
2507        // of populated fields go empty, the rollback heuristic fires.
2508        let mut tree = FormTree::new();
2509        let root = add_node(&mut tree, "root", FormNodeType::Root);
2510        let field_a = add_node(
2511            &mut tree,
2512            "FieldA",
2513            FormNodeType::Field {
2514                value: "keep".to_string(),
2515            },
2516        );
2517        let field_b = add_node(
2518            &mut tree,
2519            "FieldB",
2520            FormNodeType::Field {
2521                value: "also_keep".to_string(),
2522            },
2523        );
2524
2525        tree.get_mut(root).children = vec![field_a, field_b];
2526
2527        // Calculate scripts whose return value (Null) is written to the field,
2528        // blanking it.  The expression `Null()` is not a real FormCalc builtin,
2529        // but `0` would set the field to "0" (not empty).  Instead we use the
2530        // snapshot/rollback logic directly.
2531        // We test the heuristic by manually setting up the condition.
2532        let snapshot = super::snapshot_form(&tree);
2533
2534        // Simulate scripts clearing both fields.
2535        if let FormNodeType::Field { value } = &mut tree.get_mut(field_a).node_type {
2536            *value = String::new();
2537        }
2538        if let FormNodeType::Field { value } = &mut tree.get_mut(field_b).node_type {
2539            *value = String::new();
2540        }
2541
2542        assert!(super::should_rollback(&tree, &snapshot, 0, 2));
2543
2544        super::restore_snapshot(&mut tree, &snapshot);
2545
2546        match &tree.get(field_a).node_type {
2547            FormNodeType::Field { value } => assert_eq!(value, "keep"),
2548            _ => panic!("expected field"),
2549        }
2550        match &tree.get(field_b).node_type {
2551            FormNodeType::Field { value } => assert_eq!(value, "also_keep"),
2552            _ => panic!("expected field"),
2553        }
2554    }
2555
2556    #[test]
2557    fn no_rollback_when_scripts_succeed() {
2558        // A single working script with no errors → no rollback, change persists.
2559        let mut tree = FormTree::new();
2560        let root = add_node(&mut tree, "root", FormNodeType::Root);
2561        let total = add_node(
2562            &mut tree,
2563            "Total",
2564            FormNodeType::Field {
2565                value: String::new(),
2566            },
2567        );
2568
2569        tree.get_mut(root).children = vec![total];
2570        tree.meta_mut(total).event_scripts = vec![formcalc_script("40 + 2", "calculate")];
2571
2572        apply_dynamic_scripts(&mut tree, root).unwrap();
2573
2574        match &tree.get(total).node_type {
2575            FormNodeType::Field { value } => assert_eq!(value, "42"),
2576            _ => panic!("expected field"),
2577        }
2578    }
2579
2580    /// QF1-C regression: `formcalc_errors` counter MUST tick when a FormCalc
2581    /// script invokes an unknown function. This is the canonical signal the
2582    /// QF1-C residual scan (`scripts/xfa_formcalc_residual_scan.py`) aggregates
2583    /// off the `XFA script metadata:` stderr line.
2584    #[test]
2585    fn formcalc_unknown_function_increments_error_counter() {
2586        let mut tree = FormTree::new();
2587        let root = add_node(&mut tree, "root", FormNodeType::Root);
2588        let total = add_node(
2589            &mut tree,
2590            "Total",
2591            FormNodeType::Field {
2592                value: String::new(),
2593            },
2594        );
2595
2596        tree.get_mut(root).children = vec![total];
2597        // `definitelyNotAFormCalcBuiltin(1)` triggers `FormCalcError::UnknownFunction`
2598        // in `crates/formcalc-interpreter/src/interpreter.rs`.
2599        tree.meta_mut(total).event_scripts = vec![formcalc_script(
2600            "definitelyNotAFormCalcBuiltin(1)",
2601            "calculate",
2602        )];
2603
2604        let outcome = apply_dynamic_scripts(&mut tree, root).unwrap();
2605        assert_eq!(outcome.formcalc_run, 1, "the script must be attempted");
2606        assert_eq!(
2607            outcome.formcalc_errors, 1,
2608            "unknown-function failure must increment formcalc_errors"
2609        );
2610    }
2611
2612    // ─── Epic A enrichment tests ──────────────────────────────────────────────
2613    //
2614    // Environment variable writes are not `std::sync::atomic` — we serialise all
2615    // three tests with a process-wide `Mutex` so they cannot race on the env.
2616    static ENV_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
2617
2618    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2619        ENV_LOCK
2620            .get_or_init(|| std::sync::Mutex::new(()))
2621            .lock()
2622            .unwrap_or_else(|e| e.into_inner())
2623    }
2624
2625    /// E-1: `script_lifecycle` is populated when `XFA_FLATTEN_TRACE=1`.
2626    ///
2627    /// Builds a FormTree with a single JavaScript `initialize` event on a field.
2628    /// In `BestEffortStatic` mode the script is skipped but still recorded in
2629    /// the lifecycle vec with outcome `"skipped_mode"`.
2630    #[test]
2631    fn script_lifecycle_populated_when_trace_enabled() {
2632        let _guard = env_lock();
2633        std::env::set_var("XFA_FLATTEN_TRACE", "1");
2634
2635        let mut tree = FormTree::new();
2636        let root = add_node(&mut tree, "root", FormNodeType::Root);
2637        let field = add_node(
2638            &mut tree,
2639            "TraceField",
2640            FormNodeType::Field {
2641                value: String::new(),
2642            },
2643        );
2644        tree.get_mut(root).children = vec![field];
2645        tree.meta_mut(field).event_scripts = vec![javascript_script(
2646            "xfa.host.messageBox('trace');",
2647            "initialize",
2648        )];
2649
2650        let outcome =
2651            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::BestEffortStatic)
2652                .unwrap();
2653
2654        std::env::remove_var("XFA_FLATTEN_TRACE");
2655
2656        assert!(
2657            !outcome.script_lifecycle.is_empty(),
2658            "script_lifecycle must have at least one entry when XFA_FLATTEN_TRACE=1"
2659        );
2660        let entry = &outcome.script_lifecycle[0];
2661        assert_eq!(entry.node_name, "TraceField");
2662        assert_eq!(entry.activity, "initialize");
2663        assert_eq!(entry.lang, "javascript");
2664        // BestEffortStatic skips JS entirely — outcome must reflect that.
2665        assert_eq!(
2666            entry.outcome, "skipped_mode",
2667            "BestEffortStatic JS must appear as skipped_mode in lifecycle"
2668        );
2669    }
2670
2671    /// E-6: `skipped_activities` tallies correctly for a `click` script.
2672    ///
2673    /// A JavaScript script with `activity="click"` cannot be executed during
2674    /// flatten (not in the sandbox allowlist and skipped by BestEffortStatic).
2675    /// When `XFA_FLATTEN_TRACE=1` the `skipped_activities.click` counter must
2676    /// increment by exactly 1.
2677    #[test]
2678    fn skipped_activities_tallies_click_correctly() {
2679        let _guard = env_lock();
2680        std::env::set_var("XFA_FLATTEN_TRACE", "1");
2681
2682        let mut tree = FormTree::new();
2683        let root = add_node(&mut tree, "root", FormNodeType::Root);
2684        let btn = add_node(
2685            &mut tree,
2686            "ClickBtn",
2687            FormNodeType::Field {
2688                value: String::new(),
2689            },
2690        );
2691        tree.get_mut(root).children = vec![btn];
2692        // `click` activity — never executed during flatten regardless of mode.
2693        tree.meta_mut(btn).event_scripts = vec![javascript_script(
2694            "xfa.host.messageBox('clicked');",
2695            "click",
2696        )];
2697
2698        let outcome =
2699            apply_dynamic_scripts_with_mode(&mut tree, root, JsExecutionMode::BestEffortStatic)
2700                .unwrap();
2701
2702        std::env::remove_var("XFA_FLATTEN_TRACE");
2703
2704        assert_eq!(
2705            outcome.skipped_activities.click, 1,
2706            "exactly one click-activity JS script must be tallied in skipped_activities.click"
2707        );
2708        // Other buckets must stay at zero.
2709        assert_eq!(outcome.skipped_activities.initialize, 0);
2710        assert_eq!(outcome.skipped_activities.calculate, 0);
2711        assert_eq!(outcome.skipped_activities.other, 0);
2712    }
2713
2714    /// E-5: `form_dom_match_failures` increments when `apply_form_dom_presence`
2715    /// suppresses a named subform absent from the form DOM.
2716    ///
2717    /// The FormTree contains a named `Subform` child ("Ghost") that does NOT
2718    /// appear in the form XML packet.  The form XML contains a sibling child
2719    /// ("Present") so that the "has_subform_children" guard inside
2720    /// `apply_form_dom_presence` fires and suppression logic runs.
2721    #[test]
2722    fn form_dom_match_failures_increments_for_unmatched_subform() {
2723        use crate::flatten::{apply_form_dom_presence, XfaRenderingPolicy};
2724
2725        let _guard = env_lock();
2726        // E-5 diag log is armed when XFA_FLATTEN_TRACE or XFA_RUNTIME_DIAG is set.
2727        std::env::set_var("XFA_FLATTEN_TRACE", "1");
2728
2729        let mut tree = FormTree::new();
2730        let root = add_node(&mut tree, "root", FormNodeType::Root);
2731        // "form1" is the top-level subform that the form XML root-subform matches.
2732        let form1 = add_node(&mut tree, "form1", FormNodeType::Subform);
2733        // "Present" appears in the form DOM — will be matched.
2734        let present = add_node(&mut tree, "Present", FormNodeType::Subform);
2735        // "Ghost" is NOT in the form DOM — will be suppressed.
2736        let ghost = add_node(&mut tree, "Ghost", FormNodeType::Subform);
2737
2738        tree.get_mut(root).children = vec![form1];
2739        tree.get_mut(form1).children = vec![present, ghost];
2740
2741        // Minimal form XML: root subform "form1" contains one child "Present"
2742        // but no "Ghost" child.  Because "Present" is listed, the
2743        // `has_subform_children` guard fires and "Ghost" is suppressed.
2744        let form_xml = r#"<form>
2745  <subform name="form1">
2746    <subform name="Present"/>
2747  </subform>
2748</form>"#;
2749
2750        let (_admitted, match_failures, match_log) = apply_form_dom_presence(
2751            &mut tree,
2752            root,
2753            form_xml,
2754            XfaRenderingPolicy::SavedStateFaithful,
2755            false,
2756        );
2757
2758        std::env::remove_var("XFA_FLATTEN_TRACE");
2759
2760        assert_eq!(
2761            match_failures, 1,
2762            "exactly one unmatched named subform (Ghost) must be counted"
2763        );
2764        assert_eq!(match_log.len(), 1, "match_log must contain the Ghost entry");
2765        assert_eq!(match_log[0].template_node_name, "Ghost");
2766        assert_eq!(match_log[0].reason, "formdom_unmatched_suppressed");
2767        // Verify presence was actually suppressed on the FormTree node.
2768        assert!(
2769            tree.meta(ghost).presence.is_not_visible(),
2770            "Ghost subform must have been hidden by apply_form_dom_presence"
2771        );
2772    }
2773}