Skip to main content

pdf_xfa/js_runtime/
mod.rs

1//! M3-B Phase B — JavaScript runtime adapter (skeleton).
2//!
3//! This module is the integration boundary between `crates/pdf-xfa`'s
4//! flatten pipeline and a sandboxed JavaScript runtime. Phase B ships
5//! the boundary plus a `NullRuntime` stub. The rquickjs-backed runtime
6//! is gated behind the `xfa-js-sandboxed` Cargo feature and registers
7//! **no host bindings** — Phase C adds the first useful set per
8//! `benchmarks/runs/M3B_HOST_BINDINGS_MINIMUM_SET.md`.
9//!
10//! The default behaviour of [`crate::dynamic::apply_dynamic_scripts`]
11//! and `flatten_xfa_to_pdf` is unchanged: with the feature off and
12//! mode `BestEffortStatic`, the runtime is never invoked. Adding the
13//! adapter is intentionally behaviour-neutral.
14//!
15//! See `benchmarks/runs/M3B_RUNTIME_SECURITY_MODEL.md` for the 18
16//! invariants the adapter must respect (S-1..S-18).
17
18// Copyright (c) 2026 Innovation Trigger B.V.
19//
20// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
21// the PDFluent Commercial Licence. See the LICENSE file in this repository --
22// that file travels with the copy you received, which a URL does not.
23
24pub mod host;
25pub mod null;
26pub mod regex_guard;
27
28#[cfg(feature = "xfa-js-sandboxed")]
29pub mod sandbox_backend;
30
31pub use host::{
32    HostBindings, MutationLogEntry, MAX_INSTANCES_PER_SUBFORM, MAX_ITEMS_PER_LISTBOX,
33    MAX_MUTATIONS_PER_DOC, MAX_RESOLVE_CALLS_PER_SCRIPT, MAX_RESOLVE_RESULTS, MAX_SOM_DEPTH,
34};
35pub use null::NullRuntime;
36#[cfg(feature = "xfa-js-sandboxed")]
37pub use sandbox_backend::SandboxedRuntime;
38/// The 1.0/1.1 name at the module root (`js_runtime::QuickJsRuntime`); the type is `SandboxedRuntime`.
39#[cfg(feature = "xfa-js-sandboxed")]
40pub use sandbox_backend::SandboxedRuntime as QuickJsRuntime;
41
42/// The old path of the script runtime, kept so that code written against 1.0 and 1.1 still compiles.
43/// The engine behind it runs as WebAssembly now (`SandboxedRuntime`); `QuickJsRuntime` is the same type.
44#[cfg(feature = "xfa-js-sandboxed")]
45#[deprecated(
46    since = "1.2.0",
47    note = "use `js_runtime::SandboxedRuntime`; the engine runs as WebAssembly"
48)]
49pub mod rquickjs_backend {
50    /// Alias of [`SandboxedRuntime`](super::SandboxedRuntime).
51    pub use super::sandbox_backend::SandboxedRuntime as QuickJsRuntime;
52}
53
54use xfa_dom_resolver::data_dom::DataDom;
55use xfa_layout_engine::form::{FormNodeId, FormTree};
56
57/// Outcome of evaluating one script body inside the sandbox.
58#[derive(Debug, Clone, Default)]
59pub struct RuntimeOutcome {
60    /// True when the script ran to completion inside the sandbox.
61    pub executed: bool,
62    /// Number of host-tree mutations the script applied via the
63    /// (currently empty) host-binding allowlist. Always 0 in Phase B.
64    pub mutated_field_count: usize,
65}
66
67/// Errors the runtime adapter can emit. Every variant is recoverable
68/// at the dispatch site — the parent flatten never aborts because of
69/// a sandbox error (S-17 fail-open).
70#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
71pub enum SandboxError {
72    /// Cargo feature `xfa-js-sandboxed` not compiled in. Returned by
73    /// the [`null::NullRuntime`] for every `execute_script` call.
74    #[error("sandboxed runtime not compiled in")]
75    NotCompiledIn,
76
77    /// Script body exceeds the per-script size cap (S-11; default 64 KB).
78    #[error("script body exceeds size cap")]
79    BodyTooLarge,
80
81    /// Per-script time budget exceeded (S-9; default 100 ms hard).
82    #[error("script time budget exceeded")]
83    Timeout,
84
85    /// Per-document memory budget exceeded (S-10; default 32 MiB hard).
86    #[error("document memory budget exceeded")]
87    OutOfMemory,
88
89    /// Call stack depth exceeded the configured maximum (S-12; default 64).
90    #[error("call stack overflow")]
91    StackOverflow,
92
93    /// Activity not in the runtime allowlist for sandboxed dispatch
94    /// (S-14). UI / submission activities skip the runtime entirely
95    /// at the dispatch boundary and never reach this error path; this
96    /// variant exists for explicit binding-level phase guards.
97    #[error("activity {0:?} denied for sandbox dispatch")]
98    PhaseDenied(String),
99
100    /// Phase B: no host bindings registered. Returned when a script
101    /// attempts to read or write any `xfa.*` / `field.*` binding the
102    /// adapter has not yet exposed.
103    #[error("no host bindings registered (Phase B skeleton)")]
104    NoBindings,
105
106    /// FFI panic captured via `std::panic::catch_unwind`. Used by the
107    /// rquickjs backend to keep panics from crossing the FFI boundary
108    /// into the Rust caller.
109    #[error("sandbox panic captured: {0}")]
110    PanicCaptured(String),
111
112    /// Generic script-level error: parse, runtime, or thrown JS error.
113    #[error("script error: {0}")]
114    ScriptError(String),
115
116    /// W3-A — REDOS-01 mitigation: the script body contains a regex
117    /// pattern shape known to cause catastrophic backtracking in QuickJS's
118    /// NFA-based engine (e.g. `(a+)+$`). The body is rejected before
119    /// reaching the sandbox to bound CPU time. See
120    /// `crates/pdf-xfa/src/js_runtime/regex_guard.rs` for the heuristic
121    /// catalogue.
122    #[error("regex rejected by ReDoS guard: {0}")]
123    RegexRejected(String),
124
125    /// QF1-E / SEC-01 — defence-in-depth wall-time fallback. The primary
126    /// per-script time budget is enforced via the rquickjs interrupt
127    /// handler that polls at JS opcode boundaries (see
128    /// [`SandboxError::Timeout`]). When the interrupt callback fails to
129    /// fire for an extended period — for example because execution is
130    /// trapped inside a single C-level call (regex, JSON.parse on a
131    /// pathological input, host binding routine) that does not yield
132    /// opcode boundaries — the wall-clock can drift well beyond the
133    /// configured budget. This variant is emitted when total elapsed
134    /// time crossed the fallback threshold
135    /// ([`WALLTIME_FALLBACK_MULTIPLIER_DEFAULT`] × the configured
136    /// time budget). It is strictly a *post-hoc classification*:
137    /// the primary interrupt path remains in charge of actually aborting
138    /// script execution; this variant simply re-labels the error so
139    /// observability can distinguish "clean stop at budget" from
140    /// "stop dragged past 5×". See
141    /// `crates/pdf-xfa/src/js_runtime/rquickjs_backend.rs` and the
142    /// QF1_E report under `benchmarks/runs/xfa_enterprise_plan/quality_factory_v1/`.
143    #[error("wall-time fallback fired: elapsed {0}")]
144    WallTimeExceeded(String),
145}
146
147/// Cumulative metadata for a single document's flatten. The runtime
148/// adapter accumulates counters across calls; the dispatch site reads
149/// them via [`XfaJsRuntime::take_metadata`] when the document is done.
150#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
151pub struct RuntimeMetadata {
152    /// Scripts that ran to completion inside the sandbox.
153    pub executed: usize,
154    /// Generic runtime / script errors (parse, throw, NoBindings, …).
155    pub runtime_errors: usize,
156    /// Time-budget exhaustions.
157    pub timeouts: usize,
158    /// Memory-budget exhaustions.
159    pub oom: usize,
160    /// Phase C host-binding invocations.
161    pub host_calls: usize,
162    /// Phase C successful `field.rawValue` writes.
163    pub mutations: usize,
164    /// Phase D successful instanceManager structure writes.
165    pub instance_writes: usize,
166    /// Phase D-β successful listbox clearItems / addItem writes.
167    pub list_writes: usize,
168    /// Phase C binding-level failures (type, activity, cap, parse).
169    pub binding_errors: usize,
170    /// Phase C SOM resolution misses / failures.
171    pub resolve_failures: usize,
172    /// Phase D-γ successful DataDom reads (children / value / child-by-name).
173    pub data_reads: usize,
174    /// Phase E (XFA-JS-HOST-STUBS): Calls into host capabilities that require
175    /// genuine viewer / user interaction (UI dialogs, signature panels,
176    /// network submit). The sandbox cannot honestly satisfy these during a
177    /// non-interactive flatten; instead of raising a `TypeError` (which would
178    /// abort the script and inflate [`runtime_errors`](Self::runtime_errors))
179    /// the stubs return a safe default value and increment this counter so
180    /// the dispatch site keeps observability of "would-have-been-interactive"
181    /// touch points. Note: this counter is intentionally NOT folded into
182    /// [`is_clean`](Self::is_clean) — a script that touched
183    /// `xfa.host.messageBox` is still considered to have run cleanly because
184    /// the sandbox did not error; embedders that care about UI gaps should
185    /// inspect this field explicitly.
186    pub unsupported_host_calls: usize,
187    /// Phase D-θ.2 probe calls skipped because `parentIds.length == 1 &&
188    /// chain.length == 1` (no same-name ambiguity possible).  Every skipped
189    /// call saves one `resolveWithFullChainStrict` host round-trip.
190    pub probe_skips: usize,
191    /// D3 (trace-only): `<variables>` `<script>` objects collected from the
192    /// template for this document (root + subform scopes).
193    pub variables_scripts_collected: usize,
194    /// D3 (trace-only): `<variables>` `<text>` data items collected.
195    pub variables_data_items_collected: usize,
196    /// D3 (trace-only): script objects whose JS-side registration returned
197    /// success (namespace bound into `variablesScripts` / `subformVariables`).
198    pub script_objects_registered: usize,
199    /// D3 (trace-only): script objects that did NOT register — either a Rust
200    /// skip (`BodyTooLarge` / `RegexRejected` / panic) or a JS-side eval
201    /// failure (`setVariablesScript` returned `false`). Pure observability;
202    /// never folded into [`is_clean`](Self::is_clean) or rollback.
203    pub script_objects_register_failed: usize,
204    /// D3 (trace-only): script objects collected under a NESTED subform scope
205    /// (registered into `subformVariables` only, hence not reachable as a bare
206    /// identifier today — the "scope_hidden" gap class).
207    pub script_objects_subform_scoped: usize,
208    /// D4: total SOM lookups observed at the host resolve boundary (successes +
209    /// failures across the instrumented `resolve_*` entry points).
210    pub som_lookups_total: usize,
211    /// D4: SOM lookups that resolved to at least one node.
212    pub som_lookup_successes: usize,
213    /// D4: SOM lookups that returned NoMatch.
214    pub som_lookup_failures: usize,
215    /// D4: subform-scoped script-object names NOT exposed because the same name
216    /// is declared by ≥2 subforms (fail-closed ambiguity).
217    pub som_lookup_ambiguous: usize,
218    /// D4: subform-scoped script objects exposed to bare-identifier lookup
219    /// (unique-name, sandboxed-only).
220    pub som_subform_scripts_exposed: usize,
221    /// D4 (trace-only): SOM NoMatch references whose path is an `occur` path
222    /// (`occur` / `occur.min` / `occur.max` …). Classified, NOT resolved.
223    pub som_occur_path_refs: usize,
224    /// D5: `node.occur` handle accesses (successes + failures).
225    pub occur_lookups_total: usize,
226    /// D5: `node.occur` accesses where the node handle was live.
227    pub occur_lookup_successes: usize,
228    /// D5: `node.occur` accesses where the node handle was not live.
229    pub occur_lookup_failures: usize,
230    /// D5: reads of an `occur` property (`min`/`max`/`initial`).
231    pub occur_property_reads: usize,
232    /// D5: writes to an `occur` property (captured, not applied).
233    pub occur_property_writes: usize,
234    /// D5: writes specifically to `occur.min`.
235    pub occur_min_writes: usize,
236    /// D5: writes specifically to `occur.max`.
237    pub occur_max_writes: usize,
238    /// D5: occur mutations captured as intent (no layout effect).
239    pub occur_mutations_captured: usize,
240    /// D5: occur mutations APPLIED to layout. **Always 0 in D5** (capture-only);
241    /// D6 bumps this when `XFA_OCCUR_APPLY=1` applies a captured `occur.min`.
242    pub occur_mutations_applied: usize,
243    /// D6: captured occur mutations NOT applied (rollback, apply-flag off,
244    /// dead/non-repeatable target, unsupported prop, negative value).
245    pub occur_mutations_skipped: usize,
246    /// D6: captured occur mutations skipped because the target node is not a
247    /// repeatable container (Subform/Area/ExclGroup) — fail-closed.
248    pub occur_application_ambiguous: usize,
249    /// D6: distinct form nodes whose occur was applied.
250    pub occur_application_targets: usize,
251    /// BE-1: `$data` bare-global intercepts resolved successfully (JS layer).
252    pub som_data_root_hits: usize,
253    /// BE-1: `#items` property accesses resolved to a non-empty item list.
254    pub som_items_path_hits: usize,
255}
256
257impl RuntimeMetadata {
258    /// True when the runtime never reported any error class.
259    pub fn is_clean(&self) -> bool {
260        self.runtime_errors == 0
261            && self.timeouts == 0
262            && self.oom == 0
263            && self.binding_errors == 0
264            && self.resolve_failures == 0
265    }
266
267    /// Add another metadata snapshot into this one.
268    pub fn accumulate(&mut self, other: RuntimeMetadata) {
269        self.executed = self.executed.saturating_add(other.executed);
270        self.runtime_errors = self.runtime_errors.saturating_add(other.runtime_errors);
271        self.timeouts = self.timeouts.saturating_add(other.timeouts);
272        self.oom = self.oom.saturating_add(other.oom);
273        self.host_calls = self.host_calls.saturating_add(other.host_calls);
274        self.mutations = self.mutations.saturating_add(other.mutations);
275        self.instance_writes = self.instance_writes.saturating_add(other.instance_writes);
276        self.list_writes = self.list_writes.saturating_add(other.list_writes);
277        self.binding_errors = self.binding_errors.saturating_add(other.binding_errors);
278        self.resolve_failures = self.resolve_failures.saturating_add(other.resolve_failures);
279        self.data_reads = self.data_reads.saturating_add(other.data_reads);
280        self.unsupported_host_calls = self
281            .unsupported_host_calls
282            .saturating_add(other.unsupported_host_calls);
283        self.probe_skips = self.probe_skips.saturating_add(other.probe_skips);
284        self.variables_scripts_collected = self
285            .variables_scripts_collected
286            .saturating_add(other.variables_scripts_collected);
287        self.variables_data_items_collected = self
288            .variables_data_items_collected
289            .saturating_add(other.variables_data_items_collected);
290        self.script_objects_registered = self
291            .script_objects_registered
292            .saturating_add(other.script_objects_registered);
293        self.script_objects_register_failed = self
294            .script_objects_register_failed
295            .saturating_add(other.script_objects_register_failed);
296        self.script_objects_subform_scoped = self
297            .script_objects_subform_scoped
298            .saturating_add(other.script_objects_subform_scoped);
299        self.som_lookups_total = self
300            .som_lookups_total
301            .saturating_add(other.som_lookups_total);
302        self.som_lookup_successes = self
303            .som_lookup_successes
304            .saturating_add(other.som_lookup_successes);
305        self.som_lookup_failures = self
306            .som_lookup_failures
307            .saturating_add(other.som_lookup_failures);
308        self.som_lookup_ambiguous = self
309            .som_lookup_ambiguous
310            .saturating_add(other.som_lookup_ambiguous);
311        self.som_subform_scripts_exposed = self
312            .som_subform_scripts_exposed
313            .saturating_add(other.som_subform_scripts_exposed);
314        self.som_occur_path_refs = self
315            .som_occur_path_refs
316            .saturating_add(other.som_occur_path_refs);
317        self.occur_lookups_total = self
318            .occur_lookups_total
319            .saturating_add(other.occur_lookups_total);
320        self.occur_lookup_successes = self
321            .occur_lookup_successes
322            .saturating_add(other.occur_lookup_successes);
323        self.occur_lookup_failures = self
324            .occur_lookup_failures
325            .saturating_add(other.occur_lookup_failures);
326        self.occur_property_reads = self
327            .occur_property_reads
328            .saturating_add(other.occur_property_reads);
329        self.occur_property_writes = self
330            .occur_property_writes
331            .saturating_add(other.occur_property_writes);
332        self.occur_min_writes = self.occur_min_writes.saturating_add(other.occur_min_writes);
333        self.occur_max_writes = self.occur_max_writes.saturating_add(other.occur_max_writes);
334        self.occur_mutations_captured = self
335            .occur_mutations_captured
336            .saturating_add(other.occur_mutations_captured);
337        self.occur_mutations_applied = self
338            .occur_mutations_applied
339            .saturating_add(other.occur_mutations_applied);
340        self.occur_mutations_skipped = self
341            .occur_mutations_skipped
342            .saturating_add(other.occur_mutations_skipped);
343        self.occur_application_ambiguous = self
344            .occur_application_ambiguous
345            .saturating_add(other.occur_application_ambiguous);
346        self.occur_application_targets = self
347            .occur_application_targets
348            .saturating_add(other.occur_application_targets);
349        self.som_data_root_hits = self
350            .som_data_root_hits
351            .saturating_add(other.som_data_root_hits);
352        self.som_items_path_hits = self
353            .som_items_path_hits
354            .saturating_add(other.som_items_path_hits);
355    }
356}
357
358/// Epic A E-2/E-3: verbose per-entry diagnostic logs drained alongside
359/// [`RuntimeMetadata`] from [`HostBindings`].  Empty unless
360/// `XFA_RUNTIME_DIAG=1` is set at the HostBindings call sites.
361#[derive(Debug, Default)]
362pub struct RuntimeDiagLogs {
363    /// E-2: SOM resolution misses (capped at 200).
364    pub som_fail_log: Vec<crate::dynamic::SomFailEntry>,
365    /// E-3: instanceManager write events (capped at 200).
366    pub instance_write_log: Vec<crate::dynamic::InstanceWriteEntry>,
367}
368
369/// Default per-script wall-clock budget enforced by the rquickjs
370/// backend (S-9). Exposed as a constant so tests can reason about it
371/// without depending on the runtime backend module.
372pub const DEFAULT_TIME_BUDGET_MS: u64 = 100;
373
374/// QF1-E / SEC-01 — default multiplier for the worker-level wall-time
375/// fallback. The primary timeout enforcement is the rquickjs interrupt
376/// callback (polled at JS opcode boundaries); when the script's elapsed
377/// wall-clock exceeds this multiplier × the configured time budget, the
378/// backend re-labels the resulting error as
379/// [`SandboxError::WallTimeExceeded`] (instead of [`SandboxError::Timeout`]).
380///
381/// The default of `5` means: if the interrupt callback fires within ≤ 5×
382/// the configured budget (the normal case for `while(true){}`), behaviour
383/// is byte-identical to v1: `SandboxError::Timeout` is emitted.
384///
385/// Only when the abort drags past 5× the budget — indicating the primary
386/// interrupt mechanism was unable to fire at the budget boundary, e.g.
387/// because execution was trapped inside a single C-level call — does the
388/// fallback variant surface. This is observable telemetry, not a new
389/// kill switch: the interrupt still does the actual aborting.
390///
391/// The multiplier is generous on purpose: small CI scheduling jitter on
392/// a 50 ms budget (1× = 50 ms; 5× = 250 ms) must never accidentally
393/// trip the fallback for a script the interrupt aborted cleanly.
394pub const WALLTIME_FALLBACK_MULTIPLIER_DEFAULT: u32 = 5;
395
396/// QF1-E / SEC-01 — environment variable that overrides the wall-time
397/// fallback multiplier ([`WALLTIME_FALLBACK_MULTIPLIER_DEFAULT`]). Only
398/// values that parse as a positive `u32` ≥ 2 are honoured; anything else
399/// (absent, empty, `"0"`, `"1"`, non-numeric) keeps the default. The
400/// minimum of 2 prevents an operator from accidentally collapsing the
401/// fallback onto the primary timeout boundary, which would re-classify
402/// every clean timeout as a wall-time fallback and corrupt observability.
403pub const ENV_WALLTIME_FALLBACK_MULTIPLIER: &str = "XFA_JS_WALLTIME_FALLBACK_MULTIPLIER";
404
405/// QF1-E / SEC-01 — read the wall-time fallback multiplier from the
406/// environment, clamped to a sane range. Returns
407/// [`WALLTIME_FALLBACK_MULTIPLIER_DEFAULT`] when the env var is absent,
408/// not a valid `u32`, or below the safety minimum of `2`.
409///
410/// Reads the env var once per call; the backend snapshots the result at
411/// construction time so per-test toggling is deterministic.
412pub fn walltime_fallback_multiplier() -> u32 {
413    std::env::var(ENV_WALLTIME_FALLBACK_MULTIPLIER)
414        .ok()
415        .and_then(|s| s.parse::<u32>().ok())
416        .filter(|&n| n >= 2)
417        .unwrap_or(WALLTIME_FALLBACK_MULTIPLIER_DEFAULT)
418}
419
420/// Default per-document memory budget enforced by the rquickjs backend
421/// (S-10).
422pub const DEFAULT_MEMORY_BUDGET_BYTES: usize = 32 * 1024 * 1024;
423
424/// Hard cap on script body size (S-11). Bodies above this length are
425/// rejected before any parse attempt.
426pub const MAX_SCRIPT_BODY_BYTES: usize = 64 * 1024;
427
428/// Hard cap on `<variables><script>` body size (W2-B).
429///
430/// Variables-scripts are form-level helper libraries (XFA 3.3 §5.5):
431/// they hold the top-level `var` / `function` declarations that event
432/// scripts call as `<scriptName>.<top_level_decl>(...)`. They run once
433/// per document at registration time, under the same per-script time
434/// budget ([`DEFAULT_TIME_BUDGET_MS`]) and same per-document memory
435/// budget ([`DEFAULT_MEMORY_BUDGET_BYTES`]) as event scripts.
436///
437/// Real-world government XFA forms (Canadian IRCC `imm5709e` / `imm5710e`,
438/// Canadian Revenue Agency `t2200` / `t2-fill`) ship variables-scripts
439/// such as `validateForm` (~125 KB), `CoreFunctions` (~115 KB) and
440/// `LOV` (~507 KB) — well above the 64 KB event-script cap. With the
441/// event-script cap applied to variables-scripts, registration fails
442/// silently with [`SandboxError::BodyTooLarge`] and the dependent
443/// event scripts cannot resolve `validateForm.X()` / `CoreFunctions.X()`,
444/// surfacing as the W1-B `implicit_function` cluster (impact 79 across
445/// 10 docs).
446///
447/// 1 MiB is intentionally above the largest observed real-world body
448/// (`LOV` ≈ 507 KB on `imm5710e`) so the cap remains a defence-in-depth
449/// stop and never a routine failure path. The time and memory budgets
450/// still bound runaway parse / execute cost.
451pub const MAX_VARIABLES_SCRIPT_BODY_BYTES: usize = 1024 * 1024;
452
453/// The activities for which the sandboxed runtime accepts dispatch.
454/// Other activities (`click`, `preSubmit`, `mouseEnter`, …) skip the
455/// runtime entirely at the [`crate::dynamic::apply_dynamic_scripts_with_mode`]
456/// boundary because they do not fire during static flatten (S-14).
457pub const SANDBOX_ACTIVITY_ALLOWLIST: &[&str] = &[
458    "initialize",
459    "calculate",
460    "validate",
461    "docReady",
462    "layoutReady",
463];
464
465/// True when `activity` is in [`SANDBOX_ACTIVITY_ALLOWLIST`].
466pub fn activity_allowed_for_sandbox(activity: Option<&str>) -> bool {
467    matches!(activity, Some(a) if SANDBOX_ACTIVITY_ALLOWLIST.contains(&a))
468}
469
470/// **D1.B gated allow.** Environment variable that opts an operator into the
471/// `preSave` dispatch path during flatten. Default OFF; any value other than
472/// `"1"` keeps the W3-B closure semantics (deny `preSave` at the dispatch
473/// gate AND at the host-binding gate).
474///
475/// Cross-ref: `docs/INST_MGR_ACTIVITY_POLICY.md` v5 §6.1 (D1.B).
476///
477/// **Stop-rules (enforced by tests, never by code):**
478/// 1. Only `preSave` is affected. `preSubmit`, `click`, and every other
479///    denylist activity stay denied regardless of this flag.
480/// 2. Default-OFF. Behaviour is byte-identical to v4 (W3-B closure) when
481///    the variable is absent or unset, or set to any value other than `"1"`.
482/// 3. Flipping requires an operator-signed waiver per the policy doc; the
483///    flag is read at dispatch (one snapshot per flatten), never globally
484///    memoised, so test harnesses can toggle it per-test.
485pub const ENV_PRESAVE_DURING_FLATTEN: &str = "XFA_PRESAVE_DURING_FLATTEN";
486
487/// True when `XFA_PRESAVE_DURING_FLATTEN=1`. Any other value (absent, empty,
488/// `"0"`, `"true"`, `"yes"`, casing variants) returns false. This is the
489/// only place that reads the environment for the D1.B gate — every other
490/// site receives a `bool` argument so tests can toggle deterministically.
491pub fn presave_during_flatten_enabled() -> bool {
492    std::env::var(ENV_PRESAVE_DURING_FLATTEN).ok().as_deref() == Some("1")
493}
494
495/// **D1.B gated allow.** Same as [`activity_allowed_for_sandbox`], but
496/// also accepts `Some("preSave")` when `presave_gate` is true.
497///
498/// `presave_gate` is computed once per flatten (via
499/// [`presave_during_flatten_enabled`]) and threaded down so the dispatch
500/// path can decide deterministically per script; the host-binding layer
501/// receives the same bool via [`HostBindings::set_presave_gate`].
502///
503/// Cross-ref: `docs/INST_MGR_ACTIVITY_POLICY.md` v5 §6.1 (D1.B).
504pub fn activity_allowed_for_sandbox_with_gate(activity: Option<&str>, presave_gate: bool) -> bool {
505    if activity_allowed_for_sandbox(activity) {
506        return true;
507    }
508    presave_gate && matches!(activity, Some("preSave"))
509}
510
511/// Activities the **Phase 2 interactive commit loop** dispatches in addition to
512/// the static-flatten allowlist. A real user edit fires `change` (value
513/// committed) and `click` (button / checkbox press); `exit`/`enter` are the
514/// focus pair some forms use for the same visibility logic. These run ONLY when
515/// the host's interactive flag is set (via [`XfaJsRuntime::set_interactive`]),
516/// so the static-flatten corpus-parity path is unaffected.
517pub const INTERACTIVE_ACTIVITY_ALLOWLIST: &[&str] = &["change", "click", "exit", "enter"];
518
519/// True when `activity` is an interactive-only activity
520/// ([`INTERACTIVE_ACTIVITY_ALLOWLIST`]).
521pub fn interactive_activity_allowed(activity: Option<&str>) -> bool {
522    matches!(activity, Some(a) if INTERACTIVE_ACTIVITY_ALLOWLIST.contains(&a))
523}
524
525/// The host-side adapter the dispatch path calls. A minimal contract
526/// chosen so that swapping backends (the WebAssembly one ↔ an external sandbox)
527/// is one Cargo feature flag away.
528pub trait XfaJsRuntime {
529    /// One-time initialisation. Idempotent.
530    fn init(&mut self) -> Result<(), SandboxError>;
531
532    /// Reset per-document state (memory budget, instruction counter,
533    /// any cached compiled scripts). Called once per flatten.
534    fn reset_for_new_document(&mut self) -> Result<(), SandboxError>;
535
536    /// Phase C: install the `FormTree` the runtime should resolve paths
537    /// against and mutate. The dispatch path owns the mutable borrow and clears
538    /// the handle before returning.
539    fn set_form_handle(
540        &mut self,
541        _form: *mut FormTree,
542        _root_id: FormNodeId,
543    ) -> Result<(), SandboxError> {
544        Ok(())
545    }
546
547    /// Phase D-γ: install a read-only view of the `DataDom` for the current
548    /// document. Called once per document after `set_form_handle`, before any
549    /// scripts run. Default: no-op (backends without DataDom support ignore it).
550    ///
551    /// # Safety
552    /// Callers **must** guarantee that `dom` outlives all script execution for
553    /// this document (i.e. it must remain alive until `set_form_handle(null)`
554    /// is called). The runtime stores the pointer read-only and never writes
555    /// through it.
556    fn set_data_handle(&mut self, _dom: *const DataDom) {}
557
558    /// BE-1 tranche #1 (benign zero-instance SOM): install the set of
559    /// template-declared container names (`subform`/`subformSet`/`exclGroup`/
560    /// `area`) for the current document. Backends that resolve implicit SOM
561    /// identifiers use it to return a benign empty-node façade for a
562    /// declared-but-absent reference instead of `undefined` (Adobe semantics),
563    /// so guarded scripts (`if (!Sub.Child.isNull) {...} else {...}`) run their
564    /// else branch instead of throwing. Like [`set_data_handle`], the caller
565    /// installs this before script execution. Default: no-op (the static
566    /// `NullRuntime` ignores it, so the default/non-sandboxed path is
567    /// unaffected and stays byte-identical).
568    ///
569    /// [`set_data_handle`]: XfaJsRuntime::set_data_handle
570    fn set_declared_subform_names(&mut self, _names: std::collections::HashSet<String>) {}
571
572    /// Phase C: reset per-script host counters and install the current script
573    /// context node / activity. Backends without host bindings ignore it.
574    fn reset_per_script(
575        &mut self,
576        _current_id: FormNodeId,
577        _activity: Option<&str>,
578    ) -> Result<(), SandboxError> {
579        Ok(())
580    }
581
582    /// Phase C page-count foundation. The current flatten order runs scripts
583    /// before layout, so callers normally leave this at 0.
584    fn set_static_page_count(&mut self, _page_count: u32) -> Result<(), SandboxError> {
585        Ok(())
586    }
587
588    /// **D1.B gated allow.** Inform the runtime whether the
589    /// `XFA_PRESAVE_DURING_FLATTEN=1` opt-in is active for the current
590    /// flatten. The dispatch path computes this once per document via
591    /// [`presave_during_flatten_enabled`] and forwards it here so the host
592    /// binding layer can mirror the dispatch decision (defence-in-depth).
593    ///
594    /// Default: no-op. Backends without a host-binding gate ignore it.
595    /// The contract for backends that DO mirror the gate:
596    ///
597    /// 1. Default OFF: every call to [`HostBindings::write_activity_allowed`]
598    ///    with `current_activity = Some("preSave")` MUST return false.
599    /// 2. Gate ON: the same call MUST return true ONLY for `Some("preSave")`.
600    ///    Every other denylist activity (`preSubmit`, `click`, …) MUST
601    ///    continue to return false.
602    ///
603    /// Cross-ref: `docs/INST_MGR_ACTIVITY_POLICY.md` v5 §6.1 (D1.B).
604    fn set_presave_gate(&mut self, _enabled: bool) {}
605
606    /// **Phase 2 interactive mode.** When enabled, the backend accepts the
607    /// [`INTERACTIVE_ACTIVITY_ALLOWLIST`] activities (`change`/`click`/…) and
608    /// `field.presence` writes mutate the tree. Default no-op so the static
609    /// flatten path (and the [`NullRuntime`]) are unaffected. The interactive
610    /// dispatch sets this after `reset_for_new_document`, just before
611    /// dispatching the user-edit scripts.
612    fn set_interactive(&mut self, _enabled: bool) {}
613
614    /// Execute one script body inside the sandbox.
615    ///
616    /// `activity` is the enclosing `<event activity="...">` value if
617    /// any. The dispatch site has already filtered against
618    /// [`activity_allowed_for_sandbox`]; backends may treat unknown
619    /// activities as `PhaseDenied` for defence-in-depth.
620    fn execute_script(
621        &mut self,
622        activity: Option<&str>,
623        body: &str,
624    ) -> Result<RuntimeOutcome, SandboxError>;
625
626    /// Take the cumulative metadata since the last `take_metadata`
627    /// call (or since `reset_for_new_document`, whichever was later).
628    fn take_metadata(&mut self) -> RuntimeMetadata;
629
630    /// D6: drain captured `occur.min`/`occur.max` write intents
631    /// `(node_index, prop, value)` recorded during the script pass. The
632    /// dispatch path applies them (only when `XFA_OCCUR_APPLY=1`) after the
633    /// rollback decision. Default: none (non-sandboxed runtimes capture nothing).
634    fn take_occur_mutations(&mut self) -> Vec<(usize, String, i64)> {
635        Vec::new()
636    }
637
638    /// Epic A E-2/E-3: drain verbose per-entry diagnostic logs. Only
639    /// populated when `XFA_RUNTIME_DIAG=1` is set at the host call sites.
640    /// Default impl returns empty logs (NullRuntime, non-sandboxed paths).
641    fn take_diag_logs(&mut self) -> RuntimeDiagLogs {
642        RuntimeDiagLogs::default()
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649
650    #[test]
651    fn allowlist_accepts_initialize_and_calculate() {
652        assert!(activity_allowed_for_sandbox(Some("initialize")));
653        assert!(activity_allowed_for_sandbox(Some("calculate")));
654        assert!(activity_allowed_for_sandbox(Some("validate")));
655        assert!(activity_allowed_for_sandbox(Some("docReady")));
656        assert!(activity_allowed_for_sandbox(Some("layoutReady")));
657    }
658
659    // D1.B gate: when the flag is OFF (the default in tests via env::var
660    // being unset for the controlled key), `activity_allowed_for_sandbox_with_gate`
661    // is byte-identical to `activity_allowed_for_sandbox`. When the gate
662    // bool is wired ON, ONLY `preSave` flips to allowed — `preSubmit`,
663    // `click`, etc. stay denied (hard stop in §6.1 of the policy doc).
664    #[test]
665    fn presave_gate_off_matches_base_allowlist() {
666        for allowed in SANDBOX_ACTIVITY_ALLOWLIST {
667            assert!(activity_allowed_for_sandbox_with_gate(Some(allowed), false));
668        }
669        for denied in [
670            "preSave",
671            "preSubmit",
672            "click",
673            "mouseEnter",
674            "exit",
675            "postSave",
676        ] {
677            assert!(!activity_allowed_for_sandbox_with_gate(Some(denied), false));
678        }
679        assert!(!activity_allowed_for_sandbox_with_gate(None, false));
680    }
681
682    #[test]
683    fn presave_gate_on_unlocks_only_presave() {
684        // preSave flips from deny -> allow when the gate is ON.
685        assert!(activity_allowed_for_sandbox_with_gate(
686            Some("preSave"),
687            true
688        ));
689        // Hard-stop: every other denylist activity MUST stay denied.
690        for still_denied in [
691            "preSubmit",
692            "click",
693            "mouseEnter",
694            "mouseExit",
695            "exit",
696            "enter",
697            "change",
698            "postSave",
699            "postSubmit",
700            "ready",
701            "prePrint",
702            "postPrint",
703            "preOpen",
704            "full",
705        ] {
706            assert!(
707                !activity_allowed_for_sandbox_with_gate(Some(still_denied), true),
708                "{still_denied} must stay denied even with D1.B gate ON",
709            );
710        }
711        assert!(!activity_allowed_for_sandbox_with_gate(None, true));
712        // Default-OFF behaviour unchanged for the 5 lifecycle activities.
713        for allowed in SANDBOX_ACTIVITY_ALLOWLIST {
714            assert!(activity_allowed_for_sandbox_with_gate(Some(allowed), true));
715        }
716    }
717
718    // Note: the env-var helper `presave_during_flatten_enabled` is pinned
719    // by integration tests in `tests/m3b_phasePQ_presave_gated_w3repair_d1b.rs`
720    // (`d1b_default_off_keeps_presave_denied_at_dispatch`,
721    // `d1b_env_var_parsing_only_one_enables_gate`). Inline unit tests would
722    // race with `std::env` because cargo test runs in-process; the
723    // integration tests serialise env mutations behind a mutex guard.
724    //
725    // ENV_PRESAVE_DURING_FLATTEN constant is canonicalised at the const
726    // declaration site and never re-spelled in code.
727
728    #[test]
729    fn presave_env_var_constant_is_canonical_name() {
730        assert_eq!(ENV_PRESAVE_DURING_FLATTEN, "XFA_PRESAVE_DURING_FLATTEN");
731    }
732
733    #[test]
734    fn allowlist_rejects_ui_and_submit_activities() {
735        for ui in [
736            "click",
737            "mouseEnter",
738            "mouseExit",
739            "enter",
740            "exit",
741            "preSubmit",
742            "postSubmit",
743            "ready",
744        ] {
745            assert!(
746                !activity_allowed_for_sandbox(Some(ui)),
747                "{ui} must not be allowed",
748            );
749        }
750        assert!(!activity_allowed_for_sandbox(None));
751    }
752
753    #[test]
754    fn metadata_is_clean_when_zero() {
755        assert!(RuntimeMetadata::default().is_clean());
756        let mut m = RuntimeMetadata {
757            executed: 5,
758            ..Default::default()
759        };
760        assert!(m.is_clean(), "executed counter does not flip cleanliness");
761        m.runtime_errors = 1;
762        assert!(!m.is_clean());
763    }
764
765    #[test]
766    // Intentional const-floor contract assertions: these pin the safety
767    // floors of compile-time budget constants. assertions_on_constants is
768    // expected and desired here.
769    #[allow(clippy::assertions_on_constants)]
770    fn budget_constants_are_sane() {
771        assert!(MAX_SCRIPT_BODY_BYTES >= 4096);
772        assert!(DEFAULT_TIME_BUDGET_MS >= 25);
773        assert!(DEFAULT_MEMORY_BUDGET_BYTES >= 1024 * 1024);
774    }
775
776    // QF1-E: the wall-time fallback multiplier must be strictly > 1 so a
777    // clean interrupt at the budget boundary cannot be reclassified as
778    // WallTimeExceeded. 5 is the documented default; this test pins it as
779    // a contract between this module and the backend.
780    // Compile-time const-assert avoids `clippy::assertions_on_constants`
781    // while still failing the build if the safety floor is broken.
782    const _WALLTIME_SAFE_MIN: () = assert!(
783        WALLTIME_FALLBACK_MULTIPLIER_DEFAULT >= 2,
784        "multiplier < 2 would re-label normal Timeouts as WallTimeExceeded"
785    );
786
787    #[test]
788    fn walltime_fallback_multiplier_default_is_safe() {
789        // The compile-time `_WALLTIME_SAFE_MIN` const above is the real
790        // contract; this runtime test pins the concrete value so a
791        // refactor that changes the default but leaves the floor intact
792        // is still caught here.
793        assert_eq!(WALLTIME_FALLBACK_MULTIPLIER_DEFAULT, 5);
794    }
795
796    #[test]
797    fn walltime_fallback_env_var_name_is_canonical() {
798        assert_eq!(
799            ENV_WALLTIME_FALLBACK_MULTIPLIER,
800            "XFA_JS_WALLTIME_FALLBACK_MULTIPLIER"
801        );
802    }
803
804    // W2-B: variables-script body cap must be strictly higher than the
805    // event-script cap so XFA helper libraries (validateForm, LOV,
806    // CoreFunctions) register; it must still be bounded so an oversize
807    // body cannot bypass static defence-in-depth before the per-document
808    // memory budget engages.
809    #[test]
810    // Intentional const-floor contract assertions on compile-time caps.
811    #[allow(clippy::assertions_on_constants)]
812    fn variables_script_cap_is_above_event_cap_and_bounded() {
813        assert!(
814            MAX_VARIABLES_SCRIPT_BODY_BYTES > MAX_SCRIPT_BODY_BYTES,
815            "variables-script cap must exceed event-script cap"
816        );
817        // Sanity: must be high enough to register the largest observed
818        // real-world variables-script library (LOV ≈ 507 KB).
819        assert!(
820            MAX_VARIABLES_SCRIPT_BODY_BYTES >= 768 * 1024,
821            "variables-script cap below observed real-world max"
822        );
823        // Sanity: must be bounded well under the per-document memory
824        // budget so a single oversize body cannot consume the entire
825        // budget on parse alone.
826        assert!(
827            MAX_VARIABLES_SCRIPT_BODY_BYTES <= DEFAULT_MEMORY_BUDGET_BYTES / 8,
828            "variables-script cap must stay an order of magnitude below memory budget"
829        );
830    }
831}