pub mod host;
pub mod null;
pub mod regex_guard;
#[cfg(feature = "xfa-js-sandboxed")]
pub mod rquickjs_backend;
pub use host::{
HostBindings, MutationLogEntry, MAX_INSTANCES_PER_SUBFORM, MAX_ITEMS_PER_LISTBOX,
MAX_MUTATIONS_PER_DOC, MAX_RESOLVE_CALLS_PER_SCRIPT, MAX_RESOLVE_RESULTS, MAX_SOM_DEPTH,
};
pub use null::NullRuntime;
#[cfg(feature = "xfa-js-sandboxed")]
pub use rquickjs_backend::QuickJsRuntime;
use xfa_dom_resolver::data_dom::DataDom;
use xfa_layout_engine::form::{FormNodeId, FormTree};
#[derive(Debug, Clone, Default)]
pub struct RuntimeOutcome {
pub executed: bool,
pub mutated_field_count: usize,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum SandboxError {
#[error("sandboxed runtime not compiled in")]
NotCompiledIn,
#[error("script body exceeds size cap")]
BodyTooLarge,
#[error("script time budget exceeded")]
Timeout,
#[error("document memory budget exceeded")]
OutOfMemory,
#[error("call stack overflow")]
StackOverflow,
#[error("activity {0:?} denied for sandbox dispatch")]
PhaseDenied(String),
#[error("no host bindings registered (Phase B skeleton)")]
NoBindings,
#[error("sandbox panic captured: {0}")]
PanicCaptured(String),
#[error("script error: {0}")]
ScriptError(String),
#[error("regex rejected by ReDoS guard: {0}")]
RegexRejected(String),
#[error("wall-time fallback fired: elapsed {0}")]
WallTimeExceeded(String),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RuntimeMetadata {
pub executed: usize,
pub runtime_errors: usize,
pub timeouts: usize,
pub oom: usize,
pub host_calls: usize,
pub mutations: usize,
pub instance_writes: usize,
pub list_writes: usize,
pub binding_errors: usize,
pub resolve_failures: usize,
pub data_reads: usize,
pub unsupported_host_calls: usize,
pub probe_skips: usize,
pub variables_scripts_collected: usize,
pub variables_data_items_collected: usize,
pub script_objects_registered: usize,
pub script_objects_register_failed: usize,
pub script_objects_subform_scoped: usize,
pub som_lookups_total: usize,
pub som_lookup_successes: usize,
pub som_lookup_failures: usize,
pub som_lookup_ambiguous: usize,
pub som_subform_scripts_exposed: usize,
pub som_occur_path_refs: usize,
pub occur_lookups_total: usize,
pub occur_lookup_successes: usize,
pub occur_lookup_failures: usize,
pub occur_property_reads: usize,
pub occur_property_writes: usize,
pub occur_min_writes: usize,
pub occur_max_writes: usize,
pub occur_mutations_captured: usize,
pub occur_mutations_applied: usize,
pub occur_mutations_skipped: usize,
pub occur_application_ambiguous: usize,
pub occur_application_targets: usize,
pub som_data_root_hits: usize,
pub som_items_path_hits: usize,
}
impl RuntimeMetadata {
pub fn is_clean(&self) -> bool {
self.runtime_errors == 0
&& self.timeouts == 0
&& self.oom == 0
&& self.binding_errors == 0
&& self.resolve_failures == 0
}
pub fn accumulate(&mut self, other: RuntimeMetadata) {
self.executed = self.executed.saturating_add(other.executed);
self.runtime_errors = self.runtime_errors.saturating_add(other.runtime_errors);
self.timeouts = self.timeouts.saturating_add(other.timeouts);
self.oom = self.oom.saturating_add(other.oom);
self.host_calls = self.host_calls.saturating_add(other.host_calls);
self.mutations = self.mutations.saturating_add(other.mutations);
self.instance_writes = self.instance_writes.saturating_add(other.instance_writes);
self.list_writes = self.list_writes.saturating_add(other.list_writes);
self.binding_errors = self.binding_errors.saturating_add(other.binding_errors);
self.resolve_failures = self.resolve_failures.saturating_add(other.resolve_failures);
self.data_reads = self.data_reads.saturating_add(other.data_reads);
self.unsupported_host_calls = self
.unsupported_host_calls
.saturating_add(other.unsupported_host_calls);
self.probe_skips = self.probe_skips.saturating_add(other.probe_skips);
self.variables_scripts_collected = self
.variables_scripts_collected
.saturating_add(other.variables_scripts_collected);
self.variables_data_items_collected = self
.variables_data_items_collected
.saturating_add(other.variables_data_items_collected);
self.script_objects_registered = self
.script_objects_registered
.saturating_add(other.script_objects_registered);
self.script_objects_register_failed = self
.script_objects_register_failed
.saturating_add(other.script_objects_register_failed);
self.script_objects_subform_scoped = self
.script_objects_subform_scoped
.saturating_add(other.script_objects_subform_scoped);
self.som_lookups_total = self
.som_lookups_total
.saturating_add(other.som_lookups_total);
self.som_lookup_successes = self
.som_lookup_successes
.saturating_add(other.som_lookup_successes);
self.som_lookup_failures = self
.som_lookup_failures
.saturating_add(other.som_lookup_failures);
self.som_lookup_ambiguous = self
.som_lookup_ambiguous
.saturating_add(other.som_lookup_ambiguous);
self.som_subform_scripts_exposed = self
.som_subform_scripts_exposed
.saturating_add(other.som_subform_scripts_exposed);
self.som_occur_path_refs = self
.som_occur_path_refs
.saturating_add(other.som_occur_path_refs);
self.occur_lookups_total = self
.occur_lookups_total
.saturating_add(other.occur_lookups_total);
self.occur_lookup_successes = self
.occur_lookup_successes
.saturating_add(other.occur_lookup_successes);
self.occur_lookup_failures = self
.occur_lookup_failures
.saturating_add(other.occur_lookup_failures);
self.occur_property_reads = self
.occur_property_reads
.saturating_add(other.occur_property_reads);
self.occur_property_writes = self
.occur_property_writes
.saturating_add(other.occur_property_writes);
self.occur_min_writes = self.occur_min_writes.saturating_add(other.occur_min_writes);
self.occur_max_writes = self.occur_max_writes.saturating_add(other.occur_max_writes);
self.occur_mutations_captured = self
.occur_mutations_captured
.saturating_add(other.occur_mutations_captured);
self.occur_mutations_applied = self
.occur_mutations_applied
.saturating_add(other.occur_mutations_applied);
self.occur_mutations_skipped = self
.occur_mutations_skipped
.saturating_add(other.occur_mutations_skipped);
self.occur_application_ambiguous = self
.occur_application_ambiguous
.saturating_add(other.occur_application_ambiguous);
self.occur_application_targets = self
.occur_application_targets
.saturating_add(other.occur_application_targets);
self.som_data_root_hits = self
.som_data_root_hits
.saturating_add(other.som_data_root_hits);
self.som_items_path_hits = self
.som_items_path_hits
.saturating_add(other.som_items_path_hits);
}
}
#[derive(Debug, Default)]
pub struct RuntimeDiagLogs {
pub som_fail_log: Vec<crate::dynamic::SomFailEntry>,
pub instance_write_log: Vec<crate::dynamic::InstanceWriteEntry>,
}
pub const DEFAULT_TIME_BUDGET_MS: u64 = 100;
pub const WALLTIME_FALLBACK_MULTIPLIER_DEFAULT: u32 = 5;
pub const ENV_WALLTIME_FALLBACK_MULTIPLIER: &str = "XFA_JS_WALLTIME_FALLBACK_MULTIPLIER";
pub fn walltime_fallback_multiplier() -> u32 {
std::env::var(ENV_WALLTIME_FALLBACK_MULTIPLIER)
.ok()
.and_then(|s| s.parse::<u32>().ok())
.filter(|&n| n >= 2)
.unwrap_or(WALLTIME_FALLBACK_MULTIPLIER_DEFAULT)
}
pub const DEFAULT_MEMORY_BUDGET_BYTES: usize = 32 * 1024 * 1024;
pub const MAX_SCRIPT_BODY_BYTES: usize = 64 * 1024;
pub const MAX_VARIABLES_SCRIPT_BODY_BYTES: usize = 1024 * 1024;
pub const SANDBOX_ACTIVITY_ALLOWLIST: &[&str] = &[
"initialize",
"calculate",
"validate",
"docReady",
"layoutReady",
];
pub fn activity_allowed_for_sandbox(activity: Option<&str>) -> bool {
matches!(activity, Some(a) if SANDBOX_ACTIVITY_ALLOWLIST.contains(&a))
}
pub const ENV_PRESAVE_DURING_FLATTEN: &str = "XFA_PRESAVE_DURING_FLATTEN";
pub fn presave_during_flatten_enabled() -> bool {
std::env::var(ENV_PRESAVE_DURING_FLATTEN).ok().as_deref() == Some("1")
}
pub fn activity_allowed_for_sandbox_with_gate(activity: Option<&str>, presave_gate: bool) -> bool {
if activity_allowed_for_sandbox(activity) {
return true;
}
presave_gate && matches!(activity, Some("preSave"))
}
pub trait XfaJsRuntime {
fn init(&mut self) -> Result<(), SandboxError>;
fn reset_for_new_document(&mut self) -> Result<(), SandboxError>;
fn set_form_handle(
&mut self,
_form: *mut FormTree,
_root_id: FormNodeId,
) -> Result<(), SandboxError> {
Ok(())
}
fn set_data_handle(&mut self, _dom: *const DataDom) {}
fn set_declared_subform_names(&mut self, _names: std::collections::HashSet<String>) {}
fn reset_per_script(
&mut self,
_current_id: FormNodeId,
_activity: Option<&str>,
) -> Result<(), SandboxError> {
Ok(())
}
fn set_static_page_count(&mut self, _page_count: u32) -> Result<(), SandboxError> {
Ok(())
}
fn set_presave_gate(&mut self, _enabled: bool) {}
fn execute_script(
&mut self,
activity: Option<&str>,
body: &str,
) -> Result<RuntimeOutcome, SandboxError>;
fn take_metadata(&mut self) -> RuntimeMetadata;
fn take_occur_mutations(&mut self) -> Vec<(usize, String, i64)> {
Vec::new()
}
fn take_diag_logs(&mut self) -> RuntimeDiagLogs {
RuntimeDiagLogs::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn allowlist_accepts_initialize_and_calculate() {
assert!(activity_allowed_for_sandbox(Some("initialize")));
assert!(activity_allowed_for_sandbox(Some("calculate")));
assert!(activity_allowed_for_sandbox(Some("validate")));
assert!(activity_allowed_for_sandbox(Some("docReady")));
assert!(activity_allowed_for_sandbox(Some("layoutReady")));
}
#[test]
fn presave_gate_off_matches_base_allowlist() {
for allowed in SANDBOX_ACTIVITY_ALLOWLIST {
assert!(activity_allowed_for_sandbox_with_gate(Some(allowed), false));
}
for denied in [
"preSave",
"preSubmit",
"click",
"mouseEnter",
"exit",
"postSave",
] {
assert!(!activity_allowed_for_sandbox_with_gate(Some(denied), false));
}
assert!(!activity_allowed_for_sandbox_with_gate(None, false));
}
#[test]
fn presave_gate_on_unlocks_only_presave() {
assert!(activity_allowed_for_sandbox_with_gate(
Some("preSave"),
true
));
for still_denied in [
"preSubmit",
"click",
"mouseEnter",
"mouseExit",
"exit",
"enter",
"change",
"postSave",
"postSubmit",
"ready",
"prePrint",
"postPrint",
"preOpen",
"full",
] {
assert!(
!activity_allowed_for_sandbox_with_gate(Some(still_denied), true),
"{still_denied} must stay denied even with D1.B gate ON",
);
}
assert!(!activity_allowed_for_sandbox_with_gate(None, true));
for allowed in SANDBOX_ACTIVITY_ALLOWLIST {
assert!(activity_allowed_for_sandbox_with_gate(Some(allowed), true));
}
}
#[test]
fn presave_env_var_constant_is_canonical_name() {
assert_eq!(ENV_PRESAVE_DURING_FLATTEN, "XFA_PRESAVE_DURING_FLATTEN");
}
#[test]
fn allowlist_rejects_ui_and_submit_activities() {
for ui in [
"click",
"mouseEnter",
"mouseExit",
"enter",
"exit",
"preSubmit",
"postSubmit",
"ready",
] {
assert!(
!activity_allowed_for_sandbox(Some(ui)),
"{ui} must not be allowed",
);
}
assert!(!activity_allowed_for_sandbox(None));
}
#[test]
fn metadata_is_clean_when_zero() {
assert!(RuntimeMetadata::default().is_clean());
let mut m = RuntimeMetadata {
executed: 5,
..Default::default()
};
assert!(m.is_clean(), "executed counter does not flip cleanliness");
m.runtime_errors = 1;
assert!(!m.is_clean());
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn budget_constants_are_sane() {
assert!(MAX_SCRIPT_BODY_BYTES >= 4096);
assert!(DEFAULT_TIME_BUDGET_MS >= 25);
assert!(DEFAULT_MEMORY_BUDGET_BYTES >= 1024 * 1024);
}
const _WALLTIME_SAFE_MIN: () = assert!(
WALLTIME_FALLBACK_MULTIPLIER_DEFAULT >= 2,
"multiplier < 2 would re-label normal Timeouts as WallTimeExceeded"
);
#[test]
fn walltime_fallback_multiplier_default_is_safe() {
assert_eq!(WALLTIME_FALLBACK_MULTIPLIER_DEFAULT, 5);
}
#[test]
fn walltime_fallback_env_var_name_is_canonical() {
assert_eq!(
ENV_WALLTIME_FALLBACK_MULTIPLIER,
"XFA_JS_WALLTIME_FALLBACK_MULTIPLIER"
);
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn variables_script_cap_is_above_event_cap_and_bounded() {
assert!(
MAX_VARIABLES_SCRIPT_BODY_BYTES > MAX_SCRIPT_BODY_BYTES,
"variables-script cap must exceed event-script cap"
);
assert!(
MAX_VARIABLES_SCRIPT_BODY_BYTES >= 768 * 1024,
"variables-script cap below observed real-world max"
);
assert!(
MAX_VARIABLES_SCRIPT_BODY_BYTES <= DEFAULT_MEMORY_BUDGET_BYTES / 8,
"variables-script cap must stay an order of magnitude below memory budget"
);
}
}