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