Skip to main content

pdf_xfa/js_runtime/
rquickjs_backend.rs

1//! M3-B Phase B — rquickjs backend for [`super::XfaJsRuntime`].
2//!
3//! Compiled in only when the `xfa-js-sandboxed` Cargo feature is enabled.
4//!
5//! This backend is intentionally minimal:
6//!
7//! - No host bindings registered. Phase C adds the first useful ones per
8//!   `benchmarks/runs/M3B_HOST_BINDINGS_MINIMUM_SET.md`.
9//! - `Date.now`, `Math.random`, `fetch`, `require`, `process` and friends
10//!   are absent because they are never registered (rquickjs default
11//!   contexts expose only spec-mandated ECMAScript built-ins).
12//! - `JS_SetMemoryLimit` enforces the per-document memory budget; any
13//!   allocation that pushes the runtime over the limit fails with
14//!   [`super::SandboxError::OutOfMemory`].
15//! - Per-script time budget is enforced with the rquickjs interrupt handler
16//!   that polls a wall-clock deadline; `eval_with_options` bails out with
17//!   [`super::SandboxError::Timeout`] when the deadline elapses.
18//! - All FFI is wrapped in `std::panic::catch_unwind` so a QuickJS panic
19//!   never crosses into the parent flatten path
20//!   (`benchmarks/runs/M3B_RUNTIME_SECURITY_MODEL.md` §1 S-17).
21
22use std::cell::RefCell;
23use std::panic::{catch_unwind, AssertUnwindSafe};
24use std::rc::Rc;
25use std::sync::{
26    atomic::{AtomicBool, AtomicU64, Ordering},
27    Arc, OnceLock,
28};
29use std::time::{Duration, Instant};
30
31use rquickjs::function::Opt;
32use rquickjs::{CatchResultExt, Coerced, Context, Function, Object, Persistent, Runtime};
33use xfa_layout_engine::form::{FormNodeId, FormTree};
34
35use super::{
36    activity_allowed_for_sandbox, HostBindings, RuntimeMetadata, RuntimeOutcome, SandboxError,
37    XfaJsRuntime, DEFAULT_MEMORY_BUDGET_BYTES, DEFAULT_TIME_BUDGET_MS, MAX_SCRIPT_BODY_BYTES,
38};
39
40/// QuickJS-backed runtime adapter. One instance is reusable across many
41/// documents; callers MUST invoke [`XfaJsRuntime::reset_for_new_document`]
42/// at the start of each flatten.
43pub struct QuickJsRuntime {
44    eval_script: Option<Persistent<Function<'static>>>,
45    /// Phase D-ι: hook that registers a `<variables>` `<script>` body as a
46    /// form-level global. Called once per named script at document load.
47    set_variables_script: Option<Persistent<Function<'static>>>,
48    /// Phase D-ι: clears all registered variables-script globals at
49    /// document boundary (`reset_per_document`).
50    clear_variables_scripts: Option<Persistent<Function<'static>>>,
51    context: Context,
52    runtime: Runtime,
53    metadata: RuntimeMetadata,
54    time_budget: Duration,
55    memory_budget_bytes: usize,
56    script_deadline: Arc<AtomicU64>,
57    script_started: Arc<AtomicBool>,
58    host: Rc<RefCell<HostBindings>>,
59    bindings_registered: bool,
60}
61
62impl std::fmt::Debug for QuickJsRuntime {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.debug_struct("QuickJsRuntime")
65            .field("metadata", &self.metadata)
66            .field("time_budget_ms", &self.time_budget.as_millis())
67            .field("memory_budget_bytes", &self.memory_budget_bytes)
68            .finish()
69    }
70}
71
72fn parse_node_id_csv(raw: &str) -> Vec<FormNodeId> {
73    raw.split(',')
74        .filter_map(|part| part.trim().parse::<usize>().ok())
75        .map(FormNodeId)
76        .collect()
77}
78
79impl QuickJsRuntime {
80    /// Construct a new sandboxed runtime with default budgets.
81    pub fn new() -> Result<Self, SandboxError> {
82        let runtime =
83            Runtime::new().map_err(|e| SandboxError::ScriptError(format!("rquickjs init: {e}")))?;
84        runtime.set_memory_limit(DEFAULT_MEMORY_BUDGET_BYTES);
85        let context = Context::full(&runtime)
86            .map_err(|e| SandboxError::ScriptError(format!("rquickjs context: {e}")))?;
87
88        let script_deadline = Arc::new(AtomicU64::new(0));
89        let script_started = Arc::new(AtomicBool::new(false));
90
91        // Interrupt handler: poll the deadline. When the started flag is set
92        // and the wall-clock has crossed the deadline, return `true` to abort
93        // script execution. QuickJS turns this into a JS-level interrupt that
94        // surfaces as `eval` returning `Err`.
95        let deadline_for_handler = Arc::clone(&script_deadline);
96        let started_for_handler = Arc::clone(&script_started);
97        runtime.set_interrupt_handler(Some(Box::new(move || {
98            if !started_for_handler.load(Ordering::Acquire) {
99                return false;
100            }
101            let deadline_nanos = deadline_for_handler.load(Ordering::Acquire);
102            if deadline_nanos == 0 {
103                return false;
104            }
105            let now_nanos = Instant::now()
106                .checked_duration_since(epoch())
107                .map(|d| d.as_nanos() as u64)
108                .unwrap_or(0);
109            now_nanos >= deadline_nanos
110        })));
111
112        Ok(Self {
113            eval_script: None,
114            set_variables_script: None,
115            clear_variables_scripts: None,
116            context,
117            runtime,
118            metadata: RuntimeMetadata::default(),
119            time_budget: Duration::from_millis(DEFAULT_TIME_BUDGET_MS),
120            memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
121            script_deadline,
122            script_started,
123            host: Rc::new(RefCell::new(HostBindings::new())),
124            bindings_registered: false,
125        })
126    }
127
128    /// Override the per-script wall-clock budget. Must be called before any
129    /// `execute_script` invocation; takes effect on the next call.
130    pub fn with_time_budget(mut self, budget: Duration) -> Self {
131        self.time_budget = budget;
132        self
133    }
134
135    /// Override the per-document memory budget. Must be called before any
136    /// `execute_script` invocation; takes effect on the next document.
137    pub fn with_memory_budget(mut self, bytes: usize) -> Self {
138        self.memory_budget_bytes = bytes;
139        self.runtime.set_memory_limit(bytes);
140        self
141    }
142
143    fn set_deadline(&self) {
144        let deadline = Instant::now()
145            .checked_duration_since(epoch())
146            .map(|d| d + self.time_budget)
147            .unwrap_or(self.time_budget);
148        self.script_deadline
149            .store(deadline.as_nanos() as u64, Ordering::Release);
150        self.script_started.store(true, Ordering::Release);
151    }
152
153    fn clear_deadline(&self) {
154        self.script_started.store(false, Ordering::Release);
155        self.script_deadline.store(0, Ordering::Release);
156    }
157
158    fn register_host_bindings(&mut self) -> Result<(), String> {
159        if self.bindings_registered {
160            return Ok(());
161        }
162
163        let host = Rc::clone(&self.host);
164        let eval_script = self.context.with(|ctx| {
165            let globals = ctx.globals();
166            let internal =
167                Object::new(ctx.clone()).map_err(|e| format!("host internal object: {e}"))?;
168
169            let resolve_host = Rc::clone(&host);
170            let resolve_node_id = Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| {
171                let Some(path) = path.0 else {
172                    let _ = resolve_host.borrow_mut().resolve_node("");
173                    return -1i32;
174                };
175                resolve_host
176                    .borrow_mut()
177                    .resolve_node(&path.0)
178                    .map(|node_id| node_id.0 as i32)
179                    .unwrap_or(-1)
180            })
181            .map_err(|e| format!("resolveNodeId: {e}"))?;
182            internal
183                .set("resolveNodeId", resolve_node_id)
184                .map_err(|e| format!("set resolveNodeId: {e}"))?;
185
186            let resolve_nodes_host = Rc::clone(&host);
187            let resolve_node_ids =
188                Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> Vec<i32> {
189                    let Some(path) = path.0 else {
190                        let _ = resolve_nodes_host.borrow_mut().resolve_nodes("");
191                        return Vec::new();
192                    };
193                    resolve_nodes_host
194                        .borrow_mut()
195                        .resolve_nodes(&path.0)
196                        .into_iter()
197                        .map(|node_id| node_id.0 as i32)
198                        .collect()
199                })
200                .map_err(|e| format!("resolveNodeIds: {e}"))?;
201            internal
202                .set("resolveNodeIds", resolve_node_ids)
203                .map_err(|e| format!("set resolveNodeIds: {e}"))?;
204
205            let generation_host = Rc::clone(&host);
206            let generation = Function::new(ctx.clone(), move || {
207                generation_host.borrow().generation() as i64
208            })
209            .map_err(|e| format!("generation: {e}"))?;
210            internal
211                .set("generation", generation)
212                .map_err(|e| format!("set generation: {e}"))?;
213
214            let root_host = Rc::clone(&host);
215            let root_node_id = Function::new(ctx.clone(), move |generation: i64| -> i32 {
216                if generation < 0 {
217                    return -1;
218                }
219                root_host
220                    .borrow_mut()
221                    .root_node(generation as u64)
222                    .map(|node_id| node_id.0 as i32)
223                    .unwrap_or(-1)
224            })
225            .map_err(|e| format!("rootNodeId: {e}"))?;
226            internal
227                .set("rootNodeId", root_node_id)
228                .map_err(|e| format!("set rootNodeId: {e}"))?;
229
230            let current_host = Rc::clone(&host);
231            let current_node = Function::new(ctx.clone(), move || {
232                current_host
233                    .borrow()
234                    .current_node()
235                    .map(|node_id| node_id.0 as i32)
236                    .unwrap_or(-1)
237            })
238            .map_err(|e| format!("currentNodeId: {e}"))?;
239            internal
240                .set("currentNodeId", current_node)
241                .map_err(|e| format!("set currentNodeId: {e}"))?;
242
243            let implicit_host = Rc::clone(&host);
244            let resolve_implicit_node_id = Function::new(
245                ctx.clone(),
246                move |current_id: i32, name: Opt<Coerced<String>>| -> i32 {
247                    if current_id < 0 {
248                        return -1;
249                    }
250                    let Some(name) = name.0 else {
251                        return -1;
252                    };
253                    implicit_host
254                        .borrow_mut()
255                        .resolve_implicit(FormNodeId(current_id as usize), &name.0)
256                        .map(|node_id| node_id.0 as i32)
257                        .unwrap_or(-1)
258                },
259            )
260            .map_err(|e| format!("resolveImplicitNodeId: {e}"))?;
261            internal
262                .set("resolveImplicitNodeId", resolve_implicit_node_id)
263                .map_err(|e| format!("set resolveImplicitNodeId: {e}"))?;
264
265            let implicit_candidates_host = Rc::clone(&host);
266            let resolve_implicit_node_ids = Function::new(
267                ctx.clone(),
268                move |current_id: i32, name: Opt<Coerced<String>>| -> Vec<i32> {
269                    if current_id < 0 {
270                        return Vec::new();
271                    }
272                    let Some(name) = name.0 else {
273                        return Vec::new();
274                    };
275                    implicit_candidates_host
276                        .borrow_mut()
277                        .resolve_implicit_candidates(FormNodeId(current_id as usize), &name.0)
278                        .into_iter()
279                        .map(|node_id| node_id.0 as i32)
280                        .collect::<Vec<_>>()
281                },
282            )
283            .map_err(|e| format!("resolveImplicitNodeIds: {e}"))?;
284            internal
285                .set("resolveImplicitNodeIds", resolve_implicit_node_ids)
286                .map_err(|e| format!("set resolveImplicitNodeIds: {e}"))?;
287
288            let child_host = Rc::clone(&host);
289            let resolve_child_node_id = Function::new(
290                ctx.clone(),
291                move |parent_id: i32, name: Opt<Coerced<String>>| {
292                    if parent_id < 0 {
293                        return -1i32;
294                    }
295                    let Some(name) = name.0 else {
296                        return -1;
297                    };
298                    child_host
299                        .borrow_mut()
300                        .resolve_child(FormNodeId(parent_id as usize), &name.0)
301                        .map(|node_id| node_id.0 as i32)
302                        .unwrap_or(-1)
303                },
304            )
305            .map_err(|e| format!("resolveChildNodeId: {e}"))?;
306            internal
307                .set("resolveChildNodeId", resolve_child_node_id)
308                .map_err(|e| format!("set resolveChildNodeId: {e}"))?;
309
310            let child_candidates_host = Rc::clone(&host);
311            let resolve_child_node_ids = Function::new(
312                ctx.clone(),
313                move |parent_ids: Opt<Coerced<String>>, name: Opt<Coerced<String>>| -> Vec<i32> {
314                    let Some(parent_ids) = parent_ids.0 else {
315                        return Vec::new();
316                    };
317                    let Some(name) = name.0 else {
318                        return Vec::new();
319                    };
320                    child_candidates_host
321                        .borrow_mut()
322                        .resolve_child_candidates(&parse_node_id_csv(&parent_ids.0), &name.0)
323                        .into_iter()
324                        .map(|node_id| node_id.0 as i32)
325                        .collect()
326                },
327            )
328            .map_err(|e| format!("resolveChildNodeIds: {e}"))?;
329            internal
330                .set("resolveChildNodeIds", resolve_child_node_ids)
331                .map_err(|e| format!("set resolveChildNodeIds: {e}"))?;
332
333            let scoped_candidates_host = Rc::clone(&host);
334            let resolve_scoped_node_ids = Function::new(
335                ctx.clone(),
336                move |scope_ids: Opt<Coerced<String>>, name: Opt<Coerced<String>>| -> Vec<i32> {
337                    let Some(scope_ids) = scope_ids.0 else {
338                        return Vec::new();
339                    };
340                    let Some(name) = name.0 else {
341                        return Vec::new();
342                    };
343                    scoped_candidates_host
344                        .borrow_mut()
345                        .resolve_scoped_candidates(&parse_node_id_csv(&scope_ids.0), &name.0)
346                        .into_iter()
347                        .map(|node_id| node_id.0 as i32)
348                        .collect()
349                },
350            )
351            .map_err(|e| format!("resolveScopedNodeIds: {e}"))?;
352            internal
353                .set("resolveScopedNodeIds", resolve_scoped_node_ids)
354                .map_err(|e| format!("set resolveScopedNodeIds: {e}"))?;
355
356            let get_raw_host = Rc::clone(&host);
357            let get_raw_value = Function::new(
358                ctx.clone(),
359                move |id: i32, generation: i64| -> Option<String> {
360                    if id < 0 || generation < 0 {
361                        return None;
362                    }
363                    get_raw_host
364                        .borrow_mut()
365                        .get_raw_value(FormNodeId(id as usize), generation as u64)
366                },
367            )
368            .map_err(|e| format!("getRawValue: {e}"))?;
369            internal
370                .set("getRawValue", get_raw_value)
371                .map_err(|e| format!("set getRawValue: {e}"))?;
372
373            let set_raw_host = Rc::clone(&host);
374            let set_raw_value = Function::new(
375                ctx.clone(),
376                move |id: i32, generation: i64, value: Coerced<String>| -> bool {
377                    if id < 0 || generation < 0 {
378                        return false;
379                    }
380                    set_raw_host.borrow_mut().set_raw_value(
381                        FormNodeId(id as usize),
382                        value.0,
383                        generation as u64,
384                    )
385                },
386            )
387            .map_err(|e| format!("setRawValue: {e}"))?;
388            internal
389                .set("setRawValue", set_raw_value)
390                .map_err(|e| format!("set setRawValue: {e}"))?;
391
392            let get_occur_host = Rc::clone(&host);
393            let get_occur_property = Function::new(
394                ctx.clone(),
395                move |id: i32, generation: i64, property: Opt<Coerced<String>>| -> Option<i32> {
396                    if id < 0 || generation < 0 {
397                        return None;
398                    }
399                    let property = property.0?;
400                    get_occur_host.borrow_mut().get_occur_property(
401                        FormNodeId(id as usize),
402                        generation as u64,
403                        &property.0,
404                    )
405                },
406            )
407            .map_err(|e| format!("getOccurProperty: {e}"))?;
408            internal
409                .set("getOccurProperty", get_occur_property)
410                .map_err(|e| format!("set getOccurProperty: {e}"))?;
411
412            let set_occur_host = Rc::clone(&host);
413            let set_occur_property = Function::new(
414                ctx.clone(),
415                move |id: i32,
416                      generation: i64,
417                      property: Opt<Coerced<String>>,
418                      value: Coerced<String>|
419                      -> bool {
420                    if id < 0 || generation < 0 {
421                        return false;
422                    }
423                    let Some(property) = property.0 else {
424                        return false;
425                    };
426                    set_occur_host.borrow_mut().set_occur_property(
427                        FormNodeId(id as usize),
428                        generation as u64,
429                        &property.0,
430                        &value.0,
431                    )
432                },
433            )
434            .map_err(|e| format!("setOccurProperty: {e}"))?;
435            internal
436                .set("setOccurProperty", set_occur_property)
437                .map_err(|e| format!("set setOccurProperty: {e}"))?;
438
439            let instance_count_host = Rc::clone(&host);
440            let instance_count =
441                Function::new(ctx.clone(), move |id: i32, generation: i64| -> u32 {
442                    if id < 0 || generation < 0 {
443                        return 0;
444                    }
445                    instance_count_host
446                        .borrow_mut()
447                        .instance_count_for_handle(FormNodeId(id as usize), generation as u64)
448                })
449                .map_err(|e| format!("instanceCount: {e}"))?;
450            internal
451                .set("instanceCount", instance_count)
452                .map_err(|e| format!("set instanceCount: {e}"))?;
453
454            let zero_instance_host = Rc::clone(&host);
455            let has_zero_instance_run = Function::new(
456                ctx.clone(),
457                move |id: i32, generation: i64, name: Opt<Coerced<String>>| -> bool {
458                    if id < 0 || generation < 0 {
459                        return false;
460                    }
461                    let Some(name) = name.0 else {
462                        return false;
463                    };
464                    zero_instance_host.borrow_mut().has_zero_instance_run(
465                        FormNodeId(id as usize),
466                        generation as u64,
467                        &name.0,
468                    )
469                },
470            )
471            .map_err(|e| format!("hasZeroInstanceRun: {e}"))?;
472            internal
473                .set("hasZeroInstanceRun", has_zero_instance_run)
474                .map_err(|e| format!("set hasZeroInstanceRun: {e}"))?;
475
476            let node_index_host = Rc::clone(&host);
477            let node_index = Function::new(ctx.clone(), move |id: i32, generation: i64| -> u32 {
478                if id < 0 || generation < 0 {
479                    return 0;
480                }
481                node_index_host
482                    .borrow_mut()
483                    .instance_index_for_handle(FormNodeId(id as usize), generation as u64)
484            })
485            .map_err(|e| format!("nodeIndex: {e}"))?;
486            internal
487                .set("nodeIndex", node_index)
488                .map_err(|e| format!("set nodeIndex: {e}"))?;
489
490            let node_name_host = Rc::clone(&host);
491            let node_name = Function::new(ctx.clone(), move |id: i32, generation: i64| -> String {
492                if id < 0 || generation < 0 {
493                    return String::new();
494                }
495                node_name_host
496                    .borrow()
497                    .node_name(FormNodeId(id as usize), generation as u64)
498                    .unwrap_or_default()
499            })
500            .map_err(|e| format!("nodeName: {e}"))?;
501            internal
502                .set("nodeName", node_name)
503                .map_err(|e| format!("set nodeName: {e}"))?;
504
505            let scope_chain_host = Rc::clone(&host);
506            let scope_chain = Function::new(
507                ctx.clone(),
508                move |id: i32, generation: i64| -> Vec<String> {
509                    if id < 0 || generation < 0 {
510                        return vec![];
511                    }
512                    scope_chain_host
513                        .borrow_mut()
514                        .subform_scope_chain(FormNodeId(id as usize), generation as u64)
515                },
516            )
517            .map_err(|e| format!("getSubformScopeChain: {e}"))?;
518            internal
519                .set("getSubformScopeChain", scope_chain)
520                .map_err(|e| format!("set getSubformScopeChain: {e}"))?;
521
522            let instance_set_host = Rc::clone(&host);
523            let instance_set = Function::new(
524                ctx.clone(),
525                move |id: i32, generation: i64, n: Opt<i32>| -> i32 {
526                    if id < 0 || generation < 0 {
527                        return -1;
528                    }
529                    let n = n.0.unwrap_or(0).max(0) as u32;
530                    instance_set_host
531                        .borrow_mut()
532                        .instance_set_for_handle(FormNodeId(id as usize), generation as u64, n)
533                        .map(|count| count as i32)
534                        .unwrap_or(-1)
535                },
536            )
537            .map_err(|e| format!("instanceSet: {e}"))?;
538            internal
539                .set("instanceSet", instance_set)
540                .map_err(|e| format!("set instanceSet: {e}"))?;
541
542            let instance_add_host = Rc::clone(&host);
543            let instance_add = Function::new(ctx.clone(), move |id: i32, generation: i64| -> i32 {
544                if id < 0 || generation < 0 {
545                    return -1;
546                }
547                instance_add_host
548                    .borrow_mut()
549                    .instance_add_for_handle(FormNodeId(id as usize), generation as u64)
550                    .map(|node_id| node_id.0 as i32)
551                    .unwrap_or(-1)
552            })
553            .map_err(|e| format!("instanceAdd: {e}"))?;
554            internal
555                .set("instanceAdd", instance_add)
556                .map_err(|e| format!("set instanceAdd: {e}"))?;
557
558            let instance_remove_host = Rc::clone(&host);
559            let instance_remove = Function::new(
560                ctx.clone(),
561                move |id: i32, generation: i64, index: Opt<i32>| -> bool {
562                    if id < 0 || generation < 0 {
563                        return false;
564                    }
565                    let index = index.0.unwrap_or(0).max(0) as u32;
566                    instance_remove_host
567                        .borrow_mut()
568                        .instance_remove_for_handle(
569                            FormNodeId(id as usize),
570                            generation as u64,
571                            index,
572                        )
573                        .is_ok()
574                },
575            )
576            .map_err(|e| format!("instanceRemove: {e}"))?;
577            internal
578                .set("instanceRemove", instance_remove)
579                .map_err(|e| format!("set instanceRemove: {e}"))?;
580
581            let list_clear_host = Rc::clone(&host);
582            let list_clear = Function::new(ctx.clone(), move |id: i32, generation: i64| -> bool {
583                if id < 0 || generation < 0 {
584                    return false;
585                }
586                list_clear_host
587                    .borrow_mut()
588                    .list_clear_for_handle(FormNodeId(id as usize), generation as u64)
589                    .is_ok()
590            })
591            .map_err(|e| format!("listClear: {e}"))?;
592            internal
593                .set("listClear", list_clear)
594                .map_err(|e| format!("set listClear: {e}"))?;
595
596            let list_add_host = Rc::clone(&host);
597            let list_add = Function::new(
598                ctx.clone(),
599                move |id: i32,
600                      generation: i64,
601                      display: Coerced<String>,
602                      save: Opt<Coerced<String>>|
603                      -> bool {
604                    if id < 0 || generation < 0 {
605                        return false;
606                    }
607                    list_add_host
608                        .borrow_mut()
609                        .list_add_for_handle(
610                            FormNodeId(id as usize),
611                            generation as u64,
612                            display.0,
613                            save.0.map(|s| s.0),
614                        )
615                        .is_ok()
616                },
617            )
618            .map_err(|e| format!("listAdd: {e}"))?;
619            internal
620                .set("listAdd", list_add)
621                .map_err(|e| format!("set listAdd: {e}"))?;
622
623            let bound_item_host = Rc::clone(&host);
624            let bound_item = Function::new(
625                ctx.clone(),
626                move |id: i32, generation: i64, display: Coerced<String>| -> String {
627                    if id < 0 || generation < 0 {
628                        return display.0;
629                    }
630                    bound_item_host.borrow_mut().bound_item_for_handle(
631                        FormNodeId(id as usize),
632                        generation as u64,
633                        display.0,
634                    )
635                },
636            )
637            .map_err(|e| format!("boundItem: {e}"))?;
638            internal
639                .set("boundItem", bound_item)
640                .map_err(|e| format!("set boundItem: {e}"))?;
641
642            let num_pages_host = Rc::clone(&host);
643            let num_pages =
644                Function::new(ctx.clone(), move || num_pages_host.borrow_mut().num_pages())
645                    .map_err(|e| format!("numPages: {e}"))?;
646            internal
647                .set("numPages", num_pages)
648                .map_err(|e| format!("set numPages: {e}"))?;
649
650            let binding_error_host = Rc::clone(&host);
651            let binding_error = Function::new(ctx.clone(), move || {
652                binding_error_host.borrow_mut().metadata_binding_error();
653            })
654            .map_err(|e| format!("bindingError: {e}"))?;
655            internal
656                .set("bindingError", binding_error)
657                .map_err(|e| format!("set bindingError: {e}"))?;
658
659            let resolve_failure_host = Rc::clone(&host);
660            let resolve_failure = Function::new(ctx.clone(), move || {
661                resolve_failure_host.borrow_mut().metadata_resolve_failure();
662            })
663            .map_err(|e| format!("resolveFailure: {e}"))?;
664            internal
665                .set("resolveFailure", resolve_failure)
666                .map_err(|e| format!("set resolveFailure: {e}"))?;
667
668            // Phase D-γ: DataDom host bindings --------------------------------
669
670            let dc_host = Rc::clone(&host);
671            let data_children = Function::new(ctx.clone(), move |raw_id: i32| -> Vec<i32> {
672                if raw_id < 0 {
673                    return Vec::new();
674                }
675                dc_host
676                    .borrow_mut()
677                    .data_children(raw_id as usize)
678                    .into_iter()
679                    .map(|x| x as i32)
680                    .collect()
681            })
682            .map_err(|e| format!("dataChildren: {e}"))?;
683            internal
684                .set("dataChildren", data_children)
685                .map_err(|e| format!("set dataChildren: {e}"))?;
686
687            let dv_host = Rc::clone(&host);
688            let data_value = Function::new(ctx.clone(), move |raw_id: i32| -> Option<String> {
689                if raw_id < 0 {
690                    return None;
691                }
692                dv_host.borrow_mut().data_value(raw_id as usize)
693            })
694            .map_err(|e| format!("dataValue: {e}"))?;
695            internal
696                .set("dataValue", data_value)
697                .map_err(|e| format!("set dataValue: {e}"))?;
698
699            let dcbn_host = Rc::clone(&host);
700            let data_child_by_name = Function::new(
701                ctx.clone(),
702                move |parent_raw: i32, name: Opt<Coerced<String>>| -> i32 {
703                    if parent_raw < 0 {
704                        return -1;
705                    }
706                    let Some(name) = name.0 else {
707                        return -1;
708                    };
709                    dcbn_host
710                        .borrow_mut()
711                        .data_child_by_name(parent_raw as usize, &name.0)
712                        .map(|x| x as i32)
713                        .unwrap_or(-1)
714                },
715            )
716            .map_err(|e| format!("dataChildByName: {e}"))?;
717            internal
718                .set("dataChildByName", data_child_by_name)
719                .map_err(|e| format!("set dataChildByName: {e}"))?;
720
721            let dbr_host = Rc::clone(&host);
722            let data_bound_record = Function::new(
723                ctx.clone(),
724                move |form_node_id: i32, generation: i64| -> i32 {
725                    if form_node_id < 0 || generation < 0 {
726                        return -1;
727                    }
728                    dbr_host
729                        .borrow_mut()
730                        .data_bound_record(FormNodeId(form_node_id as usize), generation as u64)
731                        .map(|x| x as i32)
732                        .unwrap_or(-1)
733                },
734            )
735            .map_err(|e| format!("dataBoundRecord: {e}"))?;
736            internal
737                .set("dataBoundRecord", data_bound_record)
738                .map_err(|e| format!("set dataBoundRecord: {e}"))?;
739
740            let drn_host = Rc::clone(&host);
741            let data_resolve_node =
742                Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> i32 {
743                    let Some(path) = path.0 else {
744                        return -1;
745                    };
746                    drn_host
747                        .borrow_mut()
748                        .data_resolve_node(&path.0)
749                        .map(|x| x as i32)
750                        .unwrap_or(-1)
751                })
752                .map_err(|e| format!("dataResolveNode: {e}"))?;
753            internal
754                .set("dataResolveNode", data_resolve_node)
755                .map_err(|e| format!("set dataResolveNode: {e}"))?;
756
757            let drns_host = Rc::clone(&host);
758            let data_resolve_nodes =
759                Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> Vec<i32> {
760                    let Some(path) = path.0 else {
761                        return Vec::new();
762                    };
763                    drns_host
764                        .borrow_mut()
765                        .data_resolve_nodes(&path.0)
766                        .into_iter()
767                        .map(|x| x as i32)
768                        .collect()
769                })
770                .map_err(|e| format!("dataResolveNodes: {e}"))?;
771            internal
772                .set("dataResolveNodes", data_resolve_nodes)
773                .map_err(|e| format!("set dataResolveNodes: {e}"))?;
774
775            // End Phase D-γ host bindings -------------------------------------
776
777            let factory: Function = ctx
778                .eval(PHASE_C_BINDINGS_JS.as_bytes())
779                .map_err(|e| format!("binding factory parse: {e}"))?;
780            let bridge: Object = factory
781                .call((internal,))
782                .catch(&ctx)
783                .map_err(|e| format!("binding factory call: {e}"))?;
784            let xfa: Object = bridge.get("xfa").map_err(|e| format!("get xfa: {e}"))?;
785            let app: Object = bridge.get("app").map_err(|e| format!("get app: {e}"))?;
786            let eval_script: Function = bridge
787                .get("evalScript")
788                .map_err(|e| format!("get evalScript: {e}"))?;
789            let set_variables_script: Function = bridge
790                .get("setVariablesScript")
791                .map_err(|e| format!("get setVariablesScript: {e}"))?;
792            let clear_variables_scripts: Function = bridge
793                .get("clearVariablesScripts")
794                .map_err(|e| format!("get clearVariablesScripts: {e}"))?;
795            globals
796                .set("xfa", xfa)
797                .map_err(|e| format!("set xfa global: {e}"))?;
798            globals
799                .set("app", app)
800                .map_err(|e| format!("set app global: {e}"))?;
801            Ok::<
802                (
803                    Persistent<Function<'static>>,
804                    Persistent<Function<'static>>,
805                    Persistent<Function<'static>>,
806                ),
807                String,
808            >((
809                Persistent::save(&ctx, eval_script),
810                Persistent::save(&ctx, set_variables_script),
811                Persistent::save(&ctx, clear_variables_scripts),
812            ))
813        })?;
814
815        self.eval_script = Some(eval_script.0);
816        self.set_variables_script = Some(eval_script.1);
817        self.clear_variables_scripts = Some(eval_script.2);
818        self.bindings_registered = true;
819        Ok(())
820    }
821
822    /// Phase D-ι: extract top-level `var X` and `function X(` identifiers
823    /// from a `<variables>` `<script>` body. Used to build the wrapper
824    /// IIFE that returns the form-level global object. Best-effort regex
825    /// scan — handles the common XFA authoring patterns; complex
826    /// destructuring would be missed and the corresponding identifier
827    /// would simply not appear in the namespace object.
828    fn extract_top_level_idents(body: &str) -> Vec<String> {
829        let mut out: Vec<String> = Vec::new();
830        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
831        // Strip line and block comments to avoid extracting commented-out idents.
832        let stripped = strip_js_comments(body);
833        for token_kind in [
834            ("var", false),
835            ("let", false),
836            ("const", false),
837            ("function", true),
838        ] {
839            let kw = token_kind.0;
840            let is_fn = token_kind.1;
841            let mut search = stripped.as_str();
842            while let Some(idx) = search.find(kw) {
843                let (before, after) = search.split_at(idx);
844                let head_ok = before
845                    .chars()
846                    .last()
847                    .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$');
848                let tail_after_kw = &after[kw.len()..];
849                let tail_ok = tail_after_kw
850                    .chars()
851                    .next()
852                    .is_some_and(|c| c.is_whitespace());
853                if !(head_ok && tail_ok) {
854                    search = &after[1..];
855                    continue;
856                }
857                // Skip whitespace, then read an identifier.
858                let after_ws = tail_after_kw.trim_start();
859                let mut chars = after_ws.chars();
860                let ident: String = std::iter::once(chars.next())
861                    .chain(chars.map(Some))
862                    .map_while(|c| c.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '$'))
863                    .collect();
864                if ident.is_empty() || ident.chars().next().is_some_and(|c| c.is_ascii_digit()) {
865                    search = &after[kw.len()..];
866                    continue;
867                }
868                if is_fn {
869                    let after_ident = after_ws
870                        .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '$');
871                    if !after_ident.trim_start().starts_with('(') {
872                        search = &after[kw.len()..];
873                        continue;
874                    }
875                }
876                if seen.insert(ident.clone()) {
877                    out.push(ident);
878                }
879                search = &after[kw.len()..];
880            }
881        }
882        out
883    }
884
885    /// Phase D-ι: register a `<variables>` `<script name="name">` block
886    /// as a form-level global. Called by the dispatch layer after
887    /// `set_form_handle` so subsequent event/calculate scripts see the
888    /// namespace.
889    ///
890    /// Variables-script bodies are evaluated through the same QuickJS
891    /// context as event scripts, so they MUST run under the same
892    /// per-script time budget (`set_deadline` / interrupt handler) and
893    /// the same body-size cap (`MAX_SCRIPT_BODY_BYTES`). Without the
894    /// budget, an untrusted/malformed XFA template that contains
895    /// `while (true) {}` inside `<variables>` could hang flatten before
896    /// any normal event script runs (Codex P1 review on PR #1499).
897    fn register_variables_script(
898        &self,
899        name: &str,
900        body: &str,
901        subform_scope: Option<&str>,
902    ) -> Result<(), SandboxError> {
903        let Some(setter) = self.set_variables_script.clone() else {
904            return Ok(());
905        };
906        if body.len() > MAX_SCRIPT_BODY_BYTES {
907            return Err(SandboxError::BodyTooLarge);
908        }
909        let idents = Self::extract_top_level_idents(body);
910        let scope = subform_scope.unwrap_or("").to_string();
911        self.set_deadline();
912        let result = catch_unwind(AssertUnwindSafe(|| {
913            self.context.with(|ctx| -> Result<(), rquickjs::Error> {
914                let setter = setter.restore(&ctx)?;
915                let _: bool = setter.call((name, body, idents, scope))?;
916                Ok(())
917            })
918        }));
919        // Detect timeouts the same way `execute_script` does: if the
920        // deadline elapsed during evaluation, the interrupt handler aborts
921        // QuickJS with an error.
922        let deadline_now = self.script_deadline.load(Ordering::Acquire);
923        let now_nanos = Instant::now()
924            .checked_duration_since(epoch())
925            .map(|d| d.as_nanos() as u64)
926            .unwrap_or(0);
927        let timed_out = deadline_now != 0 && now_nanos >= deadline_now;
928        self.clear_deadline();
929        match result {
930            Ok(Ok(())) => Ok(()),
931            Ok(Err(_)) if timed_out => Err(SandboxError::Timeout),
932            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
933                "variables-script `{name}` register: {e}"
934            ))),
935            Err(_) => Err(SandboxError::PanicCaptured(format!(
936                "panic registering variables-script `{name}`"
937            ))),
938        }
939    }
940
941    fn clear_variables_scripts_global(&self) -> Result<(), SandboxError> {
942        let Some(clearer) = self.clear_variables_scripts.clone() else {
943            return Ok(());
944        };
945        let result = catch_unwind(AssertUnwindSafe(|| {
946            self.context.with(|ctx| -> Result<(), rquickjs::Error> {
947                let clearer = clearer.restore(&ctx)?;
948                let _: () = clearer.call(())?;
949                Ok(())
950            })
951        }));
952        match result {
953            Ok(Ok(())) => Ok(()),
954            Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
955                "variables-script clear: {e}"
956            ))),
957            Err(_) => Err(SandboxError::PanicCaptured(
958                "panic clearing variables-scripts".to_string(),
959            )),
960        }
961    }
962}
963
964/// Phase D-ι: strip `//` and `/* */` comments from a JS source body so
965/// the regex scan for top-level `var` / `function` declarations does not
966/// match commented-out tokens. Conservative — preserves strings (so a
967/// `"//"` substring inside a string literal is left alone). XFA scripts
968/// almost never have comments inside string literals, but the guard
969/// matters for correctness.
970fn strip_js_comments(src: &str) -> String {
971    let mut out = String::with_capacity(src.len());
972    let bytes = src.as_bytes();
973    let mut i = 0;
974    while i < bytes.len() {
975        let b = bytes[i];
976        if b == b'"' || b == b'\'' {
977            // Copy string literal verbatim.
978            let quote = b;
979            out.push(b as char);
980            i += 1;
981            while i < bytes.len() {
982                let c = bytes[i];
983                out.push(c as char);
984                if c == b'\\' && i + 1 < bytes.len() {
985                    out.push(bytes[i + 1] as char);
986                    i += 2;
987                    continue;
988                }
989                i += 1;
990                if c == quote {
991                    break;
992                }
993            }
994            continue;
995        }
996        if b == b'/' && i + 1 < bytes.len() {
997            let n = bytes[i + 1];
998            if n == b'/' {
999                // Line comment.
1000                while i < bytes.len() && bytes[i] != b'\n' {
1001                    i += 1;
1002                }
1003                continue;
1004            }
1005            if n == b'*' {
1006                // Block comment.
1007                i += 2;
1008                while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
1009                    i += 1;
1010                }
1011                i = (i + 2).min(bytes.len());
1012                continue;
1013            }
1014        }
1015        out.push(b as char);
1016        i += 1;
1017    }
1018    out
1019}
1020
1021const PHASE_C_BINDINGS_JS: &str = r#"
1022(function(host) {
1023  function protoGuard() {
1024    return Object.freeze(Object.create(null));
1025  }
1026
1027  function nullProtoObject() {
1028    var obj = Object.create(null);
1029    Object.defineProperty(obj, "__proto__", {
1030      value: protoGuard(),
1031      enumerable: false,
1032      configurable: false,
1033      writable: false
1034    });
1035    return obj;
1036  }
1037
1038  function lookupObject() {
1039    return Object.create(null);
1040  }
1041
1042  var deferredGlobalNames = lookupObject();
1043  [
1044    "app", "arguments", "Array", "Boolean", "Bun", "console", "Date",
1045    "decodeURI", "decodeURIComponent", "Deno", "encodeURI",
1046    "encodeURIComponent", "Error", "eval", "EvalError", "fetch",
1047    "Function", "globalThis", "Infinity", "isFinite", "isNaN", "JSON",
1048    "Map", "Math", "NaN", "Number", "Object", "parseFloat", "parseInt",
1049    "process", "RangeError", "ReferenceError", "RegExp", "require",
1050    "Set", "String", "Symbol", "SyntaxError", "TypeError", "undefined",
1051    "URIError", "WeakMap", "WeakSet", "WebSocket", "XMLHttpRequest",
1052    "xfa", "event"
1053  ].forEach(function(name) {
1054    deferredGlobalNames[name] = true;
1055  });
1056
1057  var reservedHandleProperties = lookupObject();
1058  [
1059    "__defineGetter__", "__defineSetter__", "__lookupGetter__",
1060    "__lookupSetter__", "__proto__", "constructor", "hasOwnProperty",
1061    "isPrototypeOf", "propertyIsEnumerable", "then", "toJSON",
1062    "toLocaleString", "toString", "valueOf"
1063  ].forEach(function(name) {
1064    reservedHandleProperties[name] = true;
1065  });
1066
1067  function shouldDeferGlobalName(name, localNames) {
1068    return name.charAt(0) === "_" ||
1069      localNames[name] === true ||
1070      deferredGlobalNames[name] === true;
1071  }
1072
1073  // Properties that must NOT be deferred so their specific implementations run.
1074  var handlePropertyExclusions = lookupObject();
1075  ["rawValue", "somExpression", "isNull", "clearItems", "addItem", "boundItem",
1076   "$record", "occur", "nodes", "value", "length", "item"].forEach(function(name) {
1077    handlePropertyExclusions[name] = true;
1078  });
1079
1080  function shouldDeferHandleProperty(name) {
1081    if (handlePropertyExclusions[name] === true) {
1082      return false;
1083    }
1084    return name.charAt(0) === "_" || reservedHandleProperties[name] === true;
1085  }
1086
1087  // Adobe silently clears a field when a script writes null/undefined/NaN.
1088  // rquickjs would otherwise coerce these to the literal strings "null"/"NaN".
1089  function coerceRawValue(v) {
1090    if (v === null || v === undefined) return "";
1091    if (typeof v === "number" && isNaN(v)) return "";
1092    return v;
1093  }
1094
1095  function collectLocalNames(body) {
1096    var locals = lookupObject();
1097    var match;
1098    var decls = /\b(?:var|let|const)\s+([^;]+)/g;
1099    while ((match = decls.exec(body)) !== null) {
1100      match[1].split(",").forEach(function(part) {
1101        var ident = /^\s*([A-Za-z_$][0-9A-Za-z_$]*)/.exec(part);
1102        if (ident) {
1103          locals[ident[1]] = true;
1104        }
1105      });
1106    }
1107    var funcs = /\bfunction\s+([A-Za-z_$][0-9A-Za-z_$]*)/g;
1108    while ((match = funcs.exec(body)) !== null) {
1109      locals[match[1]] = true;
1110    }
1111    return locals;
1112  }
1113
1114  function makeInstanceManager(id, generation) {
1115    var manager = nullProtoObject();
1116    Object.defineProperty(manager, "count", {
1117      enumerable: true,
1118      configurable: false,
1119      get: function() {
1120        return host.instanceCount(id, generation);
1121      }
1122    });
1123    Object.defineProperty(manager, "setInstances", {
1124      enumerable: true,
1125      configurable: false,
1126      writable: false,
1127      value: function(n) {
1128        return host.instanceSet(id, generation, n);
1129      }
1130    });
1131    Object.defineProperty(manager, "addInstance", {
1132      enumerable: true,
1133      configurable: false,
1134      writable: false,
1135      value: function() {
1136        var newId = host.instanceAdd(id, generation);
1137        return newId < 0 ? null : makeHandle(newId, generation);
1138      }
1139    });
1140    Object.defineProperty(manager, "removeInstance", {
1141      enumerable: true,
1142      configurable: false,
1143      writable: false,
1144      value: function(idx) {
1145        return host.instanceRemove(id, generation, idx);
1146      }
1147    });
1148    return Object.freeze(manager);
1149  }
1150
1151  function makeEmptyInstanceManager() {
1152    var manager = nullProtoObject();
1153    Object.defineProperty(manager, "count", {
1154      enumerable: true,
1155      configurable: false,
1156      value: 0
1157    });
1158    Object.defineProperty(manager, "setInstances", {
1159      enumerable: true,
1160      configurable: false,
1161      writable: false,
1162      value: function() { return 0; }
1163    });
1164    Object.defineProperty(manager, "addInstance", {
1165      enumerable: true,
1166      configurable: false,
1167      writable: false,
1168      value: function() { return null; }
1169    });
1170    Object.defineProperty(manager, "removeInstance", {
1171      enumerable: true,
1172      configurable: false,
1173      writable: false,
1174      value: function() { return false; }
1175    });
1176    return Object.freeze(manager);
1177  }
1178
1179  function makeOccurrenceHandle(id, generation) {
1180    var occur = nullProtoObject();
1181    ["min", "max", "initial"].forEach(function(name) {
1182      Object.defineProperty(occur, name, {
1183        enumerable: true,
1184        configurable: false,
1185        get: function() {
1186          var value = host.getOccurProperty(id, generation, name);
1187          return value === undefined ? null : value;
1188        },
1189        set: function(value) {
1190          host.setOccurProperty(id, generation, name, value);
1191        }
1192      });
1193    });
1194    return Object.seal(occur);
1195  }
1196
1197  function uniqueNodeIds(ids) {
1198    var out = [];
1199    if (!ids) return out;
1200    for (var i = 0; i < ids.length; i++) {
1201      var id = ids[i] | 0;
1202      if (id < 0) continue;
1203      var seen = false;
1204      for (var j = 0; j < out.length; j++) {
1205        if (out[j] === id) {
1206          seen = true;
1207          break;
1208        }
1209      }
1210      if (!seen) out.push(id);
1211    }
1212    return out;
1213  }
1214
1215  function nodeIdListArg(ids) {
1216    return uniqueNodeIds(ids).join(",");
1217  }
1218
1219  function resolveHandleChildIds(ids, prop) {
1220    var list = nodeIdListArg(ids);
1221    if (list.length === 0) return [];
1222    var childIds = uniqueNodeIds(host.resolveChildNodeIds(list, prop));
1223    if (childIds.length > 0) return childIds;
1224    return uniqueNodeIds(host.resolveScopedNodeIds(list, prop));
1225  }
1226
1227  function makeNodeHandleFromIds(ids, generation) {
1228    var unique = uniqueNodeIds(ids);
1229    if (unique.length === 0) return undefined;
1230    if (unique.length === 1) return makeHandle(unique[0], generation);
1231    return makeCandidateSet(unique, generation);
1232  }
1233
1234  function makeCandidateSet(ids, generation) {
1235    var candidates = uniqueNodeIds(ids);
1236    if (candidates.length === 0) return undefined;
1237    var firstId = candidates[0];
1238    var obj = nullProtoObject();
1239    return new Proxy(obj, {
1240      get: function(target, prop, receiver) {
1241        if (typeof prop !== "string") {
1242          return Reflect.get(target, prop, receiver);
1243        }
1244        if (prop === "rawValue") {
1245          var value = host.getRawValue(firstId, generation);
1246          return value === undefined ? null : value;
1247        }
1248        if (prop === "somExpression") {
1249          return "xfa[0].form[0].placeholder";
1250        }
1251        if (prop === "instanceManager") {
1252          return makeInstanceManager(firstId, generation);
1253        }
1254        if (prop === "occur") {
1255          return makeOccurrenceHandle(firstId, generation);
1256        }
1257        if (prop === "index") {
1258          return host.nodeIndex(firstId, generation);
1259        }
1260        if (prop === "setInstances") {
1261          return function(n) {
1262            return host.instanceSet(firstId, generation, n);
1263          };
1264        }
1265        if (prop === "addInstance") {
1266          return function() {
1267            var newId = host.instanceAdd(firstId, generation);
1268            return newId < 0 ? null : makeHandle(newId, generation);
1269          };
1270        }
1271        if (prop === "removeInstance") {
1272          return function(idx) {
1273            return host.instanceRemove(firstId, generation, idx);
1274          };
1275        }
1276        if (prop === "isNull") {
1277          var raw = host.getRawValue(firstId, generation);
1278          return raw === undefined || raw === null || raw === "";
1279        }
1280        if (prop === "clearItems") {
1281          return function() {
1282            return host.listClear(firstId, generation);
1283          };
1284        }
1285        if (prop === "addItem") {
1286          return function(display, save) {
1287            if (save === undefined) {
1288              return host.listAdd(firstId, generation, String(display));
1289            }
1290            return host.listAdd(firstId, generation, String(display), String(save));
1291          };
1292        }
1293        if (prop === "boundItem") {
1294          return function(displayValue) {
1295            var coerced = displayValue === null || displayValue === undefined ?
1296              "" : String(displayValue);
1297            return host.boundItem(firstId, generation, coerced);
1298          };
1299        }
1300        if (prop === "$record") {
1301          var recRaw = host.dataBoundRecord(firstId, generation);
1302          if (recRaw < 0) return makeNullDataHandle();
1303          return makeDataHandle(recRaw);
1304        }
1305        if (prop === "variables") {
1306          var csNodeName = host.nodeName(firstId, generation);
1307          if (typeof csNodeName === "string" && csNodeName.length > 0 &&
1308              subformVariables[csNodeName] !== undefined) {
1309            return subformVariables[csNodeName];
1310          }
1311          return Object.create(null);
1312        }
1313        if (prop.charAt(0) === "_" && prop.length > 1) {
1314          var bareName = prop.substring(1);
1315          var imIds = uniqueNodeIds(host.resolveChildNodeIds(nodeIdListArg(candidates), bareName));
1316          if (imIds.length > 0) {
1317            return makeInstanceManager(imIds[0], generation);
1318          }
1319          for (var imIdx = 0; imIdx < candidates.length; imIdx++) {
1320            if (host.hasZeroInstanceRun(candidates[imIdx], generation, bareName)) {
1321              return makeEmptyInstanceManager();
1322            }
1323          }
1324        }
1325        if (shouldDeferHandleProperty(prop)) {
1326          return undefined;
1327        }
1328        return makeNodeHandleFromIds(resolveHandleChildIds(candidates, prop), generation);
1329      },
1330      set: function(_target, prop, value) {
1331        if (prop === "rawValue") {
1332          host.setRawValue(firstId, generation, coerceRawValue(value));
1333        }
1334        return true;
1335      },
1336      has: function(target, prop) {
1337        if (typeof prop !== "string") {
1338          return Reflect.has(target, prop);
1339        }
1340        return prop === "rawValue" ||
1341          prop === "somExpression" ||
1342          prop === "instanceManager" ||
1343          prop === "occur" ||
1344          prop === "index" ||
1345          prop === "setInstances" ||
1346          prop === "addInstance" ||
1347          prop === "removeInstance" ||
1348          prop === "isNull" ||
1349          prop === "clearItems" ||
1350          prop === "addItem" ||
1351          prop === "boundItem" ||
1352          Reflect.has(target, prop);
1353      }
1354    });
1355  }
1356
1357  function makeHandle(id, generation) {
1358    var obj = nullProtoObject();
1359    Object.defineProperty(obj, "rawValue", {
1360      enumerable: true,
1361      configurable: false,
1362      get: function() {
1363        var value = host.getRawValue(id, generation);
1364        return value === undefined ? null : value;
1365      },
1366      set: function(value) {
1367        host.setRawValue(id, generation, coerceRawValue(value));
1368      }
1369    });
1370    // Phase C-α: defensive stub. Real Adobe forms call
1371    // `this.somExpression` to obtain the SOM path string. We don't expose
1372    // the real SOM path (introspection capability), but returning a
1373    // placeholder lets viewer-tweak scripts (e.g. acroSOM substr(15))
1374    // proceed without ReferenceError. Mutations via this handle still
1375    // require the rawValue setter, which is the only side-effect channel.
1376    Object.defineProperty(obj, "somExpression", {
1377      enumerable: false,
1378      configurable: false,
1379      get: function() {
1380        return "xfa[0].form[0].placeholder";
1381      }
1382    });
1383    return new Proxy(obj, {
1384      get: function(target, prop, receiver) {
1385        if (typeof prop !== "string") {
1386          return Reflect.get(target, prop, receiver);
1387        }
1388        if (prop === "rawValue" || prop === "somExpression") {
1389          return Reflect.get(target, prop, receiver);
1390        }
1391        if (prop === "instanceManager") {
1392          return makeInstanceManager(id, generation);
1393        }
1394        if (prop === "occur") {
1395          return makeOccurrenceHandle(id, generation);
1396        }
1397        if (prop === "index") {
1398          return host.nodeIndex(id, generation);
1399        }
1400        if (prop === "setInstances") {
1401          return function(n) {
1402            return host.instanceSet(id, generation, n);
1403          };
1404        }
1405        if (prop === "addInstance") {
1406          return function() {
1407            var newId = host.instanceAdd(id, generation);
1408            return newId < 0 ? null : makeHandle(newId, generation);
1409          };
1410        }
1411        if (prop === "removeInstance") {
1412          return function(idx) {
1413            return host.instanceRemove(id, generation, idx);
1414          };
1415        }
1416        if (prop === "isNull") {
1417          var value = host.getRawValue(id, generation);
1418          return value === undefined || value === null || value === "";
1419        }
1420        if (prop === "clearItems") {
1421          return function() {
1422            return host.listClear(id, generation);
1423          };
1424        }
1425        if (prop === "addItem") {
1426          return function(display, save) {
1427            if (save === undefined) {
1428              return host.listAdd(id, generation, String(display));
1429            }
1430            return host.listAdd(id, generation, String(display), String(save));
1431          };
1432        }
1433        // XFA 3.3 §App A `boundItem` — listbox display→save lookup. Used as
1434        // `field.boundItem(xfa.event.newText)` to translate a user-visible
1435        // option label into its underlying save value. Falls back to the
1436        // input string when no match exists (Adobe behaviour).
1437        if (prop === "boundItem") {
1438          return function(displayValue) {
1439            var coerced;
1440            if (displayValue === null || displayValue === undefined) {
1441              coerced = "";
1442            } else {
1443              coerced = String(displayValue);
1444            }
1445            return host.boundItem(id, generation, coerced);
1446          };
1447        }
1448        if (prop === "$record") {
1449          var recRaw = host.dataBoundRecord(id, generation);
1450          if (recRaw < 0) return makeNullDataHandle();
1451          return makeDataHandle(recRaw);
1452        }
1453        // Phase D-ι.2: `subformHandle.variables` returns the namespace object
1454        // holding all `<variables><script>` entries registered for this
1455        // subform by name. Enables `Page2.variables.ValidationScript.fn()`.
1456        if (prop === "variables") {
1457          var nodeName = host.nodeName(id, generation);
1458          if (typeof nodeName === "string" && nodeName.length > 0 &&
1459              subformVariables[nodeName] !== undefined) {
1460            return subformVariables[nodeName];
1461          }
1462          return Object.create(null);
1463        }
1464        // XFA 3.3 §6.4.3.2 underscore shorthand: `_<name>` on a subform
1465        // refers to the instanceManager of the same-named child subform.
1466        // Used in the wild as `parent._child.setInstances(N)`. This MUST
1467        // run before `shouldDeferHandleProperty`, which otherwise returns
1468        // `undefined` for every underscore-prefixed property and makes the
1469        // shorthand unreachable for real bound subforms.
1470        if (prop.charAt(0) === "_" && prop.length > 1) {
1471          var bareName = prop.substring(1);
1472          var imChildIds = uniqueNodeIds(host.resolveChildNodeIds(String(id), bareName));
1473          if (imChildIds.length > 0) {
1474            return makeInstanceManager(imChildIds[0], generation);
1475          }
1476          if (host.hasZeroInstanceRun(id, generation, bareName)) {
1477            return makeEmptyInstanceManager();
1478          }
1479        }
1480        if (shouldDeferHandleProperty(prop)) {
1481          return undefined;
1482        }
1483        return makeNodeHandleFromIds(resolveHandleChildIds([id], prop), generation);
1484      },
1485      set: function(_target, prop, value) {
1486        if (prop === "rawValue") {
1487          host.setRawValue(id, generation, coerceRawValue(value));
1488        }
1489        return true;
1490      },
1491      has: function(target, prop) {
1492        if (typeof prop !== "string") {
1493          return Reflect.has(target, prop);
1494        }
1495        return prop === "rawValue" ||
1496          prop === "somExpression" ||
1497          prop === "instanceManager" ||
1498          prop === "occur" ||
1499          prop === "index" ||
1500          prop === "setInstances" ||
1501          prop === "addInstance" ||
1502          prop === "removeInstance" ||
1503          prop === "isNull" ||
1504          prop === "clearItems" ||
1505          prop === "addItem" ||
1506          prop === "boundItem" ||
1507          Reflect.has(target, prop);
1508      }
1509    });
1510  }
1511
1512  // Phase C-α: viewer-stub that absorbs property writes silently.
1513  // Used as the return value of `event.target.getField()` so
1514  // AcroForm widget-tweak scripts (`field.doNotScroll = true`,
1515  // `field.required = false`, etc.) complete without error and
1516  // without mutating any flatten-relevant state.
1517  function makeViewerStub() {
1518    return new Proxy({}, {
1519      get: function(_t, _prop) { return undefined; },
1520      set: function(_t, _prop, _val) {
1521        
1522        return true;
1523      },
1524      has: function() { return true; }
1525    });
1526  }
1527
1528  function toListIndex(value) {
1529    var n = Number(value);
1530    if (!isFinite(n) || n < 0) return -1;
1531    return Math.floor(n);
1532  }
1533
1534  // XFA §8.5: a `$record` reference when there is no bound data node must return
1535  // an empty object that chains safely (`.value` → null, `.nodes.length` → 0)
1536  // rather than null, which would throw TypeError on any property access.
1537  function makeNullDataHandle() {
1538    var emptyNodes = [];
1539    emptyNodes.item = function() { return makeNullDataHandle(); };
1540    Object.freeze(emptyNodes);
1541    var sentinel = nullProtoObject();
1542    Object.defineProperty(sentinel, "value",
1543      { get: function() { return null; }, enumerable: true, configurable: false });
1544    Object.defineProperty(sentinel, "rawValue",
1545      { get: function() { return null; }, enumerable: true, configurable: false });
1546    Object.defineProperty(sentinel, "length",
1547      { get: function() { return 0; }, enumerable: true, configurable: false });
1548    Object.defineProperty(sentinel, "nodes",
1549      { get: function() { return emptyNodes; }, enumerable: true, configurable: false });
1550    sentinel.item = function() { return makeNullDataHandle(); };
1551    return new Proxy(sentinel, {
1552      get: function(target, prop) {
1553        if (prop in target) return target[prop];
1554        if (typeof prop !== "string") return undefined;
1555        return makeNullDataHandle();
1556      }
1557    });
1558  }
1559
1560  // Phase D-γ: Data DOM handle — wraps a raw DataDom node index and exposes
1561  // `.value`, `.nodes`, `.length`, `.item(i)`, and named child access via Proxy.
1562  function makeDataHandle(rawId) {
1563    if (rawId === undefined || rawId < 0) return null;
1564    var handle = nullProtoObject();
1565    Object.defineProperty(handle, "value", {
1566      get: function() {
1567        var v = host.dataValue(rawId);
1568        return (v === undefined || v === null) ? null : v;
1569      },
1570      enumerable: true, configurable: false
1571    });
1572    // rawValue is an alias for value — scripts use both forms on data handles.
1573    Object.defineProperty(handle, "rawValue", {
1574      get: function() {
1575        var v = host.dataValue(rawId);
1576        return (v === undefined || v === null) ? null : v;
1577      },
1578      enumerable: true, configurable: false
1579    });
1580    Object.defineProperty(handle, "length", {
1581      get: function() { return host.dataChildren(rawId).length; },
1582      enumerable: true, configurable: false
1583    });
1584    Object.defineProperty(handle, "nodes", {
1585      get: function() {
1586        var ids = host.dataChildren(rawId);
1587        var arr = [];
1588        for (var i = 0; i < ids.length; i++) arr.push(makeDataHandle(ids[i]));
1589        // Phase D-γ fix: XFA scripts call `nodeList.item(i)` on the array
1590        // returned by `.nodes`. Plain JS arrays have no `.item()` method —
1591        // add one that mirrors the W3C NodeList API. Out-of-bounds indices
1592        // return a null-safe sentinel so `.value` access never throws.
1593        var NULLNODE = Object.freeze({ value: null, rawValue: null });
1594        arr.item = function(idx) {
1595          var index = toListIndex(idx);
1596          if (index < 0 || index >= arr.length) return NULLNODE;
1597          return arr[index];
1598        };
1599        return Object.freeze(arr);
1600      },
1601      enumerable: true, configurable: false
1602    });
1603    handle.item = function(i) {
1604      var ids = host.dataChildren(rawId);
1605      var index = toListIndex(i);
1606      if (index < 0 || index >= ids.length) return null;
1607      return makeDataHandle(ids[index]);
1608    };
1609    return new Proxy(handle, {
1610      get: function(target, prop) {
1611        if (prop in target || typeof prop !== "string") return target[prop];
1612        if (prop === "rawValue" || prop === "value") return target.value;
1613        var childId = host.dataChildByName(rawId, prop);
1614        if (childId < 0) return makeNullDataHandle();
1615        return makeDataHandle(childId);
1616      }
1617    });
1618  }
1619
1620  var xfaHost = nullProtoObject();
1621  Object.defineProperty(xfaHost, "numPages", {
1622    enumerable: true,
1623    configurable: false,
1624    get: function() {
1625      return host.numPages();
1626    }
1627  });
1628  Object.defineProperty(xfaHost, "messageBox", {
1629    enumerable: true,
1630    configurable: false,
1631    writable: false,
1632    value: function() {
1633      host.bindingError();
1634      return null;
1635    }
1636  });
1637
1638  var xfaLayout = nullProtoObject();
1639  Object.defineProperty(xfaLayout, "pageCount", {
1640    enumerable: true,
1641    configurable: false,
1642    writable: false,
1643    value: function() {
1644      return host.numPages();
1645    }
1646  });
1647  // Phase D-γ: xfa.layout.page(node) — page number (1-based) of a form node.
1648  // During static flatten the layout is not yet run, so return a bounded
1649  // placeholder and mark the metadata as approximate.
1650  Object.defineProperty(xfaLayout, "page", {
1651    enumerable: true,
1652    configurable: false,
1653    writable: false,
1654    value: function() {
1655      host.resolveFailure();
1656      return 1;
1657    }
1658  });
1659  // Phase D-γ: xfa.layout.pageSpan(node) — number of pages a node spans.
1660  // Always 1 during static flatten; metadata records the approximation.
1661  Object.defineProperty(xfaLayout, "pageSpan", {
1662    enumerable: true,
1663    configurable: false,
1664    writable: false,
1665    value: function() {
1666      host.resolveFailure();
1667      return 1;
1668    }
1669  });
1670  Object.defineProperty(xfaLayout, "absPage", {
1671    enumerable: true,
1672    configurable: false,
1673    writable: false,
1674    value: function() {
1675      host.bindingError();
1676      return null;
1677    }
1678  });
1679
1680  var xfa = nullProtoObject();
1681  Object.defineProperty(xfa, "host", {
1682    enumerable: true,
1683    configurable: false,
1684    writable: false,
1685    value: Object.freeze(xfaHost)
1686  });
1687  Object.defineProperty(xfa, "layout", {
1688    enumerable: true,
1689    configurable: false,
1690    writable: false,
1691    value: Object.freeze(xfaLayout)
1692  });
1693  Object.defineProperty(xfa, "form", {
1694    enumerable: true,
1695    configurable: false,
1696    get: function() {
1697      var generation = host.generation();
1698      var id = host.rootNodeId(generation);
1699      return id < 0 ? null : makeHandle(id, generation);
1700    }
1701  });
1702  Object.defineProperty(xfa, "resolveNode", {
1703    enumerable: true,
1704    configurable: false,
1705    writable: false,
1706    value: function(path) {
1707      // Phase D-γ: data paths are routed to the DataDom, not the FormTree.
1708      if (typeof path === "string" &&
1709          (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
1710           path.indexOf("xfa.datasets.data.") === 0)) {
1711        var rawId = host.dataResolveNode(path);
1712        if (rawId < 0) return null;
1713        return makeDataHandle(rawId);
1714      }
1715      var id = host.resolveNodeId(path);
1716      if (id < 0) {
1717        return null;
1718      }
1719      return makeHandle(id, host.generation());
1720    }
1721  });
1722  Object.defineProperty(xfa, "resolveNodes", {
1723    enumerable: true,
1724    configurable: false,
1725    writable: false,
1726    value: function(path) {
1727      // Phase D-γ: data paths are routed to the DataDom, not the FormTree.
1728      if (typeof path === "string" &&
1729          (path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
1730           path.indexOf("xfa.datasets.data.") === 0)) {
1731        var rawIds = host.dataResolveNodes(path);
1732        var out = [];
1733        for (var i = 0; i < rawIds.length; i++) out.push(makeDataHandle(rawIds[i]));
1734        return Object.freeze(out);
1735      }
1736      var generation = host.generation();
1737      var ids = host.resolveNodeIds(path);
1738      var out = [];
1739      for (var i = 0; i < ids.length; i++) {
1740        out.push(makeHandle(ids[i], generation));
1741      }
1742      return Object.freeze(out);
1743    }
1744  });
1745  // Phase D-δ.2: expose `xfa.event` as an alias for the per-script event
1746  // global. Real Adobe Reader populates this with the firing event;
1747  // during static flatten there is no dispatched UI event, so the
1748  // accessor returns the same defensive event-stub that the per-script
1749  // `event` parameter receives — newText/prevText/change default to
1750  // empty strings, target resolves to the firing field handle.
1751  Object.defineProperty(xfa, "event", {
1752    enumerable: true,
1753    configurable: false,
1754    get: function() {
1755      return makeEvent();
1756    }
1757  });
1758
1759  var app = nullProtoObject();
1760  Object.defineProperty(app, "alert", {
1761    enumerable: true,
1762    configurable: false,
1763    writable: false,
1764    value: function() {
1765      host.bindingError();
1766      return null;
1767    }
1768  });
1769  Object.defineProperty(app, "response", {
1770    enumerable: true,
1771    configurable: false,
1772    writable: false,
1773    value: function() {
1774      return "";
1775    }
1776  });
1777  Object.defineProperty(app, "launchURL", {
1778    enumerable: true,
1779    configurable: false,
1780    writable: false,
1781    value: function() {
1782      host.bindingError();
1783      return null;
1784    }
1785  });
1786  // XFA Acrobat SDK §6: app.Application — application-level info object.
1787  // Stubbed as a no-op frozen object; scripts that branch on its presence
1788  // (e.g. PDFIUM-352) no longer throw ReferenceError.
1789  Object.defineProperty(app, "Application", {
1790    enumerable: true,
1791    configurable: false,
1792    writable: false,
1793    value: Object.freeze(nullProtoObject())
1794  });
1795
1796  // Phase C-α: viewer-only `event` global. Real Adobe Reader populates
1797  // this with the firing event; during static flatten there is no
1798  // dispatched UI event, so the object is a defensive stub that:
1799  // - exposes `target` resolving to the firing field handle (≈ `this`),
1800  //   so scripts like `event.target.getField(somPath)` complete without
1801  //   ReferenceError;
1802  // - exposes `change` as an empty string (the spec default);
1803  // - returns viewer-stubs from `target.getField()` so AcroForm widget
1804  //   tweaks (`doNotScroll`, `required`, etc.) silently absorb.
1805  function makeEvent() {
1806    var id = host.currentNodeId();
1807    var fieldHandle = id < 0 ? null : makeHandle(id, host.generation());
1808    var target = nullProtoObject();
1809    Object.defineProperty(target, "getField", {
1810      enumerable: true, configurable: false, writable: false,
1811      value: function() {
1812        
1813        return makeViewerStub();
1814      }
1815    });
1816    Object.defineProperty(target, "name", {
1817      enumerable: true, configurable: false,
1818      get: function() { return ""; }
1819    });
1820    Object.defineProperty(target, "self", {
1821      enumerable: true, configurable: false,
1822      get: function() { return fieldHandle; }
1823    });
1824    var ev = nullProtoObject();
1825    Object.defineProperty(ev, "target", {
1826      enumerable: true, configurable: false,
1827      get: function() { return target; }
1828    });
1829    // Phase D-δ.2: stable empty-string defaults for the change-event property
1830    // family so scripts that read `xfa.event.newText` / `prevText` /
1831    // `change` on initialize/calculate (where no real change event occurred)
1832    // do not throw `cannot read property 'X' of undefined`. Adobe populates
1833    // these on actual `change`/`exit` events; we run those activities later
1834    // (or never) and surface deterministic empty defaults instead.
1835    Object.defineProperty(ev, "change", {
1836      enumerable: true, configurable: false,
1837      get: function() { return ""; }
1838    });
1839    Object.defineProperty(ev, "newText", {
1840      enumerable: true, configurable: false,
1841      get: function() { return ""; }
1842    });
1843    Object.defineProperty(ev, "prevText", {
1844      enumerable: true, configurable: false,
1845      get: function() { return ""; }
1846    });
1847    Object.defineProperty(ev, "fullText", {
1848      enumerable: true, configurable: false,
1849      get: function() { return ""; }
1850    });
1851    Object.defineProperty(ev, "selStart", {
1852      enumerable: true, configurable: false,
1853      get: function() { return 0; }
1854    });
1855    Object.defineProperty(ev, "selEnd", {
1856      enumerable: true, configurable: false,
1857      get: function() { return 0; }
1858    });
1859    return Object.freeze(ev);
1860  }
1861
1862  // Phase D-γ: XFA global `util` (Acrobat SDK §Util).  Provides date/number
1863  // formatting helpers used by many XFA templates. Only the subset required
1864  // by real-corpus scripts is implemented; unknown methods return "".
1865  //
1866  // util.printd(sFormat, dDate)  — format a Date per sFormat using UTC fields.
1867  //   Supported tokens: yyyy (year), yy (two-digit year), mm (month 01-12),
1868  //   m (1-12), dd (day 01-31), d (1-31), HH (hour 00-23),
1869  //   MM (minute 00-59), SS (second 00-59).
1870  // util.printx(cPicture, cValue) — picture format; returns cValue as-is.
1871  // util.scand(sFormat, cDate)   — deterministic numeric parser for the same
1872  //   token subset; unsupported or invalid input returns Invalid Date.
1873  var xfaUtil = (function() {
1874    var DateCtor = Date;
1875    var dateUtc = Date.UTC;
1876    function pad2(n) { return (n < 10 ? "0" : "") + n; }
1877    function invalidDate() { return new DateCtor(NaN); }
1878    function escapeRegex(text) {
1879      return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1880    }
1881    function parseDate(fmt, input) {
1882      var fmtText, text;
1883      try {
1884        fmtText = String(fmt);
1885        text = String(input);
1886      } catch (_e) {
1887        return invalidDate();
1888      }
1889
1890      var groups = [];
1891      var pattern = "^";
1892      for (var i = 0; i < fmtText.length;) {
1893        var rest = fmtText.substring(i);
1894        if (rest.indexOf("yyyy") === 0) {
1895          pattern += "(\\d{4})";
1896          groups.push("yyyy");
1897          i += 4;
1898        } else if (rest.indexOf("yy") === 0) {
1899          pattern += "(\\d{2})";
1900          groups.push("yy");
1901          i += 2;
1902        } else if (rest.indexOf("mm") === 0) {
1903          pattern += "(\\d{2})";
1904          groups.push("mm");
1905          i += 2;
1906        } else if (rest.indexOf("dd") === 0) {
1907          pattern += "(\\d{2})";
1908          groups.push("dd");
1909          i += 2;
1910        } else if (rest.indexOf("HH") === 0) {
1911          pattern += "(\\d{2})";
1912          groups.push("HH");
1913          i += 2;
1914        } else if (rest.indexOf("MM") === 0) {
1915          pattern += "(\\d{2})";
1916          groups.push("MM");
1917          i += 2;
1918        } else if (rest.indexOf("SS") === 0) {
1919          pattern += "(\\d{2})";
1920          groups.push("SS");
1921          i += 2;
1922        } else if (rest.indexOf("m") === 0) {
1923          pattern += "(\\d{1,2})";
1924          groups.push("m");
1925          i += 1;
1926        } else if (rest.indexOf("d") === 0) {
1927          pattern += "(\\d{1,2})";
1928          groups.push("d");
1929          i += 1;
1930        } else {
1931          pattern += escapeRegex(fmtText.charAt(i));
1932          i += 1;
1933        }
1934      }
1935
1936      var match = new RegExp(pattern + "$").exec(text);
1937      if (!match) return invalidDate();
1938
1939      var year = NaN, month = 1, day = 1, hour = 0, minute = 0, second = 0;
1940      for (var g = 0; g < groups.length; g++) {
1941        var value = parseInt(match[g + 1], 10);
1942        if (!isFinite(value)) return invalidDate();
1943        if (groups[g] === "yyyy") year = value;
1944        else if (groups[g] === "yy") year = 2000 + value;
1945        else if (groups[g] === "mm" || groups[g] === "m") month = value;
1946        else if (groups[g] === "dd" || groups[g] === "d") day = value;
1947        else if (groups[g] === "HH") hour = value;
1948        else if (groups[g] === "MM") minute = value;
1949        else if (groups[g] === "SS") second = value;
1950      }
1951
1952      if (!isFinite(year) || month < 1 || month > 12 || day < 1 || day > 31 ||
1953          hour < 0 || hour > 23 || minute < 0 || minute > 59 ||
1954          second < 0 || second > 59) {
1955        return invalidDate();
1956      }
1957      var out = new DateCtor(dateUtc(year, month - 1, day, hour, minute, second));
1958      if (out.getUTCFullYear() !== year ||
1959          out.getUTCMonth() + 1 !== month ||
1960          out.getUTCDate() !== day ||
1961          out.getUTCHours() !== hour ||
1962          out.getUTCMinutes() !== minute ||
1963          out.getUTCSeconds() !== second) {
1964        return invalidDate();
1965      }
1966      return out;
1967    }
1968    var u = nullProtoObject();
1969    u.printd = function(fmt, date) {
1970      if (!(date instanceof DateCtor) || isNaN(date.getTime())) return "";
1971      var y = date.getUTCFullYear();
1972      var mo = date.getUTCMonth() + 1;
1973      var d  = date.getUTCDate();
1974      var h  = date.getUTCHours();
1975      var mi = date.getUTCMinutes();
1976      var s  = date.getUTCSeconds();
1977      var result = String(fmt);
1978      result = result.replace(/yyyy/g, y)
1979                     .replace(/yy/g,   String(y).slice(-2))
1980                     .replace(/mm/g,   pad2(mo))
1981                     .replace(/m/g,    mo)
1982                     .replace(/dd/g,   pad2(d))
1983                     .replace(/d/g,    d)
1984                     .replace(/HH/g,   pad2(h))
1985                     .replace(/MM/g,   pad2(mi))
1986                     .replace(/SS/g,   pad2(s));
1987      return result;
1988    };
1989    u.printx = function(_fmt, val) { return val === null || val === undefined ? "" : String(val); };
1990    u.scand  = function(fmt, str) { return parseDate(fmt, str); };
1991    return Object.freeze(u);
1992  }());
1993
1994  // Phase C-α: minimal `console` no-op. Many forms guard with
1995  // `if (typeof console !== "undefined") console.log(...)` and proceed
1996  // when the symbol exists. Stub returns undefined; never writes
1997  // anywhere observable to the script.
1998  var consoleStub = nullProtoObject();
1999  ["log","warn","error","info","debug","trace"].forEach(function(name) {
2000    Object.defineProperty(consoleStub, name, {
2001      enumerable: true, configurable: false, writable: false,
2002      value: function() {  return undefined; }
2003    });
2004  });
2005
2006  // Phase D-ι: form-level globals registered from `<variables>` `<script>`
2007  // blocks. Each entry is `name -> frozen object`. Populated by the host
2008  // once per document via `setVariablesScript`; cleared by
2009  // `clearVariablesScripts` at `reset_per_document`.
2010  var variablesScripts = lookupObject();
2011  // Phase D-ι.2: subform-scoped variables. Maps subform name -> namespace
2012  // object containing that subform's named scripts. Enables
2013  // `subformHandle.variables.ScriptName.method()` access paths.
2014  var subformVariables = lookupObject();
2015
2016  function makeImplicitGlobals(body) {
2017    var currentId = host.currentNodeId();
2018    var generation = host.generation();
2019    var localNames = collectLocalNames(String(body));
2020    var cachedHandles = lookupObject();
2021    var cachedImHandles = lookupObject();
2022    var dynamicLocals = lookupObject();
2023    // D-ι.2: ancestor subform names (innermost→outermost) for this script's
2024    // context node. Used to resolve bare names like `partNoScript` that are
2025    // defined in a parent subform's <variables> block.
2026    var scopeChain = (currentId >= 0)
2027      ? host.getSubformScopeChain(currentId, generation)
2028      : [];
2029
2030    function lookup(name) {
2031      if (cachedHandles[name] !== undefined) {
2032        return cachedHandles[name];
2033      }
2034      // Use current node at call time so that functions defined in a
2035      // variables-script IIFE (with captured `currentId`) still resolve SOM
2036      // nodes correctly when invoked from an event script with a different
2037      // active node. During normal event-script execution currentNodeId()
2038      // returns the same value as the captured `currentId`, so there is no
2039      // observable difference for the common case.
2040      var resolveId = host.currentNodeId();
2041      if (resolveId < 0) resolveId = currentId;
2042      var nodeIds = host.resolveImplicitNodeIds(resolveId, name);
2043      if (!nodeIds || nodeIds.length === 0) {
2044        return undefined;
2045      }
2046      var handle = makeNodeHandleFromIds(nodeIds, generation);
2047      cachedHandles[name] = handle;
2048      return handle;
2049    }
2050
2051    // XFA instance manager shorthand: `_NodeName` as a global bare name
2052    // resolves to the instanceManager for `NodeName` from the current context.
2053    // Adobe XFA scripts use patterns like `_PD2.setInstances(0)` at the
2054    // document level. `shouldDeferGlobalName` blocks all `_`-prefixed names
2055    // to prevent internal JS variables (e.g. `_i`) from being hijacked, so
2056    // we must intercept BEFORE that check, but only when the bare name
2057    // actually resolves to a form node.
2058    function lookupInstanceManagerShorthand(prop) {
2059      // Only handle `_X` (single underscore prefix, non-empty suffix) that
2060      // is not a double-underscore builtin (e.g. __proto__) and is not a
2061      // locally declared variable.
2062      if (prop.length < 2 || prop.charAt(1) === "_" || localNames[prop] === true) {
2063        return undefined;
2064      }
2065      var bareName = prop.substring(1);
2066      if (cachedImHandles[bareName] !== undefined) {
2067        return cachedImHandles[bareName];
2068      }
2069      var resolveId = host.currentNodeId();
2070      if (resolveId < 0) resolveId = currentId;
2071      var nodeIds = host.resolveImplicitNodeIds(resolveId, bareName);
2072      if (!nodeIds || nodeIds.length === 0) {
2073        cachedImHandles[bareName] = null;
2074        return null;
2075      }
2076      var im = makeInstanceManager(nodeIds[0], generation);
2077      cachedImHandles[bareName] = im;
2078      return im;
2079    }
2080
2081    return new Proxy(Object.create(null), {
2082      has: function(_target, prop) {
2083        if (typeof prop !== "string") {
2084          return false;
2085        }
2086        if (prop.charAt(0) === "_") {
2087          var _im = lookupInstanceManagerShorthand(prop);
2088          return _im !== null && _im !== undefined;
2089        }
2090        if (shouldDeferGlobalName(prop, localNames)) {
2091          return false;
2092        }
2093        return true;
2094      },
2095      get: function(_target, prop) {
2096        if (typeof prop !== "string") {
2097          return undefined;
2098        }
2099        if (prop.charAt(0) === "_") {
2100          var im = lookupInstanceManagerShorthand(prop);
2101          return (im === null) ? undefined : im;
2102        }
2103        if (shouldDeferGlobalName(prop, localNames)) {
2104          return undefined;
2105        }
2106        // Phase D-γ: $record as a script-level global refers to the data
2107        // record bound to the current field's enclosing subform context.
2108        // Scripts write `var addr = $record.SECTION.nodes;` — we intercept
2109        // this here instead of letting resolveImplicitNodeId fail (-1).
2110        if (prop === "$record") {
2111          var recRaw = host.dataBoundRecord(currentId, generation);
2112          if (recRaw < 0) return makeNullDataHandle();
2113          return makeDataHandle(recRaw);
2114        }
2115        // Phase D-γ: `util` is an XFA global (Acrobat SDK §Util) that provides
2116        // date/number formatting functions.  `util.printd(fmt, date)` is widely
2117        // used by XFA templates to format Date objects.  We intercept it here so
2118        // scripts can complete without a TypeError instead of throwing and
2119        // aborting all later mutations in the same script body.
2120        if (prop === "util") {
2121          return xfaUtil;
2122        }
2123        if (dynamicLocals[prop] !== undefined) {
2124          return dynamicLocals[prop];
2125        }
2126        // Phase D-ι: form-level named-script globals from <variables>
2127        // outrank the form-tree implicit lookup. Adobe XFA spec §5.5
2128        // exposes `<scriptName>.<topLevelDecl>` to all event/calculate
2129        // scripts in the same document.
2130        if (variablesScripts[prop] !== undefined) {
2131          return variablesScripts[prop];
2132        }
2133        // D-ι.2: walk ancestor subform scope chain (innermost first).
2134        // Variables defined in a parent subform's <variables> block are
2135        // accessible as bare names within all descendant scripts.
2136        for (var _sci = 0; _sci < scopeChain.length; _sci++) {
2137          var _sv = subformVariables[scopeChain[_sci]];
2138          if (_sv !== undefined && _sv[prop] !== undefined) {
2139            return _sv[prop];
2140          }
2141        }
2142        return lookup(prop);
2143      },
2144      set: function(_target, prop, value) {
2145        if (typeof prop !== "string") {
2146          return true;
2147        }
2148        if (shouldDeferGlobalName(prop, localNames)) {
2149          return false;
2150        }
2151        if (cachedHandles[prop] !== undefined) {
2152          return true;
2153        }
2154        dynamicLocals[prop] = value;
2155        return true;
2156      }
2157    });
2158  }
2159
2160  return {
2161    xfa: Object.freeze(xfa),
2162    app: Object.freeze(app),
2163    consoleStub: Object.freeze(consoleStub),
2164    // Phase D-ι: register a `<variables>` `<script name="X">…` block as a
2165    // form-level global. Called by the host once per script body at
2166    // document load. `body` is the raw script source; `identNames` is a
2167    // pre-extracted array of top-level `var` / `function` identifiers
2168    // (Rust-side regex). The body is wrapped in an IIFE that returns a
2169    // frozen object whose properties are those identifiers. Variables
2170    // scripts share the same time/memory budget enforcement as event
2171    // scripts but emit no field mutations of their own. Errors during
2172    // evaluation are absorbed: the namespace remains undefined and
2173    // dependent event scripts will fail naturally at first use.
2174    // Phase D-ι / D-ι.2: register a named `<variables><script>` body.
2175    // `subformName` (4th param, optional) is non-empty for subform-scoped
2176    // scripts; omit or pass "" for root-level scripts.
2177    //
2178    // Root-level scripts (empty subformName) go into the flat
2179    // `variablesScripts` dict only — accessible as `ScriptName.X` from
2180    // any event script in the document.
2181    //
2182    // Subform-scoped scripts go into `subformVariables[subformName][name]`
2183    // ONLY — accessible as `subformHandle.variables.ScriptName.X`. They
2184    // are intentionally NOT written to the flat dict: two subforms may
2185    // define the same script name, and writing both to the flat map would
2186    // let the second registration silently shadow the first.
2187    setVariablesScript: function(name, body, identNames, subformName) {
2188      if (typeof name !== "string" || name.length === 0) return false;
2189      if (typeof body !== "string") return false;
2190      var idents = Array.isArray(identNames) ? identNames : [];
2191      var props = "";
2192      for (var i = 0; i < idents.length; i++) {
2193        var id = idents[i];
2194        if (typeof id !== "string" || id.length === 0) continue;
2195        if (i > 0) props += ",";
2196        props += JSON.stringify(id) + ": typeof " + id +
2197                 " !== \"undefined\" ? " + id + " : undefined";
2198      }
2199      var isScoped = typeof subformName === "string" && subformName.length > 0;
2200      // Create the subform dict before the IIFE so the with-binding holds a
2201      // reference to the live object (forward cross-script refs work).
2202      if (isScoped && subformVariables[subformName] === undefined) {
2203        subformVariables[subformName] = lookupObject();
2204      }
2205      try {
2206        // Wrap the body with with(vs) so bare-name references to sibling
2207        // variable scripts resolve at CALL time from the live dictionaries.
2208        // This handles both backward and forward cross-references between
2209        // variables scripts regardless of registration order.
2210        var ns;
2211        if (isScoped) {
2212          ns = (Function("vs", "svs",
2213            "return (function(){\nwith(svs){\nwith(vs){\n" + body +
2214            "\nreturn Object.freeze({" + props + "});\n}}})();"
2215          ))(variablesScripts, subformVariables[subformName]);
2216        } else {
2217          ns = (Function("vs",
2218            "return (function(){\nwith(vs){\n" + body +
2219            "\nreturn Object.freeze({" + props + "});\n}})();"
2220          ))(variablesScripts);
2221        }
2222        if (isScoped) {
2223          subformVariables[subformName][name] = ns;
2224        } else {
2225          variablesScripts[name] = ns;
2226        }
2227        return true;
2228      } catch (_e) {
2229        return false;
2230      }
2231    },
2232    clearVariablesScripts: function() {
2233      var keys = Object.keys(variablesScripts);
2234      for (var i = 0; i < keys.length; i++) {
2235        delete variablesScripts[keys[i]];
2236      }
2237      var skeys = Object.keys(subformVariables);
2238      for (var j = 0; j < skeys.length; j++) {
2239        delete subformVariables[skeys[j]];
2240      }
2241    },
2242    evalScript: function(body) {
2243      var id = host.currentNodeId();
2244      var thisArg = id < 0 ? undefined : makeHandle(id, host.generation());
2245      // Phase C-α: install per-script `event` global in the function
2246      // closure so `event.target` resolves to the current field. Wrapping
2247      // body inside a function lets us pass `event` as a parameter
2248      // without leaking it to globalThis (where it would persist across
2249      // unrelated scripts).
2250      var ev = makeEvent();
2251      var consoleArg = consoleStub;
2252      var globals = makeImplicitGlobals(body);
2253      return (Function(
2254        "event",
2255        "console",
2256        "__globals",
2257        "with(__globals){\n" + String(body) + "\n}"
2258      )).call(thisArg, ev, consoleArg, globals);
2259    }
2260  };
2261})
2262"#;
2263
2264// Process-wide reference epoch; used together with `Instant::now() - EPOCH`
2265// to materialise a u64 nanosecond timestamp comparable across the interrupt
2266// handler closure and the dispatch path. We never expose this to scripts.
2267static EPOCH_CELL: OnceLock<Instant> = OnceLock::new();
2268fn epoch() -> Instant {
2269    *EPOCH_CELL.get_or_init(Instant::now)
2270}
2271
2272impl XfaJsRuntime for QuickJsRuntime {
2273    fn init(&mut self) -> Result<(), SandboxError> {
2274        // Defensive: ensure no host binding leaked into globalThis.
2275        // We call this in a `with` because rquickjs Contexts borrow a
2276        // !Send handle; the catch_unwind crosses the FFI boundary.
2277        let result = catch_unwind(AssertUnwindSafe(|| {
2278            self.context.with(|ctx| {
2279                let globals = ctx.globals();
2280                // Strip non-deterministic / capability-bearing globals if
2281                // any third-party crate ever registered them. Phase B
2282                // registers nothing, but defence in depth is cheap.
2283                for forbidden in [
2284                    "fetch",
2285                    "XMLHttpRequest",
2286                    "WebSocket",
2287                    "process",
2288                    "require",
2289                    "Deno",
2290                    "Bun",
2291                ] {
2292                    let _ = globals.set(forbidden, rquickjs::Undefined);
2293                }
2294                // Replace Date.now and Math.random with deterministic stubs.
2295                if let Ok(date_ctor) = globals.get::<_, rquickjs::Object>("Date") {
2296                    let zero_now = Function::new(ctx.clone(), || 0i64)
2297                        .map_err(|e| format!("date stub: {e}"))?;
2298                    let _ = date_ctor.set("now", zero_now);
2299                }
2300                if let Ok(math_ns) = globals.get::<_, rquickjs::Object>("Math") {
2301                    let _ = math_ns.set("random", rquickjs::Undefined);
2302                }
2303                Ok::<(), String>(())
2304            })?;
2305            self.register_host_bindings()?;
2306            Ok::<(), String>(())
2307        }));
2308        match result {
2309            Ok(Ok(())) => Ok(()),
2310            Ok(Err(e)) => Err(SandboxError::ScriptError(e)),
2311            Err(_) => Err(SandboxError::PanicCaptured(
2312                "panic while initialising sandbox globals".to_string(),
2313            )),
2314        }
2315    }
2316
2317    fn reset_for_new_document(&mut self) -> Result<(), SandboxError> {
2318        self.metadata = RuntimeMetadata::default();
2319        self.host.borrow_mut().reset_per_document();
2320        self.clear_deadline();
2321        // Memory limit is per-document; re-set to clear any prior accounting.
2322        self.runtime.set_memory_limit(self.memory_budget_bytes);
2323        // Phase D-ι: drop all `<variables>` namespace globals from the
2324        // previous document so they do not leak into the next. Failure
2325        // here is non-fatal — it just means a slightly polluted global
2326        // namespace, never a correctness issue, but log via metadata.
2327        if let Err(e) = self.clear_variables_scripts_global() {
2328            log::debug!("D-ι clear failed: {e:?}");
2329        }
2330        Ok(())
2331    }
2332
2333    // The `*mut FormTree` parameter is part of the existing
2334    // `XfaJsRuntime` trait — the caller in `flatten.rs` already enforces
2335    // that the pointer outlives this call. Clippy's
2336    // `not_unsafe_ptr_arg_deref` would require this signature to be
2337    // `unsafe fn`, which the trait does not allow.
2338    #[allow(clippy::not_unsafe_ptr_arg_deref)]
2339    fn set_form_handle(
2340        &mut self,
2341        form: *mut FormTree,
2342        root_id: FormNodeId,
2343    ) -> Result<(), SandboxError> {
2344        self.host.borrow_mut().set_form_handle(form, root_id);
2345        // Phase D-ι: register every `<variables>` `<script name="X">…` body
2346        // collected during merge as a form-level JS global. Done here
2347        // because by this point the form pointer is valid and the JS
2348        // runtime is initialised. Errors registering one script do not
2349        // block the others.
2350        if !form.is_null() {
2351            // SAFETY: caller guarantees `form` outlives this call.
2352            let scripts: Vec<(Option<String>, String, String)> =
2353                unsafe { (*form).variables_scripts.clone() };
2354            for (subform_scope, name, body) in scripts {
2355                if let Err(e) =
2356                    self.register_variables_script(&name, &body, subform_scope.as_deref())
2357                {
2358                    log::debug!("D-ι register `{name}` failed: {e:?}");
2359                }
2360            }
2361        }
2362        Ok(())
2363    }
2364
2365    fn set_data_handle(&mut self, dom: *const xfa_dom_resolver::data_dom::DataDom) {
2366        self.host.borrow_mut().set_data_handle(dom);
2367    }
2368
2369    fn reset_per_script(
2370        &mut self,
2371        current_id: FormNodeId,
2372        activity: Option<&str>,
2373    ) -> Result<(), SandboxError> {
2374        self.host
2375            .borrow_mut()
2376            .reset_per_script(current_id, activity);
2377        Ok(())
2378    }
2379
2380    fn set_static_page_count(&mut self, page_count: u32) -> Result<(), SandboxError> {
2381        self.host.borrow_mut().set_static_page_count(page_count);
2382        Ok(())
2383    }
2384
2385    fn execute_script(
2386        &mut self,
2387        activity: Option<&str>,
2388        body: &str,
2389    ) -> Result<RuntimeOutcome, SandboxError> {
2390        if !activity_allowed_for_sandbox(activity) {
2391            return Err(SandboxError::PhaseDenied(
2392                activity.unwrap_or("None").to_string(),
2393            ));
2394        }
2395        if body.len() > MAX_SCRIPT_BODY_BYTES {
2396            self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
2397            return Err(SandboxError::BodyTooLarge);
2398        }
2399
2400        self.set_deadline();
2401        let script_owned = body.to_string();
2402        // Phase D-γ: capture the actual JS exception message while still inside
2403        // the QuickJS context, so we get "TypeError: foo is not a function"
2404        // rather than the generic "Exception generated by QuickJS".
2405        let result = catch_unwind(AssertUnwindSafe(|| {
2406            self.context.with(|ctx| -> Result<(), rquickjs::Error> {
2407                let Some(eval_script) = self.eval_script.clone() else {
2408                    return Err(rquickjs::Error::new_from_js_message(
2409                        "host bindings",
2410                        "Function",
2411                        "Phase C eval bridge not registered",
2412                    ));
2413                };
2414                let eval_script = eval_script.restore(&ctx)?;
2415                if let Err(e) = eval_script.call::<_, ()>((script_owned,)) {
2416                    // rquickjs stores the thrown value as a pending exception in
2417                    // the context. `ctx.catch()` pops it and lets us stringify it
2418                    // for much more useful diagnostic output.
2419                    let exc_msg = if matches!(e, rquickjs::Error::Exception) {
2420                        let val = ctx.catch();
2421                        // Try to get a string representation of the exception.
2422                        if let Some(exc) = val.as_exception() {
2423                            exc.message().unwrap_or_else(|| exc.to_string())
2424                        } else {
2425                            e.to_string()
2426                        }
2427                    } else {
2428                        e.to_string()
2429                    };
2430                    return Err(rquickjs::Error::new_from_js_message(
2431                        "script", "Error", exc_msg,
2432                    ));
2433                }
2434                Ok(())
2435            })
2436        }));
2437        // Capture deadline state BEFORE clearing so the error-classification
2438        // branch below can distinguish a timeout from a genuine ScriptError.
2439        let captured_deadline = self.script_deadline.load(Ordering::Acquire);
2440        let captured_now = Instant::now()
2441            .checked_duration_since(epoch())
2442            .map(|d| d.as_nanos() as u64)
2443            .unwrap_or(0);
2444        let timed_out = captured_deadline != 0 && captured_now >= captured_deadline;
2445        self.clear_deadline();
2446
2447        match result {
2448            Ok(Ok(())) => {
2449                self.metadata.executed = self.metadata.executed.saturating_add(1);
2450                let host_metadata = self.host.borrow_mut().take_metadata();
2451                self.metadata.accumulate(host_metadata);
2452                Ok(RuntimeOutcome {
2453                    executed: true,
2454                    mutated_field_count: host_metadata.mutations,
2455                })
2456            }
2457            Ok(Err(other)) => {
2458                let host_metadata = self.host.borrow_mut().take_metadata();
2459                self.metadata.accumulate(host_metadata);
2460                // rquickjs ≤ 0.8 collapses interrupts, OOM, and thrown
2461                // exceptions into a small set of Error variants. We
2462                // distinguish a Timeout via the deadline snapshot captured
2463                // before clear_deadline() above; OOM via a substring scan of
2464                // the error message; everything else is ScriptError.
2465                if timed_out {
2466                    self.metadata.timeouts = self.metadata.timeouts.saturating_add(1);
2467                    Err(SandboxError::Timeout)
2468                } else {
2469                    let msg = other.to_string();
2470                    if msg.to_ascii_lowercase().contains("memory") {
2471                        self.metadata.oom = self.metadata.oom.saturating_add(1);
2472                        Err(SandboxError::OutOfMemory)
2473                    } else {
2474                        self.metadata.runtime_errors =
2475                            self.metadata.runtime_errors.saturating_add(1);
2476                        Err(SandboxError::ScriptError(msg))
2477                    }
2478                }
2479            }
2480            Err(_) => {
2481                let host_metadata = self.host.borrow_mut().take_metadata();
2482                self.metadata.accumulate(host_metadata);
2483                self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
2484                Err(SandboxError::PanicCaptured(
2485                    "panic during sandboxed script execution".to_string(),
2486                ))
2487            }
2488        }
2489    }
2490
2491    fn take_metadata(&mut self) -> RuntimeMetadata {
2492        std::mem::take(&mut self.metadata)
2493    }
2494}
2495
2496#[cfg(test)]
2497mod tests {
2498    use super::*;
2499
2500    fn fresh_runtime() -> QuickJsRuntime {
2501        let mut rt = QuickJsRuntime::new().expect("rquickjs init");
2502        rt.init().expect("init");
2503        rt.reset_for_new_document().expect("reset");
2504        rt
2505    }
2506
2507    #[test]
2508    fn harmless_calculate_script_executes() {
2509        let mut rt = fresh_runtime();
2510        let outcome = rt
2511            .execute_script(Some("calculate"), "var x = 1 + 1; x")
2512            .expect("ok");
2513        assert!(outcome.executed);
2514        let md = rt.take_metadata();
2515        assert_eq!(md.executed, 1);
2516        assert!(md.is_clean());
2517    }
2518
2519    #[test]
2520    fn ui_activity_is_phase_denied() {
2521        let mut rt = fresh_runtime();
2522        let err = rt.execute_script(Some("click"), "1+1").unwrap_err();
2523        assert!(matches!(err, SandboxError::PhaseDenied(_)));
2524    }
2525
2526    #[test]
2527    fn oversized_body_rejected_before_parse() {
2528        let mut rt = fresh_runtime();
2529        let body = "1;\n".repeat(MAX_SCRIPT_BODY_BYTES);
2530        let err = rt.execute_script(Some("calculate"), &body).unwrap_err();
2531        assert_eq!(err, SandboxError::BodyTooLarge);
2532    }
2533
2534    #[test]
2535    fn fetch_is_undefined() {
2536        let mut rt = fresh_runtime();
2537        // Reading `typeof fetch` from a fresh context should return
2538        // "undefined" because we never register it. Surface as a thrown
2539        // error if it isn't, by using `if (typeof fetch !== 'undefined') throw 0`.
2540        rt.execute_script(
2541            Some("calculate"),
2542            "if (typeof fetch !== 'undefined') throw new Error('fetch leaked');",
2543        )
2544        .expect("must run cleanly with fetch undefined");
2545    }
2546
2547    #[test]
2548    fn require_is_undefined() {
2549        let mut rt = fresh_runtime();
2550        rt.execute_script(
2551            Some("calculate"),
2552            "if (typeof require !== 'undefined') throw new Error('require leaked');",
2553        )
2554        .expect("must run cleanly with require undefined");
2555    }
2556
2557    #[test]
2558    fn process_is_undefined() {
2559        let mut rt = fresh_runtime();
2560        rt.execute_script(
2561            Some("calculate"),
2562            "if (typeof process !== 'undefined') throw new Error('process leaked');",
2563        )
2564        .expect("must run cleanly with process undefined");
2565    }
2566
2567    #[test]
2568    fn date_now_is_zero() {
2569        let mut rt = fresh_runtime();
2570        rt.execute_script(
2571            Some("calculate"),
2572            "if (Date.now() !== 0) throw new Error('Date.now not stubbed');",
2573        )
2574        .expect("Date.now must return 0");
2575    }
2576
2577    #[test]
2578    fn math_random_is_undefined() {
2579        let mut rt = fresh_runtime();
2580        rt.execute_script(
2581            Some("calculate"),
2582            "if (typeof Math.random !== 'undefined') throw new Error('Math.random leaked');",
2583        )
2584        .expect("Math.random must be undefined");
2585    }
2586
2587    #[test]
2588    fn infinite_loop_times_out() {
2589        let mut rt = QuickJsRuntime::new()
2590            .expect("init")
2591            .with_time_budget(Duration::from_millis(50));
2592        rt.init().unwrap();
2593        rt.reset_for_new_document().unwrap();
2594        let err = rt
2595            .execute_script(Some("calculate"), "while(true){}")
2596            .unwrap_err();
2597        assert_eq!(err, SandboxError::Timeout);
2598        let md = rt.take_metadata();
2599        assert_eq!(md.timeouts, 1);
2600        assert_eq!(md.executed, 0);
2601    }
2602
2603    #[test]
2604    fn syntax_error_is_recoverable() {
2605        let mut rt = fresh_runtime();
2606        let err = rt
2607            .execute_script(Some("calculate"), "this is not javascript {{")
2608            .unwrap_err();
2609        assert!(matches!(err, SandboxError::ScriptError(_)));
2610        // Subsequent script should still run.
2611        rt.execute_script(Some("calculate"), "var ok = 1;")
2612            .expect("recovered");
2613    }
2614}