use std::cell::{Cell, RefCell};
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::rc::Rc;
use std::time::{Duration, Instant};
use xfa_js_sandboxed::engine::{
Args, Boot, DefaultEngine, Fail, Guard, In, JsEngine, Limits, Native, Out, Ret,
};
use xfa_layout_engine::form::{FormNodeId, FormTree};
use super::regex_guard::{scan_script_for_redos, RegexScanVerdict};
use super::{
activity_allowed_for_sandbox_with_gate, walltime_fallback_multiplier, HostBindings,
RuntimeMetadata, RuntimeOutcome, SandboxError, XfaJsRuntime, DEFAULT_MEMORY_BUDGET_BYTES,
DEFAULT_TIME_BUDGET_MS, MAX_SCRIPT_BODY_BYTES, MAX_VARIABLES_SCRIPT_BODY_BYTES,
};
pub struct SandboxedRuntime {
engine: RefCell<DefaultEngine>,
metadata: RuntimeMetadata,
time_budget: Duration,
memory_budget_bytes: usize,
script_started: RefCell<Option<Instant>>,
walltime_fallback_multiplier: u32,
host: Rc<RefCell<HostBindings>>,
init_called: Cell<bool>,
bindings_registered: Cell<bool>,
}
impl std::fmt::Debug for SandboxedRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SandboxedRuntime")
.field("metadata", &self.metadata)
.field("time_budget_ms", &self.time_budget.as_millis())
.field("memory_budget_bytes", &self.memory_budget_bytes)
.finish()
}
}
fn parse_node_id_csv(raw: &str) -> Vec<FormNodeId> {
raw.split(',')
.filter_map(|part| part.trim().parse::<usize>().ok())
.map(FormNodeId)
.collect()
}
fn ids(v: Vec<FormNodeId>) -> Ret {
Ret::Ints(v.into_iter().map(|n| n.0 as i32).collect())
}
fn node_ret(v: Option<FormNodeId>) -> Ret {
Ret::Int(v.map(|n| n.0 as i64).unwrap_or(-1))
}
fn chain_of(csv: Option<String>) -> Vec<String> {
csv.map(|s| s.split(',').map(|p| p.trim().to_string()).collect())
.unwrap_or_default()
}
fn host_natives(host: &Rc<RefCell<HostBindings>>) -> Vec<(&'static str, Native)> {
let mut v: Vec<(&'static str, Native)> = Vec::new();
macro_rules! nat {
($name:literal, |$h:ident, $a:ident| $body:expr) => {{
let hh = Rc::clone(host);
v.push((
$name,
Box::new(move |$a: &Args| -> Ret {
let $h = &hh;
$body
}),
));
}};
}
nat!("resolveNodeId", |h, a| {
let Some(path) = a.opt_str(0) else {
let _ = h.borrow_mut().resolve_node("");
return Ret::Int(-1);
};
node_ret(h.borrow_mut().resolve_node(&path))
});
nat!("resolveNodeIds", |h, a| {
let Some(path) = a.opt_str(0) else {
let _ = h.borrow_mut().resolve_nodes("");
return Ret::Ints(Vec::new());
};
ids(h.borrow_mut().resolve_nodes(&path))
});
nat!("generation", |h, _a| Ret::Int(
h.borrow().generation() as i64
));
nat!("currentNodeId", |h, _a| node_ret(h.borrow().current_node()));
nat!("resolveImplicitNodeId", |h, a| {
let cur = a.i32(0);
if cur < 0 {
return Ret::Int(-1);
}
let Some(name) = a.opt_str(1) else {
return Ret::Int(-1);
};
node_ret(
h.borrow_mut()
.resolve_implicit(FormNodeId(cur as usize), &name),
)
});
nat!("resolveImplicitNodeIds", |h, a| {
let cur = a.i32(0);
let Some(name) = a.opt_str(1).filter(|_| cur >= 0) else {
return Ret::Ints(Vec::new());
};
ids(h
.borrow_mut()
.resolve_implicit_candidates(FormNodeId(cur as usize), &name))
});
nat!("resolveImplicitNodeIdsQuiet", |h, a| {
let cur = a.i32(0);
let Some(name) = a.opt_str(1).filter(|_| cur >= 0) else {
return Ret::Ints(Vec::new());
};
ids(h
.borrow_mut()
.resolve_implicit_candidates_quiet(FormNodeId(cur as usize), &name))
});
nat!("resolveChildNodeId", |h, a| {
let parent = a.i32(0);
if parent < 0 {
return Ret::Int(-1);
}
let Some(name) = a.opt_str(1) else {
return Ret::Int(-1);
};
node_ret(
h.borrow_mut()
.resolve_child(FormNodeId(parent as usize), &name),
)
});
nat!("resolveChildNodeIds", |h, a| {
let (Some(parents), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
return Ret::Ints(Vec::new());
};
ids(h
.borrow_mut()
.resolve_child_candidates(&parse_node_id_csv(&parents), &name))
});
nat!("resolveChildNodeIdsQuiet", |h, a| {
let (Some(parents), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
return Ret::Ints(Vec::new());
};
ids(h
.borrow_mut()
.resolve_child_candidates_quiet(&parse_node_id_csv(&parents), &name))
});
nat!("resolveImplicitNodeIdsHinted", |h, a| {
let cur = a.i32(0);
let Some(name) = a.opt_str(1).filter(|_| cur >= 0) else {
return Ret::Ints(Vec::new());
};
let hint = a.opt_str(2).unwrap_or_default();
ids(h.borrow_mut().resolve_implicit_candidates_hinted(
FormNodeId(cur as usize),
&name,
&hint,
))
});
nat!("resolveChildNodeIdsHinted", |h, a| {
let (Some(parents), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
return Ret::Ints(Vec::new());
};
let hint = a.opt_str(2).unwrap_or_default();
ids(h.borrow_mut().resolve_child_candidates_hinted(
&parse_node_id_csv(&parents),
&name,
&hint,
))
});
nat!("resolveScopedNodeIds", |h, a| {
let (Some(scope), Some(name)) = (a.opt_str(0), a.opt_str(1)) else {
return Ret::Ints(Vec::new());
};
ids(h
.borrow_mut()
.resolve_scoped_candidates(&parse_node_id_csv(&scope), &name))
});
nat!("resolveWithFullChain", |h, a| {
let parents = a
.opt_str(0)
.map(|s| parse_node_id_csv(&s))
.unwrap_or_default();
let cur = a.i32(1);
let chain = chain_of(a.opt_str(2));
let origin = (cur >= 0).then(|| FormNodeId(cur as usize));
ids(h
.borrow_mut()
.resolve_with_full_chain(&parents, &chain, origin))
});
nat!("resolveWithFullChainStrict", |h, a| {
let parents = a
.opt_str(0)
.map(|s| parse_node_id_csv(&s))
.unwrap_or_default();
let cur = a.i32(1);
let chain = chain_of(a.opt_str(2));
let origin = (cur >= 0).then(|| FormNodeId(cur as usize));
ids(h
.borrow_mut()
.resolve_with_full_chain_strict(&parents, &chain, origin))
});
nat!("getRawValue", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Undefined;
}
h.borrow_mut()
.get_raw_value(FormNodeId(id as usize), g as u64)
.into()
});
nat!("setRawValue", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
Ret::Bool(
h.borrow_mut()
.set_raw_value(FormNodeId(id as usize), a.str(2), g as u64),
)
});
nat!("getPresence", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Str("visible".to_string());
}
Ret::Str(
h.borrow_mut()
.get_presence(FormNodeId(id as usize), g as u64),
)
});
nat!("setPresence", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
Ret::Bool(
h.borrow_mut()
.set_presence(FormNodeId(id as usize), a.str(2), g as u64),
)
});
nat!("isInteractive", |h, _a| Ret::Bool(h.borrow().interactive()));
nat!("instanceCount", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Int(0);
}
h.borrow_mut()
.instance_count_for_handle(FormNodeId(id as usize), g as u64)
.into()
});
nat!("hasZeroInstanceRun", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
let Some(name) = a.opt_str(2) else {
return Ret::Bool(false);
};
Ret::Bool(
h.borrow_mut()
.has_zero_instance_run(FormNodeId(id as usize), g as u64, &name),
)
});
nat!("nodeIndex", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Int(0);
}
h.borrow_mut()
.instance_index_for_handle(FormNodeId(id as usize), g as u64)
.into()
});
nat!("nodeName", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Str(String::new());
}
Ret::Str(
h.borrow()
.node_name(FormNodeId(id as usize), g as u64)
.unwrap_or_default(),
)
});
nat!("occurResolve", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
Ret::Bool(
h.borrow_mut()
.occur_resolve(FormNodeId(id as usize), g as u64),
)
});
nat!("occurRead", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Int(-1);
}
Ret::Int(
h.borrow_mut()
.occur_read(FormNodeId(id as usize), g as u64, &a.str(2)),
)
});
nat!("occurCapture", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
Ret::Bool(h.borrow_mut().occur_capture(
FormNodeId(id as usize),
g as u64,
&a.str(2),
a.i64(3),
))
});
nat!("nodeIsContainer", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
Ret::Bool(
h.borrow_mut()
.node_is_container(FormNodeId(id as usize), g as u64),
)
});
nat!("parentOfNode", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Int(-1);
}
node_ret(
h.borrow_mut()
.parent_of_node(FormNodeId(id as usize), g as u64),
)
});
nat!("instanceSet", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Int(-1);
}
let n = a.opt_i32(2).unwrap_or(0).max(0) as u32;
Ret::Int(
h.borrow_mut()
.instance_set_for_handle(FormNodeId(id as usize), g as u64, n)
.map(|c| c as i64)
.unwrap_or(-1),
)
});
nat!("instanceAdd", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Int(-1);
}
node_ret(
h.borrow_mut()
.instance_add_for_handle(FormNodeId(id as usize), g as u64)
.ok(),
)
});
nat!("instanceRemove", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
let index = a.opt_i32(2).unwrap_or(0).max(0) as u32;
Ret::Bool(
h.borrow_mut()
.instance_remove_for_handle(FormNodeId(id as usize), g as u64, index)
.is_ok(),
)
});
nat!("listClear", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
Ret::Bool(
h.borrow_mut()
.list_clear_for_handle(FormNodeId(id as usize), g as u64)
.is_ok(),
)
});
nat!("listAdd", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Bool(false);
}
Ret::Bool(
h.borrow_mut()
.list_add_for_handle(FormNodeId(id as usize), g as u64, a.str(2), a.opt_str(3))
.is_ok(),
)
});
nat!("boundItem", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Str(a.str(2));
}
Ret::Str(
h.borrow_mut()
.bound_item_for_handle(FormNodeId(id as usize), g as u64, a.str(2)),
)
});
nat!("getDisplayItems", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Strs(Vec::new());
}
Ret::Strs(
h.borrow_mut()
.get_display_items(FormNodeId(id as usize), g as u64),
)
});
nat!("numPages", |h, _a| h.borrow_mut().num_pages().into());
nat!("bindingError", |h, _a| {
h.borrow_mut().metadata_binding_error();
Ret::Undefined
});
nat!("resolveFailure", |h, _a| {
h.borrow_mut().metadata_resolve_failure();
Ret::Undefined
});
nat!("somDataRootHit", |h, _a| {
h.borrow_mut().metadata_som_data_root_hit();
Ret::Undefined
});
nat!("somItemsPathHit", |h, _a| {
h.borrow_mut().metadata_som_items_path_hit();
Ret::Undefined
});
nat!("unsupportedHostCall", |h, _a| {
h.borrow_mut().metadata_unsupported_host_call();
Ret::Undefined
});
nat!("probeSkip", |h, _a| {
h.borrow_mut().metadata_probe_skip();
Ret::Undefined
});
nat!("dataChildren", |h, a| {
let raw = a.i32(0);
if raw < 0 {
return Ret::Ints(Vec::new());
}
Ret::Ints(
h.borrow_mut()
.data_children(raw as usize)
.into_iter()
.map(|x| x as i32)
.collect(),
)
});
nat!("dataValue", |h, a| {
let raw = a.i32(0);
if raw < 0 {
return Ret::Undefined;
}
h.borrow_mut().data_value(raw as usize).into()
});
nat!("dataChildByName", |h, a| {
let parent = a.i32(0);
if parent < 0 {
return Ret::Int(-1);
}
let Some(name) = a.opt_str(1) else {
return Ret::Int(-1);
};
Ret::Int(
h.borrow_mut()
.data_child_by_name(parent as usize, &name)
.map(|x| x as i64)
.unwrap_or(-1),
)
});
nat!("dataBoundRecord", |h, a| {
let (id, g) = (a.i32(0), a.i64(1));
if id < 0 || g < 0 {
return Ret::Int(-1);
}
Ret::Int(
h.borrow_mut()
.data_bound_record(FormNodeId(id as usize), g as u64)
.map(|x| x as i64)
.unwrap_or(-1),
)
});
nat!("dataResolveNode", |h, a| {
let Some(path) = a.opt_str(0) else {
return Ret::Int(-1);
};
Ret::Int(
h.borrow_mut()
.data_resolve_node(&path)
.map(|x| x as i64)
.unwrap_or(-1),
)
});
nat!("isDeclaredAbsentNode", |h, a| {
let Some(name) = a.opt_str(0) else {
return Ret::Bool(false);
};
Ret::Bool(h.borrow().is_declared_absent_node(&name))
});
nat!("dataResolveNodes", |h, a| {
let Some(path) = a.opt_str(0) else {
return Ret::Ints(Vec::new());
};
Ret::Ints(
h.borrow_mut()
.data_resolve_nodes(&path)
.into_iter()
.map(|x| x as i32)
.collect(),
)
});
v
}
fn bridge_script() -> String {
format!(
"(function(){{\n var b = ({factory})(globalThis.__xfa_host);\n \
delete globalThis.__xfa_host;\n \
globalThis.xfa = b.xfa; globalThis.app = b.app; globalThis.form = b.form;\n \
return b;\n}})()",
factory = PHASE_C_BINDINGS_JS
)
}
const HARDEN_JS: &str = r#"(function(){
["fetch","XMLHttpRequest","WebSocket","process","require","Deno","Bun"]
.forEach(function(k){ globalThis[k] = undefined; });
Date.now = function(){ return 0; };
Math.random = undefined;
})()"#;
impl SandboxedRuntime {
pub fn new() -> Result<Self, SandboxError> {
let engine = DefaultEngine::new(&Limits {
memory_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
time_budget: Duration::from_millis(DEFAULT_TIME_BUDGET_MS),
})
.map_err(|e| SandboxError::ScriptError(format!("js engine init: {e}")))?;
Ok(Self {
engine: RefCell::new(engine),
metadata: RuntimeMetadata::default(),
time_budget: Duration::from_millis(DEFAULT_TIME_BUDGET_MS),
memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
script_started: RefCell::new(None),
walltime_fallback_multiplier: walltime_fallback_multiplier(),
host: Rc::new(RefCell::new(HostBindings::new())),
init_called: Cell::new(false),
bindings_registered: Cell::new(false),
})
}
pub fn with_time_budget(mut self, budget: Duration) -> Self {
self.time_budget = budget;
self.engine.get_mut().set_time_budget(budget);
self
}
pub fn with_memory_budget(mut self, bytes: usize) -> Self {
self.memory_budget_bytes = bytes;
self.engine.get_mut().set_memory_limit(bytes);
self
}
pub fn with_walltime_fallback_multiplier(mut self, multiplier: u32) -> Self {
self.walltime_fallback_multiplier = multiplier.max(2);
self
}
fn set_deadline(&self) {
let now = Instant::now();
*self.script_started.borrow_mut() = Some(now);
self.engine.borrow_mut().set_guard(Guard {
deadline: Some(now + self.time_budget),
cancel: None,
});
}
fn clear_deadline(&self) {
self.engine.borrow_mut().set_guard(Guard::default());
}
fn deadline_elapsed(&self) -> bool {
self.script_started
.borrow()
.is_some_and(|s| s.elapsed() >= self.time_budget)
}
fn classify_timeout_or_walltime(&self) -> SandboxError {
let Some(start) = *self.script_started.borrow() else {
return SandboxError::Timeout;
};
let elapsed = start.elapsed();
let threshold = self
.time_budget
.saturating_mul(self.walltime_fallback_multiplier);
if elapsed >= threshold {
SandboxError::WallTimeExceeded(format!(
"{} ms ≥ {mult}× budget ({budget_ms} ms)",
elapsed.as_millis(),
mult = self.walltime_fallback_multiplier,
budget_ms = self.time_budget.as_millis()
))
} else {
SandboxError::Timeout
}
}
fn boot_engine(&self) -> Result<(), String> {
if self.bindings_registered.get() {
return Ok(());
}
let natives = host_natives(&self.host);
let bridge = bridge_script();
self.engine
.borrow_mut()
.boot(vec![
Boot::Eval(HARDEN_JS),
Boot::Install("__xfa_host", natives),
Boot::EvalKeep(&bridge, "bridge"),
])
.map_err(|f| format!("binding factory: {f}"))?;
self.bindings_registered.set(true);
Ok(())
}
fn extract_top_level_idents(body: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let stripped = strip_js_comments(body);
for token_kind in [
("var", false),
("let", false),
("const", false),
("function", true),
] {
let kw = token_kind.0;
let is_fn = token_kind.1;
let mut search = stripped.as_str();
while let Some(idx) = search.find(kw) {
let (before, after) = search.split_at(idx);
let head_ok = before
.chars()
.last()
.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$');
let tail_after_kw = &after[kw.len()..];
let tail_ok = tail_after_kw
.chars()
.next()
.is_some_and(|c| c.is_whitespace());
if !(head_ok && tail_ok) {
search = &after[1..];
continue;
}
let after_ws = tail_after_kw.trim_start();
let mut chars = after_ws.chars();
let ident: String = std::iter::once(chars.next())
.chain(chars.map(Some))
.map_while(|c| c.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '$'))
.collect();
if ident.is_empty() || ident.chars().next().is_some_and(|c| c.is_ascii_digit()) {
search = &after[kw.len()..];
continue;
}
if is_fn {
let after_ident = after_ws
.trim_start_matches(|c: char| c.is_alphanumeric() || c == '_' || c == '$');
if !after_ident.trim_start().starts_with('(') {
search = &after[kw.len()..];
continue;
}
}
if seen.insert(ident.clone()) {
out.push(ident);
}
search = &after[kw.len()..];
}
}
out
}
fn register_variables_script(
&self,
name: &str,
body: &str,
subform_scope: Option<&str>,
expose_global: bool,
) -> Result<bool, SandboxError> {
if self.bindings_registered_check().is_err() {
return Ok(false);
}
if body.len() > MAX_VARIABLES_SCRIPT_BODY_BYTES {
return Err(SandboxError::BodyTooLarge);
}
if let RegexScanVerdict::Reject { reason } = scan_script_for_redos(body) {
return Err(SandboxError::RegexRejected(reason));
}
let idents = Self::extract_top_level_idents(body);
let scope = subform_scope.unwrap_or("").to_string();
self.set_deadline();
let result = catch_unwind(AssertUnwindSafe(|| {
self.engine.borrow_mut().call_kept(
"bridge",
"setVariablesScript",
vec![
In::Str(name.to_string()),
In::Str(body.to_string()),
In::Strs(idents),
In::Str(scope),
In::Bool(expose_global),
],
)
}));
let timed_out = self.deadline_elapsed();
let classified = self.classify_timeout_or_walltime();
self.clear_deadline();
match result {
Ok(Ok(Out::Bool(ok))) => Ok(ok),
Ok(Ok(_)) => Ok(false),
Ok(Err(Fail::Limit(_))) => Err(classified),
Ok(Err(_)) if timed_out => Err(classified),
Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
"variables-script `{name}` register: {e:?}"
))),
Err(_) => Err(SandboxError::PanicCaptured(format!(
"panic registering variables-script `{name}`"
))),
}
}
fn bindings_registered_check(&self) -> Result<(), ()> {
if self.bindings_registered.get() {
return Ok(());
}
if !self.init_called.get() {
return Err(());
}
self.boot_engine().map_err(|e| {
log::debug!("script engine boot failed: {e}");
})
}
fn register_variables_data_item(
&self,
name: &str,
initial: &str,
subform_scope: Option<&str>,
) -> Result<(), SandboxError> {
if self.bindings_registered_check().is_err() {
return Ok(());
}
let result = catch_unwind(AssertUnwindSafe(|| {
self.engine.borrow_mut().call_kept(
"bridge",
"setVariablesDataItem",
vec![
In::Str(name.to_string()),
In::Str(initial.to_string()),
In::Str(subform_scope.unwrap_or("").to_string()),
],
)
}));
match result {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
"variables data item `{name}` register: {e:?}"
))),
Err(_) => Err(SandboxError::PanicCaptured(format!(
"panic registering variables data item `{name}`"
))),
}
}
fn clear_variables_scripts_global(&self) -> Result<(), SandboxError> {
if !self.bindings_registered.get() {
return Ok(());
}
let result = catch_unwind(AssertUnwindSafe(|| {
self.engine
.borrow_mut()
.call_kept("bridge", "clearVariablesScripts", vec![])
}));
match result {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
"variables-script clear: {e:?}"
))),
Err(_) => Err(SandboxError::PanicCaptured(
"panic clearing variables-scripts".to_string(),
)),
}
}
}
fn strip_js_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let bytes = src.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'"' || b == b'\'' {
let quote = b;
out.push(b as char);
i += 1;
while i < bytes.len() {
let c = bytes[i];
out.push(c as char);
if c == b'\\' && i + 1 < bytes.len() {
out.push(bytes[i + 1] as char);
i += 2;
continue;
}
i += 1;
if c == quote {
break;
}
}
continue;
}
if b == b'/' && i + 1 < bytes.len() {
let n = bytes[i + 1];
if n == b'/' {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if n == b'*' {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
continue;
}
}
out.push(b as char);
i += 1;
}
out
}
const PHASE_C_BINDINGS_JS: &str = r##"
(function(host) {
function protoGuard() {
return Object.freeze(Object.create(null));
}
function nullProtoObject() {
var obj = Object.create(null);
Object.defineProperty(obj, "__proto__", {
value: protoGuard(),
enumerable: false,
configurable: false,
writable: false
});
return obj;
}
function lookupObject() {
return Object.create(null);
}
var deferredGlobalNames = lookupObject();
[
"app", "arguments", "Array", "Boolean", "Bun", "console", "Date",
"decodeURI", "decodeURIComponent", "Deno", "encodeURI",
"encodeURIComponent", "Error", "eval", "EvalError", "fetch",
"Function", "globalThis", "Infinity", "isFinite", "isNaN", "JSON",
"Map", "Math", "NaN", "Number", "Object", "parseFloat", "parseInt",
"process", "RangeError", "ReferenceError", "RegExp", "require",
"Set", "String", "Symbol", "SyntaxError", "TypeError", "undefined",
"URIError", "WeakMap", "WeakSet", "WebSocket", "XMLHttpRequest",
// JS2-01 (Sprint 2 Batch B): `form` is a top-level alias of `xfa.form`.
// It must defer to globalThis so the host SOM resolver does not first
// try to resolve `form` as a form-tree node, find nothing, and then
// surface a `js_resolve_failure` for what is really a viewer global.
"xfa", "event", "form"
].forEach(function(name) {
deferredGlobalNames[name] = true;
});
var reservedHandleProperties = lookupObject();
[
"__defineGetter__", "__defineSetter__", "__lookupGetter__",
"__lookupSetter__", "__proto__", "constructor", "hasOwnProperty",
"isPrototypeOf", "propertyIsEnumerable", "then", "toJSON",
"toLocaleString", "toString", "valueOf"
].forEach(function(name) {
reservedHandleProperties[name] = true;
});
function shouldDeferGlobalName(name, localNames) {
return name.charAt(0) === "_" ||
localNames[name] === true ||
deferredGlobalNames[name] === true;
}
// Properties that must NOT be deferred so their specific implementations run.
var handlePropertyExclusions = lookupObject();
["rawValue", "somExpression", "isNull", "clearItems", "addItem", "boundItem",
"$record", "nodes", "value", "length", "item", "choiceList", "#items"].forEach(function(name) {
handlePropertyExclusions[name] = true;
});
function shouldDeferHandleProperty(name) {
if (handlePropertyExclusions[name] === true) {
return false;
}
return name.charAt(0) === "_" || reservedHandleProperties[name] === true;
}
function collectLocalNames(body) {
var locals = lookupObject();
var match;
var decls = /\b(?:var|let|const)\s+([^;]+)/g;
while ((match = decls.exec(body)) !== null) {
match[1].split(",").forEach(function(part) {
var ident = /^\s*([A-Za-z_$][0-9A-Za-z_$]*)/.exec(part);
if (ident) {
locals[ident[1]] = true;
}
});
}
var funcs = /\bfunction\s+([A-Za-z_$][0-9A-Za-z_$]*)/g;
while ((match = funcs.exec(body)) !== null) {
locals[match[1]] = true;
}
return locals;
}
// XFA-DATA-M3C: `<handle>.ui` returns the ui-config sub-object that
// Adobe templates expose for choiceList / textEdit / checkButton widget
// tweaking. During static flatten the widget tree is not materialised, so
// we hand back a minimal stub whose `choiceList` is an empty frozen
// array (matching the bare `handle.choiceList` fallback). Property
// writes are absorbed silently so scripts like
// `field.ui.choiceList.commitOn = "exit"` complete without TypeError.
function makeUiStub() {
var stub = nullProtoObject();
Object.defineProperty(stub, "choiceList", {
enumerable: true,
configurable: false,
get: function() { return Object.freeze([]); }
});
Object.defineProperty(stub, "textEdit", {
enumerable: true,
configurable: false,
get: function() { return Object.freeze({}); }
});
Object.defineProperty(stub, "checkButton", {
enumerable: true,
configurable: false,
get: function() { return Object.freeze({}); }
});
return new Proxy(stub, {
get: function(target, prop) {
if (typeof prop !== "string") return undefined;
if (prop in target) return target[prop];
// Sub-property writes / reads are absorbed: scripts walking
// unknown widget config (e.g. `ui.imageEdit.<x>`) get a chainable
// viewer-stub rather than crashing on undefined.
return makeViewerStub();
},
set: function(_t, _p, _v) { return true; },
has: function(_t, _p) { return true; }
});
}
// D5: minimal `node.occur` handle. Reads `min`/`max`/`initial` from the
// structural template (host.occurRead; -1 = unlimited/unknown). Writes to
// `min`/`max` are CAPTURED (host.occurCapture) as mutation intent and then
// discarded — no layout/pagination effect in D5. This stops
// `subform.occur.min = N` from throwing `cannot set property 'min' of
// undefined` so the script proceeds; the captured intent is for the next
// milestone's bounded apply path.
function makeOccurHandle(id, generation) {
var occ = nullProtoObject();
function defineOccurProp(name) {
Object.defineProperty(occ, name, {
enumerable: true,
configurable: false,
get: function() {
return host.occurRead(id, generation, name);
},
set: function(value) {
var n = (typeof value === "number") ? value : parseInt(value, 10);
if (isNaN(n)) { n = 0; }
host.occurCapture(id, generation, name, n);
}
});
}
defineOccurProp("min");
defineOccurProp("max");
defineOccurProp("initial");
return occ;
}
function makeInstanceManager(id, generation) {
var manager = nullProtoObject();
Object.defineProperty(manager, "count", {
enumerable: true,
configurable: false,
get: function() {
return host.instanceCount(id, generation);
}
});
Object.defineProperty(manager, "setInstances", {
enumerable: true,
configurable: false,
writable: false,
value: function(n) {
return host.instanceSet(id, generation, n);
}
});
Object.defineProperty(manager, "addInstance", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
var newId = host.instanceAdd(id, generation);
return newId < 0 ? null : makeHandle(newId, generation);
}
});
Object.defineProperty(manager, "removeInstance", {
enumerable: true,
configurable: false,
writable: false,
value: function(idx) {
return host.instanceRemove(id, generation, idx);
}
});
return Object.freeze(manager);
}
function makeEmptyInstanceManager() {
var manager = nullProtoObject();
Object.defineProperty(manager, "count", {
enumerable: true,
configurable: false,
value: 0
});
Object.defineProperty(manager, "setInstances", {
enumerable: true,
configurable: false,
writable: false,
value: function() { return 0; }
});
Object.defineProperty(manager, "addInstance", {
enumerable: true,
configurable: false,
writable: false,
value: function() { return null; }
});
Object.defineProperty(manager, "removeInstance", {
enumerable: true,
configurable: false,
writable: false,
value: function() { return false; }
});
return Object.freeze(manager);
}
function uniqueNodeIds(ids) {
var out = [];
if (!ids) return out;
for (var i = 0; i < ids.length; i++) {
var id = ids[i] | 0;
if (id < 0) continue;
var seen = false;
for (var j = 0; j < out.length; j++) {
if (out[j] === id) {
seen = true;
break;
}
}
if (!seen) out.push(id);
}
return out;
}
function nodeIdListArg(ids) {
return uniqueNodeIds(ids).join(",");
}
function resolveHandleChildIds(ids, prop) {
var list = nodeIdListArg(ids);
if (list.length === 0) return [];
var childIds = uniqueNodeIds(host.resolveChildNodeIds(list, prop));
if (childIds.length > 0) return childIds;
return uniqueNodeIds(host.resolveScopedNodeIds(list, prop));
}
function makeNodeHandleFromIds(ids, generation) {
var unique = uniqueNodeIds(ids);
if (unique.length === 0) return undefined;
if (unique.length === 1) return makeHandle(unique[0], generation);
return makeCandidateSet(unique, generation);
}
// Phase D-θ: single-segment SOM lookahead. The proxy chain `F.P1.X.rawValue`
// can resolve the leading `F.P1` correctly yet pick the wrong `P1` when
// multiple same-named siblings exist — the resolver lacks the next-segment
// context needed to disambiguate. These helpers return a one-step deferred
// wrapper that, on its next property access, asks the host's hinted
// resolver to drop candidates whose subtree does not contain the lookahead
// name. Terminal properties (rawValue, instanceManager, …) bypass the hint
// path entirely so single-token reads remain byte-for-byte identical.
function isTerminalHandleProp(name) {
if (handlePropertyExclusions[name] === true) return true;
if (reservedHandleProperties[name] === true) return true;
if (name.charAt(0) === "_") return true;
switch (name) {
case "rawValue":
case "somExpression":
case "instanceManager":
case "index":
case "setInstances":
case "addInstance":
case "removeInstance":
case "isNull":
case "clearItems":
case "addItem":
case "boundItem":
case "#items":
case "$record":
case "variables":
case "nodes":
case "length":
case "value":
case "item":
return true;
default:
return false;
}
}
function makeImplicitDeferred(currentId, name, eagerIds, generation) {
function realize(hint) {
if (!hint) return eagerIds;
var refined = uniqueNodeIds(host.resolveImplicitNodeIdsHinted(currentId, name, hint));
return refined.length > 0 ? refined : eagerIds;
}
var sentinel = nullProtoObject();
return new Proxy(sentinel, {
get: function(target, prop, receiver) {
if (typeof prop !== "string") {
return Reflect.get(target, prop, receiver);
}
// XFA-DATA2-02: short-circuit caption / resolveNode pair before any
// host call. See captionSentinel + handleResolveNode definitions.
if (prop === "caption") { return captionSentinel; }
if (prop === "resolveNode") { return handleResolveNode; }
if (prop === "resolveNodes") { return handleResolveNodes; }
var ids = isTerminalHandleProp(prop) ? eagerIds : realize(prop);
var handle = makeNodeHandleFromIds(ids, generation);
if (handle === undefined) return undefined;
return handle[prop];
},
set: function(_target, prop, value) {
var handle = makeNodeHandleFromIds(eagerIds, generation);
if (handle === undefined) return true;
handle[prop] = value;
return true;
},
has: function(_target, prop) {
if (typeof prop !== "string") return Reflect.has(sentinel, prop);
return true;
}
});
}
function makeChildDeferred(parentIds, childName, eagerChildIds, generation) {
var parentList = nodeIdListArg(parentIds);
function realize(hint) {
if (!hint || parentList.length === 0) return eagerChildIds;
var refined = uniqueNodeIds(host.resolveChildNodeIdsHinted(parentList, childName, hint));
return refined.length > 0 ? refined : eagerChildIds;
}
var sentinel = nullProtoObject();
return new Proxy(sentinel, {
get: function(target, prop, receiver) {
if (typeof prop !== "string") {
return Reflect.get(target, prop, receiver);
}
// XFA-DATA2-02: short-circuit caption / resolveNode pair before any
// host call. See captionSentinel + handleResolveNode definitions.
if (prop === "caption") { return captionSentinel; }
if (prop === "resolveNode") { return handleResolveNode; }
if (prop === "resolveNodes") { return handleResolveNodes; }
var ids = isTerminalHandleProp(prop) ? eagerChildIds : realize(prop);
var handle = makeNodeHandleFromIds(ids, generation);
if (handle === undefined) return undefined;
return handle[prop];
},
set: function(_target, prop, value) {
var handle = makeNodeHandleFromIds(eagerChildIds, generation);
if (handle === undefined) return true;
handle[prop] = value;
return true;
},
has: function(_target, prop) {
if (typeof prop !== "string") return Reflect.has(sentinel, prop);
return true;
}
});
}
function makeChainHandle(parentIds, childName, generation) {
var eagerChildIds = resolveHandleChildIds(parentIds, childName);
if (eagerChildIds.length === 0) return undefined;
if (eagerChildIds.length === 1) {
// Singleton child with a single parent leaves the host resolver no
// alternatives to filter even when a hint is provided — same-name
// sibling disambiguation is impossible. Skip the wrapper so the
// common path keeps its original Proxy identity and resolve budget.
return makeHandle(eagerChildIds[0], generation);
}
// Phase D-θ.2: same-name siblings exist; defer through the full-chain
// accumulator so a deeper segment (still unread) can disambiguate.
return makeChainProxy(parentIds, [childName], eagerChildIds, generation, -1);
}
// Phase D-θ.2: lazy chain proxy. Accumulates a SOM chain (string[]) as the
// script walks property access; on the first terminal property read it
// asks the host to resolve the FULL accumulated chain in one call,
// letting the host backtrack and pick the same-name candidate at any
// depth whose subtree actually completes the chain.
//
// Compared to D-θ.1 (single-segment hint), this lets `A.B.C.D.rawValue`
// pick the correct same-name `A` not only when one is the parent of `B`
// but specifically the one whose subtree contains the full B.C.D chain.
//
// Parameters:
// parentIds — entry parent set; empty array means implicit walk.
// chain — string[] of segments accumulated so far (>=1).
// eagerIds — fallback ids returned if the host's full-chain
// resolve produces no candidate at any depth
// (preserves D-θ.1 byte-for-byte behaviour for
// chains the host cannot improve on).
// generation — handle generation to bind to.
// currentId — implicit-walk anchor (used when parentIds is empty).
//
// Terminal property names trigger immediate resolution. Non-terminal
// string property names return a NEW chain proxy with the property name
// appended; no host call happens at that point.
function makeChainProxy(parentIds, chain, eagerIds, generation, currentId) {
var sentinel = nullProtoObject();
function fullChain(extraSeg) {
var parentCsv = nodeIdListArg(parentIds);
var chainArr = chain;
if (extraSeg !== undefined) {
chainArr = chain.slice();
chainArr.push(extraSeg);
}
var chainCsv = chainArr.join(",");
var resolved = uniqueNodeIds(
host.resolveWithFullChain(parentCsv, currentId | 0, chainCsv)
);
if (resolved.length === 0) return eagerIds;
return resolved;
}
return new Proxy(sentinel, {
get: function(target, prop, receiver) {
if (typeof prop !== "string") {
// Symbol.toPrimitive and similar non-string keys must force
// terminal resolution so coercion (e.g. via Number(handle)) sees
// the resolved candidate's primitive view rather than the proxy
// sentinel itself.
var primIds = fullChain();
var primHandle = makeNodeHandleFromIds(primIds, generation);
if (primHandle === undefined) {
return Reflect.get(target, prop, receiver);
}
return primHandle[prop];
}
// XFA-DATA2-02: caption / resolveNode / resolveNodes short-circuit on
// the chain proxy. Without this branch the chain accumulator would
// append "caption" as a non-terminal segment, call
// resolveWithFullChainStrict, miss, and return `undefined` —
// `field.caption.value` then TypeErrors. Same null/empty-list
// contract as the global pair.
if (prop === "caption") {
return captionSentinel;
}
if (prop === "resolveNode") {
return handleResolveNode;
}
if (prop === "resolveNodes") {
return handleResolveNodes;
}
if (isTerminalHandleProp(prop)) {
var ids = fullChain();
var handle = makeNodeHandleFromIds(ids, generation);
if (handle === undefined) return undefined;
return handle[prop];
}
// Non-terminal: probe the extended chain in STRICT mode. If the
// host cannot complete `[chain..., prop]` at full depth anywhere
// in the form, return `undefined` — mirroring the eager D-θ.1
// behaviour where `A.B` resolved to `undefined` when no `A.B`
// existed. When the probe succeeds, return a new chain proxy
// with the segment appended; the lazy accumulation lets a
// deeper segment still disambiguate which same-name `A` to keep.
//
// D-θ.2 probe-skip: when there is exactly one parent and the chain
// so far has exactly one segment, no same-name sibling ambiguity is
// possible — the parent uniquely identifies the node, and there are
// no alternative subtrees for the host to choose between. In that
// case the strict probe would always return the same set as eagerIds,
// so we skip the host round-trip and immediately build the next proxy
// using eagerIds as the fallback. The host-call budget is preserved
// and disambiguation is unaffected because a single-parent / single-
// segment chain cannot be disambiguated further by the host anyway.
var nextChain = chain.slice();
nextChain.push(prop);
if (parentIds.length === 1 && chain.length === 1) {
host.probeSkip();
return makeChainProxy(parentIds, nextChain, eagerIds, generation, currentId);
}
var probe = host.resolveWithFullChainStrict(
nodeIdListArg(parentIds),
currentId | 0,
nextChain.join(",")
);
if (!probe || probe.length === 0) {
return undefined;
}
// The probe result becomes the new eager-fallback so future
// probes that the host cannot improve on still degrade
// gracefully. We must NOT collapse to a single makeHandle here:
// the chain may continue to grow and disambiguate further.
var nextEager = uniqueNodeIds(probe);
return makeChainProxy(parentIds, nextChain, nextEager, generation, currentId);
},
set: function(_target, prop, value) {
// Writes are always terminal: resolve the full chain and forward.
var ids = fullChain();
var handle = makeNodeHandleFromIds(ids, generation);
if (handle === undefined) return true;
handle[prop] = value;
return true;
},
has: function(_target, prop) {
if (typeof prop !== "string") return Reflect.has(sentinel, prop);
return true;
}
});
}
function makeCandidateSet(ids, generation) {
var candidates = uniqueNodeIds(ids);
if (candidates.length === 0) return undefined;
var firstId = candidates[0];
var obj = nullProtoObject();
return new Proxy(obj, {
get: function(target, prop, receiver) {
if (typeof prop !== "string") {
return Reflect.get(target, prop, receiver);
}
if (prop === "rawValue") {
var value = host.getRawValue(firstId, generation);
return value === undefined ? null : value;
}
if (prop === "presence" && host.isInteractive()) {
return host.getPresence(firstId, generation);
}
if (prop === "somExpression") {
return "xfa[0].form[0].placeholder";
}
if (prop === "instanceManager") {
return makeInstanceManager(firstId, generation);
}
if (prop === "occur") {
host.occurResolve(firstId, generation);
return makeOccurHandle(firstId, generation);
}
if (prop === "index") {
return host.nodeIndex(firstId, generation);
}
// XFA-DATA-M3C: `<candidateSet>.parent` walks one form-tree level up
// from the leading candidate. Same semantics as `<handle>.parent`;
// see makeHandle for rationale.
if (prop === "parent") {
var parentId = host.parentOfNode(firstId, generation);
if (parentId < 0) return undefined;
return makeHandle(parentId, generation);
}
if (prop === "setInstances") {
return function(n) {
return host.instanceSet(firstId, generation, n);
};
}
if (prop === "addInstance") {
return function() {
var newId = host.instanceAdd(firstId, generation);
// WP-3 F3: return a chainable null-safe sentinel rather than null
// so scripts that immediately access `.index` / `.value` on the
// newly-added handle do not throw when the instance manager
// refuses (min/max reached, unbound).
return newId < 0 ? makeNullDataHandle() : makeHandle(newId, generation);
};
}
if (prop === "removeInstance") {
return function(idx) {
return host.instanceRemove(firstId, generation, idx);
};
}
if (prop === "isNull") {
var raw = host.getRawValue(firstId, generation);
return raw === undefined || raw === null || raw === "";
}
if (prop === "clearItems") {
return function() {
return host.listClear(firstId, generation);
};
}
if (prop === "addItem") {
return function(display, save) {
if (save === undefined) {
return host.listAdd(firstId, generation, String(display));
}
return host.listAdd(firstId, generation, String(display), String(save));
};
}
if (prop === "boundItem") {
return function(displayValue) {
var coerced = displayValue === null || displayValue === undefined ?
"" : String(displayValue);
return host.boundItem(firstId, generation, coerced);
};
}
if (prop === "$record") {
var recRaw = host.dataBoundRecord(firstId, generation);
if (recRaw < 0) return makeNullDataHandle();
return makeDataHandle(recRaw);
}
if (prop === "variables") {
var csNodeName = host.nodeName(firstId, generation);
if (typeof csNodeName === "string" && csNodeName.length > 0 &&
subformVariables[csNodeName] !== undefined) {
return subformVariables[csNodeName];
}
return Object.create(null);
}
if (prop.charAt(0) === "_" && prop.length > 1) {
var bareName = prop.substring(1);
var imIds = uniqueNodeIds(
host.resolveChildNodeIdsQuiet(nodeIdListArg(candidates), bareName)
);
if (imIds.length > 0) {
return makeInstanceManager(imIds[0], generation);
}
for (var imIdx = 0; imIdx < candidates.length; imIdx++) {
if (host.hasZeroInstanceRun(candidates[imIdx], generation, bareName)) {
return makeEmptyInstanceManager();
}
}
// XFA-DATA-M3C: same fallthrough as makeHandle underscore branch —
// expose an empty instanceManager for schema-optional same-named
// siblings that the merged tree omits, keeping schema-bound
// scripts chainable. Restricted to container candidates so
// field/draw candidate sets keep the original `undefined`
// semantics for underscored property reads.
if (host.nodeIsContainer(firstId, generation)) {
return makeEmptyInstanceManager();
}
}
// WP-3: choiceList is a listbox/combobox property not surfaced through
// the FormTree. Return an empty frozen array.
if (prop === "choiceList") {
return Object.freeze([]);
}
// BE-1: `#items` on a candidate-set — mirrors the makeHandle path.
// Uses the first candidate as the representative field id (same
// convention as boundItem / clearItems / addItem above).
if (prop === "#items") {
var csDisplayItems = host.getDisplayItems(firstId, generation);
if (csDisplayItems.length > 0) {
host.somItemsPathHit();
}
return makeItemsSubstitute(csDisplayItems);
}
// XFA-DATA-M3C: `<candidateSet>.ui` returns the widget-config stub.
// See makeUiStub on makeHandle for rationale.
if (prop === "ui") {
return makeUiStub();
}
if (prop === "formattedValue") {
var fvc = host.getRawValue(firstId, generation);
return fvc === undefined || fvc === null ? "" : String(fvc);
}
if (prop === "execEvent") {
return function() { return undefined; };
}
// XFA-DATA2-02: caption / resolveNode / resolveNodes intercept on the
// candidate-set path. Same rationale as makeHandle: skip the child
// resolver host call and its resolve_failures bump, return a frozen
// sentinel + the chainable method pair. Null contract preserved.
if (prop === "caption") {
return captionSentinel;
}
if (prop === "resolveNode") {
return handleResolveNode;
}
if (prop === "resolveNodes") {
return handleResolveNodes;
}
if (shouldDeferHandleProperty(prop)) {
return undefined;
}
return makeChainHandle(candidates, prop, generation);
},
set: function(_target, prop, value) {
// WP-3: `value` is an alias for rawValue on the set side.
if (prop === "rawValue" || prop === "value") {
host.setRawValue(firstId, generation, value);
} else if (prop === "presence" && host.isInteractive()) {
// Phase 2: visibility toggle. Gated so static flatten falls through
// to the historical silent-absorb (return true) — byte-identical.
host.setPresence(firstId, generation, value);
}
return true;
},
has: function(target, prop) {
if (typeof prop !== "string") {
return Reflect.has(target, prop);
}
return prop === "rawValue" ||
prop === "presence" ||
prop === "somExpression" ||
prop === "instanceManager" ||
prop === "index" ||
prop === "parent" ||
prop === "setInstances" ||
prop === "addInstance" ||
prop === "removeInstance" ||
prop === "isNull" ||
prop === "clearItems" ||
prop === "addItem" ||
prop === "boundItem" ||
Reflect.has(target, prop);
}
});
}
// XFA-DATA2-02: shared caption sentinel — singleton, frozen, null-safe.
//
// XFA templates commonly inspect `field.caption.value` or
// `field.caption.text` to drive labels and conditional formatting. The
// merged FormTree does not carry the optional `<caption>` sub-element, so
// looking up `caption` as a normal child name would call
// `resolve_child_candidates` → NoMatch → bump `resolve_failures`, return
// `undefined`, and the very next `.value` access would throw TypeError
// ("cannot read property 'value' of undefined") and abort the rest of the
// script's initializers.
//
// We intercept `caption` BEFORE the child-resolver call and return a
// chainable sentinel whose terminal reads are empty strings and whose
// deeper reads keep chaining via `makeNullDataHandle`. This is purely
// read-side: writes are silently absorbed (caption sub-element is a
// viewer-only field, never a flatten mutation channel). The sentinel is
// allocated once at factory build-time and frozen, so per-call access is
// an `Object.freeze`d Proxy hit with no heap allocation.
var captionSentinel = (function() {
var base = nullProtoObject();
Object.defineProperty(base, "value", { enumerable: true, configurable: false,
get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
Object.defineProperty(base, "text", { enumerable: true, configurable: false,
get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
Object.defineProperty(base, "name", { enumerable: true, configurable: false,
get: function() { return "caption"; } });
Object.defineProperty(base, "rawValue",{ enumerable: true, configurable: false,
get: function() { return ""; }, set: function(_v) { /* viewer-only */ } });
Object.defineProperty(base, "isNull", { enumerable: true, configurable: false,
get: function() { return true; } });
return new Proxy(base, {
get: function(target, prop) {
if (prop in target || typeof prop !== "string") return target[prop];
// Unknown reads stay chainable so `caption.font.typeface` etc. do
// not throw. The sentinel is read-only and write-absorbing.
return makeNullDataHandle();
},
set: function(_t, _p, _v) { return true; },
has: function() { return true; }
});
})();
// XFA-DATA2-02: shared `resolveNode` / `resolveNodes` instance methods.
//
// Beyond the global `xfa.resolveNode(path)`, XFA scripts also call these
// as methods on individual handles (`field.resolveNode("Subform.X")`,
// `subform.resolveNodes("$.field[*]")`). Without an explicit shortcut these
// property reads would fall through to `makeChainHandle`, miss in the
// child resolver and inflate `js_resolve_failures` — the very pattern the
// 60df78fe corpus replay flagged.
//
// Behaviour mirrors the global pair, including the data-path routing for
// `data.`, `$data.`, and `xfa.datasets.data.` prefixes. The Cluster C
// null-return contract is preserved: a miss in the host returns native
// `null` for the singular form and a frozen empty array for the plural.
// BE-1: XFA-compatible substitute for the <items> element returned by
// resolveNode("FieldName.#items"). In a live viewer the result is an XFA
// node whose .nodes collection contains one <text> child per option label;
// each child exposes .value / .rawValue. Scripts iterate:
// for (var i=0; i<items.nodes.length; i++) { items.nodes.item(i).value }
// We materialise that shape from the host display-strings array so existing
// scripts complete without TypeError and can read the label list.
function makeItemsSubstitute(displayStrings) {
var nodes = [];
for (var k = 0; k < displayStrings.length; k++) {
(function(label) {
var textNode = nullProtoObject();
Object.defineProperty(textNode, "value", {
enumerable: true, configurable: false, writable: false, value: label
});
Object.defineProperty(textNode, "rawValue", {
enumerable: false, configurable: false, writable: false, value: label
});
nodes.push(Object.freeze(textNode));
})(displayStrings[k]);
}
var frozenNodes = Object.freeze(nodes);
var nodesCollection = nullProtoObject();
Object.defineProperty(nodesCollection, "length", {
enumerable: true, configurable: false, get: function() { return frozenNodes.length; }
});
Object.defineProperty(nodesCollection, "item", {
enumerable: false, configurable: false, writable: false,
value: function(idx) { return frozenNodes[idx] || null; }
});
var substitute = nullProtoObject();
Object.defineProperty(substitute, "nodes", {
enumerable: true, configurable: false, writable: false, value: Object.freeze(nodesCollection)
});
Object.defineProperty(substitute, "length", {
enumerable: true, configurable: false, get: function() { return frozenNodes.length; }
});
return Object.freeze(substitute);
}
function handleResolveNode(path) {
if (typeof path === "string" &&
(path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
path.indexOf("xfa.datasets.data.") === 0)) {
var rawId = host.dataResolveNode(path);
if (rawId < 0) return null;
return makeDataHandle(rawId);
}
// BE-1: intercept `FieldName.#items` (XFA 3.3 §7.7 / §8.1).
// `#items` is the SOM class reference for a choiceList's <items> element.
// The form-tree resolver has no node for it; we resolve the field part,
// fetch its display labels, and return an XFA-compatible substitute.
// Guard: path.length > 7 prevents false-positive when lastIndexOf returns
// -1 and path.length-7 is also -1 (paths shorter than 8 chars).
if (typeof path === "string" && path.length > 7 &&
path.lastIndexOf(".#items") === path.length - 7) {
var fieldPath = path.substring(0, path.length - 7);
var fid = host.resolveNodeId(fieldPath);
if (fid >= 0) {
var displayItems = host.getDisplayItems(fid, host.generation());
if (displayItems.length > 0) {
host.somItemsPathHit();
}
return makeItemsSubstitute(displayItems);
}
return null;
}
var nid = host.resolveNodeId(path);
if (nid < 0) return null;
return makeHandle(nid, host.generation());
}
function handleResolveNodes(path) {
if (typeof path === "string" &&
(path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
path.indexOf("xfa.datasets.data.") === 0)) {
var rawIds = host.dataResolveNodes(path);
var out = [];
for (var i = 0; i < rawIds.length; i++) out.push(makeDataHandle(rawIds[i]));
return Object.freeze(out);
}
var generation = host.generation();
var ids = host.resolveNodeIds(path);
var out2 = [];
for (var j = 0; j < ids.length; j++) {
out2.push(makeHandle(ids[j], generation));
}
return Object.freeze(out2);
}
function makeHandle(id, generation) {
var obj = nullProtoObject();
Object.defineProperty(obj, "rawValue", {
enumerable: true,
configurable: false,
get: function() {
var value = host.getRawValue(id, generation);
return value === undefined ? null : value;
},
set: function(value) {
host.setRawValue(id, generation, value);
}
});
// Phase 2: presence read/write on a SOM-resolved handle. Gated on
// interactive mode; when off, the read falls through to the historical
// chain-proxy behaviour and the write is silently absorbed (static-flatten
// byte-identity).
Object.defineProperty(obj, "presence", {
// Non-enumerable so the handle surface stays narrow (Object.keys → just
// rawValue); `presence` is still accessible by name.
enumerable: false,
configurable: false,
get: function() {
return host.isInteractive()
? host.getPresence(id, generation)
: makeChainHandle([id], "presence", generation);
},
set: function(value) {
if (host.isInteractive()) {
host.setPresence(id, generation, value);
}
}
});
// Phase C-α: defensive stub. Real Adobe forms call
// `this.somExpression` to obtain the SOM path string. We don't expose
// the real SOM path (introspection capability), but returning a
// placeholder lets viewer-tweak scripts (e.g. acroSOM substr(15))
// proceed without ReferenceError. Mutations via this handle still
// require the rawValue setter, which is the only side-effect channel.
Object.defineProperty(obj, "somExpression", {
enumerable: false,
configurable: false,
get: function() {
return "xfa[0].form[0].placeholder";
}
});
return new Proxy(obj, {
get: function(target, prop, receiver) {
if (typeof prop !== "string") {
return Reflect.get(target, prop, receiver);
}
if (prop === "rawValue" || prop === "presence" || prop === "somExpression") {
return Reflect.get(target, prop, receiver);
}
if (prop === "instanceManager") {
return makeInstanceManager(id, generation);
}
if (prop === "occur") {
host.occurResolve(id, generation);
return makeOccurHandle(id, generation);
}
if (prop === "index") {
return host.nodeIndex(id, generation);
}
// XFA-DATA-M3C: `<handle>.parent` returns the form-tree parent.
// Adobe scripts commonly chain `parent.somExpression`,
// `parent.index`, or `parent._Sibling.setInstances(...)` to walk
// one level up from a field or subform without authoring an
// explicit SOM path.
if (prop === "parent") {
var parentId = host.parentOfNode(id, generation);
if (parentId < 0) return undefined;
return makeHandle(parentId, generation);
}
if (prop === "setInstances") {
return function(n) {
return host.instanceSet(id, generation, n);
};
}
if (prop === "addInstance") {
return function() {
var newId = host.instanceAdd(id, generation);
// WP-3 F3: chainable null-safe sentinel on failure (see candidateSet).
return newId < 0 ? makeNullDataHandle() : makeHandle(newId, generation);
};
}
if (prop === "removeInstance") {
return function(idx) {
return host.instanceRemove(id, generation, idx);
};
}
if (prop === "isNull") {
var value = host.getRawValue(id, generation);
return value === undefined || value === null || value === "";
}
if (prop === "clearItems") {
return function() {
return host.listClear(id, generation);
};
}
if (prop === "addItem") {
return function(display, save) {
if (save === undefined) {
return host.listAdd(id, generation, String(display));
}
return host.listAdd(id, generation, String(display), String(save));
};
}
// XFA 3.3 §App A `boundItem` — listbox display→save lookup. Used as
// `field.boundItem(xfa.event.newText)` to translate a user-visible
// option label into its underlying save value. Falls back to the
// input string when no match exists (Adobe behaviour).
if (prop === "boundItem") {
return function(displayValue) {
var coerced;
if (displayValue === null || displayValue === undefined) {
coerced = "";
} else {
coerced = String(displayValue);
}
return host.boundItem(id, generation, coerced);
};
}
if (prop === "$record") {
var recRaw = host.dataBoundRecord(id, generation);
if (recRaw < 0) return makeNullDataHandle();
return makeDataHandle(recRaw);
}
// Phase D-ι.2: `subformHandle.variables` returns the namespace object
// holding all `<variables><script>` entries registered for this
// subform by name. Enables `Page2.variables.ValidationScript.fn()`.
if (prop === "variables") {
var nodeName = host.nodeName(id, generation);
if (typeof nodeName === "string" && nodeName.length > 0 &&
subformVariables[nodeName] !== undefined) {
return subformVariables[nodeName];
}
return Object.create(null);
}
// XFA 3.3 §6.4.3.2 underscore shorthand: `_<name>` on a subform
// refers to the instanceManager of the same-named child subform.
// Used in the wild as `parent._child.setInstances(N)`. This MUST
// run before `shouldDeferHandleProperty`, which otherwise returns
// `undefined` for every underscore-prefixed property and makes the
// shorthand unreachable for real bound subforms.
if (prop.charAt(0) === "_" && prop.length > 1) {
var bareName = prop.substring(1);
var imChildIds = uniqueNodeIds(host.resolveChildNodeIdsQuiet(String(id), bareName));
if (imChildIds.length > 0) {
return makeInstanceManager(imChildIds[0], generation);
}
if (host.hasZeroInstanceRun(id, generation, bareName)) {
return makeEmptyInstanceManager();
}
// XFA-DATA-M3C: when the same-named child subform is absent from
// the merged form tree (occur.initial=0, optional bind, or
// never-instantiated schema option), Adobe still hands the
// script an empty instanceManager so `parent._Foo.setInstances(N)`
// is a chainable no-op. This sentinel is ONLY safe on container
// handles (root, subform, area, subformSet, exclGroup) — XFA
// 3.3 §6.4.3.2 limits the underscore-shorthand to those node
// classes. Fields and draws stay `undefined` so existing
// narrow-handle tests (m3b_phaseC_bindings::field_handle_is_frozen_and_narrow)
// keep their property-isolation contract.
if (host.nodeIsContainer(id, generation)) {
return makeEmptyInstanceManager();
}
}
// WP-3: choiceList is a listbox/combobox property not surfaced through
// the FormTree. Return an empty frozen array.
if (prop === "choiceList") {
return Object.freeze([]);
}
// XFA-DATA-M3C: `<handle>.ui` exposes the widget-config sub-object.
// We materialise a chainable stub (see makeUiStub) so scripts like
// `field.ui.choiceList.commitOn = "exit"` complete without throwing.
if (prop === "ui") {
return makeUiStub();
}
// XFA-DATA-M3C: `<handle>.formattedValue` (Adobe SDK §JS A) reads
// the field value formatted by its picture clause. Static flatten
// has no live picture-clause formatter; return the raw value so
// scripts can compare-and-branch without TypeError.
if (prop === "formattedValue") {
var fv = host.getRawValue(id, generation);
return fv === undefined || fv === null ? "" : String(fv);
}
// XFA-DATA-M3C: `<handle>.execEvent("activity")` (Adobe SDK) fires
// an event handler. Static flatten cannot dispatch new events
// mid-script; absorb the call as a no-op returning undefined.
if (prop === "execEvent") {
return function() { return undefined; };
}
// XFA-DATA2-02: caption sub-element + resolveNode/resolveNodes
// instance methods. Intercepted BEFORE shouldDeferHandleProperty +
// makeChainHandle so the child-resolver host call (and its
// resolve_failures bump) never happens. See captionSentinel /
// handleResolveNode/Nodes definitions above for rationale.
if (prop === "caption") {
return captionSentinel;
}
if (prop === "resolveNode") {
return handleResolveNode;
}
if (prop === "resolveNodes") {
return handleResolveNodes;
}
// BE-1: `#items` — XFA 3.3 §7.7 / §8.1 choiceList item labels.
// Scripts access `fieldHandle.#items` (e.g. `Wojewodztwo.#items`) to
// read the display-label collection. Returns an XFA-compatible
// substitute (makeItemsSubstitute) so scripts can use both direct
// array indexing and the `.nodes` / `.nodes.item(i)` access pattern.
// `#` bypasses `shouldDeferHandleProperty` via `handlePropertyExclusions`.
if (prop === "#items") {
var displayItems = host.getDisplayItems(id, generation);
if (displayItems.length > 0) {
host.somItemsPathHit();
}
return makeItemsSubstitute(displayItems);
}
if (shouldDeferHandleProperty(prop)) {
return undefined;
}
return makeChainHandle([id], prop, generation);
},
set: function(_target, prop, value) {
// WP-3: `value` is an alias for rawValue on the set side.
if (prop === "rawValue" || prop === "value") {
host.setRawValue(id, generation, value);
} else if (prop === "presence" && host.isInteractive()) {
host.setPresence(id, generation, value);
}
return true;
},
has: function(target, prop) {
if (typeof prop !== "string") {
return Reflect.has(target, prop);
}
return prop === "rawValue" ||
prop === "presence" ||
prop === "somExpression" ||
prop === "instanceManager" ||
prop === "index" ||
prop === "parent" ||
prop === "ui" ||
prop === "formattedValue" ||
prop === "execEvent" ||
prop === "setInstances" ||
prop === "addInstance" ||
prop === "removeInstance" ||
prop === "isNull" ||
prop === "clearItems" ||
prop === "addItem" ||
prop === "boundItem" ||
prop === "caption" ||
prop === "resolveNode" ||
prop === "resolveNodes" ||
prop === "#items" ||
Reflect.has(target, prop);
}
});
}
// Phase C-α: viewer-stub that absorbs property writes silently.
// Used as the return value of `event.target.getField()` so
// AcroForm widget-tweak scripts (`field.doNotScroll = true`,
// `field.required = false`, etc.) complete without error and
// without mutating any flatten-relevant state.
function makeViewerStub() {
return new Proxy({}, {
get: function(_t, _prop) { return undefined; },
set: function(_t, _prop, _val) {
return true;
},
has: function() { return true; }
});
}
function toListIndex(value) {
var n = Number(value);
if (!isFinite(n) || n < 0) return -1;
return Math.floor(n);
}
// XFA §8.5: a `$record` reference when there is no bound data node must return
// an empty object that chains safely (`.value` → null, `.nodes.length` → 0)
// rather than null, which would throw TypeError on any property access.
function makeNullDataHandle() {
var emptyNodes = [];
// XFA null-safe: item() on an empty sentinel list returns a chainable null handle,
// not native null, so callers can do nodes.item(0).value without TypeError.
emptyNodes.item = function() { return makeNullDataHandle(); };
Object.freeze(emptyNodes);
var sentinel = nullProtoObject();
// WP-3: configurable:true so the Proxy set-trap can return true without
// triggering the ECMAScript "non-configurable accessor without setter" invariant.
Object.defineProperty(sentinel, "value",
{ get: function() { return null; }, enumerable: true, configurable: true });
Object.defineProperty(sentinel, "rawValue",
{ get: function() { return null; }, enumerable: true, configurable: true });
Object.defineProperty(sentinel, "length",
{ get: function() { return 0; }, enumerable: true, configurable: true });
Object.defineProperty(sentinel, "nodes",
{ get: function() { return emptyNodes; }, enumerable: true, configurable: true });
// WP-3: instance.index on an unbound node → 0
Object.defineProperty(sentinel, "index",
{ get: function() { return 0; }, enumerable: true, configurable: true });
sentinel.item = function() { return makeNullDataHandle(); };
return new Proxy(sentinel, {
get: function(target, prop) {
if (prop in target) return target[prop];
if (typeof prop !== "string") return undefined;
return makeNullDataHandle();
},
// WP-3: absorb all writes — null data handles are read-only sentinels.
set: function(_target, _prop, _value) {
return true;
}
});
}
// BE-1 tranche #1: benign "absent declared node" façade. Returned by the
// implicit-globals `lookup` for a bare identifier that fails the scope
// resolve but names a template-declared container (gated by the host
// `isDeclaredAbsentNode`). Mirrors Adobe semantics: a reference to a
// declared-but-absent node is an EMPTY node — `isNull === true`, `rawValue`/
// `value` null, empty `nodes`, an empty `instanceManager`, and every further
// SOM segment chains to another empty node. Writes are absorbed. This lets a
// guarded script (`if (!Sub.Child.Field.isNull) {…} else {…}`) evaluate the
// guard to `false` and run its else branch (e.g. `_X.setInstances(0)`,
// `presence = "invisible"`) instead of throwing on `undefined`. Modelled on
// `makeNullDataHandle`; the explicit boolean `isNull` is essential — without
// it, `.isNull` would chain to a truthy façade object and invert the guard.
function makeAbsentNodeHandle() {
var sentinel = nullProtoObject();
Object.defineProperty(sentinel, "isNull",
{ get: function() { return true; }, enumerable: true, configurable: true });
Object.defineProperty(sentinel, "value",
{ get: function() { return null; }, enumerable: true, configurable: true });
Object.defineProperty(sentinel, "rawValue",
{ get: function() { return null; }, enumerable: true, configurable: true });
Object.defineProperty(sentinel, "length",
{ get: function() { return 0; }, enumerable: true, configurable: true });
var emptyNodes = [];
emptyNodes.item = function() { return makeAbsentNodeHandle(); };
Object.freeze(emptyNodes);
Object.defineProperty(sentinel, "nodes",
{ get: function() { return emptyNodes; }, enumerable: true, configurable: true });
Object.defineProperty(sentinel, "instanceManager",
{ get: function() { return makeEmptyInstanceManager(); }, enumerable: true, configurable: true });
sentinel.item = function() { return makeAbsentNodeHandle(); };
return new Proxy(sentinel, {
get: function(target, prop) {
if (prop in target) return target[prop];
if (typeof prop !== "string") return undefined;
// `_<Name>` underscore-shorthand → an empty instanceManager, so chained
// `Sub._Child.setInstances(n)` on an absent node is a safe no-op.
if (prop.charAt(0) === "_" && prop.length > 1) return makeEmptyInstanceManager();
// any further SOM segment chains to another empty node.
return makeAbsentNodeHandle();
},
// absorb writes (presence, rawValue, mandatory, …) — empty nodes are inert.
set: function(_target, _prop, _value) { return true; }
});
}
// Phase D-γ: Data DOM handle — wraps a raw DataDom node index and exposes
// `.value`, `.nodes`, `.length`, `.item(i)`, and named child access via Proxy.
function makeDataHandle(rawId) {
// WP-3: return null-safe sentinel so callers can chain .value / .nodes safely.
if (rawId === undefined || rawId < 0) return makeNullDataHandle();
var handle = nullProtoObject();
Object.defineProperty(handle, "value", {
get: function() {
var v = host.dataValue(rawId);
return (v === undefined || v === null) ? null : v;
},
enumerable: true, configurable: false
});
// rawValue is an alias for value — scripts use both forms on data handles.
Object.defineProperty(handle, "rawValue", {
get: function() {
var v = host.dataValue(rawId);
return (v === undefined || v === null) ? null : v;
},
enumerable: true, configurable: false
});
Object.defineProperty(handle, "length", {
get: function() { return host.dataChildren(rawId).length; },
enumerable: true, configurable: false
});
Object.defineProperty(handle, "nodes", {
get: function() {
var ids = host.dataChildren(rawId);
var arr = [];
for (var i = 0; i < ids.length; i++) arr.push(makeDataHandle(ids[i]));
// Phase D-γ fix: XFA scripts call `nodeList.item(i)` on the array
// returned by `.nodes`. Plain JS arrays have no `.item()` method —
// add one that mirrors the W3C NodeList API. Out-of-bounds indices
// return a null-safe sentinel so `.value` access never throws.
var NULLNODE = Object.freeze({ value: null, rawValue: null });
arr.item = function(idx) {
var index = toListIndex(idx);
if (index < 0 || index >= arr.length) return NULLNODE;
return arr[index];
};
return Object.freeze(arr);
},
enumerable: true, configurable: false
});
handle.item = function(i) {
var ids = host.dataChildren(rawId);
var index = toListIndex(i);
if (index < 0 || index >= ids.length) return null;
return makeDataHandle(ids[index]);
};
return new Proxy(handle, {
get: function(target, prop) {
if (prop in target || typeof prop !== "string") return target[prop];
if (prop === "rawValue" || prop === "value") return target.value;
var childId = host.dataChildByName(rawId, prop);
if (childId < 0) return makeNullDataHandle();
return makeDataHandle(childId);
}
});
}
// Phase E (XFA-JS-HOST-STUBS): `xfa.host` is the most heavily used Adobe
// Reader viewer namespace. The previous frozen stub only exposed
// `numPages` + `messageBox`, so initializer scripts that touch
// `xfa.host.title = "..."`, `xfa.host.openList(...)`, `xfa.host.beep()`,
// etc. raised TypeError on the very first property access and inflated
// `runtime_errors`. We expose a Proxy that:
// * resolves known read-only viewer properties to deterministic defaults
// (numPages, version, language, platform, name, title, validationsEnabled,
// calculationsEnabled, currentPage, pageCount);
// * absorbs every other write silently (viewer-only state has no flatten
// side-effects);
// * returns a null-safe sentinel for unknown reads so chains like
// `xfa.host.someVendorExt.message` don't TypeError;
// * exposes the interactive function family (messageBox, openList, beep,
// response, print, gotoURL, setFocus, exportData, importData,
// resetData) as safe-default thunks that ALSO bump the
// `unsupported_host_calls` counter so dispatch keeps observability
// without claiming a fake success (no "user clicked OK" lies).
// No filesystem / network / process syscalls reach the host — every
// interactive thunk is a pure JS no-op that delegates to host.unsupported
// for accounting only. See benchmarks/JS_SANDBOX_SECURITY_AUDIT.md.
var XFA_HOST_INTERACTIVE_CALLS = {
"messageBox": { kind: "ret", value: 0 },
"openList": { kind: "ret", value: -1 },
"beep": { kind: "ret", value: undefined },
"response": { kind: "ret", value: "" },
"print": { kind: "ret", value: undefined },
"gotoURL": { kind: "ret", value: undefined },
"setFocus": { kind: "ret", value: undefined },
"exportData": { kind: "ret", value: undefined },
"importData": { kind: "ret", value: undefined },
"resetData": { kind: "ret", value: undefined },
"documentCountInBatch": { kind: "ret", value: 1 },
"documentInBatch": { kind: "ret", value: 0 },
// JS2-01 (Sprint 2 Batch B): closeDoc is referenced by Canadian IMM
// and Quebec dol4n templates as an interactive viewer entry point.
// Static flatten cannot dismiss a document; safe-default undefined
// with `unsupported_host_calls` accounting keeps observability without
// a TypeError. Pure JS no-op — no filesystem / network reach the host.
"closeDoc": { kind: "ret", value: undefined }
};
// Read-side defaults for viewer-readable host properties. These are pure
// accessors — no host call leaves the sandbox. The values mirror Adobe
// Reader behaviour during static, non-interactive rendering.
function makeXfaHostBase() {
var base = nullProtoObject();
Object.defineProperty(base, "numPages", {
enumerable: true, configurable: false,
get: function() { return host.numPages(); }
});
Object.defineProperty(base, "currentPage", {
enumerable: true, configurable: true,
get: function() { return 0; },
set: function(_v) { /* viewer-only */ }
});
Object.defineProperty(base, "pageCount", {
enumerable: true, configurable: false,
get: function() { return host.numPages(); }
});
// Static deterministic identity strings. We deliberately avoid claiming
// Acrobat compatibility — scripts that branch on `xfa.host.name` for
// Adobe-specific behaviour should fall through to a non-Acrobat path.
var SCALAR_DEFAULTS = {
"version": "PDFluent-XFA",
"language": "ENU",
"platform": "PDFluent",
"name": "PDFluent",
"title": "",
"appType": "Reader",
"variation": "Reader",
"calculationsEnabled": true,
"validationsEnabled": true,
"runtimeHighlight": false,
"runtimeHighlightColor": "",
"viewerType": "PDFluent",
"fullScreen": false
};
Object.keys(SCALAR_DEFAULTS).forEach(function(k) {
var v = SCALAR_DEFAULTS[k];
Object.defineProperty(base, k, {
enumerable: true, configurable: true,
get: function() { return v; },
set: function(_v) { /* viewer-only, silently absorb */ }
});
});
return base;
}
// Install interactive function thunks. Each thunk increments the
// unsupported-host-call counter and returns a deterministic safe default.
function installXfaHostInteractive(base) {
Object.keys(XFA_HOST_INTERACTIVE_CALLS).forEach(function(name) {
var spec = XFA_HOST_INTERACTIVE_CALLS[name];
Object.defineProperty(base, name, {
enumerable: true, configurable: false, writable: false,
value: function() {
host.unsupportedHostCall(name);
return spec.value;
}
});
});
}
var xfaHostBase = makeXfaHostBase();
installXfaHostInteractive(xfaHostBase);
// Wrap in a Proxy so unknown property reads return a null-safe sentinel
// and unknown writes silently absorb. This is the "no TypeError" guarantee
// for vendor-specific xfa.host extensions referenced by templated scripts.
var xfaHost = new Proxy(xfaHostBase, {
get: function(target, prop) {
if (prop in target || typeof prop !== "string") return target[prop];
// Unknown read — never throw, never claim a value. Sentinel only.
return makeNullDataHandle();
},
set: function(_t, _p, _v) { return true; },
has: function() { return true; }
});
var xfaLayout = nullProtoObject();
Object.defineProperty(xfaLayout, "pageCount", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
return host.numPages();
}
});
// Phase D-γ: xfa.layout.page(node) — page number (1-based) of a form node.
// During static flatten the layout is not yet run, so return a bounded
// placeholder and mark the metadata as approximate.
Object.defineProperty(xfaLayout, "page", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
host.resolveFailure();
return 1;
}
});
// Phase D-γ: xfa.layout.pageSpan(node) — number of pages a node spans.
// Always 1 during static flatten; metadata records the approximation.
Object.defineProperty(xfaLayout, "pageSpan", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
host.resolveFailure();
return 1;
}
});
Object.defineProperty(xfaLayout, "absPage", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
host.bindingError();
return null;
}
});
var xfa = nullProtoObject();
Object.defineProperty(xfa, "host", {
enumerable: true,
configurable: false,
writable: false,
// Note: deliberately NOT Object.freeze'd. `xfaHost` is already a Proxy
// with sealed semantics (writes absorbed, unknown reads return sentinels).
// Freezing would force every Set trap to throw under strict-mode scripts.
value: xfaHost
});
Object.defineProperty(xfa, "layout", {
enumerable: true,
configurable: false,
writable: false,
value: Object.freeze(xfaLayout)
});
// Phase E (XFA-JS-HOST-STUBS): viewer / interaction sub-namespaces. Each
// is a Proxy that silently absorbs writes and returns chainable sentinels
// on read so scripts can complete:
//
// xfa.viewer — Reader UI state (zoom, scrollbar, toolbar, ...)
// xfa.appState — Reader-wide preference cache
// xfa.appearanceFilter — accessibility / high-contrast hints
// xfa.connection — `<connection>` outbound data binding stubs
// xfa.signature — interactive digital-signature panel
// xfa.aliasNode — script-time DOM alias rebinding
// xfa.form — top-level FormDOM root reference (we expose the
// same resolveNode/resolveNodes pair so the script
// feels uniform; legacy property reads sentinel)
//
// These never call out to the host other than for accounting; no
// filesystem / network / process syscalls reach the host bindings. Reads
// for known property names return spec-conforming defaults; everything
// else falls back to the null-safe sentinel chain.
function makeViewerStubNamespace(staticReads) {
var base = nullProtoObject();
if (staticReads) {
Object.keys(staticReads).forEach(function(k) {
var v = staticReads[k];
Object.defineProperty(base, k, {
enumerable: true, configurable: true,
get: function() { return v; },
set: function(_v) { /* viewer-only — silent absorb */ }
});
});
}
return new Proxy(base, {
get: function(target, prop) {
if (prop in target || typeof prop !== "string") return target[prop];
return makeNullDataHandle();
},
set: function(_t, _p, _v) { return true; },
has: function() { return true; }
});
}
function makeInteractiveStubNamespace(funcs) {
// funcs: { "sign": defaultRet, "verify": defaultRet, ... }
var base = nullProtoObject();
Object.keys(funcs).forEach(function(name) {
var def = funcs[name];
Object.defineProperty(base, name, {
enumerable: true, configurable: false, writable: false,
value: function() {
host.unsupportedHostCall(name);
return def;
}
});
});
return new Proxy(base, {
get: function(target, prop) {
if (prop in target || typeof prop !== "string") return target[prop];
return makeNullDataHandle();
},
set: function(_t, _p, _v) { return true; },
has: function() { return true; }
});
}
Object.defineProperty(xfa, "viewer", {
enumerable: true, configurable: false, writable: false,
value: makeViewerStubNamespace({
"zoomType": "FitWidth",
"zoom": 100,
"scrollbar": "auto",
"toolbar": true,
"menubar": true,
"statusbar": true
})
});
Object.defineProperty(xfa, "appState", {
enumerable: true, configurable: false, writable: false,
value: makeViewerStubNamespace({
"highlightRequiredFields": false,
"fieldHighlightColor": "",
"ariaEnabled": false
})
});
Object.defineProperty(xfa, "appearanceFilter", {
enumerable: true, configurable: false, writable: false,
value: makeViewerStubNamespace(null)
});
Object.defineProperty(xfa, "aliasNode", {
enumerable: true, configurable: false, writable: false,
value: makeViewerStubNamespace(null)
});
Object.defineProperty(xfa, "connection", {
enumerable: true, configurable: false, writable: false,
// `<connection>` calls hit a real service endpoint in Adobe Reader.
// Static flatten cannot honour that — every method is unsupported.
value: makeInteractiveStubNamespace({
"execute": null,
"open": null,
"close": null,
"send": null
})
});
Object.defineProperty(xfa, "signature", {
enumerable: true, configurable: false, writable: false,
// Digital signatures require an interactive certificate picker.
value: makeInteractiveStubNamespace({
"sign": false,
"verify": "unknown",
"enumerate": ""
})
});
// WP-3 F3: `xfa.validate` is a viewer-only namespace controlling form-wide
// validation behaviour (Reader UI prompts on field constraint violations).
// Adobe initializers commonly do `xfa.validate.override = 0` /
// `xfa.validate.max = N` to suppress modal dialogs that have no meaning
// in a static flatten context. Expose a Proxy that silently absorbs every
// property write (`override`, `max`, `messageMode`, etc.) and returns
// empty defaults on read, so scripts complete without TypeError.
Object.defineProperty(xfa, "validate", {
enumerable: true,
configurable: false,
writable: false,
value: new Proxy(nullProtoObject(), {
get: function(_t, prop) {
if (prop === "override" || prop === "max") return 0;
if (prop === "messageMode") return "";
if (typeof prop !== "string") return undefined;
// Unknown reads return a chainable null-safe sentinel so deeper
// access like `xfa.validate.scriptTest.message` does not throw.
return makeNullDataHandle();
},
set: function(_t, _prop, _value) { return true; },
has: function() { return true; }
})
});
Object.defineProperty(xfa, "resolveNode", {
enumerable: true,
configurable: false,
writable: false,
value: function(path) {
// Phase D-γ: data paths are routed to the DataDom, not the FormTree.
if (typeof path === "string" &&
(path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
path.indexOf("xfa.datasets.data.") === 0)) {
var rawId = host.dataResolveNode(path);
if (rawId < 0) return null;
return makeDataHandle(rawId);
}
// BE-1: intercept `FieldName.#items` — same logic as handleResolveNode.
// Scripts call `xfa.resolveNode("Wojewodztwo.#items")` to get the items
// collection for a choiceList; the form-tree SOM cannot resolve `#items`
// as a node, so we resolve the field, fetch its display labels, and
// return an XFA-compatible substitute (makeItemsSubstitute).
// Guard: path.length > 7 prevents false-positive match when
// lastIndexOf returns -1 and path.length-7 is also -1.
if (typeof path === "string" && path.length > 7 &&
path.lastIndexOf(".#items") === path.length - 7) {
var fieldPath = path.substring(0, path.length - 7);
var fid = host.resolveNodeId(fieldPath);
if (fid >= 0) {
var xrDisplayItems = host.getDisplayItems(fid, host.generation());
if (xrDisplayItems.length > 0) {
host.somItemsPathHit();
}
return makeItemsSubstitute(xrDisplayItems);
}
return null;
}
var id = host.resolveNodeId(path);
if (id < 0) {
return null;
}
return makeHandle(id, host.generation());
}
});
// JS2-01 (Sprint 2 Batch B): `xfa.form` is the FormDOM root surface
// Adobe Reader exposes (XFA 3.3 §6.1.4 — the result of merging template
// + datasets). The previous build only commented that it should be
// exposed (rquickjs_backend.rs:2477) but no actual binding existed —
// scripts that did `xfa.form.resolveNode("Form1.Subform1")` or
// `form.resolveNode(...)` ran into "cannot read property of undefined"
// or "form is not defined". We expose a Proxy that:
// * forwards `resolveNode` / `resolveNodes` to the existing
// `xfa.resolveNode` / `xfa.resolveNodes` pair (cluster C contract
// preserved — missing paths still return `null`, not a sentinel);
// * absorbs unknown property writes (viewer-only flags) silently;
// * returns a chainable null-safe sentinel for unknown reads.
// No new Rust closure is registered; the binding is pure JS routing.
function makeFormNamespace() {
var base = nullProtoObject();
Object.defineProperty(base, "resolveNode", {
enumerable: true, configurable: false, writable: false,
value: function(path) { return xfa.resolveNode(path); }
});
Object.defineProperty(base, "resolveNodes", {
enumerable: true, configurable: false, writable: false,
value: function(path) { return xfa.resolveNodes(path); }
});
// Adobe's `form` exposes `recalculate(true/false)` as a no-op trigger
// for the calculation cascade. Templates pass `1` (force) commonly.
// Static flatten already ran calculate scripts — accept the call,
// return undefined, no counter (it is not an interactive prompt).
Object.defineProperty(base, "recalculate", {
enumerable: true, configurable: false, writable: false,
value: function() { return undefined; }
});
Object.defineProperty(base, "execValidate", {
enumerable: true, configurable: false, writable: false,
value: function() { return true; }
});
Object.defineProperty(base, "execInitialize", {
enumerable: true, configurable: false, writable: false,
value: function() { return undefined; }
});
Object.defineProperty(base, "execCalculate", {
enumerable: true, configurable: false, writable: false,
value: function() { return undefined; }
});
return new Proxy(base, {
get: function(target, prop) {
if (prop in target || typeof prop !== "string") return target[prop];
return makeNullDataHandle();
},
set: function(_t, _p, _v) { return true; },
has: function() { return true; }
});
}
var formNamespace = makeFormNamespace();
Object.defineProperty(xfa, "form", {
enumerable: true,
configurable: false,
writable: false,
value: formNamespace
});
Object.defineProperty(xfa, "resolveNodes", {
enumerable: true,
configurable: false,
writable: false,
value: function(path) {
// Phase D-γ: data paths are routed to the DataDom, not the FormTree.
if (typeof path === "string" &&
(path.indexOf("data.") === 0 || path.indexOf("$data.") === 0 ||
path.indexOf("xfa.datasets.data.") === 0)) {
var rawIds = host.dataResolveNodes(path);
var out = [];
for (var i = 0; i < rawIds.length; i++) out.push(makeDataHandle(rawIds[i]));
return Object.freeze(out);
}
var generation = host.generation();
var ids = host.resolveNodeIds(path);
var out = [];
for (var i = 0; i < ids.length; i++) {
out.push(makeHandle(ids[i], generation));
}
return Object.freeze(out);
}
});
// Phase D-δ.2: expose `xfa.event` as an alias for the per-script event
// global. Real Adobe Reader populates this with the firing event;
// during static flatten there is no dispatched UI event, so the
// accessor returns the same defensive event-stub that the per-script
// `event` parameter receives — newText/prevText/change default to
// empty strings, target resolves to the firing field handle.
Object.defineProperty(xfa, "event", {
enumerable: true,
configurable: false,
get: function() {
return makeEvent();
}
});
// Phase E (XFA-JS-HOST-STUBS): Acrobat / Reader `app` global. Adobe
// initializer scripts commonly do:
// app.calculate.override = true; // viewer-only flag — silent absorb
// app.runtimeHighlight = false; // viewer-only flag — silent absorb
// app.alert("submitted"); app.launchURL("…") // interactive — counted
//
// The previous frozen `app` only stubbed two functions; every other
// property touch surfaced as TypeError. Wrap the whole namespace in a
// Proxy with the same semantics as `xfa.host`:
// * known viewer properties resolve to deterministic defaults;
// * known interactive functions return a safe default + bump
// unsupported_host_calls (NOT runtime_errors);
// * unknown reads return a null-safe sentinel chain;
// * unknown writes are absorbed.
//
// `app.calculate` is itself an absorbing sub-Proxy — every assignment
// (`app.calculate.override = true`, `app.calculate.suspend = false`, ...)
// is viewer-only and has no flatten side-effect. We document this as an
// explicit silent no-op (not UnsupportedHostCapability) because
// suppressing the viewer's calculation cascade is the script author's
// ASKING the viewer to stop, not a UI prompt to the user. See
// benchmarks/JS_SANDBOX_SECURITY_AUDIT.md for the full classification.
var APP_INTERACTIVE_CALLS = {
"alert": { kind: "ret", value: 0 }, // dialog OK pressed = 0
"launchURL": { kind: "ret", value: undefined },
"execMenuItem":{ kind: "ret", value: undefined },
"beep": { kind: "ret", value: undefined },
"openDoc": { kind: "ret", value: null },
"response": { kind: "ret", value: "" },
"mailMsg": { kind: "ret", value: undefined },
// JS2-01 (Sprint 2 Batch B): Adobe Acrobat SDK exposes `app.messageBox`
// as a richer alias of `app.alert` (modal dialog, returns the pressed
// button index — 0 = OK). Safe default mirrors `app.alert`. No new
// capability surface; pure JS no-op with counter accounting.
"messageBox": { kind: "ret", value: 0 },
// JS2-01: `app.closeDoc` is the Acrobat alias of `xfa.host.closeDoc`.
// Templates targeting both Reader and Acrobat reference both spellings;
// we counter-bump on each call and return undefined.
"closeDoc": { kind: "ret", value: undefined }
};
function makeAppBase() {
var base = nullProtoObject();
// Read-side static defaults — never call out, never lie about identity.
var SCALAR_DEFAULTS = {
"viewerType": "PDFluent",
"viewerVariation":"Reader",
"viewerVersion": 0,
"language": "ENU",
"platform": "PDFluent",
"fs": null, // file system gateway — explicitly null
"media": null, // multimedia controller — explicitly null
"fullscreen": false,
"runtimeHighlight": false,
"runtimeHighlightColor": ""
};
Object.keys(SCALAR_DEFAULTS).forEach(function(k) {
var v = SCALAR_DEFAULTS[k];
Object.defineProperty(base, k, {
enumerable: true, configurable: true,
get: function() { return v; },
set: function(_v) { /* viewer-only, silently absorb */ }
});
});
// app.calculate sub-namespace: writes absorb, reads return defaults.
Object.defineProperty(base, "calculate", {
enumerable: true, configurable: false, writable: false,
value: makeViewerStubNamespace({
"override": true,
"suspend": false
})
});
// Install interactive function thunks (same pattern as xfa.host).
Object.keys(APP_INTERACTIVE_CALLS).forEach(function(name) {
var spec = APP_INTERACTIVE_CALLS[name];
Object.defineProperty(base, name, {
enumerable: true, configurable: false, writable: false,
value: function() {
host.unsupportedHostCall(name);
return spec.value;
}
});
});
return base;
}
var app = new Proxy(makeAppBase(), {
get: function(target, prop) {
if (prop in target || typeof prop !== "string") return target[prop];
return makeNullDataHandle();
},
set: function(_t, _p, _v) { return true; },
has: function() { return true; }
});
// Phase C-α: viewer-only `event` global. Real Adobe Reader populates
// this with the firing event; during static flatten there is no
// dispatched UI event, so the object is a defensive stub that:
// - exposes `target` resolving to the firing field handle (≈ `this`),
// so scripts like `event.target.getField(somPath)` complete without
// ReferenceError;
// - exposes `change` as an empty string (the spec default);
// - returns viewer-stubs from `target.getField()` so AcroForm widget
// tweaks (`doNotScroll`, `required`, etc.) silently absorb.
function makeEvent() {
var id = host.currentNodeId();
var fieldHandle = id < 0 ? null : makeHandle(id, host.generation());
var target = nullProtoObject();
Object.defineProperty(target, "getField", {
enumerable: true, configurable: false, writable: false,
value: function() {
return makeViewerStub();
}
});
Object.defineProperty(target, "name", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(target, "self", {
enumerable: true, configurable: false,
get: function() { return fieldHandle; }
});
var ev = nullProtoObject();
Object.defineProperty(ev, "target", {
enumerable: true, configurable: false,
get: function() { return target; }
});
// Phase D-δ.2: stable empty-string defaults for the change-event property
// family so scripts that read `xfa.event.newText` / `prevText` /
// `change` on initialize/calculate (where no real change event occurred)
// do not throw `cannot read property 'X' of undefined`. Adobe populates
// these on actual `change`/`exit` events; we run those activities later
// (or never) and surface deterministic empty defaults instead.
Object.defineProperty(ev, "change", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(ev, "newText", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(ev, "prevText", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(ev, "fullText", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(ev, "selStart", {
enumerable: true, configurable: false,
get: function() { return 0; }
});
Object.defineProperty(ev, "selEnd", {
enumerable: true, configurable: false,
get: function() { return 0; }
});
// JS2-01 (Sprint 2 Batch B): the Phase E security audit §3.9 promised
// that `event` exposes the full Adobe Acrobat SDK event surface
// (`target`, `change`, `newText`, `prevText`, `fullText`, `selStart`,
// `selEnd`, plus `name`, `type`, `shift`, `modifier`, `commitKey`,
// `willCommit`, `rc`, `keyDown`, `value`, `reenter`). Only the first
// seven were implemented; scripts that probe `event.name` for the
// firing activity (`if (event.name === "calculate") {...}`) silently
// saw `undefined`, and scripts that chained off `event.X` could trip
// a "not a function" if `X` was expected to be callable.
//
// All values are deterministic spec defaults that match Adobe's
// behaviour during a non-interactive flatten (no firing event):
// name — current activity if known, else ""
// type — event type label (Adobe ≈ "Field"), "" if unknown
// shift — modifier-key state (false)
// modifier — modifier-key state (false)
// commitKey — 0 (no commit key pressed)
// willCommit — false (no commit pending)
// rc — true (accept-by-default — same as Adobe's validate)
// keyDown — false (no key pressed)
// value — "" (current widget value, unknown during static flatten)
// reenter — false (Acrobat re-entry flag)
//
// Read-only via getter, like the existing seven. The frozen ev object
// means writes to these from scripts running in strict mode would throw
// a TypeError — but no real script writes event scalars (the only
// observed pattern is `event.value = ...` on a stale `event` reference
// which is undefined, not the frozen object). If we ever observe
// legitimate writes, swap getter for configurable getter+setter.
Object.defineProperty(ev, "name", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(ev, "type", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(ev, "shift", {
enumerable: true, configurable: false,
get: function() { return false; }
});
Object.defineProperty(ev, "modifier", {
enumerable: true, configurable: false,
get: function() { return false; }
});
Object.defineProperty(ev, "commitKey", {
enumerable: true, configurable: false,
get: function() { return 0; }
});
Object.defineProperty(ev, "willCommit", {
enumerable: true, configurable: false,
get: function() { return false; }
});
Object.defineProperty(ev, "rc", {
enumerable: true, configurable: false,
get: function() { return true; }
});
Object.defineProperty(ev, "keyDown", {
enumerable: true, configurable: false,
get: function() { return false; }
});
Object.defineProperty(ev, "value", {
enumerable: true, configurable: false,
get: function() { return ""; }
});
Object.defineProperty(ev, "reenter", {
enumerable: true, configurable: false,
get: function() { return false; }
});
return Object.freeze(ev);
}
// Phase D-γ: XFA global `util` (Acrobat SDK §Util). Provides date/number
// formatting helpers used by many XFA templates. Only the subset required
// by real-corpus scripts is implemented; unknown methods return "".
//
// util.printd(sFormat, dDate) — format a Date per sFormat using UTC fields.
// Supported tokens: yyyy (year), yy (two-digit year), mm (month 01-12),
// m (1-12), dd (day 01-31), d (1-31), HH (hour 00-23),
// MM (minute 00-59), SS (second 00-59).
// util.printx(cPicture, cValue) — picture format; returns cValue as-is.
// util.scand(sFormat, cDate) — deterministic numeric parser for the same
// token subset; unsupported or invalid input returns Invalid Date.
var xfaUtil = (function() {
var DateCtor = Date;
var dateUtc = Date.UTC;
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function invalidDate() { return new DateCtor(NaN); }
function escapeRegex(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function parseDate(fmt, input) {
var fmtText, text;
try {
fmtText = String(fmt);
text = String(input);
} catch (_e) {
return invalidDate();
}
var groups = [];
var pattern = "^";
for (var i = 0; i < fmtText.length;) {
var rest = fmtText.substring(i);
if (rest.indexOf("yyyy") === 0) {
pattern += "(\\d{4})";
groups.push("yyyy");
i += 4;
} else if (rest.indexOf("yy") === 0) {
pattern += "(\\d{2})";
groups.push("yy");
i += 2;
} else if (rest.indexOf("mm") === 0) {
pattern += "(\\d{2})";
groups.push("mm");
i += 2;
} else if (rest.indexOf("dd") === 0) {
pattern += "(\\d{2})";
groups.push("dd");
i += 2;
} else if (rest.indexOf("HH") === 0) {
pattern += "(\\d{2})";
groups.push("HH");
i += 2;
} else if (rest.indexOf("MM") === 0) {
pattern += "(\\d{2})";
groups.push("MM");
i += 2;
} else if (rest.indexOf("SS") === 0) {
pattern += "(\\d{2})";
groups.push("SS");
i += 2;
} else if (rest.indexOf("m") === 0) {
pattern += "(\\d{1,2})";
groups.push("m");
i += 1;
} else if (rest.indexOf("d") === 0) {
pattern += "(\\d{1,2})";
groups.push("d");
i += 1;
} else {
pattern += escapeRegex(fmtText.charAt(i));
i += 1;
}
}
var match = new RegExp(pattern + "$").exec(text);
if (!match) return invalidDate();
var year = NaN, month = 1, day = 1, hour = 0, minute = 0, second = 0;
for (var g = 0; g < groups.length; g++) {
var value = parseInt(match[g + 1], 10);
if (!isFinite(value)) return invalidDate();
if (groups[g] === "yyyy") year = value;
else if (groups[g] === "yy") year = 2000 + value;
else if (groups[g] === "mm" || groups[g] === "m") month = value;
else if (groups[g] === "dd" || groups[g] === "d") day = value;
else if (groups[g] === "HH") hour = value;
else if (groups[g] === "MM") minute = value;
else if (groups[g] === "SS") second = value;
}
if (!isFinite(year) || month < 1 || month > 12 || day < 1 || day > 31 ||
hour < 0 || hour > 23 || minute < 0 || minute > 59 ||
second < 0 || second > 59) {
return invalidDate();
}
var out = new DateCtor(dateUtc(year, month - 1, day, hour, minute, second));
if (out.getUTCFullYear() !== year ||
out.getUTCMonth() + 1 !== month ||
out.getUTCDate() !== day ||
out.getUTCHours() !== hour ||
out.getUTCMinutes() !== minute ||
out.getUTCSeconds() !== second) {
return invalidDate();
}
return out;
}
var u = nullProtoObject();
u.printd = function(fmt, date) {
if (!(date instanceof DateCtor) || isNaN(date.getTime())) return "";
var y = date.getUTCFullYear();
var mo = date.getUTCMonth() + 1;
var d = date.getUTCDate();
var h = date.getUTCHours();
var mi = date.getUTCMinutes();
var s = date.getUTCSeconds();
var result = String(fmt);
result = result.replace(/yyyy/g, y)
.replace(/yy/g, String(y).slice(-2))
.replace(/mm/g, pad2(mo))
.replace(/m/g, mo)
.replace(/dd/g, pad2(d))
.replace(/d/g, d)
.replace(/HH/g, pad2(h))
.replace(/MM/g, pad2(mi))
.replace(/SS/g, pad2(s));
return result;
};
u.printx = function(_fmt, val) { return val === null || val === undefined ? "" : String(val); };
u.scand = function(fmt, str) { return parseDate(fmt, str); };
return Object.freeze(u);
}());
// Phase C-α: minimal `console` no-op. Many forms guard with
// `if (typeof console !== "undefined") console.log(...)` and proceed
// when the symbol exists. Stub returns undefined; never writes
// anywhere observable to the script.
var consoleStub = nullProtoObject();
["log","warn","error","info","debug","trace"].forEach(function(name) {
Object.defineProperty(consoleStub, name, {
enumerable: true, configurable: false, writable: false,
value: function() { return undefined; }
});
});
// Phase D-ι: form-level globals registered from `<variables>` `<script>`
// blocks. Each entry is `name -> frozen object`. Populated by the host
// once per document via `setVariablesScript`; cleared by
// `clearVariablesScripts` at `reset_per_document`.
var variablesScripts = lookupObject();
// Phase D-ι.2: subform-scoped variables. Maps subform name -> namespace
// object containing that subform's named scripts. Enables
// `subformHandle.variables.ScriptName.method()` access paths.
var subformVariables = lookupObject();
// W3-D RETRY: form-level mutable string data items registered from
// `<variables>` `<text name="X">value</text>` blocks (XFA 3.3 §5.5.2).
// Each entry is `name -> { value: <string> }`. Adobe Reader exposes these
// as global mutable string containers — the canonical IMM5709 pattern
// is `<text name="globValidatePressed"/>` referenced from event scripts
// as `globValidatePressed.value = "true";`. Populated by
// `setVariablesDataItem`; cleared by `clearVariablesScripts` (shared
// teardown — same per-document lifecycle).
var variablesDataItems = lookupObject();
// W3-D RETRY: subform-scoped variant of `variablesDataItems`, parallel to
// `subformVariables` for scripts. Reserved for future subform-level data
// items; currently populated only at root scope but the structure is
// here so an upcoming pass can light it up without further refactor.
var subformVariablesDataItems = lookupObject();
// D4: flat first-wins exposure of subform-scoped named `<script>` objects to
// bare-identifier lookup. The host only writes an entry here for names that
// are unique across all subforms (ambiguous names are withheld — fail-closed
// — and counted Rust-side). Consulted by the `with` proxy AFTER
// `variablesScripts` / `variablesDataItems` (root scope wins) and BEFORE the
// host SOM resolver, so a subform-scoped helper such as `countryScript`
// resolves as a bare global instead of falling through to a SOM NoMatch.
var subformScriptsByName = lookupObject();
function makeImplicitGlobals(body) {
var currentId = host.currentNodeId();
var generation = host.generation();
var localNames = collectLocalNames(String(body));
var cachedHandles = lookupObject();
var dynamicLocals = lookupObject();
function lookup(name) {
if (cachedHandles[name] !== undefined) {
return cachedHandles[name];
}
var nodeIds = uniqueNodeIds(host.resolveImplicitNodeIds(currentId, name));
if (nodeIds.length === 0) {
// BE-1 tranche #1: a bare implicit identifier that fails the scope
// resolve but names a template-declared container resolves to a benign
// EMPTY node (Adobe semantics: isNull===true), not `undefined`. This
// lets guarded scripts such as
// if (!Podmiot1.OsobaFizyczna.PESEL.isNull) {...} else {...}
// take the empty (else) branch and run their setInstances(0)/presence
// writes instead of throwing "cannot read property '…' of undefined"
// and aborting. Undeclared names still return `undefined`, preserving
// the D-θ.1 `A.B === undefined` byte-identity contract for genuine
// misses. Gated host-side by `isDeclaredAbsentNode`; sandboxed-only.
if (host.isDeclaredAbsentNode(name)) {
var facade = makeAbsentNodeHandle();
cachedHandles[name] = facade;
return facade;
}
return undefined;
}
// Phase D-θ.2: wrap an implicit hit in the full-chain accumulator. The
// resulting proxy accumulates SOM property names without contacting
// the host until a terminal property is read; only then is the full
// chain resolved with backtracking. This lets `A.B.C.D.rawValue`
// disambiguate the same-name `A` whose subtree completes the chain,
// rather than relying on a single-segment hint as in D-θ.1. Terminal
// properties still resolve byte-for-byte identically to the
// un-hinted walk so single-token reads keep their existing
// semantics.
var handle = makeChainProxy([], [name], nodeIds, generation, currentId);
cachedHandles[name] = handle;
return handle;
}
return new Proxy(Object.create(null), {
has: function(_target, prop) {
if (typeof prop !== "string") {
return false;
}
// XFA-DATA-M3C: `_<Name>` and `parent` are XFA-defined globals that
// must be visible to `with()` lookup even when the local-names
// collector treats them as locals (they shadow no real var/let).
if (prop === "parent") {
return true;
}
if (prop.charAt(0) === "_" && prop.length > 1 &&
localNames[prop] !== true) {
// Probe — only claim presence when the bare-name resolves to a
// real subform/container; otherwise fall through to the standard
// defer rules so unrelated underscored locals stay undefined.
// Quiet probe: a miss here is the schema-optional path and must
// not count as a script-level resolve failure.
var bare = prop.substring(1);
if (uniqueNodeIds(host.resolveImplicitNodeIdsQuiet(currentId, bare)).length > 0 ||
host.hasZeroInstanceRun(currentId, generation, bare)) {
return true;
}
}
if (shouldDeferGlobalName(prop, localNames)) {
return false;
}
return true;
},
get: function(_target, prop) {
if (typeof prop !== "string") {
return undefined;
}
// XFA-DATA-M3C: bare `parent` resolves to the form-tree parent of
// the current script node. Common in calculate/initialize scripts
// for `parent.index`, `parent.rawValue`, and chain navigation that
// mirrors Adobe's implicit-scope walk one step upward.
if (prop === "parent") {
var parentId = host.parentOfNode(currentId, generation);
if (parentId < 0) return undefined;
return makeHandle(parentId, generation);
}
// XFA 3.3 §6.4.3.2 underscore shorthand at the global scope:
// `_<Name>` referenced as a bare identifier denotes the
// instanceManager of a same-named subform reachable from the
// current implicit scope. Adobe Reader exposes this both as a
// child property (`parent._Foo`) and as a bare global. Without
// this branch the `shouldDeferGlobalName` rule below returns
// `undefined` for every underscored bare ident and the calling
// script throws `ReferenceError: _Foo is not defined`.
//
// Only triggers when the target subform actually exists; otherwise
// fall through so unrelated underscored locals keep their
// existing deferred-undefined semantics.
if (prop.charAt(0) === "_" && prop.length > 1 &&
localNames[prop] !== true) {
var bareName = prop.substring(1);
// Quiet probe: schema-optional misses are NOT a script-level
// resolve failure. We surface either a live manager, an empty
// manager (zero-instance run or never-instantiated optional
// subform), or fall through to the defer rules.
var imIds = uniqueNodeIds(
host.resolveImplicitNodeIdsQuiet(currentId, bareName)
);
if (imIds.length > 0) {
return makeInstanceManager(imIds[0], generation);
}
if (host.hasZeroInstanceRun(currentId, generation, bareName)) {
return makeEmptyInstanceManager();
}
}
if (shouldDeferGlobalName(prop, localNames)) {
return undefined;
}
// Phase D-γ: $record as a script-level global refers to the data
// record bound to the current field's enclosing subform context.
// Scripts write `var addr = $record.SECTION.nodes;` — we intercept
// this here instead of letting resolveImplicitNodeId fail (-1).
if (prop === "$record") {
var recRaw = host.dataBoundRecord(currentId, generation);
if (recRaw < 0) return makeNullDataHandle();
return makeDataHandle(recRaw);
}
// BE-1: `$data` as a bare global refers to the data-DOM root node
// (XFA 3.3 §3.3.2: `$data` == `xfa.datasets.data`). Scripts write
// e.g. `var root = $data; root.child.value` — we intercept here
// before the SOM resolver so the data-dom root is returned directly.
// `data_resolve_node("$data")` resolves to the root of the DataDom
// (SomRoot::Data + zero segments -> start node) via resolve_data_som.
if (prop === "$data") {
var dataRootRaw = host.dataResolveNode("$data");
if (dataRootRaw < 0) return makeNullDataHandle();
host.somDataRootHit();
return makeDataHandle(dataRootRaw);
}
// Phase D-γ: `util` is an XFA global (Acrobat SDK §Util) that provides
// date/number formatting functions. `util.printd(fmt, date)` is widely
// used by XFA templates to format Date objects. We intercept it here so
// scripts can complete without a TypeError instead of throwing and
// aborting all later mutations in the same script body.
if (prop === "util") {
return xfaUtil;
}
if (dynamicLocals[prop] !== undefined) {
return dynamicLocals[prop];
}
// Phase D-ι: form-level named-script globals from <variables>
// outrank the form-tree implicit lookup. Adobe XFA spec §5.5
// exposes `<scriptName>.<topLevelDecl>` to all event/calculate
// scripts in the same document.
if (variablesScripts[prop] !== undefined) {
return variablesScripts[prop];
}
// W3-D RETRY: form-level data items (XFA 3.3 §5.5.2) outrank the
// form-tree implicit lookup, same as scripts. This lets templates
// like Canadian IMM5709 reference `globValidatePressed.value`
// without the bare ident falling through to the SOM resolver as a
// `js_resolve_failure`. Scripts take precedence over data items in
// the unlikely case both share a name (spec-undefined; we follow
// declaration order, which is `setVariablesScript` first in the
// host loop).
if (variablesDataItems[prop] !== undefined) {
return variablesDataItems[prop];
}
// D4: subform-scoped named script objects (unique-name only) resolve as
// bare globals here, after root-scope scripts/data items and before the
// host SOM resolver. This is the minimal SOM resolution for the
// `countryScript` / `partNoScript` cluster (XFA 3.3 §5.5 subform
// variables). Ambiguous names were never written here (fail-closed).
if (subformScriptsByName[prop] !== undefined) {
return subformScriptsByName[prop];
}
return lookup(prop);
},
set: function(_target, prop, value) {
if (typeof prop !== "string") {
return true;
}
if (shouldDeferGlobalName(prop, localNames)) {
return false;
}
if (cachedHandles[prop] !== undefined) {
return true;
}
dynamicLocals[prop] = value;
return true;
}
});
}
return {
// Phase E (XFA-JS-HOST-STUBS): `xfa` itself is still frozen because all
// of its properties were installed with `configurable: false` and writes
// go through inner-Proxy `set` traps that we control. `app` is a Proxy
// whose underlying target is non-extensible after we install the
// function thunks; freezing the Proxy itself would force the absorbing
// `set` trap to throw a TypeError (proxy invariants: writes to
// non-extensible targets must reject), defeating the whole point of the
// silent-absorb design. We therefore expose `app` unfrozen — its safety
// is enforced by the Proxy traps, not by Object.freeze.
xfa: Object.freeze(xfa),
app: app,
// JS2-01 (Sprint 2 Batch B): top-level `form` global alias for
// `xfa.form`. Adobe Reader exposes both spellings; templates such as
// corpus forms reference bare `form.X`. The alias is identity-equal
// to `xfa.form` (same Proxy), so cluster C contract preservation is
// shared between the two surfaces. No new Rust closure introduced.
form: formNamespace,
consoleStub: Object.freeze(consoleStub),
// Phase D-ι: register a `<variables>` `<script name="X">…` block as a
// form-level global. Called by the host once per script body at
// document load. `body` is the raw script source; `identNames` is a
// pre-extracted array of top-level `var` / `function` identifiers
// (Rust-side regex). The body is wrapped in an IIFE that returns a
// frozen object whose properties are those identifiers. Variables
// scripts share the same time/memory budget enforcement as event
// scripts but emit no field mutations of their own. Errors during
// evaluation are absorbed: the namespace remains undefined and
// dependent event scripts will fail naturally at first use.
// Phase D-ι / D-ι.2: register a named `<variables><script>` body.
// `subformName` (4th param, optional) is non-empty for subform-scoped
// scripts; omit or pass "" for root-level scripts.
//
// Root-level scripts (empty subformName) go into the flat
// `variablesScripts` dict only — accessible as `ScriptName.X` from
// any event script in the document.
//
// Subform-scoped scripts go into `subformVariables[subformName][name]`
// ONLY — accessible as `subformHandle.variables.ScriptName.X`. They
// are intentionally NOT written to the flat dict: two subforms may
// define the same script name, and writing both to the flat map would
// let the second registration silently shadow the first.
setVariablesScript: function(name, body, identNames, subformName, exposeGlobal) {
if (typeof name !== "string" || name.length === 0) return false;
if (typeof body !== "string") return false;
var idents = Array.isArray(identNames) ? identNames : [];
var props = "";
for (var i = 0; i < idents.length; i++) {
var id = idents[i];
if (typeof id !== "string" || id.length === 0) continue;
if (i > 0) props += ",";
props += JSON.stringify(id) + ": typeof " + id +
" !== \"undefined\" ? " + id + " : undefined";
}
// XFA-DATA2-02: inject `console` and `util` into the variables-script
// closure so the form-level helper functions registered here can
// reference them lexically and the dependent event scripts that call
// those helpers do not blow up with `console is not defined` /
// `util is not defined`. Both are the same sandbox-safe singletons
// exposed to event scripts (silent no-op console, deterministic util).
var wrapper = "(function(__console, __util){\n" +
"var console = __console; var util = __util;\n" +
body +
"\nreturn Object.freeze({" + props + "});\n})";
try {
var ns = (Function("return " + wrapper))()(consoleStub, xfaUtil);
if (typeof subformName === "string" && subformName.length > 0) {
if (subformVariables[subformName] === undefined) {
subformVariables[subformName] = lookupObject();
}
subformVariables[subformName][name] = ns;
// D4: also expose to bare-identifier lookup when the host marked this
// name unique across subforms (ambiguous names are withheld).
if (exposeGlobal === true && subformScriptsByName[name] === undefined) {
subformScriptsByName[name] = ns;
}
} else {
variablesScripts[name] = ns;
}
return true;
} catch (_e) {
return false;
}
},
clearVariablesScripts: function() {
var keys = Object.keys(variablesScripts);
for (var i = 0; i < keys.length; i++) {
delete variablesScripts[keys[i]];
}
var skeys = Object.keys(subformVariables);
for (var j = 0; j < skeys.length; j++) {
delete subformVariables[skeys[j]];
}
// D4: clear the flat subform-script exposure map on the same per-document
// lifecycle as the scoped maps.
var ssnkeys = Object.keys(subformScriptsByName);
for (var n = 0; n < ssnkeys.length; n++) {
delete subformScriptsByName[ssnkeys[n]];
}
// W3-D RETRY: data items share the same per-document lifecycle as
// scripts. Dropping them here keeps a single bridge entry-point at
// `reset_for_new_document` and prevents per-document drift.
var dkeys = Object.keys(variablesDataItems);
for (var k = 0; k < dkeys.length; k++) {
delete variablesDataItems[dkeys[k]];
}
var sdkeys = Object.keys(subformVariablesDataItems);
for (var m = 0; m < sdkeys.length; m++) {
delete subformVariablesDataItems[sdkeys[m]];
}
},
// W3-D RETRY: register a `<variables>` `<text name="X">value</text>`
// data item (XFA 3.3 §5.5.2) as a form-level mutable string container.
// Idempotent per (subformName?, name): a later registration overwrites
// the earlier one, mirroring Adobe Reader's last-wins template merge.
//
// The returned shape is `{ value: <string> }` — minimal because real
// templates only read/write `.value`. Writes coerce non-string inputs
// to string (matching Adobe Reader's text-field semantics) so a
// calculate script that does `globValidatePressed.value = true` does
// not silently store a boolean.
setVariablesDataItem: function(name, initial, subformName) {
if (typeof name !== "string" || name.length === 0) return false;
var initialStr = (typeof initial === "string") ? initial : "";
// Use a closure-bound private slot so the `value` getter / setter
// can coerce on write without exposing a plain-object property that
// would let `delete item.value` succeed.
var item = (function(initStr) {
var current = initStr;
var holder = Object.create(null);
Object.defineProperty(holder, "value", {
enumerable: true,
configurable: false,
get: function() { return current; },
set: function(v) {
current = (v === undefined || v === null) ? "" : String(v);
}
});
// XFA 3.3 §5.5.2: data items also expose `.name`. Read-only.
Object.defineProperty(holder, "name", {
enumerable: true, configurable: false, writable: false,
value: name
});
return holder;
})(initialStr);
if (typeof subformName === "string" && subformName.length > 0) {
if (subformVariablesDataItems[subformName] === undefined) {
subformVariablesDataItems[subformName] = lookupObject();
}
subformVariablesDataItems[subformName][name] = item;
} else {
variablesDataItems[name] = item;
}
return true;
},
evalScript: function(body) {
var id = host.currentNodeId();
var thisArg = id < 0 ? undefined : makeHandle(id, host.generation());
// Phase C-α: install per-script `event` global in the function
// closure so `event.target` resolves to the current field. Wrapping
// body inside a function lets us pass `event` as a parameter
// without leaking it to globalThis (where it would persist across
// unrelated scripts).
var ev = makeEvent();
var consoleArg = consoleStub;
var globals = makeImplicitGlobals(body);
return (Function(
"event",
"console",
"__globals",
"with(__globals){\n" + String(body) + "\n}"
)).call(thisArg, ev, consoleArg, globals);
}
};
})
"##;
impl XfaJsRuntime for SandboxedRuntime {
fn init(&mut self) -> Result<(), SandboxError> {
self.init_called.set(true);
Ok(())
}
fn reset_for_new_document(&mut self) -> Result<(), SandboxError> {
self.metadata = RuntimeMetadata::default();
self.host.borrow_mut().reset_per_document();
self.clear_deadline();
self.engine
.get_mut()
.set_memory_limit(self.memory_budget_bytes);
if let Err(e) = self.clear_variables_scripts_global() {
log::debug!("D-ι clear failed: {e:?}");
}
Ok(())
}
#[allow(clippy::not_unsafe_ptr_arg_deref)]
fn set_form_handle(
&mut self,
form: *mut FormTree,
root_id: FormNodeId,
) -> Result<(), SandboxError> {
self.host.borrow_mut().set_form_handle(form, root_id);
if !form.is_null() {
let scripts: Vec<(Option<String>, String, String)> =
unsafe { (*form).variables_scripts.clone() };
self.metadata.variables_scripts_collected = self
.metadata
.variables_scripts_collected
.saturating_add(scripts.len());
let mut subform_name_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for (subform_scope, name, _) in &scripts {
if subform_scope.is_some() {
*subform_name_counts.entry(name.clone()).or_insert(0) += 1;
}
}
let ambiguous_subform_names = subform_name_counts.values().filter(|&&c| c > 1).count();
self.metadata.som_lookup_ambiguous = self
.metadata
.som_lookup_ambiguous
.saturating_add(ambiguous_subform_names);
for (subform_scope, name, body) in scripts {
if subform_scope.is_some() {
self.metadata.script_objects_subform_scoped = self
.metadata
.script_objects_subform_scoped
.saturating_add(1);
}
let expose_global = subform_scope.is_some()
&& subform_name_counts.get(&name).copied().unwrap_or(0) == 1;
match self.register_variables_script(
&name,
&body,
subform_scope.as_deref(),
expose_global,
) {
Ok(true) => {
self.metadata.script_objects_registered =
self.metadata.script_objects_registered.saturating_add(1);
if expose_global {
self.metadata.som_subform_scripts_exposed =
self.metadata.som_subform_scripts_exposed.saturating_add(1);
}
}
Ok(false) => {
self.metadata.script_objects_register_failed = self
.metadata
.script_objects_register_failed
.saturating_add(1);
log::debug!("D-ι register `{name}` returned false (JS eval failed)");
}
Err(e) => {
self.metadata.script_objects_register_failed = self
.metadata
.script_objects_register_failed
.saturating_add(1);
log::debug!("D-ι register `{name}` failed: {e:?}");
}
}
}
let data_items: Vec<(Option<String>, String, String)> =
unsafe { (*form).variables_data_items.clone() };
self.metadata.variables_data_items_collected = self
.metadata
.variables_data_items_collected
.saturating_add(data_items.len());
for (subform_scope, name, initial) in data_items {
match self.register_variables_data_item(&name, &initial, subform_scope.as_deref()) {
Ok(()) => {
self.metadata.script_objects_registered =
self.metadata.script_objects_registered.saturating_add(1);
}
Err(e) => {
self.metadata.script_objects_register_failed = self
.metadata
.script_objects_register_failed
.saturating_add(1);
log::debug!("W3-D register data item `{name}` failed: {e:?}");
}
}
}
}
Ok(())
}
fn set_data_handle(&mut self, dom: *const xfa_dom_resolver::data_dom::DataDom) {
self.host.borrow_mut().set_data_handle(dom);
}
fn set_declared_subform_names(&mut self, names: std::collections::HashSet<String>) {
self.host.borrow_mut().set_declared_subform_names(names);
}
fn reset_per_script(
&mut self,
current_id: FormNodeId,
activity: Option<&str>,
) -> Result<(), SandboxError> {
self.host
.borrow_mut()
.reset_per_script(current_id, activity);
Ok(())
}
fn set_static_page_count(&mut self, page_count: u32) -> Result<(), SandboxError> {
self.host.borrow_mut().set_static_page_count(page_count);
Ok(())
}
fn set_presave_gate(&mut self, enabled: bool) {
self.host.borrow_mut().set_presave_gate(enabled);
}
fn set_interactive(&mut self, enabled: bool) {
self.host.borrow_mut().set_interactive(enabled);
}
fn execute_script(
&mut self,
activity: Option<&str>,
body: &str,
) -> Result<RuntimeOutcome, SandboxError> {
let presave_gate = self.host.borrow().presave_gate();
let interactive = self.host.borrow().interactive();
let allowed = activity_allowed_for_sandbox_with_gate(activity, presave_gate)
|| (interactive && super::interactive_activity_allowed(activity));
if !allowed {
return Err(SandboxError::PhaseDenied(
activity.unwrap_or("None").to_string(),
));
}
if body.len() > MAX_SCRIPT_BODY_BYTES {
self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
return Err(SandboxError::BodyTooLarge);
}
if let RegexScanVerdict::Reject { reason } = scan_script_for_redos(body) {
self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
return Err(SandboxError::RegexRejected(reason));
}
if self.bindings_registered_check().is_err() {
self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
return Err(SandboxError::ScriptError(
"script engine not initialised".to_string(),
));
}
self.set_deadline();
let result = catch_unwind(AssertUnwindSafe(|| {
self.engine.borrow_mut().call_kept(
"bridge",
"evalScript",
vec![In::Str(body.to_string())],
)
}));
let timed_out = self.deadline_elapsed();
let classified = self.classify_timeout_or_walltime();
self.clear_deadline();
match result {
Ok(Ok(_)) => {
self.metadata.executed = self.metadata.executed.saturating_add(1);
let host_metadata = self.host.borrow_mut().take_metadata();
self.metadata.accumulate(host_metadata);
Ok(RuntimeOutcome {
executed: true,
mutated_field_count: host_metadata.mutations,
})
}
Ok(Err(fail)) => {
let host_metadata = self.host.borrow_mut().take_metadata();
self.metadata.accumulate(host_metadata);
match fail {
Fail::Limit(_) => {
self.metadata.timeouts = self.metadata.timeouts.saturating_add(1);
Err(classified)
}
_ if timed_out => {
self.metadata.timeouts = self.metadata.timeouts.saturating_add(1);
Err(classified)
}
Fail::Memory(_) => {
self.metadata.oom = self.metadata.oom.saturating_add(1);
Err(SandboxError::OutOfMemory)
}
Fail::Threw(msg) => {
self.metadata.runtime_errors =
self.metadata.runtime_errors.saturating_add(1);
Err(SandboxError::ScriptError(msg))
}
other => {
self.metadata.runtime_errors =
self.metadata.runtime_errors.saturating_add(1);
Err(SandboxError::ScriptError(format!("{other:?}")))
}
}
}
Err(_) => {
let host_metadata = self.host.borrow_mut().take_metadata();
self.metadata.accumulate(host_metadata);
self.metadata.runtime_errors = self.metadata.runtime_errors.saturating_add(1);
Err(SandboxError::PanicCaptured(
"panic during sandboxed script execution".to_string(),
))
}
}
}
fn take_metadata(&mut self) -> RuntimeMetadata {
std::mem::take(&mut self.metadata)
}
fn take_occur_mutations(&mut self) -> Vec<(usize, String, i64)> {
self.host
.borrow_mut()
.take_occur_mutations()
.into_iter()
.map(|(id, prop, value)| (id.0, prop, value))
.collect()
}
fn take_diag_logs(&mut self) -> crate::js_runtime::RuntimeDiagLogs {
self.host.borrow_mut().take_diag_logs()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_runtime() -> SandboxedRuntime {
let mut rt = SandboxedRuntime::new().expect("js engine init");
rt.init().expect("init");
rt.reset_for_new_document().expect("reset");
rt
}
#[test]
fn harmless_calculate_script_executes() {
let mut rt = fresh_runtime();
let outcome = rt
.execute_script(Some("calculate"), "var x = 1 + 1; x")
.expect("ok");
assert!(outcome.executed);
let md = rt.take_metadata();
assert_eq!(md.executed, 1);
assert!(md.is_clean());
}
#[test]
fn absent_declared_subform_resolves_to_empty_node() {
let mut rt = fresh_runtime();
let mut names = std::collections::HashSet::new();
names.insert("Podmiot1".to_string());
rt.set_declared_subform_names(names);
rt.execute_script(
Some("calculate"),
r#"
if (Podmiot1.OsobaFizyczna.PESEL.isNull !== true)
throw new Error('expected isNull === true on absent declared node');
var tookElse = false;
if (!Podmiot1.OsobaFizyczna.PESEL.isNull) { tookElse = false; }
else { tookElse = true; }
if (!tookElse) throw new Error('guard did not take the empty (else) branch');
// Chained writes on the empty node must be inert (no throw).
Podmiot1.OsobaFizyczna.presence = "invisible";
Podmiot1.OsobaFizyczna.PESEL.rawValue = 2;
if (Podmiot1.nodes.length !== 0) throw new Error('expected empty .nodes');
"#,
)
.expect("absent declared subform must resolve to a benign empty node");
}
#[test]
fn undeclared_bare_name_stays_undefined() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
"if (typeof NemoNonexistent7 !== 'undefined') \
throw new Error('D-theta.1 violated: undeclared name became defined');",
)
.expect("undeclared bare name must remain undefined");
let mut names = std::collections::HashSet::new();
names.insert("SomeOtherSubform".to_string());
rt.set_declared_subform_names(names);
rt.execute_script(
Some("calculate"),
"if (typeof NemoNonexistent7 !== 'undefined') \
throw new Error('D-theta.1 violated: undeclared name became defined (declared set present)');",
)
.expect("undeclared bare name must remain undefined even when other names are declared");
}
#[test]
fn ui_activity_is_phase_denied() {
let mut rt = fresh_runtime();
let err = rt.execute_script(Some("click"), "1+1").unwrap_err();
assert!(matches!(err, SandboxError::PhaseDenied(_)));
}
#[test]
fn oversized_body_rejected_before_parse() {
let mut rt = fresh_runtime();
let body = "1;\n".repeat(MAX_SCRIPT_BODY_BYTES);
let err = rt.execute_script(Some("calculate"), &body).unwrap_err();
assert_eq!(err, SandboxError::BodyTooLarge);
}
#[test]
fn fetch_is_undefined() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
"if (typeof fetch !== 'undefined') throw new Error('fetch leaked');",
)
.expect("must run cleanly with fetch undefined");
}
#[test]
fn require_is_undefined() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
"if (typeof require !== 'undefined') throw new Error('require leaked');",
)
.expect("must run cleanly with require undefined");
}
#[test]
fn process_is_undefined() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
"if (typeof process !== 'undefined') throw new Error('process leaked');",
)
.expect("must run cleanly with process undefined");
}
#[test]
fn date_now_is_zero() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
"if (Date.now() !== 0) throw new Error('Date.now not stubbed');",
)
.expect("Date.now must return 0");
}
#[test]
fn math_random_is_undefined() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
"if (typeof Math.random !== 'undefined') throw new Error('Math.random leaked');",
)
.expect("Math.random must be undefined");
}
#[test]
fn infinite_loop_times_out() {
let mut rt = SandboxedRuntime::new()
.expect("init")
.with_time_budget(Duration::from_millis(50));
rt.init().unwrap();
rt.reset_for_new_document().unwrap();
let err = rt
.execute_script(Some("calculate"), "while(true){}")
.unwrap_err();
assert_eq!(err, SandboxError::Timeout);
let md = rt.take_metadata();
assert_eq!(md.timeouts, 1);
assert_eq!(md.executed, 0);
}
#[test]
fn syntax_error_is_recoverable() {
let mut rt = fresh_runtime();
let err = rt
.execute_script(Some("calculate"), "this is not javascript {{")
.unwrap_err();
assert!(matches!(err, SandboxError::ScriptError(_)));
rt.execute_script(Some("calculate"), "var ok = 1;")
.expect("recovered");
}
#[test]
fn null_receiver_silent_setter() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
r#"
$record.NONEXISTENT_FIELD.rawValue = "hello";
$record.NONEXISTENT_FIELD.value = "world";
"#,
)
.expect("WP-3 null_receiver_silent_setter: set on null DataHandle must not throw");
}
#[test]
fn null_record_returns_empty_nodelist() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("calculate"),
r#"
var nodes = $record.FIELD.nodes;
if (nodes.length !== 0) {
throw new Error("expected nodes.length == 0, got " + nodes.length);
}
// item() on an empty null-sentinel NodeList returns a chainable null handle (not
// native null) so callers can safely do .item(0).value without TypeError.
var itemVal = nodes.item(0).value;
if (itemVal !== null) {
throw new Error("expected nodes.item(0).value == null, got " + itemVal);
}
"#,
)
.expect("WP-3 null_record_returns_empty_nodelist: $record.FIELD.nodes must be empty and chainable");
}
#[test]
fn xfa_validate_stub_absorbs_writes_silently() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("initialize"),
r#"
xfa.validate.override = 0;
xfa.validate.max = 5;
xfa.validate.messageMode = "warning";
xfa.validate.scriptTest.message = "ignored";
xfa.validate.scriptTest.nested.deeper = true;
// Reads must return harmless defaults, never throw.
if (xfa.validate.override !== 0) throw new Error("override default");
if (xfa.validate.max !== 0) throw new Error("max default");
if (xfa.validate.messageMode !== "") throw new Error("messageMode default");
"#,
)
.expect("WP-3 F3 xfa_validate_stub: writes must absorb and reads must not throw");
}
#[test]
fn add_instance_failure_returns_chainable_null_handle() {
let mut rt = fresh_runtime();
rt.execute_script(
Some("initialize"),
r#"
// Resolve a non-existent subform, addInstance() on an unbound instance
// manager must yield a chainable handle whose `.index` is a number and
// whose `.rawValue` setter is silent.
var im = xfa.resolveNode("NoSuchSubform");
if (im !== null) {
// If a real handle was returned (shouldn't be), addInstance still must
// either return a real handle or a null-safe sentinel — never throw.
var added = im.addInstance();
if (typeof added.index !== "number") {
throw new Error("added.index must be a number, got " + typeof added.index);
}
added.rawValue = "chained"; // silent on sentinel
added.value = "chained"; // silent on sentinel
}
"#,
)
.expect("WP-3 F3 add_instance_failure: chainable sentinel on failure path");
}
}