Skip to main content

pdf_xfa/js_runtime/
sandbox_backend.rs

1//! Sandboxed JavaScript runtime adapter for [`super::XfaJsRuntime`].
2//!
3//! Compiled in only when the `xfa-js-sandboxed` Cargo feature is enabled.
4//! The adapter is written against [`xfa_js_sandboxed::engine::JsEngine`]; the
5//! engine behind it is QuickJS compiled to WebAssembly and interpreted by
6//! `wasmi`, so no native C code runs.
7//!
8//! - No capability-bearing globals: `fetch`, `require`, `process`, ... are
9//!   removed in [`XfaJsRuntime::init`]; `Date.now` returns 0 and
10//!   `Math.random` is undefined.
11//! - Scripts are evaluated as strict code and reach the form only through
12//!   the host functions registered in [`host_natives`].
13//! - Per-script time budget: the wall-clock deadline (polled by the guest's
14//!   interrupt handler) plus a fuel budget in the interpreter as a backstop;
15//!   exceeding either gives [`SandboxError::Timeout`].
16//! - Heap limit: QuickJS' allocation limit inside the guest, with the guest's
17//!   linear memory capped just above it; hitting it gives
18//!   [`SandboxError::OutOfMemory`].
19//! - The engine boots on the first script, not in [`XfaJsRuntime::init`], and a
20//!   finished boot is restored from a snapshot for every later runtime.
21//! - Every engine call is wrapped in `std::panic::catch_unwind`.
22
23// Copyright (c) 2026 Innovation Trigger B.V.
24//
25// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
26// the PDFluent Commercial Licence. See the LICENSE file in this repository --
27// that file travels with the copy you received, which a URL does not.
28
29use std::cell::{Cell, RefCell};
30use std::panic::{catch_unwind, AssertUnwindSafe};
31use std::rc::Rc;
32use std::time::{Duration, Instant};
33
34use xfa_js_sandboxed::engine::{
35    Args, Boot, DefaultEngine, Fail, Guard, In, JsEngine, Limits, Native, Out, Ret,
36};
37use xfa_layout_engine::form::{FormNodeId, FormTree};
38
39use super::regex_guard::{scan_script_for_redos, RegexScanVerdict};
40use super::{
41    activity_allowed_for_sandbox_with_gate, walltime_fallback_multiplier, HostBindings,
42    RuntimeMetadata, RuntimeOutcome, SandboxError, XfaJsRuntime, DEFAULT_MEMORY_BUDGET_BYTES,
43    DEFAULT_TIME_BUDGET_MS, MAX_SCRIPT_BODY_BYTES, MAX_VARIABLES_SCRIPT_BODY_BYTES,
44};
45
46/// Sandboxed runtime adapter. One instance is reusable across many
47/// documents; callers MUST invoke [`XfaJsRuntime::reset_for_new_document`]
48/// at the start of each flatten.
49pub struct SandboxedRuntime {
50    engine: RefCell<DefaultEngine>,
51    metadata: RuntimeMetadata,
52    time_budget: Duration,
53    memory_budget_bytes: usize,
54    /// Start of the currently executing script, if any.
55    script_started: RefCell<Option<Instant>>,
56    /// QF1-E / SEC-01 — multiplier on `time_budget` used by the wall-time
57    /// fallback. Snapshotted at construction time from
58    /// [`super::walltime_fallback_multiplier`] so tests can toggle the env
59    /// var per-instance without racing other tests.
60    walltime_fallback_multiplier: u32,
61    host: Rc<RefCell<HostBindings>>,
62    /// `init()` was called; the engine is booted on first use, so a form
63    /// without scripts never pays for it.
64    init_called: Cell<bool>,
65    bindings_registered: Cell<bool>,
66}
67
68impl std::fmt::Debug for SandboxedRuntime {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("SandboxedRuntime")
71            .field("metadata", &self.metadata)
72            .field("time_budget_ms", &self.time_budget.as_millis())
73            .field("memory_budget_bytes", &self.memory_budget_bytes)
74            .finish()
75    }
76}
77
78fn parse_node_id_csv(raw: &str) -> Vec<FormNodeId> {
79    raw.split(',')
80        .filter_map(|part| part.trim().parse::<usize>().ok())
81        .map(FormNodeId)
82        .collect()
83}
84
85fn ids(v: Vec<FormNodeId>) -> Ret {
86    Ret::Ints(v.into_iter().map(|n| n.0 as i32).collect())
87}
88
89fn node_ret(v: Option<FormNodeId>) -> Ret {
90    Ret::Int(v.map(|n| n.0 as i64).unwrap_or(-1))
91}
92
93fn chain_of(csv: Option<String>) -> Vec<String> {
94    csv.map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
95        .unwrap_or_default()
96}
97
98/// The host functions the JS binding layer (`PHASE_C_BINDINGS_JS`) calls.
99/// Argument and return conventions mirror what the bindings expect: ids are
100/// numbers (-1 = none), lists are arrays, a missing optional argument is
101/// treated like an empty/absent value.
102fn host_natives(host: &Rc<RefCell<HostBindings>>) -> Vec<(&'static str, Native)> {
103    let mut v: Vec<(&'static str, Native)> = Vec::new();
104    macro_rules! nat {
105        ($name:literal, |$h:ident, $a:ident| $body:expr) => {{
106            let hh = Rc::clone(host);
107            v.push((
108                $name,
109                Box::new(move |$a: &Args| -> Ret {
110                    let $h = &hh;
111                    $body
112                }),
113            ));
114        }};
115    }
116
117    nat!("resolveNodeId", |h, a| {
118        let Some(path) = a.opt_str(0) else {
119            let _ = h.borrow_mut().resolve_node("");
120            return Ret::Int(-1);
121        };
122        node_ret(h.borrow_mut().resolve_node(&path))
123    });
124    nat!("resolveNodeIds", |h, a| {
125        let Some(path) = a.opt_str(0) else {
126            let _ = h.borrow_mut().resolve_nodes("");
127            return Ret::Ints(Vec::new());
128        };
129        ids(h.borrow_mut().resolve_nodes(&path))
130    });
131    nat!("generation", |h, _a| Ret::Int(
132        h.borrow().generation() as i64
133    ));
134    nat!("currentNodeId", |h, _a| node_ret(h.borrow().current_node()));
135    nat!("resolveImplicitNodeId", |h, a| {
136        let cur = a.i32(0);
137        if cur < 0 {
138            return Ret::Int(-1);
139        }
140        let Some(name) = a.opt_str(1) else {
141            return Ret::Int(-1);
142        };
143        node_ret(
144            h.borrow_mut()
145                .resolve_implicit(FormNodeId(cur as usize), &name),
146        )
147    });
148    nat!("resolveImplicitNodeIds", |h, a| {
149        let cur = a.i32(0);
150        let Some(name) = a.opt_str(1).filter(|_| cur >= 0) else {
151            return Ret::Ints(Vec::new());
152        };
153        ids(h
154            .borrow_mut()
155            .resolve_implicit_candidates(FormNodeId(cur as usize), &name))
156    });
157    nat!("resolveImplicitNodeIdsQuiet", |h, a| {
158        let cur = a.i32(0);
159        let Some(name) = a.opt_str(1).filter(|_| cur >= 0) else {
160            return Ret::Ints(Vec::new());
161        };
162        ids(h
163            .borrow_mut()
164            .resolve_implicit_candidates_quiet(FormNodeId(cur as usize), &name))
165    });
166    nat!("resolveChildNodeId", |h, a| {
167        let parent = a.i32(0);
168        if parent < 0 {
169            return Ret::Int(-1);
170        }
171        let Some(name) = a.opt_str(1) else {
172            return Ret::Int(-1);
173        };
174        node_ret(
175            h.borrow_mut()
176                .resolve_child(FormNodeId(parent as usize), &name),
177        )
178    });
179    nat!("resolveChildNodeIds", |h, a| {
180        let (Some(parents), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
181            return Ret::Ints(Vec::new());
182        };
183        ids(h
184            .borrow_mut()
185            .resolve_child_candidates(&parse_node_id_csv(&parents), &name))
186    });
187    nat!("resolveChildNodeIdsQuiet", |h, a| {
188        let (Some(parents), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
189            return Ret::Ints(Vec::new());
190        };
191        ids(h
192            .borrow_mut()
193            .resolve_child_candidates_quiet(&parse_node_id_csv(&parents), &name))
194    });
195    nat!("resolveImplicitNodeIdsHinted", |h, a| {
196        let cur = a.i32(0);
197        let Some(name) = a.opt_str(1).filter(|_| cur >= 0) else {
198            return Ret::Ints(Vec::new());
199        };
200        let hint = a.opt_str(2).unwrap_or_default();
201        ids(h.borrow_mut().resolve_implicit_candidates_hinted(
202            FormNodeId(cur as usize),
203            &name,
204            &hint,
205        ))
206    });
207    nat!("resolveChildNodeIdsHinted", |h, a| {
208        let (Some(parents), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
209            return Ret::Ints(Vec::new());
210        };
211        let hint = a.opt_str(2).unwrap_or_default();
212        ids(h.borrow_mut().resolve_child_candidates_hinted(
213            &parse_node_id_csv(&parents),
214            &name,
215            &hint,
216        ))
217    });
218    nat!("resolveScopedNodeIds", |h, a| {
219        let (Some(scope), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
220            return Ret::Ints(Vec::new());
221        };
222        ids(h
223            .borrow_mut()
224            .resolve_scoped_candidates(&parse_node_id_csv(&scope), &name))
225    });
226    nat!("resolveWithFullChain", |h, a| {
227        let parents = a
228            .opt_str(0)
229            .map(|s| parse_node_id_csv(&s))
230            .unwrap_or_default();
231        let cur = a.i32(1);
232        let chain = chain_of(a.opt_str(2));
233        let origin = (cur >= 0).then(|| FormNodeId(cur as usize));
234        ids(h
235            .borrow_mut()
236            .resolve_with_full_chain(&parents, &chain, origin))
237    });
238    nat!("resolveWithFullChainStrict", |h, a| {
239        let parents = a
240            .opt_str(0)
241            .map(|s| parse_node_id_csv(&s))
242            .unwrap_or_default();
243        let cur = a.i32(1);
244        let chain = chain_of(a.opt_str(2));
245        let origin = (cur >= 0).then(|| FormNodeId(cur as usize));
246        ids(h
247            .borrow_mut()
248            .resolve_with_full_chain_strict(&parents, &chain, origin))
249    });
250    nat!("getRawValue", |h, a| {
251        let (id, g) = (a.i32(0), a.i64(1));
252        if id < 0 || g < 0 {
253            return Ret::Undefined;
254        }
255        h.borrow_mut()
256            .get_raw_value(FormNodeId(id as usize), g as u64)
257            .into()
258    });
259    nat!("setRawValue", |h, a| {
260        let (id, g) = (a.i32(0), a.i64(1));
261        if id < 0 || g < 0 {
262            return Ret::Bool(false);
263        }
264        Ret::Bool(
265            h.borrow_mut()
266                .set_raw_value(FormNodeId(id as usize), a.str(2), g as u64),
267        )
268    });
269    nat!("getPresence", |h, a| {
270        let (id, g) = (a.i32(0), a.i64(1));
271        if id < 0 || g < 0 {
272            return Ret::Str("visible".to_string());
273        }
274        Ret::Str(
275            h.borrow_mut()
276                .get_presence(FormNodeId(id as usize), g as u64),
277        )
278    });
279    nat!("setPresence", |h, a| {
280        let (id, g) = (a.i32(0), a.i64(1));
281        if id < 0 || g < 0 {
282            return Ret::Bool(false);
283        }
284        Ret::Bool(
285            h.borrow_mut()
286                .set_presence(FormNodeId(id as usize), a.str(2), g as u64),
287        )
288    });
289    nat!("isInteractive", |h, _a| Ret::Bool(h.borrow().interactive()));
290    nat!("instanceCount", |h, a| {
291        let (id, g) = (a.i32(0), a.i64(1));
292        if id < 0 || g < 0 {
293            return Ret::Int(0);
294        }
295        h.borrow_mut()
296            .instance_count_for_handle(FormNodeId(id as usize), g as u64)
297            .into()
298    });
299    nat!("hasZeroInstanceRun", |h, a| {
300        let (id, g) = (a.i32(0), a.i64(1));
301        if id < 0 || g < 0 {
302            return Ret::Bool(false);
303        }
304        let Some(name) = a.opt_str(2) else {
305            return Ret::Bool(false);
306        };
307        Ret::Bool(
308            h.borrow_mut()
309                .has_zero_instance_run(FormNodeId(id as usize), g as u64, &name),
310        )
311    });
312    nat!("nodeIndex", |h, a| {
313        let (id, g) = (a.i32(0), a.i64(1));
314        if id < 0 || g < 0 {
315            return Ret::Int(0);
316        }
317        h.borrow_mut()
318            .instance_index_for_handle(FormNodeId(id as usize), g as u64)
319            .into()
320    });
321    nat!("nodeName", |h, a| {
322        let (id, g) = (a.i32(0), a.i64(1));
323        if id < 0 || g < 0 {
324            return Ret::Str(String::new());
325        }
326        Ret::Str(
327            h.borrow()
328                .node_name(FormNodeId(id as usize), g as u64)
329                .unwrap_or_default(),
330        )
331    });
332    nat!("occurResolve", |h, a| {
333        let (id, g) = (a.i32(0), a.i64(1));
334        if id < 0 || g < 0 {
335            return Ret::Bool(false);
336        }
337        Ret::Bool(
338            h.borrow_mut()
339                .occur_resolve(FormNodeId(id as usize), g as u64),
340        )
341    });
342    nat!("occurRead", |h, a| {
343        let (id, g) = (a.i32(0), a.i64(1));
344        if id < 0 || g < 0 {
345            return Ret::Int(-1);
346        }
347        Ret::Int(
348            h.borrow_mut()
349                .occur_read(FormNodeId(id as usize), g as u64, &a.str(2)),
350        )
351    });
352    nat!("occurCapture", |h, a| {
353        let (id, g) = (a.i32(0), a.i64(1));
354        if id < 0 || g < 0 {
355            return Ret::Bool(false);
356        }
357        Ret::Bool(h.borrow_mut().occur_capture(
358            FormNodeId(id as usize),
359            g as u64,
360            &a.str(2),
361            a.i64(3),
362        ))
363    });
364    nat!("nodeIsContainer", |h, a| {
365        let (id, g) = (a.i32(0), a.i64(1));
366        if id < 0 || g < 0 {
367            return Ret::Bool(false);
368        }
369        Ret::Bool(
370            h.borrow_mut()
371                .node_is_container(FormNodeId(id as usize), g as u64),
372        )
373    });
374    nat!("parentOfNode", |h, a| {
375        let (id, g) = (a.i32(0), a.i64(1));
376        if id < 0 || g < 0 {
377            return Ret::Int(-1);
378        }
379        node_ret(
380            h.borrow_mut()
381                .parent_of_node(FormNodeId(id as usize), g as u64),
382        )
383    });
384    nat!("instanceSet", |h, a| {
385        let (id, g) = (a.i32(0), a.i64(1));
386        if id < 0 || g < 0 {
387            return Ret::Int(-1);
388        }
389        let n = a.opt_i32(2).unwrap_or(0).max(0) as u32;
390        Ret::Int(
391            h.borrow_mut()
392                .instance_set_for_handle(FormNodeId(id as usize), g as u64, n)
393                .map(|c| c as i64)
394                .unwrap_or(-1),
395        )
396    });
397    nat!("instanceAdd", |h, a| {
398        let (id, g) = (a.i32(0), a.i64(1));
399        if id < 0 || g < 0 {
400            return Ret::Int(-1);
401        }
402        node_ret(
403            h.borrow_mut()
404                .instance_add_for_handle(FormNodeId(id as usize), g as u64)
405                .ok(),
406        )
407    });
408    nat!("instanceRemove", |h, a| {
409        let (id, g) = (a.i32(0), a.i64(1));
410        if id < 0 || g < 0 {
411            return Ret::Bool(false);
412        }
413        let index = a.opt_i32(2).unwrap_or(0).max(0) as u32;
414        Ret::Bool(
415            h.borrow_mut()
416                .instance_remove_for_handle(FormNodeId(id as usize), g as u64, index)
417                .is_ok(),
418        )
419    });
420    nat!("listClear", |h, a| {
421        let (id, g) = (a.i32(0), a.i64(1));
422        if id < 0 || g < 0 {
423            return Ret::Bool(false);
424        }
425        Ret::Bool(
426            h.borrow_mut()
427                .list_clear_for_handle(FormNodeId(id as usize), g as u64)
428                .is_ok(),
429        )
430    });
431    nat!("listAdd", |h, a| {
432        let (id, g) = (a.i32(0), a.i64(1));
433        if id < 0 || g < 0 {
434            return Ret::Bool(false);
435        }
436        Ret::Bool(
437            h.borrow_mut()
438                .list_add_for_handle(FormNodeId(id as usize), g as u64, a.str(2), a.opt_str(3))
439                .is_ok(),
440        )
441    });
442    nat!("boundItem", |h, a| {
443        let (id, g) = (a.i32(0), a.i64(1));
444        if id < 0 || g < 0 {
445            return Ret::Str(a.str(2));
446        }
447        Ret::Str(
448            h.borrow_mut()
449                .bound_item_for_handle(FormNodeId(id as usize), g as u64, a.str(2)),
450        )
451    });
452    nat!("getDisplayItems", |h, a| {
453        let (id, g) = (a.i32(0), a.i64(1));
454        if id < 0 || g < 0 {
455            return Ret::Strs(Vec::new());
456        }
457        Ret::Strs(
458            h.borrow_mut()
459                .get_display_items(FormNodeId(id as usize), g as u64),
460        )
461    });
462    nat!("numPages", |h, _a| h.borrow_mut().num_pages().into());
463    nat!("bindingError", |h, _a| {
464        h.borrow_mut().metadata_binding_error();
465        Ret::Undefined
466    });
467    nat!("resolveFailure", |h, _a| {
468        h.borrow_mut().metadata_resolve_failure();
469        Ret::Undefined
470    });
471    nat!("somDataRootHit", |h, _a| {
472        h.borrow_mut().metadata_som_data_root_hit();
473        Ret::Undefined
474    });
475    nat!("somItemsPathHit", |h, _a| {
476        h.borrow_mut().metadata_som_items_path_hit();
477        Ret::Undefined
478    });
479    nat!("unsupportedHostCall", |h, _a| {
480        h.borrow_mut().metadata_unsupported_host_call();
481        Ret::Undefined
482    });
483    nat!("probeSkip", |h, _a| {
484        h.borrow_mut().metadata_probe_skip();
485        Ret::Undefined
486    });
487    nat!("dataChildren", |h, a| {
488        let raw = a.i32(0);
489        if raw < 0 {
490            return Ret::Ints(Vec::new());
491        }
492        Ret::Ints(
493            h.borrow_mut()
494                .data_children(raw as usize)
495                .into_iter()
496                .map(|x| x as i32)
497                .collect(),
498        )
499    });
500    nat!("dataValue", |h, a| {
501        let raw = a.i32(0);
502        if raw < 0 {
503            return Ret::Undefined;
504        }
505        h.borrow_mut().data_value(raw as usize).into()
506    });
507    nat!("dataChildByName", |h, a| {
508        let parent = a.i32(0);
509        if parent < 0 {
510            return Ret::Int(-1);
511        }
512        let Some(name) = a.opt_str(1) else {
513            return Ret::Int(-1);
514        };
515        Ret::Int(
516            h.borrow_mut()
517                .data_child_by_name(parent as usize, &name)
518                .map(|x| x as i64)
519                .unwrap_or(-1),
520        )
521    });
522    nat!("dataBoundRecord", |h, a| {
523        let (id, g) = (a.i32(0), a.i64(1));
524        if id < 0 || g < 0 {
525            return Ret::Int(-1);
526        }
527        Ret::Int(
528            h.borrow_mut()
529                .data_bound_record(FormNodeId(id as usize), g as u64)
530                .map(|x| x as i64)
531                .unwrap_or(-1),
532        )
533    });
534    nat!("dataResolveNode", |h, a| {
535        let Some(path) = a.opt_str(0) else {
536            return Ret::Int(-1);
537        };
538        Ret::Int(
539            h.borrow_mut()
540                .data_resolve_node(&path)
541                .map(|x| x as i64)
542                .unwrap_or(-1),
543        )
544    });
545    nat!("isDeclaredAbsentNode", |h, a| {
546        let Some(name) = a.opt_str(0) else {
547            return Ret::Bool(false);
548        };
549        Ret::Bool(h.borrow().is_declared_absent_node(&name))
550    });
551    nat!("dataResolveNodes", |h, a| {
552        let Some(path) = a.opt_str(0) else {
553            return Ret::Ints(Vec::new());
554        };
555        Ret::Ints(
556            h.borrow_mut()
557                .data_resolve_nodes(&path)
558                .into_iter()
559                .map(|x| x as i32)
560                .collect(),
561        )
562    });
563    v
564}
565
566/// Script that builds the binding layer from the host functions (installed
567/// as `__xfa_host`), publishes `xfa`/`app`/`form` as globals and returns the
568/// bridge object (kept by the engine; never visible to scripts).
569fn bridge_script() -> String {
570    format!(
571        "(function(){{\n var b = ({factory})(globalThis.__xfa_host);\n \
572         delete globalThis.__xfa_host;\n \
573         globalThis.xfa = b.xfa; globalThis.app = b.app; globalThis.form = b.form;\n \
574         return b;\n}})()",
575        factory = PHASE_C_BINDINGS_JS
576    )
577}
578
579/// Removes capability-bearing globals and replaces the non-deterministic
580/// ones (defence in depth; the engines do not provide most of them).
581const HARDEN_JS: &str = r#"(function(){
582  ["fetch","XMLHttpRequest","WebSocket","process","require","Deno","Bun"]
583    .forEach(function(k){ globalThis[k] = undefined; });
584  Date.now = function(){ return 0; };
585  Math.random = undefined;
586})()"#;
587
588impl SandboxedRuntime {
589    /// Construct a new sandboxed runtime with default budgets.
590    pub fn new() -> Result<Self, SandboxError> {
591        let engine = DefaultEngine::new(&Limits {
592            memory_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
593            time_budget: Duration::from_millis(DEFAULT_TIME_BUDGET_MS),
594        })
595        .map_err(|e| SandboxError::ScriptError(format!("js engine init: {e}")))?;
596        Ok(Self {
597            engine: RefCell::new(engine),
598            metadata: RuntimeMetadata::default(),
599            time_budget: Duration::from_millis(DEFAULT_TIME_BUDGET_MS),
600            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
601            script_started: RefCell::new(None),
602            walltime_fallback_multiplier: walltime_fallback_multiplier(),
603            host: Rc::new(RefCell::new(HostBindings::new())),
604            init_called: Cell::new(false),
605            bindings_registered: Cell::new(false),
606        })
607    }
608
609    /// Override the per-script wall-clock budget. Must be called before any
610    /// `execute_script` invocation; takes effect on the next call.
611    pub fn with_time_budget(mut self, budget: Duration) -> Self {
612        self.time_budget = budget;
613        self.engine.get_mut().set_time_budget(budget);
614        self
615    }
616
617    /// Override the per-document memory budget. Must be called before any
618    /// `execute_script` invocation; takes effect on the next document.
619    pub fn with_memory_budget(mut self, bytes: usize) -> Self {
620        self.memory_budget_bytes = bytes;
621        self.engine.get_mut().set_memory_limit(bytes);
622        self
623    }
624
625    /// QF1-E / SEC-01 — override the wall-time fallback multiplier. The
626    /// default is read from
627    /// [`super::ENV_WALLTIME_FALLBACK_MULTIPLIER`] / falls back to
628    /// [`super::WALLTIME_FALLBACK_MULTIPLIER_DEFAULT`]. Values below `2`
629    /// are clamped to `2` so a clean timeout cannot be re-labelled as
630    /// `WallTimeExceeded` by accident. Tests use this to deterministically
631    /// pin the multiplier without racing the process-global env var.
632    pub fn with_walltime_fallback_multiplier(mut self, multiplier: u32) -> Self {
633        self.walltime_fallback_multiplier = multiplier.max(2);
634        self
635    }
636
637    /// Arm the wall-clock deadline for the next evaluation.
638    fn set_deadline(&self) {
639        let now = Instant::now();
640        *self.script_started.borrow_mut() = Some(now);
641        self.engine.borrow_mut().set_guard(Guard {
642            deadline: Some(now + self.time_budget),
643            cancel: None,
644        });
645    }
646
647    fn clear_deadline(&self) {
648        self.engine.borrow_mut().set_guard(Guard::default());
649    }
650
651    /// True when the running script has been going longer than its budget.
652    fn deadline_elapsed(&self) -> bool {
653        self.script_started
654            .borrow()
655            .is_some_and(|s| s.elapsed() >= self.time_budget)
656    }
657
658    /// QF1-E / SEC-01 — classify a timeout-shaped error as either the
659    /// primary [`SandboxError::Timeout`] (stopped within multiplier ×
660    /// budget) or the defence-in-depth [`SandboxError::WallTimeExceeded`]
661    /// (elapsed ≥ multiplier × budget at classification time). The latter
662    /// only appears when the engine was trapped in work it cannot interrupt.
663    fn classify_timeout_or_walltime(&self) -> SandboxError {
664        let Some(start) = *self.script_started.borrow() else {
665            return SandboxError::Timeout;
666        };
667        let elapsed = start.elapsed();
668        let threshold = self
669            .time_budget
670            .saturating_mul(self.walltime_fallback_multiplier);
671        if elapsed >= threshold {
672            SandboxError::WallTimeExceeded(format!(
673                "{} ms ≥ {mult}× budget ({budget_ms} ms)",
674                elapsed.as_millis(),
675                mult = self.walltime_fallback_multiplier,
676                budget_ms = self.time_budget.as_millis()
677            ))
678        } else {
679            SandboxError::Timeout
680        }
681    }
682
683    /// The document-independent setup: harden the globals, install the host
684    /// functions, build the binding layer. One `boot` call, so an engine that
685    /// can snapshot restores the finished state instead of re-running it.
686    fn boot_engine(&self) -> Result<(), String> {
687        if self.bindings_registered.get() {
688            return Ok(());
689        }
690        let natives = host_natives(&self.host);
691        let bridge = bridge_script();
692        self.engine
693            .borrow_mut()
694            .boot(vec![
695                Boot::Eval(HARDEN_JS),
696                Boot::Install("__xfa_host", natives),
697                Boot::EvalKeep(&bridge, "bridge"),
698            ])
699            .map_err(|f| format!("binding factory: {f}"))?;
700        self.bindings_registered.set(true);
701        Ok(())
702    }
703
704    /// Phase D-ι: extract top-level `var X` and `function X(` identifiers
705    /// from a `<variables>` `<script>` body. Used to build the wrapper
706    /// IIFE that returns the form-level global object. Best-effort regex
707    /// scan — handles the common XFA authoring patterns; complex
708    /// destructuring would be missed and the corresponding identifier
709    /// would simply not appear in the namespace object.
710    fn extract_top_level_idents(body: &str) -> Vec<String> {
711        let mut out: Vec<String> = Vec::new();
712        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
713        // Strip line and block comments to avoid extracting commented-out idents.
714        let stripped = strip_js_comments(body);
715        for token_kind in [
716            ("var", false),
717            ("let", false),
718            ("const", false),
719            ("function", true),
720        ] {
721            let kw = token_kind.0;
722            let is_fn = token_kind.1;
723            let mut search = stripped.as_str();
724            while let Some(idx) = search.find(kw) {
725                let (before, after) = search.split_at(idx);
726                let head_ok = before
727                    .chars()
728                    .last()
729                    .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$');
730                let tail_after_kw = &after[kw.len()..];
731                let tail_ok = tail_after_kw
732                    .chars()
733                    .next()
734                    .is_some_and(|c| c.is_whitespace());
735                if !(head_ok && tail_ok) {
736                    search = &after[1..];
737                    continue;
738                }
739                // Skip whitespace, then read an identifier.
740                let after_ws = tail_after_kw.trim_start();
741                let mut chars = after_ws.chars();
742                let ident: String = std::iter::once(chars.next())
743                    .chain(chars.map(Some))
744                    .map_while(|c| c.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '$'))
745                    .collect();
746                if ident.is_empty() || ident.chars().next().is_some_and(|c| c.is_ascii_digit()) {
747                    search = &after[kw.len()..];
748                    continue;
749                }
750                if is_fn {
751                    let after_ident = after_ws
752                        .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '$');
753                    if !after_ident.trim_start().starts_with('(') {
754                        search = &after[kw.len()..];
755                        continue;
756                    }
757                }
758                if seen.insert(ident.clone()) {
759                    out.push(ident);
760                }
761                search = &after[kw.len()..];
762            }
763        }
764        out
765    }
766
767    /// Phase D-ι: register a `<variables>` `<script name="name">` block
768    /// as a form-level global. Called by the dispatch layer after
769    /// `set_form_handle` so subsequent event/calculate scripts see the
770    /// namespace.
771    ///
772    /// Variables-script bodies are evaluated through the same engine as
773    /// event scripts, so they MUST run under the same per-script time
774    /// budget and the same body-size cap. Returns `Ok(true)` when the
775    /// JS-side `setVariablesScript` bound the namespace, `Ok(false)` when it
776    /// returned `false` (eval failure caught JS-side), or `Err` on a
777    /// Rust-side skip (cap / REDOS / timeout / panic).
778    fn register_variables_script(
779        &self,
780        name: &str,
781        body: &str,
782        subform_scope: Option<&str>,
783        expose_global: bool,
784    ) -> Result<bool, SandboxError> {
785        if self.bindings_registered_check().is_err() {
786            return Ok(false);
787        }
788        // W2-B: variables-scripts use a higher body-size cap than event
789        // scripts because they are form-level helper libraries (XFA 3.3
790        // §5.5); real government forms ship 100-500 KB libraries.
791        if body.len() > MAX_VARIABLES_SCRIPT_BODY_BYTES {
792            return Err(SandboxError::BodyTooLarge);
793        }
794        // W3-A — REDOS-01 mitigation also gates variables-scripts.
795        if let RegexScanVerdict::Reject { reason } = scan_script_for_redos(body) {
796            return Err(SandboxError::RegexRejected(reason));
797        }
798        let idents = Self::extract_top_level_idents(body);
799        let scope = subform_scope.unwrap_or("").to_string();
800        self.set_deadline();
801        let result = catch_unwind(AssertUnwindSafe(|| {
802            self.engine.borrow_mut().call_kept(
803                "bridge",
804                "setVariablesScript",
805                vec![
806                    In::Str(name.to_string()),
807                    In::Str(body.to_string()),
808                    In::Strs(idents),
809                    In::Str(scope),
810                    In::Bool(expose_global),
811                ],
812            )
813        }));
814        let timed_out = self.deadline_elapsed();
815        let classified = self.classify_timeout_or_walltime();
816        self.clear_deadline();
817        match result {
818            Ok(Ok(Out::Bool(ok))) => Ok(ok),
819            Ok(Ok(_)) => Ok(false),
820            Ok(Err(Fail::Limit(_))) => Err(classified),
821            Ok(Err(_)) if timed_out => Err(classified),
822            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
823                "variables-script `{name}` register: {e:?}"
824            ))),
825            Err(_) => Err(SandboxError::PanicCaptured(format!(
826                "panic registering variables-script `{name}`"
827            ))),
828        }
829    }
830
831    /// Boot the engine if `init()` asked for it and it has not happened yet.
832    /// `Err` when `init()` was never called or the boot failed.
833    fn bindings_registered_check(&self) -> Result<(), ()> {
834        if self.bindings_registered.get() {
835            return Ok(());
836        }
837        if !self.init_called.get() {
838            return Err(());
839        }
840        self.boot_engine().map_err(|e| {
841            log::debug!("script engine boot failed: {e}");
842        })
843    }
844
845    /// W3-D RETRY: register one `<variables><text name="X">value</text>`
846    /// data item (XFA 3.3 §5.5.2) as a form-level mutable string container.
847    /// Sister of [`Self::register_variables_script`] but with no body cap,
848    /// no REDOS scan, and no time budget — data items carry text payloads,
849    /// not JavaScript code. Idempotent per `(subform_scope, name)`.
850    fn register_variables_data_item(
851        &self,
852        name: &str,
853        initial: &str,
854        subform_scope: Option<&str>,
855    ) -> Result<(), SandboxError> {
856        if self.bindings_registered_check().is_err() {
857            return Ok(());
858        }
859        let result = catch_unwind(AssertUnwindSafe(|| {
860            self.engine.borrow_mut().call_kept(
861                "bridge",
862                "setVariablesDataItem",
863                vec![
864                    In::Str(name.to_string()),
865                    In::Str(initial.to_string()),
866                    In::Str(subform_scope.unwrap_or("").to_string()),
867                ],
868            )
869        }));
870        match result {
871            Ok(Ok(_)) => Ok(()),
872            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
873                "variables data item `{name}` register: {e:?}"
874            ))),
875            Err(_) => Err(SandboxError::PanicCaptured(format!(
876                "panic registering variables data item `{name}`"
877            ))),
878        }
879    }
880
881    fn clear_variables_scripts_global(&self) -> Result<(), SandboxError> {
882        // Nothing to clear in an engine that was never booted.
883        if !self.bindings_registered.get() {
884            return Ok(());
885        }
886        let result = catch_unwind(AssertUnwindSafe(|| {
887            self.engine
888                .borrow_mut()
889                .call_kept("bridge", "clearVariablesScripts", vec![])
890        }));
891        match result {
892            Ok(Ok(_)) => Ok(()),
893            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
894                "variables-script clear: {e:?}"
895            ))),
896            Err(_) => Err(SandboxError::PanicCaptured(
897                "panic clearing variables-scripts".to_string(),
898            )),
899        }
900    }
901}
902
903/// Phase D-ι: strip `//` and `/* */` comments from a JS source body so
904/// the regex scan for top-level `var` / `function` declarations does not
905/// match commented-out tokens. Conservative — preserves strings (so a
906/// `"//"` substring inside a string literal is left alone). XFA scripts
907/// almost never have comments inside string literals, but the guard
908/// matters for correctness.
909fn strip_js_comments(src: &str) -> String {
910    let mut out = String::with_capacity(src.len());
911    let bytes = src.as_bytes();
912    let mut i = 0;
913    while i < bytes.len() {
914        let b = bytes[i];
915        if b == b'"' || b == b'\'' {
916            // Copy string literal verbatim.
917            let quote = b;
918            out.push(b as char);
919            i += 1;
920            while i < bytes.len() {
921                let c = bytes[i];
922                out.push(c as char);
923                if c == b'\\' && i + 1 < bytes.len() {
924                    out.push(bytes[i + 1] as char);
925                    i += 2;
926                    continue;
927                }
928                i += 1;
929                if c == quote {
930                    break;
931                }
932            }
933            continue;
934        }
935        if b == b'/' && i + 1 < bytes.len() {
936            let n = bytes[i + 1];
937            if n == b'/' {
938                // Line comment.
939                while i < bytes.len() && bytes[i] != b'\n' {
940                    i += 1;
941                }
942                continue;
943            }
944            if n == b'*' {
945                // Block comment.
946                i += 2;
947                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
948                    i += 1;
949                }
950                i = (i + 2).min(bytes.len());
951                continue;
952            }
953        }
954        out.push(b as char);
955        i += 1;
956    }
957    out
958}
959
960const PHASE_C_BINDINGS_JS: &str = r##"
961(function(host) {
962  function protoGuard() {
963    return Object.freeze(Object.create(null));
964  }
965
966  function nullProtoObject() {
967    var obj = Object.create(null);
968    Object.defineProperty(obj, "__proto__", {
969      value: protoGuard(),
970      enumerable: false,
971      configurable: false,
972      writable: false
973    });
974    return obj;
975  }
976
977  function lookupObject() {
978    return Object.create(null);
979  }
980
981  var deferredGlobalNames = lookupObject();
982  [
983    "app", "arguments", "Array", "Boolean", "Bun", "console", "Date",
984    "decodeURI", "decodeURIComponent", "Deno", "encodeURI",
985    "encodeURIComponent", "Error", "eval", "EvalError", "fetch",
986    "Function", "globalThis", "Infinity", "isFinite", "isNaN", "JSON",
987    "Map", "Math", "NaN", "Number", "Object", "parseFloat", "parseInt",
988    "process", "RangeError", "ReferenceError", "RegExp", "require",
989    "Set", "String", "Symbol", "SyntaxError", "TypeError", "undefined",
990    "URIError", "WeakMap", "WeakSet", "WebSocket", "XMLHttpRequest",
991    // JS2-01 (Sprint 2 Batch B): `form` is a top-level alias of `xfa.form`.
992    // It must defer to globalThis so the host SOM resolver does not first
993    // try to resolve `form` as a form-tree node, find nothing, and then
994    // surface a `js_resolve_failure` for what is really a viewer global.
995    "xfa", "event", "form"
996  ].forEach(function(name) {
997    deferredGlobalNames[name] = true;
998  });
999
1000  var reservedHandleProperties = lookupObject();
1001  [
1002    "__defineGetter__", "__defineSetter__", "__lookupGetter__",
1003    "__lookupSetter__", "__proto__", "constructor", "hasOwnProperty",
1004    "isPrototypeOf", "propertyIsEnumerable", "then", "toJSON",
1005    "toLocaleString", "toString", "valueOf"
1006  ].forEach(function(name) {
1007    reservedHandleProperties[name] = true;
1008  });
1009
1010  function shouldDeferGlobalName(name, localNames) {
1011    return name.charAt(0) === "_" ||
1012      localNames[name] === true ||
1013      deferredGlobalNames[name] === true;
1014  }
1015
1016  // Properties that must NOT be deferred so their specific implementations run.
1017  var handlePropertyExclusions = lookupObject();
1018  ["rawValue", "somExpression", "isNull", "clearItems", "addItem", "boundItem",
1019   "$record", "nodes", "value", "length", "item", "choiceList", "#items"].forEach(function(name) {
1020    handlePropertyExclusions[name] = true;
1021  });
1022
1023  function shouldDeferHandleProperty(name) {
1024    if (handlePropertyExclusions[name] === true) {
1025      return false;
1026    }
1027    return name.charAt(0) === "_" || reservedHandleProperties[name] === true;
1028  }
1029
1030  function collectLocalNames(body) {
1031    var locals = lookupObject();
1032    var match;
1033    var decls = /\b(?:var|let|const)\s+([^;]+)/g;
1034    while ((match = decls.exec(body)) !== null) {
1035      match[1].split(",").forEach(function(part) {
1036        var ident = /^\s*([A-Za-z_$][0-9A-Za-z_$]*)/.exec(part);
1037        if (ident) {
1038          locals[ident[1]] = true;
1039        }
1040      });
1041    }
1042    var funcs = /\bfunction\s+([A-Za-z_$][0-9A-Za-z_$]*)/g;
1043    while ((match = funcs.exec(body)) !== null) {
1044      locals[match[1]] = true;
1045    }
1046    return locals;
1047  }
1048
1049  // XFA-DATA-M3C: `<handle>.ui` returns the ui-config sub-object that
1050  // Adobe templates expose for choiceList / textEdit / checkButton widget
1051  // tweaking. During static flatten the widget tree is not materialised, so
1052  // we hand back a minimal stub whose `choiceList` is an empty frozen
1053  // array (matching the bare `handle.choiceList` fallback). Property
1054  // writes are absorbed silently so scripts like
1055  // `field.ui.choiceList.commitOn = "exit"` complete without TypeError.
1056  function makeUiStub() {
1057    var stub = nullProtoObject();
1058    Object.defineProperty(stub, "choiceList", {
1059      enumerable: true,
1060      configurable: false,
1061      get: function() { return Object.freeze([]); }
1062    });
1063    Object.defineProperty(stub, "textEdit", {
1064      enumerable: true,
1065      configurable: false,
1066      get: function() { return Object.freeze({}); }
1067    });
1068    Object.defineProperty(stub, "checkButton", {
1069      enumerable: true,
1070      configurable: false,
1071      get: function() { return Object.freeze({}); }
1072    });
1073    return new Proxy(stub, {
1074      get: function(target, prop) {
1075        if (typeof prop !== "string") return undefined;
1076        if (prop in target) return target[prop];
1077        // Sub-property writes / reads are absorbed: scripts walking
1078        // unknown widget config (e.g. `ui.imageEdit.<x>`) get a chainable
1079        // viewer-stub rather than crashing on undefined.
1080        return makeViewerStub();
1081      },
1082      set: function(_t, _p, _v) { return true; },
1083      has: function(_t, _p) { return true; }
1084    });
1085  }
1086
1087  // D5: minimal `node.occur` handle. Reads `min`/`max`/`initial` from the
1088  // structural template (host.occurRead; -1 = unlimited/unknown). Writes to
1089  // `min`/`max` are CAPTURED (host.occurCapture) as mutation intent and then
1090  // discarded — no layout/pagination effect in D5. This stops
1091  // `subform.occur.min = N` from throwing `cannot set property 'min' of
1092  // undefined` so the script proceeds; the captured intent is for the next
1093  // milestone's bounded apply path.
1094  function makeOccurHandle(id, generation) {
1095    var occ = nullProtoObject();
1096    function defineOccurProp(name) {
1097      Object.defineProperty(occ, name, {
1098        enumerable: true,
1099        configurable: false,
1100        get: function() {
1101          return host.occurRead(id, generation, name);
1102        },
1103        set: function(value) {
1104          var n = (typeof value === "number") ? value : parseInt(value, 10);
1105          if (isNaN(n)) { n = 0; }
1106          host.occurCapture(id, generation, name, n);
1107        }
1108      });
1109    }
1110    defineOccurProp("min");
1111    defineOccurProp("max");
1112    defineOccurProp("initial");
1113    return occ;
1114  }
1115
1116  function makeInstanceManager(id, generation) {
1117    var manager = nullProtoObject();
1118    Object.defineProperty(manager, "count", {
1119      enumerable: true,
1120      configurable: false,
1121      get: function() {
1122        return host.instanceCount(id, generation);
1123      }
1124    });
1125    Object.defineProperty(manager, "setInstances", {
1126      enumerable: true,
1127      configurable: false,
1128      writable: false,
1129      value: function(n) {
1130        return host.instanceSet(id, generation, n);
1131      }
1132    });
1133    Object.defineProperty(manager, "addInstance", {
1134      enumerable: true,
1135      configurable: false,
1136      writable: false,
1137      value: function() {
1138        var newId = host.instanceAdd(id, generation);
1139        return newId < 0 ? null : makeHandle(newId, generation);
1140      }
1141    });
1142    Object.defineProperty(manager, "removeInstance", {
1143      enumerable: true,
1144      configurable: false,
1145      writable: false,
1146      value: function(idx) {
1147        return host.instanceRemove(id, generation, idx);
1148      }
1149    });
1150    return Object.freeze(manager);
1151  }
1152
1153  function makeEmptyInstanceManager() {
1154    var manager = nullProtoObject();
1155    Object.defineProperty(manager, "count", {
1156      enumerable: true,
1157      configurable: false,
1158      value: 0
1159    });
1160    Object.defineProperty(manager, "setInstances", {
1161      enumerable: true,
1162      configurable: false,
1163      writable: false,
1164      value: function() { return 0; }
1165    });
1166    Object.defineProperty(manager, "addInstance", {
1167      enumerable: true,
1168      configurable: false,
1169      writable: false,
1170      value: function() { return null; }
1171    });
1172    Object.defineProperty(manager, "removeInstance", {
1173      enumerable: true,
1174      configurable: false,
1175      writable: false,
1176      value: function() { return false; }
1177    });
1178    return Object.freeze(manager);
1179  }
1180
1181  function uniqueNodeIds(ids) {
1182    var out = [];
1183    if (!ids) return out;
1184    for (var i = 0; i < ids.length; i++) {
1185      var id = ids[i] | 0;
1186      if (id < 0) continue;
1187      var seen = false;
1188      for (var j = 0; j < out.length; j++) {
1189        if (out[j] === id) {
1190          seen = true;
1191          break;
1192        }
1193      }
1194      if (!seen) out.push(id);
1195    }
1196    return out;
1197  }
1198
1199  function nodeIdListArg(ids) {
1200    return uniqueNodeIds(ids).join(",");
1201  }
1202
1203  function resolveHandleChildIds(ids, prop) {
1204    var list = nodeIdListArg(ids);
1205    if (list.length === 0) return [];
1206    var childIds = uniqueNodeIds(host.resolveChildNodeIds(list, prop));
1207    if (childIds.length > 0) return childIds;
1208    return uniqueNodeIds(host.resolveScopedNodeIds(list, prop));
1209  }
1210
1211  function makeNodeHandleFromIds(ids, generation) {
1212    var unique = uniqueNodeIds(ids);
1213    if (unique.length === 0) return undefined;
1214    if (unique.length === 1) return makeHandle(unique[0], generation);
1215    return makeCandidateSet(unique, generation);
1216  }
1217
1218  // Phase D-θ: single-segment SOM lookahead. The proxy chain `F.P1.X.rawValue`
1219  // can resolve the leading `F.P1` correctly yet pick the wrong `P1` when
1220  // multiple same-named siblings exist — the resolver lacks the next-segment
1221  // context needed to disambiguate. These helpers return a one-step deferred
1222  // wrapper that, on its next property access, asks the host's hinted
1223  // resolver to drop candidates whose subtree does not contain the lookahead
1224  // name. Terminal properties (rawValue, instanceManager, …) bypass the hint
1225  // path entirely so single-token reads remain byte-for-byte identical.
1226  function isTerminalHandleProp(name) {
1227    if (handlePropertyExclusions[name] === true) return true;
1228    if (reservedHandleProperties[name] === true) return true;
1229    if (name.charAt(0) === "_") return true;
1230    switch (name) {
1231      case "rawValue":
1232      case "somExpression":
1233      case "instanceManager":
1234      case "index":
1235      case "setInstances":
1236      case "addInstance":
1237      case "removeInstance":
1238      case "isNull":
1239      case "clearItems":
1240      case "addItem":
1241      case "boundItem":
1242      case "#items":
1243      case "$record":
1244      case "variables":
1245      case "nodes":
1246      case "length":
1247      case "value":
1248      case "item":
1249        return true;
1250      default:
1251        return false;
1252    }
1253  }
1254
1255  function makeImplicitDeferred(currentId, name, eagerIds, generation) {
1256    function realize(hint) {
1257      if (!hint) return eagerIds;
1258      var refined = uniqueNodeIds(host.resolveImplicitNodeIdsHinted(currentId, name, hint));
1259      return refined.length > 0 ? refined : eagerIds;
1260    }
1261    var sentinel = nullProtoObject();
1262    return new Proxy(sentinel, {
1263      get: function(target, prop, receiver) {
1264        if (typeof prop !== "string") {
1265          return Reflect.get(target, prop, receiver);
1266        }
1267        // XFA-DATA2-02: short-circuit caption / resolveNode pair before any
1268        // host call. See captionSentinel + handleResolveNode definitions.
1269        if (prop === "caption") { return captionSentinel; }
1270        if (prop === "resolveNode") { return handleResolveNode; }
1271        if (prop === "resolveNodes") { return handleResolveNodes; }
1272        var ids = isTerminalHandleProp(prop) ? eagerIds : realize(prop);
1273        var handle = makeNodeHandleFromIds(ids, generation);
1274        if (handle === undefined) return undefined;
1275        return handle[prop];
1276      },
1277      set: function(_target, prop, value) {
1278        var handle = makeNodeHandleFromIds(eagerIds, generation);
1279        if (handle === undefined) return true;
1280        handle[prop] = value;
1281        return true;
1282      },
1283      has: function(_target, prop) {
1284        if (typeof prop !== "string") return Reflect.has(sentinel, prop);
1285        return true;
1286      }
1287    });
1288  }
1289
1290  function makeChildDeferred(parentIds, childName, eagerChildIds, generation) {
1291    var parentList = nodeIdListArg(parentIds);
1292    function realize(hint) {
1293      if (!hint || parentList.length === 0) return eagerChildIds;
1294      var refined = uniqueNodeIds(host.resolveChildNodeIdsHinted(parentList, childName, hint));
1295      return refined.length > 0 ? refined : eagerChildIds;
1296    }
1297    var sentinel = nullProtoObject();
1298    return new Proxy(sentinel, {
1299      get: function(target, prop, receiver) {
1300        if (typeof prop !== "string") {
1301          return Reflect.get(target, prop, receiver);
1302        }
1303        // XFA-DATA2-02: short-circuit caption / resolveNode pair before any
1304        // host call. See captionSentinel + handleResolveNode definitions.
1305        if (prop === "caption") { return captionSentinel; }
1306        if (prop === "resolveNode") { return handleResolveNode; }
1307        if (prop === "resolveNodes") { return handleResolveNodes; }
1308        var ids = isTerminalHandleProp(prop) ? eagerChildIds : realize(prop);
1309        var handle = makeNodeHandleFromIds(ids, generation);
1310        if (handle === undefined) return undefined;
1311        return handle[prop];
1312      },
1313      set: function(_target, prop, value) {
1314        var handle = makeNodeHandleFromIds(eagerChildIds, generation);
1315        if (handle === undefined) return true;
1316        handle[prop] = value;
1317        return true;
1318      },
1319      has: function(_target, prop) {
1320        if (typeof prop !== "string") return Reflect.has(sentinel, prop);
1321        return true;
1322      }
1323    });
1324  }
1325
1326  function makeChainHandle(parentIds, childName, generation) {
1327    var eagerChildIds = resolveHandleChildIds(parentIds, childName);
1328    if (eagerChildIds.length === 0) return undefined;
1329    if (eagerChildIds.length === 1) {
1330      // Singleton child with a single parent leaves the host resolver no
1331      // alternatives to filter even when a hint is provided — same-name
1332      // sibling disambiguation is impossible. Skip the wrapper so the
1333      // common path keeps its original Proxy identity and resolve budget.
1334      return makeHandle(eagerChildIds[0], generation);
1335    }
1336    // Phase D-θ.2: same-name siblings exist; defer through the full-chain
1337    // accumulator so a deeper segment (still unread) can disambiguate.
1338    return makeChainProxy(parentIds, [childName], eagerChildIds, generation, -1);
1339  }
1340
1341  // Phase D-θ.2: lazy chain proxy. Accumulates a SOM chain (string[]) as the
1342  // script walks property access; on the first terminal property read it
1343  // asks the host to resolve the FULL accumulated chain in one call,
1344  // letting the host backtrack and pick the same-name candidate at any
1345  // depth whose subtree actually completes the chain.
1346  //
1347  // Compared to D-θ.1 (single-segment hint), this lets `A.B.C.D.rawValue`
1348  // pick the correct same-name `A` not only when one is the parent of `B`
1349  // but specifically the one whose subtree contains the full B.C.D chain.
1350  //
1351  // Parameters:
1352  //   parentIds            — entry parent set; empty array means implicit walk.
1353  //   chain                — string[] of segments accumulated so far (>=1).
1354  //   eagerIds             — fallback ids returned if the host's full-chain
1355  //                          resolve produces no candidate at any depth
1356  //                          (preserves D-θ.1 byte-for-byte behaviour for
1357  //                          chains the host cannot improve on).
1358  //   generation           — handle generation to bind to.
1359  //   currentId            — implicit-walk anchor (used when parentIds is empty).
1360  //
1361  // Terminal property names trigger immediate resolution. Non-terminal
1362  // string property names return a NEW chain proxy with the property name
1363  // appended; no host call happens at that point.
1364  function makeChainProxy(parentIds, chain, eagerIds, generation, currentId) {
1365    var sentinel = nullProtoObject();
1366    function fullChain(extraSeg) {
1367      var parentCsv = nodeIdListArg(parentIds);
1368      var chainArr = chain;
1369      if (extraSeg !== undefined) {
1370        chainArr = chain.slice();
1371        chainArr.push(extraSeg);
1372      }
1373      var chainCsv = chainArr.join(",");
1374      var resolved = uniqueNodeIds(
1375        host.resolveWithFullChain(parentCsv, currentId | 0, chainCsv)
1376      );
1377      if (resolved.length === 0) return eagerIds;
1378      return resolved;
1379    }
1380    return new Proxy(sentinel, {
1381      get: function(target, prop, receiver) {
1382        if (typeof prop !== "string") {
1383          // Symbol.toPrimitive and similar non-string keys must force
1384          // terminal resolution so coercion (e.g. via Number(handle)) sees
1385          // the resolved candidate's primitive view rather than the proxy
1386          // sentinel itself.
1387          var primIds = fullChain();
1388          var primHandle = makeNodeHandleFromIds(primIds, generation);
1389          if (primHandle === undefined) {
1390            return Reflect.get(target, prop, receiver);
1391          }
1392          return primHandle[prop];
1393        }
1394        // XFA-DATA2-02: caption / resolveNode / resolveNodes short-circuit on
1395        // the chain proxy. Without this branch the chain accumulator would
1396        // append "caption" as a non-terminal segment, call
1397        // resolveWithFullChainStrict, miss, and return `undefined` —
1398        // `field.caption.value` then TypeErrors. Same null/empty-list
1399        // contract as the global pair.
1400        if (prop === "caption") {
1401          return captionSentinel;
1402        }
1403        if (prop === "resolveNode") {
1404          return handleResolveNode;
1405        }
1406        if (prop === "resolveNodes") {
1407          return handleResolveNodes;
1408        }
1409        if (isTerminalHandleProp(prop)) {
1410          var ids = fullChain();
1411          var handle = makeNodeHandleFromIds(ids, generation);
1412          if (handle === undefined) return undefined;
1413          return handle[prop];
1414        }
1415        // Non-terminal: probe the extended chain in STRICT mode. If the
1416        // host cannot complete `[chain..., prop]` at full depth anywhere
1417        // in the form, return `undefined` — mirroring the eager D-θ.1
1418        // behaviour where `A.B` resolved to `undefined` when no `A.B`
1419        // existed. When the probe succeeds, return a new chain proxy
1420        // with the segment appended; the lazy accumulation lets a
1421        // deeper segment still disambiguate which same-name `A` to keep.
1422        //
1423        // D-θ.2 probe-skip: when there is exactly one parent and the chain
1424        // so far has exactly one segment, no same-name sibling ambiguity is
1425        // possible — the parent uniquely identifies the node, and there are
1426        // no alternative subtrees for the host to choose between.  In that
1427        // case the strict probe would always return the same set as eagerIds,
1428        // so we skip the host round-trip and immediately build the next proxy
1429        // using eagerIds as the fallback.  The host-call budget is preserved
1430        // and disambiguation is unaffected because a single-parent / single-
1431        // segment chain cannot be disambiguated further by the host anyway.
1432        var nextChain = chain.slice();
1433        nextChain.push(prop);
1434        if (parentIds.length === 1 && chain.length === 1) {
1435          host.probeSkip();
1436          return makeChainProxy(parentIds, nextChain, eagerIds, generation, currentId);
1437        }
1438        var probe = host.resolveWithFullChainStrict(
1439          nodeIdListArg(parentIds),
1440          currentId | 0,
1441          nextChain.join(",")
1442        );
1443        if (!probe || probe.length === 0) {
1444          return undefined;
1445        }
1446        // The probe result becomes the new eager-fallback so future
1447        // probes that the host cannot improve on still degrade
1448        // gracefully. We must NOT collapse to a single makeHandle here:
1449        // the chain may continue to grow and disambiguate further.
1450        var nextEager = uniqueNodeIds(probe);
1451        return makeChainProxy(parentIds, nextChain, nextEager, generation, currentId);
1452      },
1453      set: function(_target, prop, value) {
1454        // Writes are always terminal: resolve the full chain and forward.
1455        var ids = fullChain();
1456        var handle = makeNodeHandleFromIds(ids, generation);
1457        if (handle === undefined) return true;
1458        handle[prop] = value;
1459        return true;
1460      },
1461      has: function(_target, prop) {
1462        if (typeof prop !== "string") return Reflect.has(sentinel, prop);
1463        return true;
1464      }
1465    });
1466  }
1467
1468  function makeCandidateSet(ids, generation) {
1469    var candidates = uniqueNodeIds(ids);
1470    if (candidates.length === 0) return undefined;
1471    var firstId = candidates[0];
1472    var obj = nullProtoObject();
1473    return new Proxy(obj, {
1474      get: function(target, prop, receiver) {
1475        if (typeof prop !== "string") {
1476          return Reflect.get(target, prop, receiver);
1477        }
1478        if (prop === "rawValue") {
1479          var value = host.getRawValue(firstId, generation);
1480          return value === undefined ? null : value;
1481        }
1482        if (prop === "presence" && host.isInteractive()) {
1483          return host.getPresence(firstId, generation);
1484        }
1485        if (prop === "somExpression") {
1486          return "xfa[0].form[0].placeholder";
1487        }
1488        if (prop === "instanceManager") {
1489          return makeInstanceManager(firstId, generation);
1490        }
1491        if (prop === "occur") {
1492          host.occurResolve(firstId, generation);
1493          return makeOccurHandle(firstId, generation);
1494        }
1495        if (prop === "index") {
1496          return host.nodeIndex(firstId, generation);
1497        }
1498        // XFA-DATA-M3C: `<candidateSet>.parent` walks one form-tree level up
1499        // from the leading candidate. Same semantics as `<handle>.parent`;
1500        // see makeHandle for rationale.
1501        if (prop === "parent") {
1502          var parentId = host.parentOfNode(firstId, generation);
1503          if (parentId < 0) return undefined;
1504          return makeHandle(parentId, generation);
1505        }
1506        if (prop === "setInstances") {
1507          return function(n) {
1508            return host.instanceSet(firstId, generation, n);
1509          };
1510        }
1511        if (prop === "addInstance") {
1512          return function() {
1513            var newId = host.instanceAdd(firstId, generation);
1514            // WP-3 F3: return a chainable null-safe sentinel rather than null
1515            // so scripts that immediately access `.index` / `.value` on the
1516            // newly-added handle do not throw when the instance manager
1517            // refuses (min/max reached, unbound).
1518            return newId < 0 ? makeNullDataHandle() : makeHandle(newId, generation);
1519          };
1520        }
1521        if (prop === "removeInstance") {
1522          return function(idx) {
1523            return host.instanceRemove(firstId, generation, idx);
1524          };
1525        }
1526        if (prop === "isNull") {
1527          var raw = host.getRawValue(firstId, generation);
1528          return raw === undefined || raw === null || raw === "";
1529        }
1530        if (prop === "clearItems") {
1531          return function() {
1532            return host.listClear(firstId, generation);
1533          };
1534        }
1535        if (prop === "addItem") {
1536          return function(display, save) {
1537            if (save === undefined) {
1538              return host.listAdd(firstId, generation, String(display));
1539            }
1540            return host.listAdd(firstId, generation, String(display), String(save));
1541          };
1542        }
1543        if (prop === "boundItem") {
1544          return function(displayValue) {
1545            var coerced = displayValue === null || displayValue === undefined ?
1546              "" : String(displayValue);
1547            return host.boundItem(firstId, generation, coerced);
1548          };
1549        }
1550        if (prop === "$record") {
1551          var recRaw = host.dataBoundRecord(firstId, generation);
1552          if (recRaw < 0) return makeNullDataHandle();
1553          return makeDataHandle(recRaw);
1554        }
1555        if (prop === "variables") {
1556          var csNodeName = host.nodeName(firstId, generation);
1557          if (typeof csNodeName === "string" && csNodeName.length > 0 &&
1558              subformVariables[csNodeName] !== undefined) {
1559            return subformVariables[csNodeName];
1560          }
1561          return Object.create(null);
1562        }
1563        if (prop.charAt(0) === "_" && prop.length > 1) {
1564          var bareName = prop.substring(1);
1565          var imIds = uniqueNodeIds(
1566            host.resolveChildNodeIdsQuiet(nodeIdListArg(candidates), bareName)
1567          );
1568          if (imIds.length > 0) {
1569            return makeInstanceManager(imIds[0], generation);
1570          }
1571          for (var imIdx = 0; imIdx < candidates.length; imIdx++) {
1572            if (host.hasZeroInstanceRun(candidates[imIdx], generation, bareName)) {
1573              return makeEmptyInstanceManager();
1574            }
1575          }
1576          // XFA-DATA-M3C: same fallthrough as makeHandle underscore branch —
1577          // expose an empty instanceManager for schema-optional same-named
1578          // siblings that the merged tree omits, keeping schema-bound
1579          // scripts chainable. Restricted to container candidates so
1580          // field/draw candidate sets keep the original `undefined`
1581          // semantics for underscored property reads.
1582          if (host.nodeIsContainer(firstId, generation)) {
1583            return makeEmptyInstanceManager();
1584          }
1585        }
1586        // WP-3: choiceList is a listbox/combobox property not surfaced through
1587        // the FormTree. Return an empty frozen array.
1588        if (prop === "choiceList") {
1589          return Object.freeze([]);
1590        }
1591        // BE-1: `#items` on a candidate-set — mirrors the makeHandle path.
1592        // Uses the first candidate as the representative field id (same
1593        // convention as boundItem / clearItems / addItem above).
1594        if (prop === "#items") {
1595          var csDisplayItems = host.getDisplayItems(firstId, generation);
1596          if (csDisplayItems.length > 0) {
1597            host.somItemsPathHit();
1598          }
1599          return makeItemsSubstitute(csDisplayItems);
1600        }
1601        // XFA-DATA-M3C: `<candidateSet>.ui` returns the widget-config stub.
1602        // See makeUiStub on makeHandle for rationale.
1603        if (prop === "ui") {
1604          return makeUiStub();
1605        }
1606        if (prop === "formattedValue") {
1607          var fvc = host.getRawValue(firstId, generation);
1608          return fvc === undefined || fvc === null ? "" : String(fvc);
1609        }
1610        if (prop === "execEvent") {
1611          return function() { return undefined; };
1612        }
1613        // XFA-DATA2-02: caption / resolveNode / resolveNodes intercept on the
1614        // candidate-set path. Same rationale as makeHandle: skip the child
1615        // resolver host call and its resolve_failures bump, return a frozen
1616        // sentinel + the chainable method pair. Null contract preserved.
1617        if (prop === "caption") {
1618          return captionSentinel;
1619        }
1620        if (prop === "resolveNode") {
1621          return handleResolveNode;
1622        }
1623        if (prop === "resolveNodes") {
1624          return handleResolveNodes;
1625        }
1626        if (shouldDeferHandleProperty(prop)) {
1627          return undefined;
1628        }
1629        return makeChainHandle(candidates, prop, generation);
1630      },
1631      set: function(_target, prop, value) {
1632        // WP-3: `value` is an alias for rawValue on the set side.
1633        if (prop === "rawValue" || prop === "value") {
1634          host.setRawValue(firstId, generation, value);
1635        } else if (prop === "presence" && host.isInteractive()) {
1636          // Phase 2: visibility toggle. Gated so static flatten falls through
1637          // to the historical silent-absorb (return true) — byte-identical.
1638          host.setPresence(firstId, generation, value);
1639        }
1640        return true;
1641      },
1642      has: function(target, prop) {
1643        if (typeof prop !== "string") {
1644          return Reflect.has(target, prop);
1645        }
1646        return prop === "rawValue" ||
1647          prop === "presence" ||
1648          prop === "somExpression" ||
1649          prop === "instanceManager" ||
1650          prop === "index" ||
1651          prop === "parent" ||
1652          prop === "setInstances" ||
1653          prop === "addInstance" ||
1654          prop === "removeInstance" ||
1655          prop === "isNull" ||
1656          prop === "clearItems" ||
1657          prop === "addItem" ||
1658          prop === "boundItem" ||
1659          Reflect.has(target, prop);
1660      }
1661    });
1662  }
1663
1664  // XFA-DATA2-02: shared caption sentinel — singleton, frozen, null-safe.
1665  //
1666  // XFA templates commonly inspect `field.caption.value` or
1667  // `field.caption.text` to drive labels and conditional formatting. The
1668  // merged FormTree does not carry the optional `<caption>` sub-element, so
1669  // looking up `caption` as a normal child name would call
1670  // `resolve_child_candidates` → NoMatch → bump `resolve_failures`, return
1671  // `undefined`, and the very next `.value` access would throw TypeError
1672  // ("cannot read property 'value' of undefined") and abort the rest of the
1673  // script's initializers.
1674  //
1675  // We intercept `caption` BEFORE the child-resolver call and return a
1676  // chainable sentinel whose terminal reads are empty strings and whose
1677  // deeper reads keep chaining via `makeNullDataHandle`. This is purely
1678  // read-side: writes are silently absorbed (caption sub-element is a
1679  // viewer-only field, never a flatten mutation channel). The sentinel is
1680  // allocated once at factory build-time and frozen, so per-call access is
1681  // an `Object.freeze`d Proxy hit with no heap allocation.
1682  var captionSentinel = (function() {
1683    var base = nullProtoObject();
1684    Object.defineProperty(base, "value",   { enumerable: true, configurable: false,
1685      get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
1686    Object.defineProperty(base, "text",    { enumerable: true, configurable: false,
1687      get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
1688    Object.defineProperty(base, "name",    { enumerable: true, configurable: false,
1689      get: function() { return "caption"; } });
1690    Object.defineProperty(base, "rawValue",{ enumerable: true, configurable: false,
1691      get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
1692    Object.defineProperty(base, "isNull",  { enumerable: true, configurable: false,
1693      get: function() { return true; } });
1694    return new Proxy(base, {
1695      get: function(target, prop) {
1696        if (prop in target || typeof prop !== "string") return target[prop];
1697        // Unknown reads stay chainable so `caption.font.typeface` etc. do
1698        // not throw. The sentinel is read-only and write-absorbing.
1699        return makeNullDataHandle();
1700      },
1701      set: function(_t, _p, _v) { return true; },
1702      has: function() { return true; }
1703    });
1704  })();
1705
1706  // XFA-DATA2-02: shared `resolveNode` / `resolveNodes` instance methods.
1707  //
1708  // Beyond the global `xfa.resolveNode(path)`, XFA scripts also call these
1709  // as methods on individual handles (`field.resolveNode("Subform.X")`,
1710  // `subform.resolveNodes("$.field[*]")`). Without an explicit shortcut these
1711  // property reads would fall through to `makeChainHandle`, miss in the
1712  // child resolver and inflate `js_resolve_failures` — the very pattern the
1713  // 60df78fe corpus replay flagged.
1714  //
1715  // Behaviour mirrors the global pair, including the data-path routing for
1716  // `data.`, `$data.`, and `xfa.datasets.data.` prefixes. The Cluster C
1717  // null-return contract is preserved: a miss in the host returns native
1718  // `null` for the singular form and a frozen empty array for the plural.
1719  // BE-1: XFA-compatible substitute for the <items> element returned by
1720  // resolveNode("FieldName.#items").  In a live viewer the result is an XFA
1721  // node whose .nodes collection contains one <text> child per option label;
1722  // each child exposes .value / .rawValue.  Scripts iterate:
1723  //   for (var i=0; i<items.nodes.length; i++) { items.nodes.item(i).value }
1724  // We materialise that shape from the host display-strings array so existing
1725  // scripts complete without TypeError and can read the label list.
1726  function makeItemsSubstitute(displayStrings) {
1727    var nodes = [];
1728    for (var k = 0; k < displayStrings.length; k++) {
1729      (function(label) {
1730        var textNode = nullProtoObject();
1731        Object.defineProperty(textNode, "value", {
1732          enumerable: true, configurable: false, writable: false, value: label
1733        });
1734        Object.defineProperty(textNode, "rawValue", {
1735          enumerable: false, configurable: false, writable: false, value: label
1736        });
1737        nodes.push(Object.freeze(textNode));
1738      })(displayStrings[k]);
1739    }
1740    var frozenNodes = Object.freeze(nodes);
1741    var nodesCollection = nullProtoObject();
1742    Object.defineProperty(nodesCollection, "length", {
1743      enumerable: true, configurable: false, get: function() { return frozenNodes.length; }
1744    });
1745    Object.defineProperty(nodesCollection, "item", {
1746      enumerable: false, configurable: false, writable: false,
1747      value: function(idx) { return frozenNodes[idx] || null; }
1748    });
1749    var substitute = nullProtoObject();
1750    Object.defineProperty(substitute, "nodes", {
1751      enumerable: true, configurable: false, writable: false, value: Object.freeze(nodesCollection)
1752    });
1753    Object.defineProperty(substitute, "length", {
1754      enumerable: true, configurable: false, get: function() { return frozenNodes.length; }
1755    });
1756    return Object.freeze(substitute);
1757  }
1758
1759  function handleResolveNode(path) {
1760    if (typeof path === "string" &&
1761        (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
1762         path.indexOf("xfa.datasets.data.") === 0)) {
1763      var rawId = host.dataResolveNode(path);
1764      if (rawId < 0) return null;
1765      return makeDataHandle(rawId);
1766    }
1767    // BE-1: intercept `FieldName.#items` (XFA 3.3 §7.7 / §8.1).
1768    // `#items` is the SOM class reference for a choiceList's <items> element.
1769    // The form-tree resolver has no node for it; we resolve the field part,
1770    // fetch its display labels, and return an XFA-compatible substitute.
1771    // Guard: path.length > 7 prevents false-positive when lastIndexOf returns
1772    // -1 and path.length-7 is also -1 (paths shorter than 8 chars).
1773    if (typeof path === "string" && path.length > 7 &&
1774        path.lastIndexOf(".#items") === path.length - 7) {
1775      var fieldPath = path.substring(0, path.length - 7);
1776      var fid = host.resolveNodeId(fieldPath);
1777      if (fid >= 0) {
1778        var displayItems = host.getDisplayItems(fid, host.generation());
1779        if (displayItems.length > 0) {
1780          host.somItemsPathHit();
1781        }
1782        return makeItemsSubstitute(displayItems);
1783      }
1784      return null;
1785    }
1786    var nid = host.resolveNodeId(path);
1787    if (nid < 0) return null;
1788    return makeHandle(nid, host.generation());
1789  }
1790  function handleResolveNodes(path) {
1791    if (typeof path === "string" &&
1792        (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
1793         path.indexOf("xfa.datasets.data.") === 0)) {
1794      var rawIds = host.dataResolveNodes(path);
1795      var out = [];
1796      for (var i = 0; i < rawIds.length; i++) out.push(makeDataHandle(rawIds[i]));
1797      return Object.freeze(out);
1798    }
1799    var generation = host.generation();
1800    var ids = host.resolveNodeIds(path);
1801    var out2 = [];
1802    for (var j = 0; j < ids.length; j++) {
1803      out2.push(makeHandle(ids[j], generation));
1804    }
1805    return Object.freeze(out2);
1806  }
1807
1808  function makeHandle(id, generation) {
1809    var obj = nullProtoObject();
1810    Object.defineProperty(obj, "rawValue", {
1811      enumerable: true,
1812      configurable: false,
1813      get: function() {
1814        var value = host.getRawValue(id, generation);
1815        return value === undefined ? null : value;
1816      },
1817      set: function(value) {
1818        host.setRawValue(id, generation, value);
1819      }
1820    });
1821    // Phase 2: presence read/write on a SOM-resolved handle. Gated on
1822    // interactive mode; when off, the read falls through to the historical
1823    // chain-proxy behaviour and the write is silently absorbed (static-flatten
1824    // byte-identity).
1825    Object.defineProperty(obj, "presence", {
1826      // Non-enumerable so the handle surface stays narrow (Object.keys → just
1827      // rawValue); `presence` is still accessible by name.
1828      enumerable: false,
1829      configurable: false,
1830      get: function() {
1831        return host.isInteractive()
1832          ? host.getPresence(id, generation)
1833          : makeChainHandle([id], "presence", generation);
1834      },
1835      set: function(value) {
1836        if (host.isInteractive()) {
1837          host.setPresence(id, generation, value);
1838        }
1839      }
1840    });
1841    // Phase C-α: defensive stub. Real Adobe forms call
1842    // `this.somExpression` to obtain the SOM path string. We don't expose
1843    // the real SOM path (introspection capability), but returning a
1844    // placeholder lets viewer-tweak scripts (e.g. acroSOM substr(15))
1845    // proceed without ReferenceError. Mutations via this handle still
1846    // require the rawValue setter, which is the only side-effect channel.
1847    Object.defineProperty(obj, "somExpression", {
1848      enumerable: false,
1849      configurable: false,
1850      get: function() {
1851        return "xfa[0].form[0].placeholder";
1852      }
1853    });
1854    return new Proxy(obj, {
1855      get: function(target, prop, receiver) {
1856        if (typeof prop !== "string") {
1857          return Reflect.get(target, prop, receiver);
1858        }
1859        if (prop === "rawValue" || prop === "presence" || prop === "somExpression") {
1860          return Reflect.get(target, prop, receiver);
1861        }
1862        if (prop === "instanceManager") {
1863          return makeInstanceManager(id, generation);
1864        }
1865        if (prop === "occur") {
1866          host.occurResolve(id, generation);
1867          return makeOccurHandle(id, generation);
1868        }
1869        if (prop === "index") {
1870          return host.nodeIndex(id, generation);
1871        }
1872        // XFA-DATA-M3C: `<handle>.parent` returns the form-tree parent.
1873        // Adobe scripts commonly chain `parent.somExpression`,
1874        // `parent.index`, or `parent._Sibling.setInstances(...)` to walk
1875        // one level up from a field or subform without authoring an
1876        // explicit SOM path.
1877        if (prop === "parent") {
1878          var parentId = host.parentOfNode(id, generation);
1879          if (parentId < 0) return undefined;
1880          return makeHandle(parentId, generation);
1881        }
1882        if (prop === "setInstances") {
1883          return function(n) {
1884            return host.instanceSet(id, generation, n);
1885          };
1886        }
1887        if (prop === "addInstance") {
1888          return function() {
1889            var newId = host.instanceAdd(id, generation);
1890            // WP-3 F3: chainable null-safe sentinel on failure (see candidateSet).
1891            return newId < 0 ? makeNullDataHandle() : makeHandle(newId, generation);
1892          };
1893        }
1894        if (prop === "removeInstance") {
1895          return function(idx) {
1896            return host.instanceRemove(id, generation, idx);
1897          };
1898        }
1899        if (prop === "isNull") {
1900          var value = host.getRawValue(id, generation);
1901          return value === undefined || value === null || value === "";
1902        }
1903        if (prop === "clearItems") {
1904          return function() {
1905            return host.listClear(id, generation);
1906          };
1907        }
1908        if (prop === "addItem") {
1909          return function(display, save) {
1910            if (save === undefined) {
1911              return host.listAdd(id, generation, String(display));
1912            }
1913            return host.listAdd(id, generation, String(display), String(save));
1914          };
1915        }
1916        // XFA 3.3 §App A `boundItem` — listbox display→save lookup. Used as
1917        // `field.boundItem(xfa.event.newText)` to translate a user-visible
1918        // option label into its underlying save value. Falls back to the
1919        // input string when no match exists (Adobe behaviour).
1920        if (prop === "boundItem") {
1921          return function(displayValue) {
1922            var coerced;
1923            if (displayValue === null || displayValue === undefined) {
1924              coerced = "";
1925            } else {
1926              coerced = String(displayValue);
1927            }
1928            return host.boundItem(id, generation, coerced);
1929          };
1930        }
1931        if (prop === "$record") {
1932          var recRaw = host.dataBoundRecord(id, generation);
1933          if (recRaw < 0) return makeNullDataHandle();
1934          return makeDataHandle(recRaw);
1935        }
1936        // Phase D-ι.2: `subformHandle.variables` returns the namespace object
1937        // holding all `<variables><script>` entries registered for this
1938        // subform by name. Enables `Page2.variables.ValidationScript.fn()`.
1939        if (prop === "variables") {
1940          var nodeName = host.nodeName(id, generation);
1941          if (typeof nodeName === "string" && nodeName.length > 0 &&
1942              subformVariables[nodeName] !== undefined) {
1943            return subformVariables[nodeName];
1944          }
1945          return Object.create(null);
1946        }
1947        // XFA 3.3 §6.4.3.2 underscore shorthand: `_<name>` on a subform
1948        // refers to the instanceManager of the same-named child subform.
1949        // Used in the wild as `parent._child.setInstances(N)`. This MUST
1950        // run before `shouldDeferHandleProperty`, which otherwise returns
1951        // `undefined` for every underscore-prefixed property and makes the
1952        // shorthand unreachable for real bound subforms.
1953        if (prop.charAt(0) === "_" && prop.length > 1) {
1954          var bareName = prop.substring(1);
1955          var imChildIds = uniqueNodeIds(host.resolveChildNodeIdsQuiet(String(id), bareName));
1956          if (imChildIds.length > 0) {
1957            return makeInstanceManager(imChildIds[0], generation);
1958          }
1959          if (host.hasZeroInstanceRun(id, generation, bareName)) {
1960            return makeEmptyInstanceManager();
1961          }
1962          // XFA-DATA-M3C: when the same-named child subform is absent from
1963          // the merged form tree (occur.initial=0, optional bind, or
1964          // never-instantiated schema option), Adobe still hands the
1965          // script an empty instanceManager so `parent._Foo.setInstances(N)`
1966          // is a chainable no-op. This sentinel is ONLY safe on container
1967          // handles (root, subform, area, subformSet, exclGroup) — XFA
1968          // 3.3 §6.4.3.2 limits the underscore-shorthand to those node
1969          // classes. Fields and draws stay `undefined` so existing
1970          // narrow-handle tests (m3b_phaseC_bindings::field_handle_is_frozen_and_narrow)
1971          // keep their property-isolation contract.
1972          if (host.nodeIsContainer(id, generation)) {
1973            return makeEmptyInstanceManager();
1974          }
1975        }
1976        // WP-3: choiceList is a listbox/combobox property not surfaced through
1977        // the FormTree. Return an empty frozen array.
1978        if (prop === "choiceList") {
1979          return Object.freeze([]);
1980        }
1981        // XFA-DATA-M3C: `<handle>.ui` exposes the widget-config sub-object.
1982        // We materialise a chainable stub (see makeUiStub) so scripts like
1983        // `field.ui.choiceList.commitOn = "exit"` complete without throwing.
1984        if (prop === "ui") {
1985          return makeUiStub();
1986        }
1987        // XFA-DATA-M3C: `<handle>.formattedValue` (Adobe SDK §JS A) reads
1988        // the field value formatted by its picture clause. Static flatten
1989        // has no live picture-clause formatter; return the raw value so
1990        // scripts can compare-and-branch without TypeError.
1991        if (prop === "formattedValue") {
1992          var fv = host.getRawValue(id, generation);
1993          return fv === undefined || fv === null ? "" : String(fv);
1994        }
1995        // XFA-DATA-M3C: `<handle>.execEvent("activity")` (Adobe SDK) fires
1996        // an event handler. Static flatten cannot dispatch new events
1997        // mid-script; absorb the call as a no-op returning undefined.
1998        if (prop === "execEvent") {
1999          return function() { return undefined; };
2000        }
2001        // XFA-DATA2-02: caption sub-element + resolveNode/resolveNodes
2002        // instance methods. Intercepted BEFORE shouldDeferHandleProperty +
2003        // makeChainHandle so the child-resolver host call (and its
2004        // resolve_failures bump) never happens. See captionSentinel /
2005        // handleResolveNode/Nodes definitions above for rationale.
2006        if (prop === "caption") {
2007          return captionSentinel;
2008        }
2009        if (prop === "resolveNode") {
2010          return handleResolveNode;
2011        }
2012        if (prop === "resolveNodes") {
2013          return handleResolveNodes;
2014        }
2015        // BE-1: `#items` — XFA 3.3 §7.7 / §8.1 choiceList item labels.
2016        // Scripts access `fieldHandle.#items` (e.g. `Wojewodztwo.#items`) to
2017        // read the display-label collection.  Returns an XFA-compatible
2018        // substitute (makeItemsSubstitute) so scripts can use both direct
2019        // array indexing and the `.nodes` / `.nodes.item(i)` access pattern.
2020        // `#` bypasses `shouldDeferHandleProperty` via `handlePropertyExclusions`.
2021        if (prop === "#items") {
2022          var displayItems = host.getDisplayItems(id, generation);
2023          if (displayItems.length > 0) {
2024            host.somItemsPathHit();
2025          }
2026          return makeItemsSubstitute(displayItems);
2027        }
2028        if (shouldDeferHandleProperty(prop)) {
2029          return undefined;
2030        }
2031        return makeChainHandle([id], prop, generation);
2032      },
2033      set: function(_target, prop, value) {
2034        // WP-3: `value` is an alias for rawValue on the set side.
2035        if (prop === "rawValue" || prop === "value") {
2036          host.setRawValue(id, generation, value);
2037        } else if (prop === "presence" && host.isInteractive()) {
2038          host.setPresence(id, generation, value);
2039        }
2040        return true;
2041      },
2042      has: function(target, prop) {
2043        if (typeof prop !== "string") {
2044          return Reflect.has(target, prop);
2045        }
2046        return prop === "rawValue" ||
2047          prop === "presence" ||
2048          prop === "somExpression" ||
2049          prop === "instanceManager" ||
2050          prop === "index" ||
2051          prop === "parent" ||
2052          prop === "ui" ||
2053          prop === "formattedValue" ||
2054          prop === "execEvent" ||
2055          prop === "setInstances" ||
2056          prop === "addInstance" ||
2057          prop === "removeInstance" ||
2058          prop === "isNull" ||
2059          prop === "clearItems" ||
2060          prop === "addItem" ||
2061          prop === "boundItem" ||
2062          prop === "caption" ||
2063          prop === "resolveNode" ||
2064          prop === "resolveNodes" ||
2065          prop === "#items" ||
2066          Reflect.has(target, prop);
2067      }
2068    });
2069  }
2070
2071  // Phase C-α: viewer-stub that absorbs property writes silently.
2072  // Used as the return value of `event.target.getField()` so
2073  // AcroForm widget-tweak scripts (`field.doNotScroll = true`,
2074  // `field.required = false`, etc.) complete without error and
2075  // without mutating any flatten-relevant state.
2076  function makeViewerStub() {
2077    return new Proxy({}, {
2078      get: function(_t, _prop) { return undefined; },
2079      set: function(_t, _prop, _val) {
2080        
2081        return true;
2082      },
2083      has: function() { return true; }
2084    });
2085  }
2086
2087  function toListIndex(value) {
2088    var n = Number(value);
2089    if (!isFinite(n) || n < 0) return -1;
2090    return Math.floor(n);
2091  }
2092
2093  // XFA §8.5: a `$record` reference when there is no bound data node must return
2094  // an empty object that chains safely (`.value` → null, `.nodes.length` → 0)
2095  // rather than null, which would throw TypeError on any property access.
2096  function makeNullDataHandle() {
2097    var emptyNodes = [];
2098    // XFA null-safe: item() on an empty sentinel list returns a chainable null handle,
2099    // not native null, so callers can do nodes.item(0).value without TypeError.
2100    emptyNodes.item = function() { return makeNullDataHandle(); };
2101    Object.freeze(emptyNodes);
2102    var sentinel = nullProtoObject();
2103    // WP-3: configurable:true so the Proxy set-trap can return true without
2104    // triggering the ECMAScript "non-configurable accessor without setter" invariant.
2105    Object.defineProperty(sentinel, "value",
2106      { get: function() { return null; }, enumerable: true, configurable: true });
2107    Object.defineProperty(sentinel, "rawValue",
2108      { get: function() { return null; }, enumerable: true, configurable: true });
2109    Object.defineProperty(sentinel, "length",
2110      { get: function() { return 0; }, enumerable: true, configurable: true });
2111    Object.defineProperty(sentinel, "nodes",
2112      { get: function() { return emptyNodes; }, enumerable: true, configurable: true });
2113    // WP-3: instance.index on an unbound node → 0
2114    Object.defineProperty(sentinel, "index",
2115      { get: function() { return 0; }, enumerable: true, configurable: true });
2116    sentinel.item = function() { return makeNullDataHandle(); };
2117    return new Proxy(sentinel, {
2118      get: function(target, prop) {
2119        if (prop in target) return target[prop];
2120        if (typeof prop !== "string") return undefined;
2121        return makeNullDataHandle();
2122      },
2123      // WP-3: absorb all writes — null data handles are read-only sentinels.
2124      set: function(_target, _prop, _value) {
2125        return true;
2126      }
2127    });
2128  }
2129
2130  // BE-1 tranche #1: benign "absent declared node" façade. Returned by the
2131  // implicit-globals `lookup` for a bare identifier that fails the scope
2132  // resolve but names a template-declared container (gated by the host
2133  // `isDeclaredAbsentNode`). Mirrors Adobe semantics: a reference to a
2134  // declared-but-absent node is an EMPTY node — `isNull === true`, `rawValue`/
2135  // `value` null, empty `nodes`, an empty `instanceManager`, and every further
2136  // SOM segment chains to another empty node. Writes are absorbed. This lets a
2137  // guarded script (`if (!Sub.Child.Field.isNull) {…} else {…}`) evaluate the
2138  // guard to `false` and run its else branch (e.g. `_X.setInstances(0)`,
2139  // `presence = "invisible"`) instead of throwing on `undefined`. Modelled on
2140  // `makeNullDataHandle`; the explicit boolean `isNull` is essential — without
2141  // it, `.isNull` would chain to a truthy façade object and invert the guard.
2142  function makeAbsentNodeHandle() {
2143    var sentinel = nullProtoObject();
2144    Object.defineProperty(sentinel, "isNull",
2145      { get: function() { return true; }, enumerable: true, configurable: true });
2146    Object.defineProperty(sentinel, "value",
2147      { get: function() { return null; }, enumerable: true, configurable: true });
2148    Object.defineProperty(sentinel, "rawValue",
2149      { get: function() { return null; }, enumerable: true, configurable: true });
2150    Object.defineProperty(sentinel, "length",
2151      { get: function() { return 0; }, enumerable: true, configurable: true });
2152    var emptyNodes = [];
2153    emptyNodes.item = function() { return makeAbsentNodeHandle(); };
2154    Object.freeze(emptyNodes);
2155    Object.defineProperty(sentinel, "nodes",
2156      { get: function() { return emptyNodes; }, enumerable: true, configurable: true });
2157    Object.defineProperty(sentinel, "instanceManager",
2158      { get: function() { return makeEmptyInstanceManager(); }, enumerable: true, configurable: true });
2159    sentinel.item = function() { return makeAbsentNodeHandle(); };
2160    return new Proxy(sentinel, {
2161      get: function(target, prop) {
2162        if (prop in target) return target[prop];
2163        if (typeof prop !== "string") return undefined;
2164        // `_<Name>` underscore-shorthand → an empty instanceManager, so chained
2165        // `Sub._Child.setInstances(n)` on an absent node is a safe no-op.
2166        if (prop.charAt(0) === "_" && prop.length > 1) return makeEmptyInstanceManager();
2167        // any further SOM segment chains to another empty node.
2168        return makeAbsentNodeHandle();
2169      },
2170      // absorb writes (presence, rawValue, mandatory, …) — empty nodes are inert.
2171      set: function(_target, _prop, _value) { return true; }
2172    });
2173  }
2174
2175  // Phase D-γ: Data DOM handle — wraps a raw DataDom node index and exposes
2176  // `.value`, `.nodes`, `.length`, `.item(i)`, and named child access via Proxy.
2177  function makeDataHandle(rawId) {
2178    // WP-3: return null-safe sentinel so callers can chain .value / .nodes safely.
2179    if (rawId === undefined || rawId < 0) return makeNullDataHandle();
2180    var handle = nullProtoObject();
2181    Object.defineProperty(handle, "value", {
2182      get: function() {
2183        var v = host.dataValue(rawId);
2184        return (v === undefined || v === null) ? null : v;
2185      },
2186      enumerable: true, configurable: false
2187    });
2188    // rawValue is an alias for value — scripts use both forms on data handles.
2189    Object.defineProperty(handle, "rawValue", {
2190      get: function() {
2191        var v = host.dataValue(rawId);
2192        return (v === undefined || v === null) ? null : v;
2193      },
2194      enumerable: true, configurable: false
2195    });
2196    Object.defineProperty(handle, "length", {
2197      get: function() { return host.dataChildren(rawId).length; },
2198      enumerable: true, configurable: false
2199    });
2200    Object.defineProperty(handle, "nodes", {
2201      get: function() {
2202        var ids = host.dataChildren(rawId);
2203        var arr = [];
2204        for (var i = 0; i < ids.length; i++) arr.push(makeDataHandle(ids[i]));
2205        // Phase D-γ fix: XFA scripts call `nodeList.item(i)` on the array
2206        // returned by `.nodes`. Plain JS arrays have no `.item()` method —
2207        // add one that mirrors the W3C NodeList API. Out-of-bounds indices
2208        // return a null-safe sentinel so `.value` access never throws.
2209        var NULLNODE = Object.freeze({ value: null, rawValue: null });
2210        arr.item = function(idx) {
2211          var index = toListIndex(idx);
2212          if (index < 0 || index >= arr.length) return NULLNODE;
2213          return arr[index];
2214        };
2215        return Object.freeze(arr);
2216      },
2217      enumerable: true, configurable: false
2218    });
2219    handle.item = function(i) {
2220      var ids = host.dataChildren(rawId);
2221      var index = toListIndex(i);
2222      if (index < 0 || index >= ids.length) return null;
2223      return makeDataHandle(ids[index]);
2224    };
2225    return new Proxy(handle, {
2226      get: function(target, prop) {
2227        if (prop in target || typeof prop !== "string") return target[prop];
2228        if (prop === "rawValue" || prop === "value") return target.value;
2229        var childId = host.dataChildByName(rawId, prop);
2230        if (childId < 0) return makeNullDataHandle();
2231        return makeDataHandle(childId);
2232      }
2233    });
2234  }
2235
2236  // Phase E (XFA-JS-HOST-STUBS): `xfa.host` is the most heavily used Adobe
2237  // Reader viewer namespace. The previous frozen stub only exposed
2238  // `numPages` + `messageBox`, so initializer scripts that touch
2239  // `xfa.host.title = "..."`, `xfa.host.openList(...)`, `xfa.host.beep()`,
2240  // etc. raised TypeError on the very first property access and inflated
2241  // `runtime_errors`. We expose a Proxy that:
2242  //   * resolves known read-only viewer properties to deterministic defaults
2243  //     (numPages, version, language, platform, name, title, validationsEnabled,
2244  //     calculationsEnabled, currentPage, pageCount);
2245  //   * absorbs every other write silently (viewer-only state has no flatten
2246  //     side-effects);
2247  //   * returns a null-safe sentinel for unknown reads so chains like
2248  //     `xfa.host.someVendorExt.message` don't TypeError;
2249  //   * exposes the interactive function family (messageBox, openList, beep,
2250  //     response, print, gotoURL, setFocus, exportData, importData,
2251  //     resetData) as safe-default thunks that ALSO bump the
2252  //     `unsupported_host_calls` counter so dispatch keeps observability
2253  //     without claiming a fake success (no "user clicked OK" lies).
2254  // No filesystem / network / process syscalls reach the host — every
2255  // interactive thunk is a pure JS no-op that delegates to host.unsupported
2256  // for accounting only. See benchmarks/JS_SANDBOX_SECURITY_AUDIT.md.
2257  var XFA_HOST_INTERACTIVE_CALLS = {
2258    "messageBox":   { kind: "ret", value: 0 },
2259    "openList":     { kind: "ret", value: -1 },
2260    "beep":         { kind: "ret", value: undefined },
2261    "response":     { kind: "ret", value: "" },
2262    "print":        { kind: "ret", value: undefined },
2263    "gotoURL":      { kind: "ret", value: undefined },
2264    "setFocus":     { kind: "ret", value: undefined },
2265    "exportData":   { kind: "ret", value: undefined },
2266    "importData":   { kind: "ret", value: undefined },
2267    "resetData":    { kind: "ret", value: undefined },
2268    "documentCountInBatch": { kind: "ret", value: 1 },
2269    "documentInBatch":      { kind: "ret", value: 0 },
2270    // JS2-01 (Sprint 2 Batch B): closeDoc is referenced by Canadian IMM
2271    // and Quebec dol4n templates as an interactive viewer entry point.
2272    // Static flatten cannot dismiss a document; safe-default undefined
2273    // with `unsupported_host_calls` accounting keeps observability without
2274    // a TypeError. Pure JS no-op — no filesystem / network reach the host.
2275    "closeDoc":     { kind: "ret", value: undefined }
2276  };
2277
2278  // Read-side defaults for viewer-readable host properties. These are pure
2279  // accessors — no host call leaves the sandbox. The values mirror Adobe
2280  // Reader behaviour during static, non-interactive rendering.
2281  function makeXfaHostBase() {
2282    var base = nullProtoObject();
2283    Object.defineProperty(base, "numPages", {
2284      enumerable: true, configurable: false,
2285      get: function() { return host.numPages(); }
2286    });
2287    Object.defineProperty(base, "currentPage", {
2288      enumerable: true, configurable: true,
2289      get: function() { return 0; },
2290      set: function(_v) { /* viewer-only */ }
2291    });
2292    Object.defineProperty(base, "pageCount", {
2293      enumerable: true, configurable: false,
2294      get: function() { return host.numPages(); }
2295    });
2296    // Static deterministic identity strings. We deliberately avoid claiming
2297    // Acrobat compatibility — scripts that branch on `xfa.host.name` for
2298    // Adobe-specific behaviour should fall through to a non-Acrobat path.
2299    var SCALAR_DEFAULTS = {
2300      "version":    "PDFluent-XFA",
2301      "language":   "ENU",
2302      "platform":   "PDFluent",
2303      "name":       "PDFluent",
2304      "title":      "",
2305      "appType":    "Reader",
2306      "variation":  "Reader",
2307      "calculationsEnabled": true,
2308      "validationsEnabled":  true,
2309      "runtimeHighlight":    false,
2310      "runtimeHighlightColor": "",
2311      "viewerType":          "PDFluent",
2312      "fullScreen":          false
2313    };
2314    Object.keys(SCALAR_DEFAULTS).forEach(function(k) {
2315      var v = SCALAR_DEFAULTS[k];
2316      Object.defineProperty(base, k, {
2317        enumerable: true, configurable: true,
2318        get: function() { return v; },
2319        set: function(_v) { /* viewer-only, silently absorb */ }
2320      });
2321    });
2322    return base;
2323  }
2324
2325  // Install interactive function thunks. Each thunk increments the
2326  // unsupported-host-call counter and returns a deterministic safe default.
2327  function installXfaHostInteractive(base) {
2328    Object.keys(XFA_HOST_INTERACTIVE_CALLS).forEach(function(name) {
2329      var spec = XFA_HOST_INTERACTIVE_CALLS[name];
2330      Object.defineProperty(base, name, {
2331        enumerable: true, configurable: false, writable: false,
2332        value: function() {
2333          host.unsupportedHostCall(name);
2334          return spec.value;
2335        }
2336      });
2337    });
2338  }
2339
2340  var xfaHostBase = makeXfaHostBase();
2341  installXfaHostInteractive(xfaHostBase);
2342  // Wrap in a Proxy so unknown property reads return a null-safe sentinel
2343  // and unknown writes silently absorb. This is the "no TypeError" guarantee
2344  // for vendor-specific xfa.host extensions referenced by templated scripts.
2345  var xfaHost = new Proxy(xfaHostBase, {
2346    get: function(target, prop) {
2347      if (prop in target || typeof prop !== "string") return target[prop];
2348      // Unknown read — never throw, never claim a value. Sentinel only.
2349      return makeNullDataHandle();
2350    },
2351    set: function(_t, _p, _v) { return true; },
2352    has: function() { return true; }
2353  });
2354
2355  var xfaLayout = nullProtoObject();
2356  Object.defineProperty(xfaLayout, "pageCount", {
2357    enumerable: true,
2358    configurable: false,
2359    writable: false,
2360    value: function() {
2361      return host.numPages();
2362    }
2363  });
2364  // Phase D-γ: xfa.layout.page(node) — page number (1-based) of a form node.
2365  // During static flatten the layout is not yet run, so return a bounded
2366  // placeholder and mark the metadata as approximate.
2367  Object.defineProperty(xfaLayout, "page", {
2368    enumerable: true,
2369    configurable: false,
2370    writable: false,
2371    value: function() {
2372      host.resolveFailure();
2373      return 1;
2374    }
2375  });
2376  // Phase D-γ: xfa.layout.pageSpan(node) — number of pages a node spans.
2377  // Always 1 during static flatten; metadata records the approximation.
2378  Object.defineProperty(xfaLayout, "pageSpan", {
2379    enumerable: true,
2380    configurable: false,
2381    writable: false,
2382    value: function() {
2383      host.resolveFailure();
2384      return 1;
2385    }
2386  });
2387  Object.defineProperty(xfaLayout, "absPage", {
2388    enumerable: true,
2389    configurable: false,
2390    writable: false,
2391    value: function() {
2392      host.bindingError();
2393      return null;
2394    }
2395  });
2396
2397  var xfa = nullProtoObject();
2398  Object.defineProperty(xfa, "host", {
2399    enumerable: true,
2400    configurable: false,
2401    writable: false,
2402    // Note: deliberately NOT Object.freeze'd. `xfaHost` is already a Proxy
2403    // with sealed semantics (writes absorbed, unknown reads return sentinels).
2404    // Freezing would force every Set trap to throw under strict-mode scripts.
2405    value: xfaHost
2406  });
2407  Object.defineProperty(xfa, "layout", {
2408    enumerable: true,
2409    configurable: false,
2410    writable: false,
2411    value: Object.freeze(xfaLayout)
2412  });
2413
2414  // Phase E (XFA-JS-HOST-STUBS): viewer / interaction sub-namespaces. Each
2415  // is a Proxy that silently absorbs writes and returns chainable sentinels
2416  // on read so scripts can complete:
2417  //
2418  //   xfa.viewer        — Reader UI state (zoom, scrollbar, toolbar, ...)
2419  //   xfa.appState      — Reader-wide preference cache
2420  //   xfa.appearanceFilter — accessibility / high-contrast hints
2421  //   xfa.connection    — `<connection>` outbound data binding stubs
2422  //   xfa.signature     — interactive digital-signature panel
2423  //   xfa.aliasNode     — script-time DOM alias rebinding
2424  //   xfa.form          — top-level FormDOM root reference (we expose the
2425  //                       same resolveNode/resolveNodes pair so the script
2426  //                       feels uniform; legacy property reads sentinel)
2427  //
2428  // These never call out to the host other than for accounting; no
2429  // filesystem / network / process syscalls reach the host bindings. Reads
2430  // for known property names return spec-conforming defaults; everything
2431  // else falls back to the null-safe sentinel chain.
2432  function makeViewerStubNamespace(staticReads) {
2433    var base = nullProtoObject();
2434    if (staticReads) {
2435      Object.keys(staticReads).forEach(function(k) {
2436        var v = staticReads[k];
2437        Object.defineProperty(base, k, {
2438          enumerable: true, configurable: true,
2439          get: function() { return v; },
2440          set: function(_v) { /* viewer-only — silent absorb */ }
2441        });
2442      });
2443    }
2444    return new Proxy(base, {
2445      get: function(target, prop) {
2446        if (prop in target || typeof prop !== "string") return target[prop];
2447        return makeNullDataHandle();
2448      },
2449      set: function(_t, _p, _v) { return true; },
2450      has: function() { return true; }
2451    });
2452  }
2453
2454  function makeInteractiveStubNamespace(funcs) {
2455    // funcs: { "sign": defaultRet, "verify": defaultRet, ... }
2456    var base = nullProtoObject();
2457    Object.keys(funcs).forEach(function(name) {
2458      var def = funcs[name];
2459      Object.defineProperty(base, name, {
2460        enumerable: true, configurable: false, writable: false,
2461        value: function() {
2462          host.unsupportedHostCall(name);
2463          return def;
2464        }
2465      });
2466    });
2467    return new Proxy(base, {
2468      get: function(target, prop) {
2469        if (prop in target || typeof prop !== "string") return target[prop];
2470        return makeNullDataHandle();
2471      },
2472      set: function(_t, _p, _v) { return true; },
2473      has: function() { return true; }
2474    });
2475  }
2476
2477  Object.defineProperty(xfa, "viewer", {
2478    enumerable: true, configurable: false, writable: false,
2479    value: makeViewerStubNamespace({
2480      "zoomType":  "FitWidth",
2481      "zoom":      100,
2482      "scrollbar": "auto",
2483      "toolbar":   true,
2484      "menubar":   true,
2485      "statusbar": true
2486    })
2487  });
2488  Object.defineProperty(xfa, "appState", {
2489    enumerable: true, configurable: false, writable: false,
2490    value: makeViewerStubNamespace({
2491      "highlightRequiredFields": false,
2492      "fieldHighlightColor":     "",
2493      "ariaEnabled":             false
2494    })
2495  });
2496  Object.defineProperty(xfa, "appearanceFilter", {
2497    enumerable: true, configurable: false, writable: false,
2498    value: makeViewerStubNamespace(null)
2499  });
2500  Object.defineProperty(xfa, "aliasNode", {
2501    enumerable: true, configurable: false, writable: false,
2502    value: makeViewerStubNamespace(null)
2503  });
2504  Object.defineProperty(xfa, "connection", {
2505    enumerable: true, configurable: false, writable: false,
2506    // `<connection>` calls hit a real service endpoint in Adobe Reader.
2507    // Static flatten cannot honour that — every method is unsupported.
2508    value: makeInteractiveStubNamespace({
2509      "execute":   null,
2510      "open":      null,
2511      "close":     null,
2512      "send":      null
2513    })
2514  });
2515  Object.defineProperty(xfa, "signature", {
2516    enumerable: true, configurable: false, writable: false,
2517    // Digital signatures require an interactive certificate picker.
2518    value: makeInteractiveStubNamespace({
2519      "sign":     false,
2520      "verify":   "unknown",
2521      "enumerate": ""
2522    })
2523  });
2524  // WP-3 F3: `xfa.validate` is a viewer-only namespace controlling form-wide
2525  // validation behaviour (Reader UI prompts on field constraint violations).
2526  // Adobe initializers commonly do `xfa.validate.override = 0` /
2527  // `xfa.validate.max = N` to suppress modal dialogs that have no meaning
2528  // in a static flatten context. Expose a Proxy that silently absorbs every
2529  // property write (`override`, `max`, `messageMode`, etc.) and returns
2530  // empty defaults on read, so scripts complete without TypeError.
2531  Object.defineProperty(xfa, "validate", {
2532    enumerable: true,
2533    configurable: false,
2534    writable: false,
2535    value: new Proxy(nullProtoObject(), {
2536      get: function(_t, prop) {
2537        if (prop === "override" || prop === "max") return 0;
2538        if (prop === "messageMode") return "";
2539        if (typeof prop !== "string") return undefined;
2540        // Unknown reads return a chainable null-safe sentinel so deeper
2541        // access like `xfa.validate.scriptTest.message` does not throw.
2542        return makeNullDataHandle();
2543      },
2544      set: function(_t, _prop, _value) { return true; },
2545      has: function() { return true; }
2546    })
2547  });
2548  Object.defineProperty(xfa, "resolveNode", {
2549    enumerable: true,
2550    configurable: false,
2551    writable: false,
2552    value: function(path) {
2553      // Phase D-γ: data paths are routed to the DataDom, not the FormTree.
2554      if (typeof path === "string" &&
2555          (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
2556           path.indexOf("xfa.datasets.data.") === 0)) {
2557        var rawId = host.dataResolveNode(path);
2558        if (rawId < 0) return null;
2559        return makeDataHandle(rawId);
2560      }
2561      // BE-1: intercept `FieldName.#items` — same logic as handleResolveNode.
2562      // Scripts call `xfa.resolveNode("Wojewodztwo.#items")` to get the items
2563      // collection for a choiceList; the form-tree SOM cannot resolve `#items`
2564      // as a node, so we resolve the field, fetch its display labels, and
2565      // return an XFA-compatible substitute (makeItemsSubstitute).
2566      // Guard: path.length > 7 prevents false-positive match when
2567      // lastIndexOf returns -1 and path.length-7 is also -1.
2568      if (typeof path === "string" && path.length > 7 &&
2569          path.lastIndexOf(".#items") === path.length - 7) {
2570        var fieldPath = path.substring(0, path.length - 7);
2571        var fid = host.resolveNodeId(fieldPath);
2572        if (fid >= 0) {
2573          var xrDisplayItems = host.getDisplayItems(fid, host.generation());
2574          if (xrDisplayItems.length > 0) {
2575            host.somItemsPathHit();
2576          }
2577          return makeItemsSubstitute(xrDisplayItems);
2578        }
2579        return null;
2580      }
2581      var id = host.resolveNodeId(path);
2582      if (id < 0) {
2583        return null;
2584      }
2585      return makeHandle(id, host.generation());
2586    }
2587  });
2588  // JS2-01 (Sprint 2 Batch B): `xfa.form` is the FormDOM root surface
2589  // Adobe Reader exposes (XFA 3.3 §6.1.4 — the result of merging template
2590  // + datasets). The previous build only commented that it should be
2591  // exposed (rquickjs_backend.rs:2477) but no actual binding existed —
2592  // scripts that did `xfa.form.resolveNode("Form1.Subform1")` or
2593  // `form.resolveNode(...)` ran into "cannot read property of undefined"
2594  // or "form is not defined". We expose a Proxy that:
2595  //   * forwards `resolveNode` / `resolveNodes` to the existing
2596  //     `xfa.resolveNode` / `xfa.resolveNodes` pair (cluster C contract
2597  //     preserved — missing paths still return `null`, not a sentinel);
2598  //   * absorbs unknown property writes (viewer-only flags) silently;
2599  //   * returns a chainable null-safe sentinel for unknown reads.
2600  // No new Rust closure is registered; the binding is pure JS routing.
2601  function makeFormNamespace() {
2602    var base = nullProtoObject();
2603    Object.defineProperty(base, "resolveNode", {
2604      enumerable: true, configurable: false, writable: false,
2605      value: function(path) { return xfa.resolveNode(path); }
2606    });
2607    Object.defineProperty(base, "resolveNodes", {
2608      enumerable: true, configurable: false, writable: false,
2609      value: function(path) { return xfa.resolveNodes(path); }
2610    });
2611    // Adobe's `form` exposes `recalculate(true/false)` as a no-op trigger
2612    // for the calculation cascade. Templates pass `1` (force) commonly.
2613    // Static flatten already ran calculate scripts — accept the call,
2614    // return undefined, no counter (it is not an interactive prompt).
2615    Object.defineProperty(base, "recalculate", {
2616      enumerable: true, configurable: false, writable: false,
2617      value: function() { return undefined; }
2618    });
2619    Object.defineProperty(base, "execValidate", {
2620      enumerable: true, configurable: false, writable: false,
2621      value: function() { return true; }
2622    });
2623    Object.defineProperty(base, "execInitialize", {
2624      enumerable: true, configurable: false, writable: false,
2625      value: function() { return undefined; }
2626    });
2627    Object.defineProperty(base, "execCalculate", {
2628      enumerable: true, configurable: false, writable: false,
2629      value: function() { return undefined; }
2630    });
2631    return new Proxy(base, {
2632      get: function(target, prop) {
2633        if (prop in target || typeof prop !== "string") return target[prop];
2634        return makeNullDataHandle();
2635      },
2636      set: function(_t, _p, _v) { return true; },
2637      has: function() { return true; }
2638    });
2639  }
2640  var formNamespace = makeFormNamespace();
2641  Object.defineProperty(xfa, "form", {
2642    enumerable: true,
2643    configurable: false,
2644    writable: false,
2645    value: formNamespace
2646  });
2647  Object.defineProperty(xfa, "resolveNodes", {
2648    enumerable: true,
2649    configurable: false,
2650    writable: false,
2651    value: function(path) {
2652      // Phase D-γ: data paths are routed to the DataDom, not the FormTree.
2653      if (typeof path === "string" &&
2654          (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
2655           path.indexOf("xfa.datasets.data.") === 0)) {
2656        var rawIds = host.dataResolveNodes(path);
2657        var out = [];
2658        for (var i = 0; i < rawIds.length; i++) out.push(makeDataHandle(rawIds[i]));
2659        return Object.freeze(out);
2660      }
2661      var generation = host.generation();
2662      var ids = host.resolveNodeIds(path);
2663      var out = [];
2664      for (var i = 0; i < ids.length; i++) {
2665        out.push(makeHandle(ids[i], generation));
2666      }
2667      return Object.freeze(out);
2668    }
2669  });
2670  // Phase D-δ.2: expose `xfa.event` as an alias for the per-script event
2671  // global. Real Adobe Reader populates this with the firing event;
2672  // during static flatten there is no dispatched UI event, so the
2673  // accessor returns the same defensive event-stub that the per-script
2674  // `event` parameter receives — newText/prevText/change default to
2675  // empty strings, target resolves to the firing field handle.
2676  Object.defineProperty(xfa, "event", {
2677    enumerable: true,
2678    configurable: false,
2679    get: function() {
2680      return makeEvent();
2681    }
2682  });
2683
2684  // Phase E (XFA-JS-HOST-STUBS): Acrobat / Reader `app` global. Adobe
2685  // initializer scripts commonly do:
2686  //   app.calculate.override = true;       // viewer-only flag — silent absorb
2687  //   app.runtimeHighlight   = false;      // viewer-only flag — silent absorb
2688  //   app.alert("submitted"); app.launchURL("…")  // interactive — counted
2689  //
2690  // The previous frozen `app` only stubbed two functions; every other
2691  // property touch surfaced as TypeError. Wrap the whole namespace in a
2692  // Proxy with the same semantics as `xfa.host`:
2693  //   * known viewer properties resolve to deterministic defaults;
2694  //   * known interactive functions return a safe default + bump
2695  //     unsupported_host_calls (NOT runtime_errors);
2696  //   * unknown reads return a null-safe sentinel chain;
2697  //   * unknown writes are absorbed.
2698  //
2699  // `app.calculate` is itself an absorbing sub-Proxy — every assignment
2700  // (`app.calculate.override = true`, `app.calculate.suspend = false`, ...)
2701  // is viewer-only and has no flatten side-effect. We document this as an
2702  // explicit silent no-op (not UnsupportedHostCapability) because
2703  // suppressing the viewer's calculation cascade is the script author's
2704  // ASKING the viewer to stop, not a UI prompt to the user. See
2705  // benchmarks/JS_SANDBOX_SECURITY_AUDIT.md for the full classification.
2706  var APP_INTERACTIVE_CALLS = {
2707    "alert":       { kind: "ret", value: 0 },     // dialog OK pressed = 0
2708    "launchURL":   { kind: "ret", value: undefined },
2709    "execMenuItem":{ kind: "ret", value: undefined },
2710    "beep":        { kind: "ret", value: undefined },
2711    "openDoc":     { kind: "ret", value: null },
2712    "response":    { kind: "ret", value: "" },
2713    "mailMsg":     { kind: "ret", value: undefined },
2714    // JS2-01 (Sprint 2 Batch B): Adobe Acrobat SDK exposes `app.messageBox`
2715    // as a richer alias of `app.alert` (modal dialog, returns the pressed
2716    // button index — 0 = OK). Safe default mirrors `app.alert`. No new
2717    // capability surface; pure JS no-op with counter accounting.
2718    "messageBox":  { kind: "ret", value: 0 },
2719    // JS2-01: `app.closeDoc` is the Acrobat alias of `xfa.host.closeDoc`.
2720    // Templates targeting both Reader and Acrobat reference both spellings;
2721    // we counter-bump on each call and return undefined.
2722    "closeDoc":    { kind: "ret", value: undefined }
2723  };
2724  function makeAppBase() {
2725    var base = nullProtoObject();
2726    // Read-side static defaults — never call out, never lie about identity.
2727    var SCALAR_DEFAULTS = {
2728      "viewerType":     "PDFluent",
2729      "viewerVariation":"Reader",
2730      "viewerVersion":  0,
2731      "language":       "ENU",
2732      "platform":       "PDFluent",
2733      "fs":             null,  // file system gateway — explicitly null
2734      "media":          null,  // multimedia controller — explicitly null
2735      "fullscreen":     false,
2736      "runtimeHighlight": false,
2737      "runtimeHighlightColor": ""
2738    };
2739    Object.keys(SCALAR_DEFAULTS).forEach(function(k) {
2740      var v = SCALAR_DEFAULTS[k];
2741      Object.defineProperty(base, k, {
2742        enumerable: true, configurable: true,
2743        get: function() { return v; },
2744        set: function(_v) { /* viewer-only, silently absorb */ }
2745      });
2746    });
2747    // app.calculate sub-namespace: writes absorb, reads return defaults.
2748    Object.defineProperty(base, "calculate", {
2749      enumerable: true, configurable: false, writable: false,
2750      value: makeViewerStubNamespace({
2751        "override": true,
2752        "suspend":  false
2753      })
2754    });
2755    // Install interactive function thunks (same pattern as xfa.host).
2756    Object.keys(APP_INTERACTIVE_CALLS).forEach(function(name) {
2757      var spec = APP_INTERACTIVE_CALLS[name];
2758      Object.defineProperty(base, name, {
2759        enumerable: true, configurable: false, writable: false,
2760        value: function() {
2761          host.unsupportedHostCall(name);
2762          return spec.value;
2763        }
2764      });
2765    });
2766    return base;
2767  }
2768  var app = new Proxy(makeAppBase(), {
2769    get: function(target, prop) {
2770      if (prop in target || typeof prop !== "string") return target[prop];
2771      return makeNullDataHandle();
2772    },
2773    set: function(_t, _p, _v) { return true; },
2774    has: function() { return true; }
2775  });
2776
2777  // Phase C-α: viewer-only `event` global. Real Adobe Reader populates
2778  // this with the firing event; during static flatten there is no
2779  // dispatched UI event, so the object is a defensive stub that:
2780  // - exposes `target` resolving to the firing field handle (≈ `this`),
2781  //   so scripts like `event.target.getField(somPath)` complete without
2782  //   ReferenceError;
2783  // - exposes `change` as an empty string (the spec default);
2784  // - returns viewer-stubs from `target.getField()` so AcroForm widget
2785  //   tweaks (`doNotScroll`, `required`, etc.) silently absorb.
2786  function makeEvent() {
2787    var id = host.currentNodeId();
2788    var fieldHandle = id < 0 ? null : makeHandle(id, host.generation());
2789    var target = nullProtoObject();
2790    Object.defineProperty(target, "getField", {
2791      enumerable: true, configurable: false, writable: false,
2792      value: function() {
2793        
2794        return makeViewerStub();
2795      }
2796    });
2797    Object.defineProperty(target, "name", {
2798      enumerable: true, configurable: false,
2799      get: function() { return ""; }
2800    });
2801    Object.defineProperty(target, "self", {
2802      enumerable: true, configurable: false,
2803      get: function() { return fieldHandle; }
2804    });
2805    var ev = nullProtoObject();
2806    Object.defineProperty(ev, "target", {
2807      enumerable: true, configurable: false,
2808      get: function() { return target; }
2809    });
2810    // Phase D-δ.2: stable empty-string defaults for the change-event property
2811    // family so scripts that read `xfa.event.newText` / `prevText` /
2812    // `change` on initialize/calculate (where no real change event occurred)
2813    // do not throw `cannot read property 'X' of undefined`. Adobe populates
2814    // these on actual `change`/`exit` events; we run those activities later
2815    // (or never) and surface deterministic empty defaults instead.
2816    Object.defineProperty(ev, "change", {
2817      enumerable: true, configurable: false,
2818      get: function() { return ""; }
2819    });
2820    Object.defineProperty(ev, "newText", {
2821      enumerable: true, configurable: false,
2822      get: function() { return ""; }
2823    });
2824    Object.defineProperty(ev, "prevText", {
2825      enumerable: true, configurable: false,
2826      get: function() { return ""; }
2827    });
2828    Object.defineProperty(ev, "fullText", {
2829      enumerable: true, configurable: false,
2830      get: function() { return ""; }
2831    });
2832    Object.defineProperty(ev, "selStart", {
2833      enumerable: true, configurable: false,
2834      get: function() { return 0; }
2835    });
2836    Object.defineProperty(ev, "selEnd", {
2837      enumerable: true, configurable: false,
2838      get: function() { return 0; }
2839    });
2840    // JS2-01 (Sprint 2 Batch B): the Phase E security audit §3.9 promised
2841    // that `event` exposes the full Adobe Acrobat SDK event surface
2842    // (`target`, `change`, `newText`, `prevText`, `fullText`, `selStart`,
2843    // `selEnd`, plus `name`, `type`, `shift`, `modifier`, `commitKey`,
2844    // `willCommit`, `rc`, `keyDown`, `value`, `reenter`). Only the first
2845    // seven were implemented; scripts that probe `event.name` for the
2846    // firing activity (`if (event.name === "calculate") {...}`) silently
2847    // saw `undefined`, and scripts that chained off `event.X` could trip
2848    // a "not a function" if `X` was expected to be callable.
2849    //
2850    // All values are deterministic spec defaults that match Adobe's
2851    // behaviour during a non-interactive flatten (no firing event):
2852    //   name       — current activity if known, else ""
2853    //   type       — event type label (Adobe ≈ "Field"), "" if unknown
2854    //   shift      — modifier-key state (false)
2855    //   modifier   — modifier-key state (false)
2856    //   commitKey  — 0 (no commit key pressed)
2857    //   willCommit — false (no commit pending)
2858    //   rc         — true (accept-by-default — same as Adobe's validate)
2859    //   keyDown    — false (no key pressed)
2860    //   value      — "" (current widget value, unknown during static flatten)
2861    //   reenter    — false (Acrobat re-entry flag)
2862    //
2863    // Read-only via getter, like the existing seven. The frozen ev object
2864    // means writes to these from scripts running in strict mode would throw
2865    // a TypeError — but no real script writes event scalars (the only
2866    // observed pattern is `event.value = ...` on a stale `event` reference
2867    // which is undefined, not the frozen object). If we ever observe
2868    // legitimate writes, swap getter for configurable getter+setter.
2869    Object.defineProperty(ev, "name", {
2870      enumerable: true, configurable: false,
2871      get: function() { return ""; }
2872    });
2873    Object.defineProperty(ev, "type", {
2874      enumerable: true, configurable: false,
2875      get: function() { return ""; }
2876    });
2877    Object.defineProperty(ev, "shift", {
2878      enumerable: true, configurable: false,
2879      get: function() { return false; }
2880    });
2881    Object.defineProperty(ev, "modifier", {
2882      enumerable: true, configurable: false,
2883      get: function() { return false; }
2884    });
2885    Object.defineProperty(ev, "commitKey", {
2886      enumerable: true, configurable: false,
2887      get: function() { return 0; }
2888    });
2889    Object.defineProperty(ev, "willCommit", {
2890      enumerable: true, configurable: false,
2891      get: function() { return false; }
2892    });
2893    Object.defineProperty(ev, "rc", {
2894      enumerable: true, configurable: false,
2895      get: function() { return true; }
2896    });
2897    Object.defineProperty(ev, "keyDown", {
2898      enumerable: true, configurable: false,
2899      get: function() { return false; }
2900    });
2901    Object.defineProperty(ev, "value", {
2902      enumerable: true, configurable: false,
2903      get: function() { return ""; }
2904    });
2905    Object.defineProperty(ev, "reenter", {
2906      enumerable: true, configurable: false,
2907      get: function() { return false; }
2908    });
2909    return Object.freeze(ev);
2910  }
2911
2912  // Phase D-γ: XFA global `util` (Acrobat SDK §Util).  Provides date/number
2913  // formatting helpers used by many XFA templates. Only the subset required
2914  // by real-corpus scripts is implemented; unknown methods return "".
2915  //
2916  // util.printd(sFormat, dDate)  — format a Date per sFormat using UTC fields.
2917  //   Supported tokens: yyyy (year), yy (two-digit year), mm (month 01-12),
2918  //   m (1-12), dd (day 01-31), d (1-31), HH (hour 00-23),
2919  //   MM (minute 00-59), SS (second 00-59).
2920  // util.printx(cPicture, cValue) — picture format; returns cValue as-is.
2921  // util.scand(sFormat, cDate)   — deterministic numeric parser for the same
2922  //   token subset; unsupported or invalid input returns Invalid Date.
2923  var xfaUtil = (function() {
2924    var DateCtor = Date;
2925    var dateUtc = Date.UTC;
2926    function pad2(n) { return (n < 10 ? "0" : "") + n; }
2927    function invalidDate() { return new DateCtor(NaN); }
2928    function escapeRegex(text) {
2929      return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2930    }
2931    function parseDate(fmt, input) {
2932      var fmtText, text;
2933      try {
2934        fmtText = String(fmt);
2935        text = String(input);
2936      } catch (_e) {
2937        return invalidDate();
2938      }
2939
2940      var groups = [];
2941      var pattern = "^";
2942      for (var i = 0; i < fmtText.length;) {
2943        var rest = fmtText.substring(i);
2944        if (rest.indexOf("yyyy") === 0) {
2945          pattern += "(\\d{4})";
2946          groups.push("yyyy");
2947          i += 4;
2948        } else if (rest.indexOf("yy") === 0) {
2949          pattern += "(\\d{2})";
2950          groups.push("yy");
2951          i += 2;
2952        } else if (rest.indexOf("mm") === 0) {
2953          pattern += "(\\d{2})";
2954          groups.push("mm");
2955          i += 2;
2956        } else if (rest.indexOf("dd") === 0) {
2957          pattern += "(\\d{2})";
2958          groups.push("dd");
2959          i += 2;
2960        } else if (rest.indexOf("HH") === 0) {
2961          pattern += "(\\d{2})";
2962          groups.push("HH");
2963          i += 2;
2964        } else if (rest.indexOf("MM") === 0) {
2965          pattern += "(\\d{2})";
2966          groups.push("MM");
2967          i += 2;
2968        } else if (rest.indexOf("SS") === 0) {
2969          pattern += "(\\d{2})";
2970          groups.push("SS");
2971          i += 2;
2972        } else if (rest.indexOf("m") === 0) {
2973          pattern += "(\\d{1,2})";
2974          groups.push("m");
2975          i += 1;
2976        } else if (rest.indexOf("d") === 0) {
2977          pattern += "(\\d{1,2})";
2978          groups.push("d");
2979          i += 1;
2980        } else {
2981          pattern += escapeRegex(fmtText.charAt(i));
2982          i += 1;
2983        }
2984      }
2985
2986      var match = new RegExp(pattern + "$").exec(text);
2987      if (!match) return invalidDate();
2988
2989      var year = NaN, month = 1, day = 1, hour = 0, minute = 0, second = 0;
2990      for (var g = 0; g < groups.length; g++) {
2991        var value = parseInt(match[g + 1], 10);
2992        if (!isFinite(value)) return invalidDate();
2993        if (groups[g] === "yyyy") year = value;
2994        else if (groups[g] === "yy") year = 2000 + value;
2995        else if (groups[g] === "mm" || groups[g] === "m") month = value;
2996        else if (groups[g] === "dd" || groups[g] === "d") day = value;
2997        else if (groups[g] === "HH") hour = value;
2998        else if (groups[g] === "MM") minute = value;
2999        else if (groups[g] === "SS") second = value;
3000      }
3001
3002      if (!isFinite(year) || month < 1 || month > 12 || day < 1 || day > 31 ||
3003          hour < 0 || hour > 23 || minute < 0 || minute > 59 ||
3004          second < 0 || second > 59) {
3005        return invalidDate();
3006      }
3007      var out = new DateCtor(dateUtc(year, month - 1, day, hour, minute, second));
3008      if (out.getUTCFullYear() !== year ||
3009          out.getUTCMonth() + 1 !== month ||
3010          out.getUTCDate() !== day ||
3011          out.getUTCHours() !== hour ||
3012          out.getUTCMinutes() !== minute ||
3013          out.getUTCSeconds() !== second) {
3014        return invalidDate();
3015      }
3016      return out;
3017    }
3018    var u = nullProtoObject();
3019    u.printd = function(fmt, date) {
3020      if (!(date instanceof DateCtor) || isNaN(date.getTime())) return "";
3021      var y = date.getUTCFullYear();
3022      var mo = date.getUTCMonth() + 1;
3023      var d  = date.getUTCDate();
3024      var h  = date.getUTCHours();
3025      var mi = date.getUTCMinutes();
3026      var s  = date.getUTCSeconds();
3027      var result = String(fmt);
3028      result = result.replace(/yyyy/g, y)
3029                     .replace(/yy/g,   String(y).slice(-2))
3030                     .replace(/mm/g,   pad2(mo))
3031                     .replace(/m/g,    mo)
3032                     .replace(/dd/g,   pad2(d))
3033                     .replace(/d/g,    d)
3034                     .replace(/HH/g,   pad2(h))
3035                     .replace(/MM/g,   pad2(mi))
3036                     .replace(/SS/g,   pad2(s));
3037      return result;
3038    };
3039    u.printx = function(_fmt, val) { return val === null || val === undefined ? "" : String(val); };
3040    u.scand  = function(fmt, str) { return parseDate(fmt, str); };
3041    return Object.freeze(u);
3042  }());
3043
3044  // Phase C-α: minimal `console` no-op. Many forms guard with
3045  // `if (typeof console !== "undefined") console.log(...)` and proceed
3046  // when the symbol exists. Stub returns undefined; never writes
3047  // anywhere observable to the script.
3048  var consoleStub = nullProtoObject();
3049  ["log","warn","error","info","debug","trace"].forEach(function(name) {
3050    Object.defineProperty(consoleStub, name, {
3051      enumerable: true, configurable: false, writable: false,
3052      value: function() {  return undefined; }
3053    });
3054  });
3055
3056  // Phase D-ι: form-level globals registered from `<variables>` `<script>`
3057  // blocks. Each entry is `name -> frozen object`. Populated by the host
3058  // once per document via `setVariablesScript`; cleared by
3059  // `clearVariablesScripts` at `reset_per_document`.
3060  var variablesScripts = lookupObject();
3061  // Phase D-ι.2: subform-scoped variables. Maps subform name -> namespace
3062  // object containing that subform's named scripts. Enables
3063  // `subformHandle.variables.ScriptName.method()` access paths.
3064  var subformVariables = lookupObject();
3065  // W3-D RETRY: form-level mutable string data items registered from
3066  // `<variables>` `<text name="X">value</text>` blocks (XFA 3.3 §5.5.2).
3067  // Each entry is `name -> { value: <string> }`. Adobe Reader exposes these
3068  // as global mutable string containers — the canonical IMM5709 pattern
3069  // is `<text name="globValidatePressed"/>` referenced from event scripts
3070  // as `globValidatePressed.value = "true";`. Populated by
3071  // `setVariablesDataItem`; cleared by `clearVariablesScripts` (shared
3072  // teardown — same per-document lifecycle).
3073  var variablesDataItems = lookupObject();
3074  // W3-D RETRY: subform-scoped variant of `variablesDataItems`, parallel to
3075  // `subformVariables` for scripts. Reserved for future subform-level data
3076  // items; currently populated only at root scope but the structure is
3077  // here so an upcoming pass can light it up without further refactor.
3078  var subformVariablesDataItems = lookupObject();
3079  // D4: flat first-wins exposure of subform-scoped named `<script>` objects to
3080  // bare-identifier lookup. The host only writes an entry here for names that
3081  // are unique across all subforms (ambiguous names are withheld — fail-closed
3082  // — and counted Rust-side). Consulted by the `with` proxy AFTER
3083  // `variablesScripts` / `variablesDataItems` (root scope wins) and BEFORE the
3084  // host SOM resolver, so a subform-scoped helper such as `countryScript`
3085  // resolves as a bare global instead of falling through to a SOM NoMatch.
3086  var subformScriptsByName = lookupObject();
3087
3088  function makeImplicitGlobals(body) {
3089    var currentId = host.currentNodeId();
3090    var generation = host.generation();
3091    var localNames = collectLocalNames(String(body));
3092    var cachedHandles = lookupObject();
3093    var dynamicLocals = lookupObject();
3094
3095    function lookup(name) {
3096      if (cachedHandles[name] !== undefined) {
3097        return cachedHandles[name];
3098      }
3099      var nodeIds = uniqueNodeIds(host.resolveImplicitNodeIds(currentId, name));
3100      if (nodeIds.length === 0) {
3101        // BE-1 tranche #1: a bare implicit identifier that fails the scope
3102        // resolve but names a template-declared container resolves to a benign
3103        // EMPTY node (Adobe semantics: isNull===true), not `undefined`. This
3104        // lets guarded scripts such as
3105        //   if (!Podmiot1.OsobaFizyczna.PESEL.isNull) {...} else {...}
3106        // take the empty (else) branch and run their setInstances(0)/presence
3107        // writes instead of throwing "cannot read property '…' of undefined"
3108        // and aborting. Undeclared names still return `undefined`, preserving
3109        // the D-θ.1 `A.B === undefined` byte-identity contract for genuine
3110        // misses. Gated host-side by `isDeclaredAbsentNode`; sandboxed-only.
3111        if (host.isDeclaredAbsentNode(name)) {
3112          var facade = makeAbsentNodeHandle();
3113          cachedHandles[name] = facade;
3114          return facade;
3115        }
3116        return undefined;
3117      }
3118      // Phase D-θ.2: wrap an implicit hit in the full-chain accumulator. The
3119      // resulting proxy accumulates SOM property names without contacting
3120      // the host until a terminal property is read; only then is the full
3121      // chain resolved with backtracking. This lets `A.B.C.D.rawValue`
3122      // disambiguate the same-name `A` whose subtree completes the chain,
3123      // rather than relying on a single-segment hint as in D-θ.1. Terminal
3124      // properties still resolve byte-for-byte identically to the
3125      // un-hinted walk so single-token reads keep their existing
3126      // semantics.
3127      var handle = makeChainProxy([], [name], nodeIds, generation, currentId);
3128      cachedHandles[name] = handle;
3129      return handle;
3130    }
3131
3132    return new Proxy(Object.create(null), {
3133      has: function(_target, prop) {
3134        if (typeof prop !== "string") {
3135          return false;
3136        }
3137        // XFA-DATA-M3C: `_<Name>` and `parent` are XFA-defined globals that
3138        // must be visible to `with()` lookup even when the local-names
3139        // collector treats them as locals (they shadow no real var/let).
3140        if (prop === "parent") {
3141          return true;
3142        }
3143        if (prop.charAt(0) === "_" && prop.length > 1 &&
3144            localNames[prop] !== true) {
3145          // Probe — only claim presence when the bare-name resolves to a
3146          // real subform/container; otherwise fall through to the standard
3147          // defer rules so unrelated underscored locals stay undefined.
3148          // Quiet probe: a miss here is the schema-optional path and must
3149          // not count as a script-level resolve failure.
3150          var bare = prop.substring(1);
3151          if (uniqueNodeIds(host.resolveImplicitNodeIdsQuiet(currentId, bare)).length > 0 ||
3152              host.hasZeroInstanceRun(currentId, generation, bare)) {
3153            return true;
3154          }
3155        }
3156        if (shouldDeferGlobalName(prop, localNames)) {
3157          return false;
3158        }
3159        return true;
3160      },
3161      get: function(_target, prop) {
3162        if (typeof prop !== "string") {
3163          return undefined;
3164        }
3165        // XFA-DATA-M3C: bare `parent` resolves to the form-tree parent of
3166        // the current script node. Common in calculate/initialize scripts
3167        // for `parent.index`, `parent.rawValue`, and chain navigation that
3168        // mirrors Adobe's implicit-scope walk one step upward.
3169        if (prop === "parent") {
3170          var parentId = host.parentOfNode(currentId, generation);
3171          if (parentId < 0) return undefined;
3172          return makeHandle(parentId, generation);
3173        }
3174        // XFA 3.3 §6.4.3.2 underscore shorthand at the global scope:
3175        // `_<Name>` referenced as a bare identifier denotes the
3176        // instanceManager of a same-named subform reachable from the
3177        // current implicit scope. Adobe Reader exposes this both as a
3178        // child property (`parent._Foo`) and as a bare global. Without
3179        // this branch the `shouldDeferGlobalName` rule below returns
3180        // `undefined` for every underscored bare ident and the calling
3181        // script throws `ReferenceError: _Foo is not defined`.
3182        //
3183        // Only triggers when the target subform actually exists; otherwise
3184        // fall through so unrelated underscored locals keep their
3185        // existing deferred-undefined semantics.
3186        if (prop.charAt(0) === "_" && prop.length > 1 &&
3187            localNames[prop] !== true) {
3188          var bareName = prop.substring(1);
3189          // Quiet probe: schema-optional misses are NOT a script-level
3190          // resolve failure. We surface either a live manager, an empty
3191          // manager (zero-instance run or never-instantiated optional
3192          // subform), or fall through to the defer rules.
3193          var imIds = uniqueNodeIds(
3194            host.resolveImplicitNodeIdsQuiet(currentId, bareName)
3195          );
3196          if (imIds.length > 0) {
3197            return makeInstanceManager(imIds[0], generation);
3198          }
3199          if (host.hasZeroInstanceRun(currentId, generation, bareName)) {
3200            return makeEmptyInstanceManager();
3201          }
3202        }
3203        if (shouldDeferGlobalName(prop, localNames)) {
3204          return undefined;
3205        }
3206        // Phase D-γ: $record as a script-level global refers to the data
3207        // record bound to the current field's enclosing subform context.
3208        // Scripts write `var addr = $record.SECTION.nodes;` — we intercept
3209        // this here instead of letting resolveImplicitNodeId fail (-1).
3210        if (prop === "$record") {
3211          var recRaw = host.dataBoundRecord(currentId, generation);
3212          if (recRaw < 0) return makeNullDataHandle();
3213          return makeDataHandle(recRaw);
3214        }
3215        // BE-1: `$data` as a bare global refers to the data-DOM root node
3216        // (XFA 3.3 §3.3.2: `$data` == `xfa.datasets.data`).  Scripts write
3217        // e.g. `var root = $data; root.child.value` — we intercept here
3218        // before the SOM resolver so the data-dom root is returned directly.
3219        // `data_resolve_node("$data")` resolves to the root of the DataDom
3220        // (SomRoot::Data + zero segments -> start node) via resolve_data_som.
3221        if (prop === "$data") {
3222          var dataRootRaw = host.dataResolveNode("$data");
3223          if (dataRootRaw < 0) return makeNullDataHandle();
3224          host.somDataRootHit();
3225          return makeDataHandle(dataRootRaw);
3226        }
3227        // Phase D-γ: `util` is an XFA global (Acrobat SDK §Util) that provides
3228        // date/number formatting functions.  `util.printd(fmt, date)` is widely
3229        // used by XFA templates to format Date objects.  We intercept it here so
3230        // scripts can complete without a TypeError instead of throwing and
3231        // aborting all later mutations in the same script body.
3232        if (prop === "util") {
3233          return xfaUtil;
3234        }
3235        if (dynamicLocals[prop] !== undefined) {
3236          return dynamicLocals[prop];
3237        }
3238        // Phase D-ι: form-level named-script globals from <variables>
3239        // outrank the form-tree implicit lookup. Adobe XFA spec §5.5
3240        // exposes `<scriptName>.<topLevelDecl>` to all event/calculate
3241        // scripts in the same document.
3242        if (variablesScripts[prop] !== undefined) {
3243          return variablesScripts[prop];
3244        }
3245        // W3-D RETRY: form-level data items (XFA 3.3 §5.5.2) outrank the
3246        // form-tree implicit lookup, same as scripts. This lets templates
3247        // like Canadian IMM5709 reference `globValidatePressed.value`
3248        // without the bare ident falling through to the SOM resolver as a
3249        // `js_resolve_failure`. Scripts take precedence over data items in
3250        // the unlikely case both share a name (spec-undefined; we follow
3251        // declaration order, which is `setVariablesScript` first in the
3252        // host loop).
3253        if (variablesDataItems[prop] !== undefined) {
3254          return variablesDataItems[prop];
3255        }
3256        // D4: subform-scoped named script objects (unique-name only) resolve as
3257        // bare globals here, after root-scope scripts/data items and before the
3258        // host SOM resolver. This is the minimal SOM resolution for the
3259        // `countryScript` / `partNoScript` cluster (XFA 3.3 §5.5 subform
3260        // variables). Ambiguous names were never written here (fail-closed).
3261        if (subformScriptsByName[prop] !== undefined) {
3262          return subformScriptsByName[prop];
3263        }
3264        return lookup(prop);
3265      },
3266      set: function(_target, prop, value) {
3267        if (typeof prop !== "string") {
3268          return true;
3269        }
3270        if (shouldDeferGlobalName(prop, localNames)) {
3271          return false;
3272        }
3273        if (cachedHandles[prop] !== undefined) {
3274          return true;
3275        }
3276        dynamicLocals[prop] = value;
3277        return true;
3278      }
3279    });
3280  }
3281
3282  return {
3283    // Phase E (XFA-JS-HOST-STUBS): `xfa` itself is still frozen because all
3284    // of its properties were installed with `configurable: false` and writes
3285    // go through inner-Proxy `set` traps that we control. `app` is a Proxy
3286    // whose underlying target is non-extensible after we install the
3287    // function thunks; freezing the Proxy itself would force the absorbing
3288    // `set` trap to throw a TypeError (proxy invariants: writes to
3289    // non-extensible targets must reject), defeating the whole point of the
3290    // silent-absorb design. We therefore expose `app` unfrozen — its safety
3291    // is enforced by the Proxy traps, not by Object.freeze.
3292    xfa: Object.freeze(xfa),
3293    app: app,
3294    // JS2-01 (Sprint 2 Batch B): top-level `form` global alias for
3295    // `xfa.form`. Adobe Reader exposes both spellings; templates such as
3296    // corpus forms reference bare `form.X`. The alias is identity-equal
3297    // to `xfa.form` (same Proxy), so cluster C contract preservation is
3298    // shared between the two surfaces. No new Rust closure introduced.
3299    form: formNamespace,
3300    consoleStub: Object.freeze(consoleStub),
3301    // Phase D-ι: register a `<variables>` `<script name="X">…` block as a
3302    // form-level global. Called by the host once per script body at
3303    // document load. `body` is the raw script source; `identNames` is a
3304    // pre-extracted array of top-level `var` / `function` identifiers
3305    // (Rust-side regex). The body is wrapped in an IIFE that returns a
3306    // frozen object whose properties are those identifiers. Variables
3307    // scripts share the same time/memory budget enforcement as event
3308    // scripts but emit no field mutations of their own. Errors during
3309    // evaluation are absorbed: the namespace remains undefined and
3310    // dependent event scripts will fail naturally at first use.
3311    // Phase D-ι / D-ι.2: register a named `<variables><script>` body.
3312    // `subformName` (4th param, optional) is non-empty for subform-scoped
3313    // scripts; omit or pass "" for root-level scripts.
3314    //
3315    // Root-level scripts (empty subformName) go into the flat
3316    // `variablesScripts` dict only — accessible as `ScriptName.X` from
3317    // any event script in the document.
3318    //
3319    // Subform-scoped scripts go into `subformVariables[subformName][name]`
3320    // ONLY — accessible as `subformHandle.variables.ScriptName.X`. They
3321    // are intentionally NOT written to the flat dict: two subforms may
3322    // define the same script name, and writing both to the flat map would
3323    // let the second registration silently shadow the first.
3324    setVariablesScript: function(name, body, identNames, subformName, exposeGlobal) {
3325      if (typeof name !== "string" || name.length === 0) return false;
3326      if (typeof body !== "string") return false;
3327      var idents = Array.isArray(identNames) ? identNames : [];
3328      var props = "";
3329      for (var i = 0; i < idents.length; i++) {
3330        var id = idents[i];
3331        if (typeof id !== "string" || id.length === 0) continue;
3332        if (i > 0) props += ",";
3333        props += JSON.stringify(id) + ": typeof " + id +
3334                 " !== \"undefined\" ? " + id + " : undefined";
3335      }
3336      // XFA-DATA2-02: inject `console` and `util` into the variables-script
3337      // closure so the form-level helper functions registered here can
3338      // reference them lexically and the dependent event scripts that call
3339      // those helpers do not blow up with `console is not defined` /
3340      // `util is not defined`. Both are the same sandbox-safe singletons
3341      // exposed to event scripts (silent no-op console, deterministic util).
3342      var wrapper = "(function(__console, __util){\n" +
3343                    "var console = __console; var util = __util;\n" +
3344                    body +
3345                    "\nreturn Object.freeze({" + props + "});\n})";
3346      try {
3347        var ns = (Function("return " + wrapper))()(consoleStub, xfaUtil);
3348        if (typeof subformName === "string" && subformName.length > 0) {
3349          if (subformVariables[subformName] === undefined) {
3350            subformVariables[subformName] = lookupObject();
3351          }
3352          subformVariables[subformName][name] = ns;
3353          // D4: also expose to bare-identifier lookup when the host marked this
3354          // name unique across subforms (ambiguous names are withheld).
3355          if (exposeGlobal === true && subformScriptsByName[name] === undefined) {
3356            subformScriptsByName[name] = ns;
3357          }
3358        } else {
3359          variablesScripts[name] = ns;
3360        }
3361        return true;
3362      } catch (_e) {
3363        return false;
3364      }
3365    },
3366    clearVariablesScripts: function() {
3367      var keys = Object.keys(variablesScripts);
3368      for (var i = 0; i < keys.length; i++) {
3369        delete variablesScripts[keys[i]];
3370      }
3371      var skeys = Object.keys(subformVariables);
3372      for (var j = 0; j < skeys.length; j++) {
3373        delete subformVariables[skeys[j]];
3374      }
3375      // D4: clear the flat subform-script exposure map on the same per-document
3376      // lifecycle as the scoped maps.
3377      var ssnkeys = Object.keys(subformScriptsByName);
3378      for (var n = 0; n < ssnkeys.length; n++) {
3379        delete subformScriptsByName[ssnkeys[n]];
3380      }
3381      // W3-D RETRY: data items share the same per-document lifecycle as
3382      // scripts. Dropping them here keeps a single bridge entry-point at
3383      // `reset_for_new_document` and prevents per-document drift.
3384      var dkeys = Object.keys(variablesDataItems);
3385      for (var k = 0; k < dkeys.length; k++) {
3386        delete variablesDataItems[dkeys[k]];
3387      }
3388      var sdkeys = Object.keys(subformVariablesDataItems);
3389      for (var m = 0; m < sdkeys.length; m++) {
3390        delete subformVariablesDataItems[sdkeys[m]];
3391      }
3392    },
3393    // W3-D RETRY: register a `<variables>` `<text name="X">value</text>`
3394    // data item (XFA 3.3 §5.5.2) as a form-level mutable string container.
3395    // Idempotent per (subformName?, name): a later registration overwrites
3396    // the earlier one, mirroring Adobe Reader's last-wins template merge.
3397    //
3398    // The returned shape is `{ value: <string> }` — minimal because real
3399    // templates only read/write `.value`. Writes coerce non-string inputs
3400    // to string (matching Adobe Reader's text-field semantics) so a
3401    // calculate script that does `globValidatePressed.value = true` does
3402    // not silently store a boolean.
3403    setVariablesDataItem: function(name, initial, subformName) {
3404      if (typeof name !== "string" || name.length === 0) return false;
3405      var initialStr = (typeof initial === "string") ? initial : "";
3406      // Use a closure-bound private slot so the `value` getter / setter
3407      // can coerce on write without exposing a plain-object property that
3408      // would let `delete item.value` succeed.
3409      var item = (function(initStr) {
3410        var current = initStr;
3411        var holder = Object.create(null);
3412        Object.defineProperty(holder, "value", {
3413          enumerable: true,
3414          configurable: false,
3415          get: function() { return current; },
3416          set: function(v) {
3417            current = (v === undefined || v === null) ? "" : String(v);
3418          }
3419        });
3420        // XFA 3.3 §5.5.2: data items also expose `.name`. Read-only.
3421        Object.defineProperty(holder, "name", {
3422          enumerable: true, configurable: false, writable: false,
3423          value: name
3424        });
3425        return holder;
3426      })(initialStr);
3427      if (typeof subformName === "string" && subformName.length > 0) {
3428        if (subformVariablesDataItems[subformName] === undefined) {
3429          subformVariablesDataItems[subformName] = lookupObject();
3430        }
3431        subformVariablesDataItems[subformName][name] = item;
3432      } else {
3433        variablesDataItems[name] = item;
3434      }
3435      return true;
3436    },
3437    evalScript: function(body) {
3438      var id = host.currentNodeId();
3439      var thisArg = id < 0 ? undefined : makeHandle(id, host.generation());
3440      // Phase C-α: install per-script `event` global in the function
3441      // closure so `event.target` resolves to the current field. Wrapping
3442      // body inside a function lets us pass `event` as a parameter
3443      // without leaking it to globalThis (where it would persist across
3444      // unrelated scripts).
3445      var ev = makeEvent();
3446      var consoleArg = consoleStub;
3447      var globals = makeImplicitGlobals(body);
3448      return (Function(
3449        "event",
3450        "console",
3451        "__globals",
3452        "with(__globals){\n" + String(body) + "\n}"
3453      )).call(thisArg, ev, consoleArg, globals);
3454    }
3455  };
3456})
3457"##;
3458
3459impl XfaJsRuntime for SandboxedRuntime {
3460    fn init(&mut self) -> Result<(), SandboxError> {
3461        // Booting the engine (hardened globals, host functions, the binding
3462        // layer) is deferred to the first script; see `bindings_registered_check`.
3463        self.init_called.set(true);
3464        Ok(())
3465    }
3466
3467    fn reset_for_new_document(&mut self) -> Result<(), SandboxError> {
3468        self.metadata = RuntimeMetadata::default();
3469        self.host.borrow_mut().reset_per_document();
3470        self.clear_deadline();
3471        // Memory limit is per-document; re-set to clear any prior accounting.
3472        self.engine
3473            .get_mut()
3474            .set_memory_limit(self.memory_budget_bytes);
3475        // Phase D-ι: drop all `<variables>` namespace globals from the
3476        // previous document so they do not leak into the next. Failure
3477        // here is non-fatal — it just means a slightly polluted global
3478        // namespace, never a correctness issue, but log via metadata.
3479        if let Err(e) = self.clear_variables_scripts_global() {
3480            log::debug!("D-ι clear failed: {e:?}");
3481        }
3482        Ok(())
3483    }
3484
3485    // The `*mut FormTree` parameter is part of the existing
3486    // `XfaJsRuntime` trait — the caller in `flatten.rs` already enforces
3487    // that the pointer outlives this call. Clippy's
3488    // `not_unsafe_ptr_arg_deref` would require this signature to be
3489    // `unsafe fn`, which the trait does not allow.
3490    #[allow(clippy::not_unsafe_ptr_arg_deref)]
3491    fn set_form_handle(
3492        &mut self,
3493        form: *mut FormTree,
3494        root_id: FormNodeId,
3495    ) -> Result<(), SandboxError> {
3496        self.host.borrow_mut().set_form_handle(form, root_id);
3497        // Phase D-ι: register every `<variables>` `<script name="X">…` body
3498        // collected during merge as a form-level JS global. Done here
3499        // because by this point the form pointer is valid and the JS
3500        // runtime is initialised. Errors registering one script do not
3501        // block the others.
3502        if !form.is_null() {
3503            // SAFETY: caller guarantees `form` outlives this call.
3504            let scripts: Vec<(Option<String>, String, String)> =
3505                unsafe { (*form).variables_scripts.clone() };
3506            // D3 (trace-only): count collected vs registered vs failed and the
3507            // subform-scoped subset. Pure observability — no behaviour change.
3508            self.metadata.variables_scripts_collected = self
3509                .metadata
3510                .variables_scripts_collected
3511                .saturating_add(scripts.len());
3512            // D4: decide which subform-scoped script names are unique (and thus
3513            // eligible for bare-identifier exposure). A name declared by ≥2
3514            // subforms is ambiguous and withheld (fail-closed); count each such
3515            // ambiguous name once.
3516            let mut subform_name_counts: std::collections::HashMap<String, usize> =
3517                std::collections::HashMap::new();
3518            for (subform_scope, name, _) in &scripts {
3519                if subform_scope.is_some() {
3520                    *subform_name_counts.entry(name.clone()).or_insert(0) += 1;
3521                }
3522            }
3523            let ambiguous_subform_names = subform_name_counts.values().filter(|&&c| c > 1).count();
3524            self.metadata.som_lookup_ambiguous = self
3525                .metadata
3526                .som_lookup_ambiguous
3527                .saturating_add(ambiguous_subform_names);
3528            for (subform_scope, name, body) in scripts {
3529                if subform_scope.is_some() {
3530                    self.metadata.script_objects_subform_scoped = self
3531                        .metadata
3532                        .script_objects_subform_scoped
3533                        .saturating_add(1);
3534                }
3535                // D4: expose unique-name subform-scoped scripts as bare globals.
3536                let expose_global = subform_scope.is_some()
3537                    && subform_name_counts.get(&name).copied().unwrap_or(0) == 1;
3538                match self.register_variables_script(
3539                    &name,
3540                    &body,
3541                    subform_scope.as_deref(),
3542                    expose_global,
3543                ) {
3544                    Ok(true) => {
3545                        self.metadata.script_objects_registered =
3546                            self.metadata.script_objects_registered.saturating_add(1);
3547                        // Count only scripts that actually registered AND were
3548                        // exposed (JS bound the namespace into the flat map).
3549                        if expose_global {
3550                            self.metadata.som_subform_scripts_exposed =
3551                                self.metadata.som_subform_scripts_exposed.saturating_add(1);
3552                        }
3553                    }
3554                    Ok(false) => {
3555                        self.metadata.script_objects_register_failed = self
3556                            .metadata
3557                            .script_objects_register_failed
3558                            .saturating_add(1);
3559                        log::debug!("D-ι register `{name}` returned false (JS eval failed)");
3560                    }
3561                    Err(e) => {
3562                        self.metadata.script_objects_register_failed = self
3563                            .metadata
3564                            .script_objects_register_failed
3565                            .saturating_add(1);
3566                        log::debug!("D-ι register `{name}` failed: {e:?}");
3567                    }
3568                }
3569            }
3570            // W3-D RETRY: register `<variables><text name="X">…</text>`
3571            // data items after scripts so a same-named script (declaration
3572            // order) keeps precedence — matches the JS-side `get` trap
3573            // which consults `variablesScripts` before `variablesDataItems`.
3574            // SAFETY: same lifetime guarantee as above.
3575            let data_items: Vec<(Option<String>, String, String)> =
3576                unsafe { (*form).variables_data_items.clone() };
3577            // D3 (trace-only): count collected vs registered vs failed.
3578            self.metadata.variables_data_items_collected = self
3579                .metadata
3580                .variables_data_items_collected
3581                .saturating_add(data_items.len());
3582            for (subform_scope, name, initial) in data_items {
3583                match self.register_variables_data_item(&name, &initial, subform_scope.as_deref()) {
3584                    Ok(()) => {
3585                        self.metadata.script_objects_registered =
3586                            self.metadata.script_objects_registered.saturating_add(1);
3587                    }
3588                    Err(e) => {
3589                        self.metadata.script_objects_register_failed = self
3590                            .metadata
3591                            .script_objects_register_failed
3592                            .saturating_add(1);
3593                        log::debug!("W3-D register data item `{name}` failed: {e:?}");
3594                    }
3595                }
3596            }
3597        }
3598        Ok(())
3599    }
3600
3601    fn set_data_handle(&mut self, dom: *const xfa_dom_resolver::data_dom::DataDom) {
3602        self.host.borrow_mut().set_data_handle(dom);
3603    }
3604
3605    fn set_declared_subform_names(&mut self, names: std::collections::HashSet<String>) {
3606        self.host.borrow_mut().set_declared_subform_names(names);
3607    }
3608
3609    fn reset_per_script(
3610        &mut self,
3611        current_id: FormNodeId,
3612        activity: Option<&str>,
3613    ) -> Result<(), SandboxError> {
3614        self.host
3615            .borrow_mut()
3616            .reset_per_script(current_id, activity);
3617        Ok(())
3618    }
3619
3620    fn set_static_page_count(&mut self, page_count: u32) -> Result<(), SandboxError> {
3621        self.host.borrow_mut().set_static_page_count(page_count);
3622        Ok(())
3623    }
3624
3625    fn set_presave_gate(&mut self, enabled: bool) {
3626        // D1.B: mirror the dispatch decision into the host-binding layer so
3627        // mutating host calls see the same gate.
3628        self.host.borrow_mut().set_presave_gate(enabled);
3629    }
3630
3631    fn set_interactive(&mut self, enabled: bool) {
3632        // Phase 2: arm the host's interactive gate so `change`/`click`
3633        // dispatch and `setPresence` writes are accepted for this episode.
3634        self.host.borrow_mut().set_interactive(enabled);
3635    }
3636
3637    fn execute_script(
3638        &mut self,
3639        activity: Option<&str>,
3640        body: &str,
3641    ) -> Result<RuntimeOutcome, SandboxError> {
3642        // D1.B: when the per-flatten gate is ON, the QuickJS backend's
3643        // defence-in-depth check accepts `preSave` exactly like the dispatch
3644        // gate. Without the gate, behaviour is byte-identical to v4 (W3-B
3645        // closure). Hard-stop: only `preSave` is unlocked; every other
3646        // denylist activity stays denied. The gate value is owned by the
3647        // host bindings (set via `set_presave_gate` from dispatch).
3648        let presave_gate = self.host.borrow().presave_gate();
3649        let interactive = self.host.borrow().interactive();
3650        let allowed = activity_allowed_for_sandbox_with_gate(activity, presave_gate)
3651            || (interactive && super::interactive_activity_allowed(activity));
3652        if !allowed {
3653            return Err(SandboxError::PhaseDenied(
3654                activity.unwrap_or("None").to_string(),
3655            ));
3656        }
3657        if body.len() > MAX_SCRIPT_BODY_BYTES {
3658            self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
3659            return Err(SandboxError::BodyTooLarge);
3660        }
3661
3662        // W3-A — REDOS-01 mitigation. Reject script bodies containing
3663        // catastrophic-backtracking regex shapes BEFORE handing them to
3664        // QuickJS, because QuickJS's interrupt handler is polled at JS
3665        // opcode boundaries — not inside the regex C code — and cannot
3666        // bound a single pathological `test()` call.
3667        if let RegexScanVerdict::Reject { reason } = scan_script_for_redos(body) {
3668            self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
3669            return Err(SandboxError::RegexRejected(reason));
3670        }
3671
3672        if self.bindings_registered_check().is_err() {
3673            self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
3674            return Err(SandboxError::ScriptError(
3675                "script engine not initialised".to_string(),
3676            ));
3677        }
3678
3679        self.set_deadline();
3680        let result = catch_unwind(AssertUnwindSafe(|| {
3681            self.engine.borrow_mut().call_kept(
3682                "bridge",
3683                "evalScript",
3684                vec![In::Str(body.to_string())],
3685            )
3686        }));
3687        // Snapshot the deadline state BEFORE clearing so the classification
3688        // below can tell a timeout from a genuine ScriptError.
3689        let timed_out = self.deadline_elapsed();
3690        let classified = self.classify_timeout_or_walltime();
3691        self.clear_deadline();
3692
3693        match result {
3694            Ok(Ok(_)) => {
3695                self.metadata.executed = self.metadata.executed.saturating_add(1);
3696                let host_metadata = self.host.borrow_mut().take_metadata();
3697                self.metadata.accumulate(host_metadata);
3698                Ok(RuntimeOutcome {
3699                    executed: true,
3700                    mutated_field_count: host_metadata.mutations,
3701                })
3702            }
3703            Ok(Err(fail)) => {
3704                let host_metadata = self.host.borrow_mut().take_metadata();
3705                self.metadata.accumulate(host_metadata);
3706                match fail {
3707                    // Stopped by the engine's time/step limit, or the
3708                    // deadline passed while the script ran. Both
3709                    // classifications bump the same `timeouts` counter.
3710                    Fail::Limit(_) => {
3711                        self.metadata.timeouts = self.metadata.timeouts.saturating_add(1);
3712                        Err(classified)
3713                    }
3714                    _ if timed_out => {
3715                        self.metadata.timeouts = self.metadata.timeouts.saturating_add(1);
3716                        Err(classified)
3717                    }
3718                    Fail::Memory(_) => {
3719                        self.metadata.oom = self.metadata.oom.saturating_add(1);
3720                        Err(SandboxError::OutOfMemory)
3721                    }
3722                    Fail::Threw(msg) => {
3723                        self.metadata.runtime_errors =
3724                            self.metadata.runtime_errors.saturating_add(1);
3725                        Err(SandboxError::ScriptError(msg))
3726                    }
3727                    // `Fail` is non-exhaustive: a failure kind this version does not know is a script error.
3728                    other => {
3729                        self.metadata.runtime_errors =
3730                            self.metadata.runtime_errors.saturating_add(1);
3731                        Err(SandboxError::ScriptError(format!("{other:?}")))
3732                    }
3733                }
3734            }
3735            Err(_) => {
3736                let host_metadata = self.host.borrow_mut().take_metadata();
3737                self.metadata.accumulate(host_metadata);
3738                self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
3739                Err(SandboxError::PanicCaptured(
3740                    "panic during sandboxed script execution".to_string(),
3741                ))
3742            }
3743        }
3744    }
3745
3746    fn take_metadata(&mut self) -> RuntimeMetadata {
3747        std::mem::take(&mut self.metadata)
3748    }
3749
3750    fn take_occur_mutations(&mut self) -> Vec<(usize, String, i64)> {
3751        self.host
3752            .borrow_mut()
3753            .take_occur_mutations()
3754            .into_iter()
3755            .map(|(id, prop, value)| (id.0, prop, value))
3756            .collect()
3757    }
3758
3759    fn take_diag_logs(&mut self) -> crate::js_runtime::RuntimeDiagLogs {
3760        self.host.borrow_mut().take_diag_logs()
3761    }
3762}
3763
3764#[cfg(test)]
3765mod tests {
3766    use super::*;
3767
3768    fn fresh_runtime() -> SandboxedRuntime {
3769        let mut rt = SandboxedRuntime::new().expect("js engine init");
3770        rt.init().expect("init");
3771        rt.reset_for_new_document().expect("reset");
3772        rt
3773    }
3774
3775    #[test]
3776    fn harmless_calculate_script_executes() {
3777        let mut rt = fresh_runtime();
3778        let outcome = rt
3779            .execute_script(Some("calculate"), "var x = 1 + 1; x")
3780            .expect("ok");
3781        assert!(outcome.executed);
3782        let md = rt.take_metadata();
3783        assert_eq!(md.executed, 1);
3784        assert!(md.is_clean());
3785    }
3786
3787    // BE-1 tranche #1: a bare implicit reference to a template-declared
3788    // container that has no live node resolves to a benign EMPTY node
3789    // (isNull===true, chainable, writes absorbed) — so the guarded
3790    // second-party pattern takes its else branch instead of throwing
3791    // "cannot read property '…' of undefined" and aborting the script.
3792    #[test]
3793    fn absent_declared_subform_resolves_to_empty_node() {
3794        let mut rt = fresh_runtime();
3795        let mut names = std::collections::HashSet::new();
3796        names.insert("Podmiot1".to_string());
3797        rt.set_declared_subform_names(names);
3798        // Mirrors the real NIP1/PESEL1 initialize script on 2ff85101.
3799        rt.execute_script(
3800            Some("calculate"),
3801            r#"
3802            if (Podmiot1.OsobaFizyczna.PESEL.isNull !== true)
3803                throw new Error('expected isNull === true on absent declared node');
3804            var tookElse = false;
3805            if (!Podmiot1.OsobaFizyczna.PESEL.isNull) { tookElse = false; }
3806            else { tookElse = true; }
3807            if (!tookElse) throw new Error('guard did not take the empty (else) branch');
3808            // Chained writes on the empty node must be inert (no throw).
3809            Podmiot1.OsobaFizyczna.presence = "invisible";
3810            Podmiot1.OsobaFizyczna.PESEL.rawValue = 2;
3811            if (Podmiot1.nodes.length !== 0) throw new Error('expected empty .nodes');
3812            "#,
3813        )
3814        .expect("absent declared subform must resolve to a benign empty node");
3815    }
3816
3817    // BE-1 tranche #1: the discriminator is necessary — an UNDECLARED bare name
3818    // must still surface as `undefined`, preserving the D-θ.1 byte-identity
3819    // contract (`A.B === undefined` for genuine misses). Without the declared
3820    // set installed, the same identifier stays undefined.
3821    #[test]
3822    fn undeclared_bare_name_stays_undefined() {
3823        let mut rt = fresh_runtime();
3824        // No declared names installed at all.
3825        rt.execute_script(
3826            Some("calculate"),
3827            "if (typeof NemoNonexistent7 !== 'undefined') \
3828             throw new Error('D-theta.1 violated: undeclared name became defined');",
3829        )
3830        .expect("undeclared bare name must remain undefined");
3831        // Even with an UNRELATED declared name present, an undeclared ref stays undefined.
3832        let mut names = std::collections::HashSet::new();
3833        names.insert("SomeOtherSubform".to_string());
3834        rt.set_declared_subform_names(names);
3835        rt.execute_script(
3836            Some("calculate"),
3837            "if (typeof NemoNonexistent7 !== 'undefined') \
3838             throw new Error('D-theta.1 violated: undeclared name became defined (declared set present)');",
3839        )
3840        .expect("undeclared bare name must remain undefined even when other names are declared");
3841    }
3842
3843    #[test]
3844    fn ui_activity_is_phase_denied() {
3845        let mut rt = fresh_runtime();
3846        let err = rt.execute_script(Some("click"), "1+1").unwrap_err();
3847        assert!(matches!(err, SandboxError::PhaseDenied(_)));
3848    }
3849
3850    #[test]
3851    fn oversized_body_rejected_before_parse() {
3852        let mut rt = fresh_runtime();
3853        let body = "1;\n".repeat(MAX_SCRIPT_BODY_BYTES);
3854        let err = rt.execute_script(Some("calculate"), &body).unwrap_err();
3855        assert_eq!(err, SandboxError::BodyTooLarge);
3856    }
3857
3858    #[test]
3859    fn fetch_is_undefined() {
3860        let mut rt = fresh_runtime();
3861        // Reading `typeof fetch` from a fresh context should return
3862        // "undefined" because we never register it. Surface as a thrown
3863        // error if it isn't, by using `if (typeof fetch !== 'undefined') throw 0`.
3864        rt.execute_script(
3865            Some("calculate"),
3866            "if (typeof fetch !== 'undefined') throw new Error('fetch leaked');",
3867        )
3868        .expect("must run cleanly with fetch undefined");
3869    }
3870
3871    #[test]
3872    fn require_is_undefined() {
3873        let mut rt = fresh_runtime();
3874        rt.execute_script(
3875            Some("calculate"),
3876            "if (typeof require !== 'undefined') throw new Error('require leaked');",
3877        )
3878        .expect("must run cleanly with require undefined");
3879    }
3880
3881    #[test]
3882    fn process_is_undefined() {
3883        let mut rt = fresh_runtime();
3884        rt.execute_script(
3885            Some("calculate"),
3886            "if (typeof process !== 'undefined') throw new Error('process leaked');",
3887        )
3888        .expect("must run cleanly with process undefined");
3889    }
3890
3891    #[test]
3892    fn date_now_is_zero() {
3893        let mut rt = fresh_runtime();
3894        rt.execute_script(
3895            Some("calculate"),
3896            "if (Date.now() !== 0) throw new Error('Date.now not stubbed');",
3897        )
3898        .expect("Date.now must return 0");
3899    }
3900
3901    #[test]
3902    fn math_random_is_undefined() {
3903        let mut rt = fresh_runtime();
3904        rt.execute_script(
3905            Some("calculate"),
3906            "if (typeof Math.random !== 'undefined') throw new Error('Math.random leaked');",
3907        )
3908        .expect("Math.random must be undefined");
3909    }
3910
3911    #[test]
3912    fn infinite_loop_times_out() {
3913        let mut rt = SandboxedRuntime::new()
3914            .expect("init")
3915            .with_time_budget(Duration::from_millis(50));
3916        rt.init().unwrap();
3917        rt.reset_for_new_document().unwrap();
3918        let err = rt
3919            .execute_script(Some("calculate"), "while(true){}")
3920            .unwrap_err();
3921        assert_eq!(err, SandboxError::Timeout);
3922        let md = rt.take_metadata();
3923        assert_eq!(md.timeouts, 1);
3924        assert_eq!(md.executed, 0);
3925    }
3926
3927    #[test]
3928    fn syntax_error_is_recoverable() {
3929        let mut rt = fresh_runtime();
3930        let err = rt
3931            .execute_script(Some("calculate"), "this is not javascript {{")
3932            .unwrap_err();
3933        assert!(matches!(err, SandboxError::ScriptError(_)));
3934        // Subsequent script should still run.
3935        rt.execute_script(Some("calculate"), "var ok = 1;")
3936            .expect("recovered");
3937    }
3938
3939    #[test]
3940    fn null_receiver_silent_setter() {
3941        let mut rt = fresh_runtime();
3942        rt.execute_script(
3943            Some("calculate"),
3944            r#"
3945$record.NONEXISTENT_FIELD.rawValue = "hello";
3946$record.NONEXISTENT_FIELD.value = "world";
3947"#,
3948        )
3949        .expect("WP-3 null_receiver_silent_setter: set on null DataHandle must not throw");
3950    }
3951
3952    #[test]
3953    fn null_record_returns_empty_nodelist() {
3954        let mut rt = fresh_runtime();
3955        rt.execute_script(
3956            Some("calculate"),
3957            r#"
3958var nodes = $record.FIELD.nodes;
3959if (nodes.length !== 0) {
3960  throw new Error("expected nodes.length == 0, got " + nodes.length);
3961}
3962// item() on an empty null-sentinel NodeList returns a chainable null handle (not
3963// native null) so callers can safely do .item(0).value without TypeError.
3964var itemVal = nodes.item(0).value;
3965if (itemVal !== null) {
3966  throw new Error("expected nodes.item(0).value == null, got " + itemVal);
3967}
3968"#,
3969        )
3970        .expect("WP-3 null_record_returns_empty_nodelist: $record.FIELD.nodes must be empty and chainable");
3971    }
3972
3973    // WP-3 F3: xfa.validate is a no-op viewer stub absorbing writes silently.
3974    // Adobe-generated initialize scripts call `xfa.validate.override = 0`,
3975    // `xfa.validate.max = N`, `xfa.validate.scriptTest.message = "…"`; without
3976    // the stub these throw "Cannot set property X of undefined" because
3977    // `xfa.validate` was previously not defined on the host xfa object.
3978    #[test]
3979    fn xfa_validate_stub_absorbs_writes_silently() {
3980        let mut rt = fresh_runtime();
3981        rt.execute_script(
3982            Some("initialize"),
3983            r#"
3984xfa.validate.override = 0;
3985xfa.validate.max = 5;
3986xfa.validate.messageMode = "warning";
3987xfa.validate.scriptTest.message = "ignored";
3988xfa.validate.scriptTest.nested.deeper = true;
3989// Reads must return harmless defaults, never throw.
3990if (xfa.validate.override !== 0) throw new Error("override default");
3991if (xfa.validate.max !== 0) throw new Error("max default");
3992if (xfa.validate.messageMode !== "") throw new Error("messageMode default");
3993"#,
3994        )
3995        .expect("WP-3 F3 xfa_validate_stub: writes must absorb and reads must not throw");
3996    }
3997
3998    // WP-3 F3: addInstance failure returns chainable sentinel so common
3999    // pattern `parent._Child.addInstance().rawValue = X` does not throw when
4000    // the instance manager refuses (occur/max bounds reached or unbound).
4001    // Note: this test exercises only the JS-side null-safety contract; full
4002    // end-to-end instance addition lives in m3b_phaseD_instance_manager.rs.
4003    #[test]
4004    fn add_instance_failure_returns_chainable_null_handle() {
4005        let mut rt = fresh_runtime();
4006        // No FormTree is installed, so any addInstance call must fail at the
4007        // host shim and surface a sentinel rather than native null.
4008        rt.execute_script(
4009            Some("initialize"),
4010            r#"
4011// Resolve a non-existent subform, addInstance() on an unbound instance
4012// manager must yield a chainable handle whose `.index` is a number and
4013// whose `.rawValue` setter is silent.
4014var im = xfa.resolveNode("NoSuchSubform");
4015if (im !== null) {
4016  // If a real handle was returned (shouldn't be), addInstance still must
4017  // either return a real handle or a null-safe sentinel — never throw.
4018  var added = im.addInstance();
4019  if (typeof added.index !== "number") {
4020    throw new Error("added.index must be a number, got " + typeof added.index);
4021  }
4022  added.rawValue = "chained";  // silent on sentinel
4023  added.value = "chained";     // silent on sentinel
4024}
4025"#,
4026        )
4027        .expect("WP-3 F3 add_instance_failure: chainable sentinel on failure path");
4028    }
4029}