use std::cell::RefCell;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::rc::Rc;
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, OnceLock,
};
use std::time::{Duration, Instant};
use rquickjs::function::Opt;
use rquickjs::{CatchResultExt, Coerced, Context, Function, Object, Persistent, Runtime};
use xfa_layout_engine::form::{FormNodeId, FormTree};
use super::{
activity_allowed_for_sandbox, HostBindings, RuntimeMetadata, RuntimeOutcome, SandboxError,
XfaJsRuntime, DEFAULT_MEMORY_BUDGET_BYTES, DEFAULT_TIME_BUDGET_MS, MAX_SCRIPT_BODY_BYTES,
};
pub struct QuickJsRuntime {
eval_script: Option<Persistent<Function<'static>>>,
set_variables_script: Option<Persistent<Function<'static>>>,
clear_variables_scripts: Option<Persistent<Function<'static>>>,
context: Context,
runtime: Runtime,
metadata: RuntimeMetadata,
time_budget: Duration,
memory_budget_bytes: usize,
script_deadline: Arc<AtomicU64>,
script_started: Arc<AtomicBool>,
host: Rc<RefCell<HostBindings>>,
bindings_registered: bool,
}
impl std::fmt::Debug for QuickJsRuntime {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QuickJsRuntime")
.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()
}
impl QuickJsRuntime {
pub fn new() -> Result<Self, SandboxError> {
let runtime =
Runtime::new().map_err(|e| SandboxError::ScriptError(format!("rquickjs init: {e}")))?;
runtime.set_memory_limit(DEFAULT_MEMORY_BUDGET_BYTES);
let context = Context::full(&runtime)
.map_err(|e| SandboxError::ScriptError(format!("rquickjs context: {e}")))?;
let script_deadline = Arc::new(AtomicU64::new(0));
let script_started = Arc::new(AtomicBool::new(false));
let deadline_for_handler = Arc::clone(&script_deadline);
let started_for_handler = Arc::clone(&script_started);
runtime.set_interrupt_handler(Some(Box::new(move || {
if !started_for_handler.load(Ordering::Acquire) {
return false;
}
let deadline_nanos = deadline_for_handler.load(Ordering::Acquire);
if deadline_nanos == 0 {
return false;
}
let now_nanos = Instant::now()
.checked_duration_since(epoch())
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
now_nanos >= deadline_nanos
})));
Ok(Self {
eval_script: None,
set_variables_script: None,
clear_variables_scripts: None,
context,
runtime,
metadata: RuntimeMetadata::default(),
time_budget: Duration::from_millis(DEFAULT_TIME_BUDGET_MS),
memory_budget_bytes: DEFAULT_MEMORY_BUDGET_BYTES,
script_deadline,
script_started,
host: Rc::new(RefCell::new(HostBindings::new())),
bindings_registered: false,
})
}
pub fn with_time_budget(mut self, budget: Duration) -> Self {
self.time_budget = budget;
self
}
pub fn with_memory_budget(mut self, bytes: usize) -> Self {
self.memory_budget_bytes = bytes;
self.runtime.set_memory_limit(bytes);
self
}
fn set_deadline(&self) {
let deadline = Instant::now()
.checked_duration_since(epoch())
.map(|d| d + self.time_budget)
.unwrap_or(self.time_budget);
self.script_deadline
.store(deadline.as_nanos() as u64, Ordering::Release);
self.script_started.store(true, Ordering::Release);
}
fn clear_deadline(&self) {
self.script_started.store(false, Ordering::Release);
self.script_deadline.store(0, Ordering::Release);
}
fn register_host_bindings(&mut self) -> Result<(), String> {
if self.bindings_registered {
return Ok(());
}
let host = Rc::clone(&self.host);
let eval_script = self.context.with(|ctx| {
let globals = ctx.globals();
let internal =
Object::new(ctx.clone()).map_err(|e| format!("host internal object: {e}"))?;
let resolve_host = Rc::clone(&host);
let resolve_node_id = Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| {
let Some(path) = path.0 else {
let _ = resolve_host.borrow_mut().resolve_node("");
return -1i32;
};
resolve_host
.borrow_mut()
.resolve_node(&path.0)
.map(|node_id| node_id.0 as i32)
.unwrap_or(-1)
})
.map_err(|e| format!("resolveNodeId: {e}"))?;
internal
.set("resolveNodeId", resolve_node_id)
.map_err(|e| format!("set resolveNodeId: {e}"))?;
let resolve_nodes_host = Rc::clone(&host);
let resolve_node_ids =
Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> Vec<i32> {
let Some(path) = path.0 else {
let _ = resolve_nodes_host.borrow_mut().resolve_nodes("");
return Vec::new();
};
resolve_nodes_host
.borrow_mut()
.resolve_nodes(&path.0)
.into_iter()
.map(|node_id| node_id.0 as i32)
.collect()
})
.map_err(|e| format!("resolveNodeIds: {e}"))?;
internal
.set("resolveNodeIds", resolve_node_ids)
.map_err(|e| format!("set resolveNodeIds: {e}"))?;
let generation_host = Rc::clone(&host);
let generation = Function::new(ctx.clone(), move || {
generation_host.borrow().generation() as i64
})
.map_err(|e| format!("generation: {e}"))?;
internal
.set("generation", generation)
.map_err(|e| format!("set generation: {e}"))?;
let root_host = Rc::clone(&host);
let root_node_id = Function::new(ctx.clone(), move |generation: i64| -> i32 {
if generation < 0 {
return -1;
}
root_host
.borrow_mut()
.root_node(generation as u64)
.map(|node_id| node_id.0 as i32)
.unwrap_or(-1)
})
.map_err(|e| format!("rootNodeId: {e}"))?;
internal
.set("rootNodeId", root_node_id)
.map_err(|e| format!("set rootNodeId: {e}"))?;
let current_host = Rc::clone(&host);
let current_node = Function::new(ctx.clone(), move || {
current_host
.borrow()
.current_node()
.map(|node_id| node_id.0 as i32)
.unwrap_or(-1)
})
.map_err(|e| format!("currentNodeId: {e}"))?;
internal
.set("currentNodeId", current_node)
.map_err(|e| format!("set currentNodeId: {e}"))?;
let implicit_host = Rc::clone(&host);
let resolve_implicit_node_id = Function::new(
ctx.clone(),
move |current_id: i32, name: Opt<Coerced<String>>| -> i32 {
if current_id < 0 {
return -1;
}
let Some(name) = name.0 else {
return -1;
};
implicit_host
.borrow_mut()
.resolve_implicit(FormNodeId(current_id as usize), &name.0)
.map(|node_id| node_id.0 as i32)
.unwrap_or(-1)
},
)
.map_err(|e| format!("resolveImplicitNodeId: {e}"))?;
internal
.set("resolveImplicitNodeId", resolve_implicit_node_id)
.map_err(|e| format!("set resolveImplicitNodeId: {e}"))?;
let implicit_candidates_host = Rc::clone(&host);
let resolve_implicit_node_ids = Function::new(
ctx.clone(),
move |current_id: i32, name: Opt<Coerced<String>>| -> Vec<i32> {
if current_id < 0 {
return Vec::new();
}
let Some(name) = name.0 else {
return Vec::new();
};
implicit_candidates_host
.borrow_mut()
.resolve_implicit_candidates(FormNodeId(current_id as usize), &name.0)
.into_iter()
.map(|node_id| node_id.0 as i32)
.collect::<Vec<_>>()
},
)
.map_err(|e| format!("resolveImplicitNodeIds: {e}"))?;
internal
.set("resolveImplicitNodeIds", resolve_implicit_node_ids)
.map_err(|e| format!("set resolveImplicitNodeIds: {e}"))?;
let child_host = Rc::clone(&host);
let resolve_child_node_id = Function::new(
ctx.clone(),
move |parent_id: i32, name: Opt<Coerced<String>>| {
if parent_id < 0 {
return -1i32;
}
let Some(name) = name.0 else {
return -1;
};
child_host
.borrow_mut()
.resolve_child(FormNodeId(parent_id as usize), &name.0)
.map(|node_id| node_id.0 as i32)
.unwrap_or(-1)
},
)
.map_err(|e| format!("resolveChildNodeId: {e}"))?;
internal
.set("resolveChildNodeId", resolve_child_node_id)
.map_err(|e| format!("set resolveChildNodeId: {e}"))?;
let child_candidates_host = Rc::clone(&host);
let resolve_child_node_ids = Function::new(
ctx.clone(),
move |parent_ids: Opt<Coerced<String>>, name: Opt<Coerced<String>>| -> Vec<i32> {
let Some(parent_ids) = parent_ids.0 else {
return Vec::new();
};
let Some(name) = name.0 else {
return Vec::new();
};
child_candidates_host
.borrow_mut()
.resolve_child_candidates(&parse_node_id_csv(&parent_ids.0), &name.0)
.into_iter()
.map(|node_id| node_id.0 as i32)
.collect()
},
)
.map_err(|e| format!("resolveChildNodeIds: {e}"))?;
internal
.set("resolveChildNodeIds", resolve_child_node_ids)
.map_err(|e| format!("set resolveChildNodeIds: {e}"))?;
let scoped_candidates_host = Rc::clone(&host);
let resolve_scoped_node_ids = Function::new(
ctx.clone(),
move |scope_ids: Opt<Coerced<String>>, name: Opt<Coerced<String>>| -> Vec<i32> {
let Some(scope_ids) = scope_ids.0 else {
return Vec::new();
};
let Some(name) = name.0 else {
return Vec::new();
};
scoped_candidates_host
.borrow_mut()
.resolve_scoped_candidates(&parse_node_id_csv(&scope_ids.0), &name.0)
.into_iter()
.map(|node_id| node_id.0 as i32)
.collect()
},
)
.map_err(|e| format!("resolveScopedNodeIds: {e}"))?;
internal
.set("resolveScopedNodeIds", resolve_scoped_node_ids)
.map_err(|e| format!("set resolveScopedNodeIds: {e}"))?;
let get_raw_host = Rc::clone(&host);
let get_raw_value = Function::new(
ctx.clone(),
move |id: i32, generation: i64| -> Option<String> {
if id < 0 || generation < 0 {
return None;
}
get_raw_host
.borrow_mut()
.get_raw_value(FormNodeId(id as usize), generation as u64)
},
)
.map_err(|e| format!("getRawValue: {e}"))?;
internal
.set("getRawValue", get_raw_value)
.map_err(|e| format!("set getRawValue: {e}"))?;
let set_raw_host = Rc::clone(&host);
let set_raw_value = Function::new(
ctx.clone(),
move |id: i32, generation: i64, value: Coerced<String>| -> bool {
if id < 0 || generation < 0 {
return false;
}
set_raw_host.borrow_mut().set_raw_value(
FormNodeId(id as usize),
value.0,
generation as u64,
)
},
)
.map_err(|e| format!("setRawValue: {e}"))?;
internal
.set("setRawValue", set_raw_value)
.map_err(|e| format!("set setRawValue: {e}"))?;
let get_occur_host = Rc::clone(&host);
let get_occur_property = Function::new(
ctx.clone(),
move |id: i32, generation: i64, property: Opt<Coerced<String>>| -> Option<i32> {
if id < 0 || generation < 0 {
return None;
}
let property = property.0?;
get_occur_host.borrow_mut().get_occur_property(
FormNodeId(id as usize),
generation as u64,
&property.0,
)
},
)
.map_err(|e| format!("getOccurProperty: {e}"))?;
internal
.set("getOccurProperty", get_occur_property)
.map_err(|e| format!("set getOccurProperty: {e}"))?;
let set_occur_host = Rc::clone(&host);
let set_occur_property = Function::new(
ctx.clone(),
move |id: i32,
generation: i64,
property: Opt<Coerced<String>>,
value: Coerced<String>|
-> bool {
if id < 0 || generation < 0 {
return false;
}
let Some(property) = property.0 else {
return false;
};
set_occur_host.borrow_mut().set_occur_property(
FormNodeId(id as usize),
generation as u64,
&property.0,
&value.0,
)
},
)
.map_err(|e| format!("setOccurProperty: {e}"))?;
internal
.set("setOccurProperty", set_occur_property)
.map_err(|e| format!("set setOccurProperty: {e}"))?;
let instance_count_host = Rc::clone(&host);
let instance_count =
Function::new(ctx.clone(), move |id: i32, generation: i64| -> u32 {
if id < 0 || generation < 0 {
return 0;
}
instance_count_host
.borrow_mut()
.instance_count_for_handle(FormNodeId(id as usize), generation as u64)
})
.map_err(|e| format!("instanceCount: {e}"))?;
internal
.set("instanceCount", instance_count)
.map_err(|e| format!("set instanceCount: {e}"))?;
let zero_instance_host = Rc::clone(&host);
let has_zero_instance_run = Function::new(
ctx.clone(),
move |id: i32, generation: i64, name: Opt<Coerced<String>>| -> bool {
if id < 0 || generation < 0 {
return false;
}
let Some(name) = name.0 else {
return false;
};
zero_instance_host.borrow_mut().has_zero_instance_run(
FormNodeId(id as usize),
generation as u64,
&name.0,
)
},
)
.map_err(|e| format!("hasZeroInstanceRun: {e}"))?;
internal
.set("hasZeroInstanceRun", has_zero_instance_run)
.map_err(|e| format!("set hasZeroInstanceRun: {e}"))?;
let node_index_host = Rc::clone(&host);
let node_index = Function::new(ctx.clone(), move |id: i32, generation: i64| -> u32 {
if id < 0 || generation < 0 {
return 0;
}
node_index_host
.borrow_mut()
.instance_index_for_handle(FormNodeId(id as usize), generation as u64)
})
.map_err(|e| format!("nodeIndex: {e}"))?;
internal
.set("nodeIndex", node_index)
.map_err(|e| format!("set nodeIndex: {e}"))?;
let node_name_host = Rc::clone(&host);
let node_name = Function::new(ctx.clone(), move |id: i32, generation: i64| -> String {
if id < 0 || generation < 0 {
return String::new();
}
node_name_host
.borrow()
.node_name(FormNodeId(id as usize), generation as u64)
.unwrap_or_default()
})
.map_err(|e| format!("nodeName: {e}"))?;
internal
.set("nodeName", node_name)
.map_err(|e| format!("set nodeName: {e}"))?;
let scope_chain_host = Rc::clone(&host);
let scope_chain = Function::new(
ctx.clone(),
move |id: i32, generation: i64| -> Vec<String> {
if id < 0 || generation < 0 {
return vec![];
}
scope_chain_host
.borrow_mut()
.subform_scope_chain(FormNodeId(id as usize), generation as u64)
},
)
.map_err(|e| format!("getSubformScopeChain: {e}"))?;
internal
.set("getSubformScopeChain", scope_chain)
.map_err(|e| format!("set getSubformScopeChain: {e}"))?;
let instance_set_host = Rc::clone(&host);
let instance_set = Function::new(
ctx.clone(),
move |id: i32, generation: i64, n: Opt<i32>| -> i32 {
if id < 0 || generation < 0 {
return -1;
}
let n = n.0.unwrap_or(0).max(0) as u32;
instance_set_host
.borrow_mut()
.instance_set_for_handle(FormNodeId(id as usize), generation as u64, n)
.map(|count| count as i32)
.unwrap_or(-1)
},
)
.map_err(|e| format!("instanceSet: {e}"))?;
internal
.set("instanceSet", instance_set)
.map_err(|e| format!("set instanceSet: {e}"))?;
let instance_add_host = Rc::clone(&host);
let instance_add = Function::new(ctx.clone(), move |id: i32, generation: i64| -> i32 {
if id < 0 || generation < 0 {
return -1;
}
instance_add_host
.borrow_mut()
.instance_add_for_handle(FormNodeId(id as usize), generation as u64)
.map(|node_id| node_id.0 as i32)
.unwrap_or(-1)
})
.map_err(|e| format!("instanceAdd: {e}"))?;
internal
.set("instanceAdd", instance_add)
.map_err(|e| format!("set instanceAdd: {e}"))?;
let instance_remove_host = Rc::clone(&host);
let instance_remove = Function::new(
ctx.clone(),
move |id: i32, generation: i64, index: Opt<i32>| -> bool {
if id < 0 || generation < 0 {
return false;
}
let index = index.0.unwrap_or(0).max(0) as u32;
instance_remove_host
.borrow_mut()
.instance_remove_for_handle(
FormNodeId(id as usize),
generation as u64,
index,
)
.is_ok()
},
)
.map_err(|e| format!("instanceRemove: {e}"))?;
internal
.set("instanceRemove", instance_remove)
.map_err(|e| format!("set instanceRemove: {e}"))?;
let list_clear_host = Rc::clone(&host);
let list_clear = Function::new(ctx.clone(), move |id: i32, generation: i64| -> bool {
if id < 0 || generation < 0 {
return false;
}
list_clear_host
.borrow_mut()
.list_clear_for_handle(FormNodeId(id as usize), generation as u64)
.is_ok()
})
.map_err(|e| format!("listClear: {e}"))?;
internal
.set("listClear", list_clear)
.map_err(|e| format!("set listClear: {e}"))?;
let list_add_host = Rc::clone(&host);
let list_add = Function::new(
ctx.clone(),
move |id: i32,
generation: i64,
display: Coerced<String>,
save: Opt<Coerced<String>>|
-> bool {
if id < 0 || generation < 0 {
return false;
}
list_add_host
.borrow_mut()
.list_add_for_handle(
FormNodeId(id as usize),
generation as u64,
display.0,
save.0.map(|s| s.0),
)
.is_ok()
},
)
.map_err(|e| format!("listAdd: {e}"))?;
internal
.set("listAdd", list_add)
.map_err(|e| format!("set listAdd: {e}"))?;
let bound_item_host = Rc::clone(&host);
let bound_item = Function::new(
ctx.clone(),
move |id: i32, generation: i64, display: Coerced<String>| -> String {
if id < 0 || generation < 0 {
return display.0;
}
bound_item_host.borrow_mut().bound_item_for_handle(
FormNodeId(id as usize),
generation as u64,
display.0,
)
},
)
.map_err(|e| format!("boundItem: {e}"))?;
internal
.set("boundItem", bound_item)
.map_err(|e| format!("set boundItem: {e}"))?;
let num_pages_host = Rc::clone(&host);
let num_pages =
Function::new(ctx.clone(), move || num_pages_host.borrow_mut().num_pages())
.map_err(|e| format!("numPages: {e}"))?;
internal
.set("numPages", num_pages)
.map_err(|e| format!("set numPages: {e}"))?;
let binding_error_host = Rc::clone(&host);
let binding_error = Function::new(ctx.clone(), move || {
binding_error_host.borrow_mut().metadata_binding_error();
})
.map_err(|e| format!("bindingError: {e}"))?;
internal
.set("bindingError", binding_error)
.map_err(|e| format!("set bindingError: {e}"))?;
let resolve_failure_host = Rc::clone(&host);
let resolve_failure = Function::new(ctx.clone(), move || {
resolve_failure_host.borrow_mut().metadata_resolve_failure();
})
.map_err(|e| format!("resolveFailure: {e}"))?;
internal
.set("resolveFailure", resolve_failure)
.map_err(|e| format!("set resolveFailure: {e}"))?;
let dc_host = Rc::clone(&host);
let data_children = Function::new(ctx.clone(), move |raw_id: i32| -> Vec<i32> {
if raw_id < 0 {
return Vec::new();
}
dc_host
.borrow_mut()
.data_children(raw_id as usize)
.into_iter()
.map(|x| x as i32)
.collect()
})
.map_err(|e| format!("dataChildren: {e}"))?;
internal
.set("dataChildren", data_children)
.map_err(|e| format!("set dataChildren: {e}"))?;
let dv_host = Rc::clone(&host);
let data_value = Function::new(ctx.clone(), move |raw_id: i32| -> Option<String> {
if raw_id < 0 {
return None;
}
dv_host.borrow_mut().data_value(raw_id as usize)
})
.map_err(|e| format!("dataValue: {e}"))?;
internal
.set("dataValue", data_value)
.map_err(|e| format!("set dataValue: {e}"))?;
let dcbn_host = Rc::clone(&host);
let data_child_by_name = Function::new(
ctx.clone(),
move |parent_raw: i32, name: Opt<Coerced<String>>| -> i32 {
if parent_raw < 0 {
return -1;
}
let Some(name) = name.0 else {
return -1;
};
dcbn_host
.borrow_mut()
.data_child_by_name(parent_raw as usize, &name.0)
.map(|x| x as i32)
.unwrap_or(-1)
},
)
.map_err(|e| format!("dataChildByName: {e}"))?;
internal
.set("dataChildByName", data_child_by_name)
.map_err(|e| format!("set dataChildByName: {e}"))?;
let dbr_host = Rc::clone(&host);
let data_bound_record = Function::new(
ctx.clone(),
move |form_node_id: i32, generation: i64| -> i32 {
if form_node_id < 0 || generation < 0 {
return -1;
}
dbr_host
.borrow_mut()
.data_bound_record(FormNodeId(form_node_id as usize), generation as u64)
.map(|x| x as i32)
.unwrap_or(-1)
},
)
.map_err(|e| format!("dataBoundRecord: {e}"))?;
internal
.set("dataBoundRecord", data_bound_record)
.map_err(|e| format!("set dataBoundRecord: {e}"))?;
let drn_host = Rc::clone(&host);
let data_resolve_node =
Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> i32 {
let Some(path) = path.0 else {
return -1;
};
drn_host
.borrow_mut()
.data_resolve_node(&path.0)
.map(|x| x as i32)
.unwrap_or(-1)
})
.map_err(|e| format!("dataResolveNode: {e}"))?;
internal
.set("dataResolveNode", data_resolve_node)
.map_err(|e| format!("set dataResolveNode: {e}"))?;
let drns_host = Rc::clone(&host);
let data_resolve_nodes =
Function::new(ctx.clone(), move |path: Opt<Coerced<String>>| -> Vec<i32> {
let Some(path) = path.0 else {
return Vec::new();
};
drns_host
.borrow_mut()
.data_resolve_nodes(&path.0)
.into_iter()
.map(|x| x as i32)
.collect()
})
.map_err(|e| format!("dataResolveNodes: {e}"))?;
internal
.set("dataResolveNodes", data_resolve_nodes)
.map_err(|e| format!("set dataResolveNodes: {e}"))?;
let factory: Function = ctx
.eval(PHASE_C_BINDINGS_JS.as_bytes())
.map_err(|e| format!("binding factory parse: {e}"))?;
let bridge: Object = factory
.call((internal,))
.catch(&ctx)
.map_err(|e| format!("binding factory call: {e}"))?;
let xfa: Object = bridge.get("xfa").map_err(|e| format!("get xfa: {e}"))?;
let app: Object = bridge.get("app").map_err(|e| format!("get app: {e}"))?;
let eval_script: Function = bridge
.get("evalScript")
.map_err(|e| format!("get evalScript: {e}"))?;
let set_variables_script: Function = bridge
.get("setVariablesScript")
.map_err(|e| format!("get setVariablesScript: {e}"))?;
let clear_variables_scripts: Function = bridge
.get("clearVariablesScripts")
.map_err(|e| format!("get clearVariablesScripts: {e}"))?;
globals
.set("xfa", xfa)
.map_err(|e| format!("set xfa global: {e}"))?;
globals
.set("app", app)
.map_err(|e| format!("set app global: {e}"))?;
Ok::<
(
Persistent<Function<'static>>,
Persistent<Function<'static>>,
Persistent<Function<'static>>,
),
String,
>((
Persistent::save(&ctx, eval_script),
Persistent::save(&ctx, set_variables_script),
Persistent::save(&ctx, clear_variables_scripts),
))
})?;
self.eval_script = Some(eval_script.0);
self.set_variables_script = Some(eval_script.1);
self.clear_variables_scripts = Some(eval_script.2);
self.bindings_registered = 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>,
) -> Result<(), SandboxError> {
let Some(setter) = self.set_variables_script.clone() else {
return Ok(());
};
if body.len() > MAX_SCRIPT_BODY_BYTES {
return Err(SandboxError::BodyTooLarge);
}
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.context.with(|ctx| -> Result<(), rquickjs::Error> {
let setter = setter.restore(&ctx)?;
let _: bool = setter.call((name, body, idents, scope))?;
Ok(())
})
}));
let deadline_now = self.script_deadline.load(Ordering::Acquire);
let now_nanos = Instant::now()
.checked_duration_since(epoch())
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let timed_out = deadline_now != 0 && now_nanos >= deadline_now;
self.clear_deadline();
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(_)) if timed_out => Err(SandboxError::Timeout),
Ok(Err(e)) => Err(SandboxError::ScriptError(format!(
"variables-script `{name}` register: {e}"
))),
Err(_) => Err(SandboxError::PanicCaptured(format!(
"panic registering variables-script `{name}`"
))),
}
}
fn clear_variables_scripts_global(&self) -> Result<(), SandboxError> {
let Some(clearer) = self.clear_variables_scripts.clone() else {
return Ok(());
};
let result = catch_unwind(AssertUnwindSafe(|| {
self.context.with(|ctx| -> Result<(), rquickjs::Error> {
let clearer = clearer.restore(&ctx)?;
let _: () = clearer.call(())?;
Ok(())
})
}));
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",
"xfa", "event"
].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", "occur", "nodes", "value", "length", "item"].forEach(function(name) {
handlePropertyExclusions[name] = true;
});
function shouldDeferHandleProperty(name) {
if (handlePropertyExclusions[name] === true) {
return false;
}
return name.charAt(0) === "_" || reservedHandleProperties[name] === true;
}
// Adobe silently clears a field when a script writes null/undefined/NaN.
// rquickjs would otherwise coerce these to the literal strings "null"/"NaN".
function coerceRawValue(v) {
if (v === null || v === undefined) return "";
if (typeof v === "number" && isNaN(v)) return "";
return v;
}
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;
}
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 makeOccurrenceHandle(id, generation) {
var occur = nullProtoObject();
["min", "max", "initial"].forEach(function(name) {
Object.defineProperty(occur, name, {
enumerable: true,
configurable: false,
get: function() {
var value = host.getOccurProperty(id, generation, name);
return value === undefined ? null : value;
},
set: function(value) {
host.setOccurProperty(id, generation, name, value);
}
});
});
return Object.seal(occur);
}
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);
}
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 === "somExpression") {
return "xfa[0].form[0].placeholder";
}
if (prop === "instanceManager") {
return makeInstanceManager(firstId, generation);
}
if (prop === "occur") {
return makeOccurrenceHandle(firstId, generation);
}
if (prop === "index") {
return host.nodeIndex(firstId, generation);
}
if (prop === "setInstances") {
return function(n) {
return host.instanceSet(firstId, generation, n);
};
}
if (prop === "addInstance") {
return function() {
var newId = host.instanceAdd(firstId, generation);
return newId < 0 ? null : 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.resolveChildNodeIds(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();
}
}
}
if (shouldDeferHandleProperty(prop)) {
return undefined;
}
return makeNodeHandleFromIds(resolveHandleChildIds(candidates, prop), generation);
},
set: function(_target, prop, value) {
if (prop === "rawValue") {
host.setRawValue(firstId, generation, coerceRawValue(value));
}
return true;
},
has: function(target, prop) {
if (typeof prop !== "string") {
return Reflect.has(target, prop);
}
return prop === "rawValue" ||
prop === "somExpression" ||
prop === "instanceManager" ||
prop === "occur" ||
prop === "index" ||
prop === "setInstances" ||
prop === "addInstance" ||
prop === "removeInstance" ||
prop === "isNull" ||
prop === "clearItems" ||
prop === "addItem" ||
prop === "boundItem" ||
Reflect.has(target, prop);
}
});
}
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, coerceRawValue(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 === "somExpression") {
return Reflect.get(target, prop, receiver);
}
if (prop === "instanceManager") {
return makeInstanceManager(id, generation);
}
if (prop === "occur") {
return makeOccurrenceHandle(id, generation);
}
if (prop === "index") {
return host.nodeIndex(id, generation);
}
if (prop === "setInstances") {
return function(n) {
return host.instanceSet(id, generation, n);
};
}
if (prop === "addInstance") {
return function() {
var newId = host.instanceAdd(id, generation);
return newId < 0 ? null : 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.resolveChildNodeIds(String(id), bareName));
if (imChildIds.length > 0) {
return makeInstanceManager(imChildIds[0], generation);
}
if (host.hasZeroInstanceRun(id, generation, bareName)) {
return makeEmptyInstanceManager();
}
}
if (shouldDeferHandleProperty(prop)) {
return undefined;
}
return makeNodeHandleFromIds(resolveHandleChildIds([id], prop), generation);
},
set: function(_target, prop, value) {
if (prop === "rawValue") {
host.setRawValue(id, generation, coerceRawValue(value));
}
return true;
},
has: function(target, prop) {
if (typeof prop !== "string") {
return Reflect.has(target, prop);
}
return prop === "rawValue" ||
prop === "somExpression" ||
prop === "instanceManager" ||
prop === "occur" ||
prop === "index" ||
prop === "setInstances" ||
prop === "addInstance" ||
prop === "removeInstance" ||
prop === "isNull" ||
prop === "clearItems" ||
prop === "addItem" ||
prop === "boundItem" ||
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 = [];
emptyNodes.item = function() { return makeNullDataHandle(); };
Object.freeze(emptyNodes);
var sentinel = nullProtoObject();
Object.defineProperty(sentinel, "value",
{ get: function() { return null; }, enumerable: true, configurable: false });
Object.defineProperty(sentinel, "rawValue",
{ get: function() { return null; }, enumerable: true, configurable: false });
Object.defineProperty(sentinel, "length",
{ get: function() { return 0; }, enumerable: true, configurable: false });
Object.defineProperty(sentinel, "nodes",
{ get: function() { return emptyNodes; }, enumerable: true, configurable: false });
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();
}
});
}
// 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) {
if (rawId === undefined || rawId < 0) return null;
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);
}
});
}
var xfaHost = nullProtoObject();
Object.defineProperty(xfaHost, "numPages", {
enumerable: true,
configurable: false,
get: function() {
return host.numPages();
}
});
Object.defineProperty(xfaHost, "messageBox", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
host.bindingError();
return null;
}
});
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,
value: Object.freeze(xfaHost)
});
Object.defineProperty(xfa, "layout", {
enumerable: true,
configurable: false,
writable: false,
value: Object.freeze(xfaLayout)
});
Object.defineProperty(xfa, "form", {
enumerable: true,
configurable: false,
get: function() {
var generation = host.generation();
var id = host.rootNodeId(generation);
return id < 0 ? null : makeHandle(id, generation);
}
});
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);
}
var id = host.resolveNodeId(path);
if (id < 0) {
return null;
}
return makeHandle(id, host.generation());
}
});
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();
}
});
var app = nullProtoObject();
Object.defineProperty(app, "alert", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
host.bindingError();
return null;
}
});
Object.defineProperty(app, "response", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
return "";
}
});
Object.defineProperty(app, "launchURL", {
enumerable: true,
configurable: false,
writable: false,
value: function() {
host.bindingError();
return null;
}
});
// XFA Acrobat SDK §6: app.Application — application-level info object.
// Stubbed as a no-op frozen object; scripts that branch on its presence
// (e.g. PDFIUM-352) no longer throw ReferenceError.
Object.defineProperty(app, "Application", {
enumerable: true,
configurable: false,
writable: false,
value: Object.freeze(nullProtoObject())
});
// 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; }
});
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();
function makeImplicitGlobals(body) {
var currentId = host.currentNodeId();
var generation = host.generation();
var localNames = collectLocalNames(String(body));
var cachedHandles = lookupObject();
var cachedImHandles = lookupObject();
var dynamicLocals = lookupObject();
// D-ι.2: ancestor subform names (innermost→outermost) for this script's
// context node. Used to resolve bare names like `partNoScript` that are
// defined in a parent subform's <variables> block.
var scopeChain = (currentId >= 0)
? host.getSubformScopeChain(currentId, generation)
: [];
function lookup(name) {
if (cachedHandles[name] !== undefined) {
return cachedHandles[name];
}
// Use current node at call time so that functions defined in a
// variables-script IIFE (with captured `currentId`) still resolve SOM
// nodes correctly when invoked from an event script with a different
// active node. During normal event-script execution currentNodeId()
// returns the same value as the captured `currentId`, so there is no
// observable difference for the common case.
var resolveId = host.currentNodeId();
if (resolveId < 0) resolveId = currentId;
var nodeIds = host.resolveImplicitNodeIds(resolveId, name);
if (!nodeIds || nodeIds.length === 0) {
return undefined;
}
var handle = makeNodeHandleFromIds(nodeIds, generation);
cachedHandles[name] = handle;
return handle;
}
// XFA instance manager shorthand: `_NodeName` as a global bare name
// resolves to the instanceManager for `NodeName` from the current context.
// Adobe XFA scripts use patterns like `_PD2.setInstances(0)` at the
// document level. `shouldDeferGlobalName` blocks all `_`-prefixed names
// to prevent internal JS variables (e.g. `_i`) from being hijacked, so
// we must intercept BEFORE that check, but only when the bare name
// actually resolves to a form node.
function lookupInstanceManagerShorthand(prop) {
// Only handle `_X` (single underscore prefix, non-empty suffix) that
// is not a double-underscore builtin (e.g. __proto__) and is not a
// locally declared variable.
if (prop.length < 2 || prop.charAt(1) === "_" || localNames[prop] === true) {
return undefined;
}
var bareName = prop.substring(1);
if (cachedImHandles[bareName] !== undefined) {
return cachedImHandles[bareName];
}
var resolveId = host.currentNodeId();
if (resolveId < 0) resolveId = currentId;
var nodeIds = host.resolveImplicitNodeIds(resolveId, bareName);
if (!nodeIds || nodeIds.length === 0) {
cachedImHandles[bareName] = null;
return null;
}
var im = makeInstanceManager(nodeIds[0], generation);
cachedImHandles[bareName] = im;
return im;
}
return new Proxy(Object.create(null), {
has: function(_target, prop) {
if (typeof prop !== "string") {
return false;
}
if (prop.charAt(0) === "_") {
var _im = lookupInstanceManagerShorthand(prop);
return _im !== null && _im !== undefined;
}
if (shouldDeferGlobalName(prop, localNames)) {
return false;
}
return true;
},
get: function(_target, prop) {
if (typeof prop !== "string") {
return undefined;
}
if (prop.charAt(0) === "_") {
var im = lookupInstanceManagerShorthand(prop);
return (im === null) ? undefined : im;
}
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);
}
// 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];
}
// D-ι.2: walk ancestor subform scope chain (innermost first).
// Variables defined in a parent subform's <variables> block are
// accessible as bare names within all descendant scripts.
for (var _sci = 0; _sci < scopeChain.length; _sci++) {
var _sv = subformVariables[scopeChain[_sci]];
if (_sv !== undefined && _sv[prop] !== undefined) {
return _sv[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 {
xfa: Object.freeze(xfa),
app: Object.freeze(app),
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) {
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";
}
var isScoped = typeof subformName === "string" && subformName.length > 0;
// Create the subform dict before the IIFE so the with-binding holds a
// reference to the live object (forward cross-script refs work).
if (isScoped && subformVariables[subformName] === undefined) {
subformVariables[subformName] = lookupObject();
}
try {
// Wrap the body with with(vs) so bare-name references to sibling
// variable scripts resolve at CALL time from the live dictionaries.
// This handles both backward and forward cross-references between
// variables scripts regardless of registration order.
var ns;
if (isScoped) {
ns = (Function("vs", "svs",
"return (function(){\nwith(svs){\nwith(vs){\n" + body +
"\nreturn Object.freeze({" + props + "});\n}}})();"
))(variablesScripts, subformVariables[subformName]);
} else {
ns = (Function("vs",
"return (function(){\nwith(vs){\n" + body +
"\nreturn Object.freeze({" + props + "});\n}})();"
))(variablesScripts);
}
if (isScoped) {
subformVariables[subformName][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]];
}
},
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);
}
};
})
"#;
static EPOCH_CELL: OnceLock<Instant> = OnceLock::new();
fn epoch() -> Instant {
*EPOCH_CELL.get_or_init(Instant::now)
}
impl XfaJsRuntime for QuickJsRuntime {
fn init(&mut self) -> Result<(), SandboxError> {
let result = catch_unwind(AssertUnwindSafe(|| {
self.context.with(|ctx| {
let globals = ctx.globals();
for forbidden in [
"fetch",
"XMLHttpRequest",
"WebSocket",
"process",
"require",
"Deno",
"Bun",
] {
let _ = globals.set(forbidden, rquickjs::Undefined);
}
if let Ok(date_ctor) = globals.get::<_, rquickjs::Object>("Date") {
let zero_now = Function::new(ctx.clone(), || 0i64)
.map_err(|e| format!("date stub: {e}"))?;
let _ = date_ctor.set("now", zero_now);
}
if let Ok(math_ns) = globals.get::<_, rquickjs::Object>("Math") {
let _ = math_ns.set("random", rquickjs::Undefined);
}
Ok::<(), String>(())
})?;
self.register_host_bindings()?;
Ok::<(), String>(())
}));
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(SandboxError::ScriptError(e)),
Err(_) => Err(SandboxError::PanicCaptured(
"panic while initialising sandbox globals".to_string(),
)),
}
}
fn reset_for_new_document(&mut self) -> Result<(), SandboxError> {
self.metadata = RuntimeMetadata::default();
self.host.borrow_mut().reset_per_document();
self.clear_deadline();
self.runtime.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() };
for (subform_scope, name, body) in scripts {
if let Err(e) =
self.register_variables_script(&name, &body, subform_scope.as_deref())
{
log::debug!("D-ι register `{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 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 execute_script(
&mut self,
activity: Option<&str>,
body: &str,
) -> Result<RuntimeOutcome, SandboxError> {
if !activity_allowed_for_sandbox(activity) {
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);
}
self.set_deadline();
let script_owned = body.to_string();
let result = catch_unwind(AssertUnwindSafe(|| {
self.context.with(|ctx| -> Result<(), rquickjs::Error> {
let Some(eval_script) = self.eval_script.clone() else {
return Err(rquickjs::Error::new_from_js_message(
"host bindings",
"Function",
"Phase C eval bridge not registered",
));
};
let eval_script = eval_script.restore(&ctx)?;
if let Err(e) = eval_script.call::<_, ()>((script_owned,)) {
let exc_msg = if matches!(e, rquickjs::Error::Exception) {
let val = ctx.catch();
if let Some(exc) = val.as_exception() {
exc.message().unwrap_or_else(|| exc.to_string())
} else {
e.to_string()
}
} else {
e.to_string()
};
return Err(rquickjs::Error::new_from_js_message(
"script", "Error", exc_msg,
));
}
Ok(())
})
}));
let captured_deadline = self.script_deadline.load(Ordering::Acquire);
let captured_now = Instant::now()
.checked_duration_since(epoch())
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let timed_out = captured_deadline != 0 && captured_now >= captured_deadline;
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(other)) => {
let host_metadata = self.host.borrow_mut().take_metadata();
self.metadata.accumulate(host_metadata);
if timed_out {
self.metadata.timeouts = self.metadata.timeouts.saturating_add(1);
Err(SandboxError::Timeout)
} else {
let msg = other.to_string();
if msg.to_ascii_lowercase().contains("memory") {
self.metadata.oom = self.metadata.oom.saturating_add(1);
Err(SandboxError::OutOfMemory)
} else {
self.metadata.runtime_errors =
self.metadata.runtime_errors.saturating_add(1);
Err(SandboxError::ScriptError(msg))
}
}
}
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_runtime() -> QuickJsRuntime {
let mut rt = QuickJsRuntime::new().expect("rquickjs 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 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 = QuickJsRuntime::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");
}
}