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