Files
pdfcraft/crates/js/src/tests.rs
T
aiongg f017ca3b78 Merge PR #3580: M6: form buttons show the side panels (app.execMenuItem)
crates/js/src/lib.rs: module doc app/util list has execMenuItem and scand; install() doc comment from main.
crates/js/src/tests.rs, crates/engine/src/tests.rs: both sides' appended tests kept.
parity/acrobat-features.toml: js notes: execMenuItem wording plus scand wording.
2026-10-24 06:23:47 +00:00

1801 lines
90 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
fn fields() -> Vec<FieldState> {
let mut agree = FieldState::new("agree", FieldType::CheckBox, vec![]);
agree.options = vec![("Yes".into(), "Yes".into())];
let mut size = FieldState::new("size", FieldType::ComboBox, vec!["m".into()]);
size.options = vec![("s".into(), "Small".into()), ("m".into(), "Medium".into()), ("l".into(), "Large".into())];
vec![
FieldState::new("price", FieldType::Text, vec!["12.5".into()]),
FieldState::new("qty", FieldType::Text, vec!["4".into()]),
FieldState::new("total", FieldType::Text, vec![]),
FieldState::new("zip", FieldType::Text, vec!["02134".into()]),
agree,
size,
]
}
fn doc() -> DocInfo {
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())], xfa: false }
}
fn go(script: &str, event: &Event) -> Outcome {
run(script, event, &doc(), &fields(), &[], Limits::default())
}
#[test]
fn calculate_scripts_read_fields_and_set_the_value() {
let o = go("event.value = this.getField('price').value * getField('qty').value;", &Event::field("Calculate", "total", ""));
assert_eq!(o.error, None);
assert_eq!(o.value, "50");
assert!(o.rc);
}
#[test]
fn numbers_with_leading_zeros_stay_text() {
let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", ""));
assert_eq!(o.value, "string:number");
}
#[test]
fn validate_scripts_reject_with_rc_and_alert() {
let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11"));
assert!(!o.rc);
assert_eq!(o.alerts, ["Too many"]);
let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3"));
assert!(o.rc);
}
#[test]
fn keystroke_scripts_see_the_change() {
let mut e = Event::field("Keystroke", "qty", "1");
e.change = "x".into();
e.will_commit = false;
let o = go("event.rc = /^[0-9]*$/.test(event.change);", &e);
assert!(!o.rc);
}
#[test]
fn scripts_change_other_fields_and_their_properties() {
let o = go(
"var t = getField('total'); t.value = 99; t.readonly = true; t.display = display.hidden; \
getField('agree').checkThisBox(0, true); getField('size').value = 'l'; t.textColor = color.red;",
&Event::field("Mouse Up", "agree", ""),
);
assert_eq!(o.error, None);
let total = o.changed.iter().find(|f| f.name == "total").unwrap();
assert_eq!(total.value, ["99"]);
assert!(total.readonly);
assert_eq!(total.display, DISPLAY_HIDDEN);
assert_eq!(total.text_color.as_deref(), Some(&["RGB".to_string(), "1".into(), "0".into(), "0".into()][..]));
assert_eq!(o.changed.iter().find(|f| f.name == "agree").unwrap().value, ["Yes"]);
assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]);
}
#[test]
fn choice_and_check_box_object_model() {
let o = go(
"var s = getField('size'); event.value = [s.numItems, s.getItemAt(2, false), s.currentValueIndices, s.valueAsString, \
getField('agree').value, getField('agree').isBoxChecked(0), s.type].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.value, "3|Large|1|m|Off|false|combobox");
}
#[test]
fn util_printf_printd_and_printx() {
let o = go(
"event.value = [util.printf('%,0.2f', 1234567.891), util.printf('%05d|%s|%x', 42, 'hi', 255), util.printf('%,2.2f', 1234.5), \
util.printd('mmm d, yyyy HH:MM', new Date(2024, 0, 5, 9, 7)), util.printd('dddd', new Date(2024, 0, 5)), \
util.printx('(999) 999-9999', '5551234567'), util.printx('>AAA', 'abc')].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "1,234,567.89|00042|hi|FF|1.234,50|Jan 5, 2024 09:07|Friday|(555) 123-4567|ABC");
}
#[test]
fn printd_escapes_multibyte_chars() {
let o = go(
"event.value = [util.printd('yyyy \\\\é', new Date(2024, 0, 5)), util.printd('\\\\€ yyyy', new Date(2024, 0, 5)), \
util.printd('\\\\y yyyy', new Date(2024, 0, 5))].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "2024 é|€ 2024|y 2024");
}
fn assert_printf_error(script: &str, message: &str) {
let o = go(script, &Event::doc("Open"));
assert!(o.error.as_deref().is_some_and(|e| e.starts_with(message)), "{script}: {:?}", o.error);
let o = xfa::run_xfa(
script,
&xfa::XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() },
&xfa::XfaDoc::default(),
&xfa_model::form(),
Limits::default(),
);
assert!(o.error.as_deref().is_some_and(|e| e.starts_with(message)), "XFA: {script}: {:?}", o.error);
}
#[test]
fn util_printf_rejects_large_width() {
assert_printf_error("util.printf('%99999999999d', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_large_precision() {
assert_printf_error("util.printf('%.99999999999f', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_overflowing_width() {
assert_printf_error("util.printf('%9999999999999999999999999999999999999999d', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_overflowing_precision() {
assert_printf_error("util.printf('%.9999999999999999999999999999999999999999f', 1)", "TypeError: util.printf width or precision exceeds 4096");
}
#[test]
fn util_printf_rejects_limits_for_all_conversions() {
for conv in ['d', 'f', 's', 'x'] {
for spec in ["4097", ".4097", "99999999999", ".99999999999"] {
assert_printf_error(&format!("util.printf('%{spec}{conv}', 1)"), "TypeError: util.printf width or precision exceeds 4096");
}
}
}
#[test]
fn util_printf_bounds_total_output() {
for script in [
"util.printf('%4096d'.repeat(300), 1)",
"util.printf('%04096d'.repeat(300), -1)",
"util.printf('aa'.repeat(524288) + 'a')",
"util.printf('é'.repeat(524289))",
"util.printf('%%%%'.repeat(524288) + '%%')",
"util.printf('%s', 'aa'.repeat(524288) + 'a')",
"util.printf('%s%s', 'aa'.repeat(524288), 'b')",
] {
assert_printf_error(script, "TypeError: util.printf output exceeds 1048576 bytes");
}
}
#[test]
fn util_printf_accepts_limits_and_preserves_formats() {
let o = go(
"event.value = [util.printf('%4096d', 1).length, util.printf('%04096d', -1).length, \
util.printf('%.4096f', 1).length, util.printf('%.4096s', 'a'.repeat(4097)).length, \
util.printf('%4096x', 255).length, util.printf('%.4096x', 255), \
util.printf('%4096d'.repeat(255) + '%4095d', 1).length, util.printf('%4096d'.repeat(256), 1).length, \
util.printf('é'.repeat(524288)).length, util.printf('a%%b:%+06d|%4s|%.2s|%x', 42, 'é', 'é界a', 255)].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "4096|4096|4098|4096|4096|FF|1048575|1048576|524288|a%b:+00042| é|é界|FF");
}
fn assert_array_error(script: &str) {
let o = go(script, &Event::field("Calculate", "total", ""));
assert!(o.error.as_deref().is_some_and(|e| e.starts_with("TypeError: array length exceeds 1048576")), "{script}: {:?}", o.error);
assert!(o.requests.is_empty() && o.changed.is_empty(), "{script}");
}
#[test]
fn reset_form_and_field_setters_refuse_huge_arrays() {
for script in [
"this.resetForm(new Array(4294967295))",
"getField('total').value = new Array(4294967295)",
"event.target.value = new Array(4294967295)",
"getField('total').textColor = new Array(4294967295)",
"getField('total').fillColor = new Array(4294967295)",
] {
assert_array_error(script);
}
}
#[test]
fn reset_form_and_field_setters_take_arrays_up_to_the_limit() {
let o = go("this.resetForm(new Array(1 << 20)); getField('total').value = new Array(1 << 20);", &Event::field("Calculate", "total", ""));
assert_eq!(o.error, None);
assert!(matches!(&o.requests[..], [Request::Reset(names)] if names.len() == 1 << 20 && names.iter().all(String::is_empty)));
assert_eq!(o.changed.iter().find(|f| f.name == "total").unwrap().value, [""]);
assert_array_error("this.resetForm(new Array((1 << 20) + 1))");
assert_array_error("getField('total').value = new Array((1 << 20) + 1)");
}
#[test]
fn reset_form_and_field_setters_still_read_short_arrays() {
let mut f = fields();
f.push(FieldState::new("pick", FieldType::ListBox, vec![]));
let o = run(
"this.resetForm(['a', 'b']); getField('pick').value = ['x', 'y', 'z']; getField('total').value = ['x', 'y', 'z'];",
&Event::field("Calculate", "total", ""),
&doc(),
&f,
&[],
Limits::default(),
);
assert_eq!(o.error, None);
assert_eq!(o.requests, [Request::Reset(vec!["a".into(), "b".into()])]);
assert_eq!(o.changed.iter().find(|f| f.name == "pick").unwrap().value, ["x", "y", "z"]);
assert_eq!(o.changed.iter().find(|f| f.name == "total").unwrap().value, ["x"]);
}
#[test]
fn document_requests_and_console() {
let o = go(
"console.println('hello ' + this.documentFileName + ' ' + numPages + ' ' + info.title); this.pageNum = 2; \
this.resetForm(['qty']); this.print(); app.launchURL('https://example.org'); this.submitForm({cURL: 'https://example.org/f'});",
&Event::field("Mouse Up", "agree", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.console, ["hello order.pdf 3 Order"]);
assert_eq!(
o.requests,
[
Request::GoToPage(2),
Request::Reset(vec!["qty".into()]),
Request::Print,
Request::LaunchUrl("https://example.org".into()),
Request::Submit("https://example.org/f".into())
]
);
}
/// Bytes of text a run handed back in its messages, console lines and requests.
fn kept_bytes(o: &Outcome) -> usize {
let req = |r: &Request| match r {
Request::Reset(names) => names.iter().map(String::len).sum(),
Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(),
_ => 0,
};
o.console.iter().chain(&o.alerts).map(String::len).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
}
#[test]
fn console_alerts_and_requests_are_capped_in_count_and_bytes() {
let o = go(
"var s = 'x'; while (s.length < 8192) s += s; \
for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \
var a = []; for (var i = 0; i < 2000; i++) a.push(s); \
for (var i = 0; i < 5; i++) this.resetForm(a); \
for (var i = 0; i < 12000; i++) app.launchURL('u');",
&Event::field("Mouse Up", "agree", ""),
);
assert_eq!(o.error, None);
assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len());
assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len());
assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len());
assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097));
assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o));
assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last());
}
#[test]
fn printd_reads_a_long_literal_run_in_linear_work() {
crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed);
let o = go("event.value = util.printd(new Array(20001).join('x'), new Date(2024, 0, 5)).length;", &Event::field("Calculate", "total", ""));
assert_eq!(o.error, None);
assert_eq!(o.value, "20000");
// Other tests format short dates at the same time; they add a few dozen bytes at most.
let scanned = crate::PRINTD_SCANNED.load(std::sync::atomic::Ordering::Relaxed);
assert!(scanned < 40_000, "printd scanned {scanned} bytes for a 20000-byte format");
}
#[test]
fn document_level_functions_are_available() {
let o = run(
"event.value = double(getField('qty').value);",
&Event::field("Calculate", "total", ""),
&doc(),
&fields(),
&["function double(x) { return x * 2; }".into()],
Limits::default(),
);
assert_eq!(o.value, "8");
}
/// #3940: a document script that throws after defining a helper (here calling an Acrobat method
/// PdfCraft lacks) still leaves the helper, and the later scripts, to the field script.
#[test]
fn a_failing_document_script_does_not_stop_the_field_script() {
let docs = [
"function double(x) { return x * 2; }\nthis.getField('qty').buttonSetIcon(null);\nvar after = 1;".to_string(),
"function plus(x) { return x + 1; }".to_string(),
"this is not javascript".to_string(),
];
let o = run(
"event.value = plus(double(getField('qty').value));",
&Event::field("Calculate", "total", ""),
&doc(),
&fields(),
&docs,
Limits::default(),
);
assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "9"));
assert_eq!(o.doc_errors.len(), 2, "{:?}", o.doc_errors);
assert!(o.doc_errors[0].starts_with("document script 1: TypeError"), "{:?}", o.doc_errors);
assert!(o.doc_errors[1].starts_with("document script 3: SyntaxError"), "{:?}", o.doc_errors);
// The failing script stopped where it failed.
let o = run("event.value = typeof after;", &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default());
assert_eq!(o.value, "undefined");
// Working document scripts report nothing.
assert!(go("event.value = 1;", &Event::field("Calculate", "total", "")).doc_errors.is_empty());
}
/// A document script refused before running (too long, or nested too deeply to run safely) is
/// skipped like one that throws: never run, reported in its place, the rest still run.
#[test]
fn a_document_script_too_big_to_run_is_skipped_and_reported() {
let docs = [
"this.getField('qty').buttonSetIcon(null);".to_string(),
format!("var big = 1{};", "+1".repeat(MAX_SCRIPT_BYTES / 2)),
"function double(x) { return x * 2; }".to_string(),
format!("var deep = {}1{};", "(".repeat(10_000), ")".repeat(10_000)),
];
let o = run(
"event.value = double(getField('qty').value) + typeof big;",
&Event::field("Calculate", "total", ""),
&doc(),
&fields(),
&docs,
Limits::default(),
);
assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "8undefined"));
let places: Vec<&str> = o.doc_errors.iter().map(|e| e.split(':').next().unwrap_or_default()).collect();
assert_eq!(places, ["document script 1", "document script 2", "document script 4"], "{:?}", o.doc_errors);
assert!(o.doc_errors[1].contains("too long") && o.doc_errors[2].contains("nested too deeply"), "{:?}", o.doc_errors);
// The field script's own limits still refuse it outright.
let o = run(&format!("1{}", "+1".repeat(MAX_SCRIPT_BYTES)), &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default());
assert!(o.error.is_some_and(|e| e.contains("too long")) && o.doc_errors.is_empty());
}
#[test]
fn errors_and_runaway_scripts_are_contained() {
let o = go("event.value = nosuch.thing;", &Event::field("Calculate", "total", "keep"));
assert!(o.error.is_some());
let o = run("while (true) {}", &Event::doc("Open"), &doc(), &fields(), &[], Limits { loop_iterations: 10_000, recursion: 64 });
assert!(o.error.is_some(), "the loop limit stops it");
let o = go("function f() { return f(); } f();", &Event::doc("Open"));
assert!(o.error.is_some(), "the recursion limit stops it");
let o = go("getField('nope').value", &Event::doc("Open"));
assert!(o.error.as_deref().unwrap_or("").contains("null") || o.error.is_some());
}
#[test]
fn the_sandbox_has_no_host_access() {
let o = go(
"event.value = [typeof require, typeof process, typeof fetch, typeof XMLHttpRequest, typeof importScripts].join(',');",
&Event::doc("Open"),
);
assert_eq!(o.value, "undefined,undefined,undefined,undefined,undefined");
}
/// Hostile nesting used to overflow the stack in boa's parser and compiler, aborting the app
/// (about 100 nested parentheses were enough on a 2 MiB stack).
#[test]
fn deeply_nested_scripts_fail_instead_of_crashing() {
let n = 100_000;
let hostile = [
format!("event.value = {}1{};", "(".repeat(n), ")".repeat(n)),
format!("var a = {}1{};", "[".repeat(n), "]".repeat(n)),
format!("{}{}", "{".repeat(n), "}".repeat(n)),
format!("var a = {}1;", "!".repeat(n)),
format!("var a = {}1;", "- ".repeat(n)),
format!("var a = {}1;", "1?1:".repeat(n)),
format!("var f = {}1;", "a=>".repeat(n)),
];
for s in &hostile {
let o = go(s, &Event::doc("Open"));
assert!(o.error.is_some(), "{}…", &s[..20]);
}
// A hostile document-level script is refused too: it never runs, and is reported, while
// the field script still does (#3940).
let o = run("event.value = 'ran';", &Event::field("Calculate", "a", ""), &doc(), &fields(), &[hostile[0].clone()], Limits::default());
assert_eq!((o.error, o.value.as_str()), (None, "ran"));
assert!(o.doc_errors.len() == 1 && o.doc_errors[0].contains("nested too deeply"), "{:?}", o.doc_errors);
}
#[test]
fn ordinary_nesting_still_runs() {
let n = 40;
let s = format!("event.value = {}1{} + [[[2]]][0][0][0] + (1 ? 2 : 3);", "(".repeat(n), ")".repeat(n));
let o = go(&s, &Event::field("Calculate", "a", ""));
assert_eq!(o.error, None);
assert_eq!(o.value, "5");
// Brackets in strings and comments don't count.
let s = format!("// {}\nevent.value = '{}'.length;", "(".repeat(1000), "[".repeat(1000));
assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000");
}
/// Code built from strings at run time would reach the parser without the size and nesting
/// checks the script itself passed, so `eval` and the `Function` constructors refuse it.
#[test]
fn scripts_cannot_compile_code_from_strings() {
for s in [
"event.value = eval('1 + 1');",
"event.value = (0, eval)('1 + 1');",
"event.value = new Function('return 2')();",
"event.value = Function('a', 'return a')(2);",
"event.value = (function* () {}).constructor('yield 2')().next().value;",
"event.value = new Array(1001).join('(') + '1' + new Array(1001).join(')'); event.value = eval(event.value);",
] {
let o = go(s, &Event::field("Calculate", "a", ""));
assert!(o.error.as_deref().is_some_and(|e| e.contains("compile code from strings")), "{s}: {o:?}");
}
}
mod xfa_model {
use crate::Limits;
use crate::xfa::*;
pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode {
XfaNode {
name: name.into(),
som: som.into(),
kind: Some(XfaKind::Field),
value: value.into(),
numeric,
presence: "visible".into(),
..Default::default()
}
}
/// form1 > page1 > { qty, price, total, details (subform, hidden) > note, table > row[0..2] > { what, amount }, go (button) }
pub(super) fn form() -> XfaNode {
let row = |i: usize| XfaNode {
name: "row".into(),
som: format!("form1[0].page1[0].table[0].row[{i}]"),
kind: Some(XfaKind::Subform),
repeatable: true,
occur_min: 1,
occur_max: None,
index: i,
children: vec![
field("what", &format!("form1[0].page1[0].table[0].row[{i}].what[0]"), if i == 0 { "Rent" } else { "" }, false),
field("amount", &format!("form1[0].page1[0].table[0].row[{i}].amount[0]"), if i == 0 { "10" } else { "5" }, true),
],
..Default::default()
};
XfaNode {
name: "form1".into(),
som: "form1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![XfaNode {
name: "page1".into(),
som: "form1[0].page1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![
field("qty", "form1[0].page1[0].qty[0]", "4", true),
field("price", "form1[0].page1[0].price[0]", "2.5", true),
field("total", "form1[0].page1[0].total[0]", "", true),
XfaNode {
name: "details".into(),
som: "form1[0].page1[0].details[0]".into(),
kind: Some(XfaKind::Subform),
presence: "hidden".into(),
children: vec![field("note", "form1[0].page1[0].details[0].note[0]", "", false)],
..Default::default()
},
XfaNode {
name: "table".into(),
som: "form1[0].page1[0].table[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![row(0), row(1)],
..Default::default()
},
field("go", "form1[0].page1[0].go[0]", "", false),
],
..Default::default()
}],
..Default::default()
}
}
/// Run the JavaScript `script` for `activity` on the test form, from the node at `target`.
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 };
run_xfa(script, &ev, &doc, &form(), Limits::default())
}
/// A calculate script finds its siblings by name and yields its last expression, and a
/// value it sets comes back as an effect.
#[test]
fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() {
let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("10"));
let o = go("this.rawValue = xfa.form.form1.page1.qty.rawValue + 1;", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]);
}
/// A long value written again and again stops at the run's byte budget, and the write that
/// does not fit is left out of the field the script reads as well as of the effect.
#[test]
fn a_value_past_the_byte_budget_is_left_out_of_reads_and_effects_alike() {
let o = go(
"var s = 'x'; while (s.length < 65536) s += s; \
for (var i = 0; i < 300; i++) details.note.rawValue = ('00000' + i).slice(-6) + s.slice(6); \
details.note.rawValue.slice(0, 6)",
"form1[0].page1[0].total[0]",
"calculate",
);
assert_eq!(o.error, None, "{o:?}");
let set: Vec<&str> = o
.effects
.iter()
.filter_map(|e| if let XfaEffect::SetValue { value, .. } = e { Some(value.get(..6).unwrap_or(value)) } else { None })
.collect();
assert_eq!(set.len(), 1);
assert_eq!(o.result.as_deref(), set.first().copied());
}
/// The script runs through `eval`, so code built from strings at run time is held to the
/// script's own size and nesting checks instead of being refused outright.
#[test]
fn code_built_from_strings_is_checked_like_the_script() {
let total = "form1[0].page1[0].total[0]";
assert_eq!(go("eval('qty.rawValue * 2')", total, "calculate").result.as_deref(), Some("8"));
assert_eq!(go("new Function('return 3')()", total, "calculate").result.as_deref(), Some("3"));
for s in [
"eval(new Array(1001).join('(') + '1' + new Array(1001).join(')'))",
"new Function(new Array(1001).join('[') + new Array(1001).join(']'))",
] {
let o = go(s, total, "calculate");
assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{s}: {o:?}");
}
}
/// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020.
pub(super) fn dated_form() -> XfaNode {
let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) };
XfaNode {
name: "form1".into(),
som: "form1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![XfaNode {
name: "page1".into(),
som: "form1[0].page1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![d],
..Default::default()
}],
..Default::default()
}
}
/// `formattedValue` reads the value as the field's picture shows it, and `rawValue` the
/// value itself; once the script sets the value, `formattedValue` gives that value.
#[test]
fn formatted_value_reads_the_shown_value_until_the_script_sets_one() {
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |script: &str| run_xfa(script, &ev, &doc, &dated_form(), Limits::default());
let o = run("this.formattedValue + ' ' + this.rawValue");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
let o = run("this.rawValue = '2026-10-21'; this.formattedValue");
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
}
/// Long values a script sets, and long results, are cut where they are stored, with a note.
#[test]
fn long_values_and_results_are_cut_where_they_are_stored() {
// The value kept for later reads is the one the effect saves.
let o = go("details.note.rawValue = new Array(100001).join('x'); details.note.rawValue.length", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("65536"));
assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes);
// A calculate's result is cut like a value set, and noted.
let o = go("new Array(100001).join('x')", "form1[0].page1[0].details[0].note[0]", "calculate");
assert_eq!(o.result.map(|r| r.chars().count()), Some(65_536));
assert!(o.notes.iter().any(|n| n.contains("result")), "{:?}", o.notes);
}
#[test]
fn values_read_as_numbers_or_strings_and_null_when_empty() {
let o = go(
"typeof qty.rawValue + ':' + typeof this.rawValue + ':' + (total.rawValue === null) + ':' + typeof table.row.what.rawValue",
"form1[0].page1[0].price[0]",
"click",
);
assert_eq!(o.result.as_deref(), Some("number:number:true:string"));
}
#[test]
fn navigation_parent_resolve_node_and_nodes() {
let o = go(
"this.parent.name + '/' + this.parent.parent.className + '/' + this.resolveNode('qty').rawValue + '/' + xfa.resolveNode('$form.form1.page1.table.row[1].amount').rawValue + '/' + this.parent.nodes.length + '/' + xfa.resolveNodes('form1.page1.table.row[*]').length + '/' + xfa.resolveNode('$form..amount').somExpression",
"form1[0].page1[0].price[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("page1/subform/4/5/6/2/form1[0].page1[0].table[0].row[0].amount[0]"));
}
#[test]
fn presence_and_access_changes_are_effects() {
let o = go("details.presence = 'visible'; details.note.access = 'readOnly'; this.presence = 'nonsense';", "form1[0].page1[0].go[0]", "click");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(
o.effects,
vec![
XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() },
XfaEffect::SetAccess { som: "form1[0].page1[0].details[0].note[0]".into(), access: "readOnly".into() },
]
);
}
#[test]
fn instance_managers_add_remove_and_count() {
let o = go(
"var n = table._row.count; var r = table._row.addInstance(1); r.what.rawValue = 'New'; n + '/' + table._row.count + '/' + r.somExpression + '/' + r.index",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("2/3/form1[0].page1[0].table[0].row[2]/2"));
assert_eq!(
o.effects,
vec![
XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() },
XfaEffect::SetValue { som: "form1[0].page1[0].table[0].row[2].what[0]".into(), value: "New".into() },
]
);
// Through the instance manager property, from inside a row; removing renumbers.
let o = go(
"this.parent.instanceManager.removeInstance(0); xfa.resolveNode('table.row[0]').amount.rawValue",
"form1[0].page1[0].table[0].row[1].amount[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("5"), "the second row is now the first");
assert_eq!(o.effects, vec![XfaEffect::RemoveInstance { som: "form1[0].page1[0].table[0].row[0]".into() }]);
// setInstances, and the floor of one instance.
let o = go(
"table._row.setInstances(4); var a = table._row.count; table._row.setInstances(0); a + '/' + table._row.count",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.result.as_deref(), Some("4/1"));
}
#[test]
fn host_layout_event_and_app_calls_become_effects() {
let o = go(
"xfa.host.messageBox('Hi', 'T', 3, 1); xfa.host.resetData('qty, price'); xfa.host.print(1, '0', '2', 0, 0, 0, 0, 0); app.execMenuItem('SaveAs'); app.launchURL('https://x.test'); xfa.host.setFocus('qty'); xfa.form.recalculate(1); console.println('p' + xfa.layout.page(this) + '/' + xfa.layout.pageCount() + '/' + xfa.host.currentPage + '/' + xfa.event.name); this.border.fill.color.value = '255,0,0'; this.fillColor = 'x'; util.printf('%d', 7)",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(
o.effects,
vec![
XfaEffect::MessageBox("Hi".into()),
XfaEffect::ResetData(vec!["qty".into(), "price".into()]),
XfaEffect::Print,
XfaEffect::SaveAs,
XfaEffect::LaunchUrl("https://x.test".into()),
XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()),
XfaEffect::Recalculate,
]
);
assert_eq!(o.console, vec!["p2/3/1/click".to_string()]);
assert_eq!(o.result.as_deref(), Some("7"));
}
#[test]
fn field_names_never_shadow_javascript_builtins() {
let mut f = form();
f.children[0].children.push(field("Date", "form1[0].page1[0].Date[0]", "x", false));
f.children[0].children.push(field("eval", "form1[0].page1[0].eval[0]", "y", false));
let ev = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
let o = run_xfa(
"typeof new Date().getTime() + ':' + this.parent.Date.rawValue + ':' + this.parent.eval.rawValue",
&ev,
&XfaDoc::default(),
&f,
Limits::default(),
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("number:x:y"));
}
#[test]
fn validate_scripts_give_a_verdict_and_errors_are_reported() {
let o = go("this.rawValue === null || this.rawValue <= 3", "form1[0].page1[0].qty[0]", "validate");
assert_eq!(o.result_bool, Some(false));
let o = go("nosuch.rawValue = 1", "form1[0].page1[0].qty[0]", "click");
assert!(o.error.is_some() && o.effects.is_empty());
let o = go("while (true) {}", "form1[0].page1[0].qty[0]", "click");
assert!(o.error.is_some(), "the loop limit stops it");
let o = go(&"(".repeat(200), "form1[0].page1[0].qty[0]", "click");
assert!(o.error.is_some());
// Adding instances is capped; a runaway loop can't blow the tree up.
let o = go("for (var i = 0; i < 5000; i++) table._row.addInstance(1); table._row.count", "form1[0].page1[0].go[0]", "click");
assert_eq!(o.result.as_deref(), Some("1002"));
}
#[test]
fn values_set_again_merge_and_hostile_loops_are_capped() {
// A calculate that sets its own value in a loop leaves one effect: the last value.
let o = go("for (var i = 0; i < 50000; i++) this.rawValue = i;", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "49999".into() }], "{:?}", o.error);
// Order-dependent effects in between keep the values on either side.
let o = go("qty.rawValue = 1; xfa.host.resetData(); qty.rawValue = 2; qty.rawValue = 3;", "form1[0].page1[0]", "click");
assert_eq!(o.effects.len(), 3, "{:?}", o.effects);
assert_eq!(o.effects.last(), Some(&XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "3".into() }));
// Message boxes and console lines in a loop stop at their caps, and say so.
let o = go("for (var i = 0; i < 100000; i++) { xfa.host.messageBox('m' + i); console.println('c' + i); }", "form1[0].page1[0]", "initialize");
let alerts = o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count();
assert_eq!(alerts, MAX_ALERTS);
assert_eq!(o.console.len(), MAX_CONSOLE);
assert!(o.notes.iter().any(|n| n.contains("messages")) && o.notes.iter().any(|n| n.contains("console")), "{:?}", o.notes);
// Distinct effects stop at the effect cap.
let o = go("for (var i = 0; i < 30000; i++) xfa.host.beep();", "form1[0].page1[0]", "click");
assert_eq!(o.effects.len(), MAX_EFFECTS);
assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes);
// Long messages are cut.
let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click");
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097));
}
/// Past the byte budget, adding or removing a row is left out of the snapshot as well as the
/// effects, so the script never counts rows the form will not get.
#[test]
fn rows_changed_past_the_byte_budget_are_left_out_of_the_snapshot_too() {
let o = go(
"var s = 'x'; while (s.length < 4096) s += s; for (var i = 0; i < 9000; i++) xfa.host.gotoURL(s); \
table._row.removeInstance(0); var added = table._row.addInstance(1); (added === null) + ':' + table._row.count",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert!(o.notes.iter().any(|n| n.contains("MiB")), "{:?}", o.notes);
assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. })));
assert_eq!(o.result.as_deref(), Some("true:2"));
}
/// Past the effect cap, presence and access set on a new object are left out of the snapshot
/// as well as the effects; one set again on an object that already has its effect still lands.
#[test]
fn presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() {
let read = "qty.presence + ':' + qty.access + ':' + price.presence";
let before = go(read, "form1[0].page1[0]", "click").result;
let o = go(
&format!(
"price.presence = 'invisible'; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.presence = 'hidden'; qty.access = 'readOnly'; price.presence = 'hidden'; {read}"
),
"form1[0].page1[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.effects.len(), MAX_EFFECTS);
let unchanged = before.as_deref().and_then(|b| b.rsplit_once(':')).map(|(qty, _)| format!("{qty}:hidden"));
assert_eq!(o.result, unchanged);
assert!(o.effects.contains(&XfaEffect::SetPresence { som: "form1[0].page1[0].price[0]".into(), presence: "hidden".into() }));
assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetPresence { som, .. } | XfaEffect::SetAccess { som, .. } if som.contains("qty"))));
}
/// Past the effect cap, a value written to a field with no effect yet is left out of reads as
/// well as the effects; a field written again before the cap still takes its new value.
#[test]
fn a_value_past_the_effect_cap_is_left_out_of_reads_and_effects_alike() {
let o = go(
"price.rawValue = 1; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.rawValue = 9; price.rawValue = 3; qty.rawValue + ':' + price.rawValue",
"form1[0].page1[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.effects.len(), MAX_EFFECTS);
assert_eq!(o.result.as_deref(), Some("4:3"));
assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { som, .. } if som.contains("qty"))));
}
#[test]
fn urls_and_reset_lists_are_clipped_and_deduplicated() {
let o = go(
"var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \
var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);",
"form1[0].page1[0]",
"initialize",
);
assert_eq!(o.error, None);
let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect();
assert_eq!(urls.len(), 600);
assert!(urls.iter().all(|u| u.chars().count() <= 4_097));
let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum();
assert_eq!(names, 1);
}
#[test]
fn a_slow_engine_start_does_not_count_against_the_script() {
let ms = std::time::Duration::from_millis;
// On a busy machine the engine can take longer to start than a script may run: the
// script still runs (abandoning it would turn the form's scripts off).
let o = run_within_limits("test", ms(10_000), ms(500), move |started| {
std::thread::sleep(ms(1_500));
started();
XfaOutcome { result: Some("ran".into()), ..Default::default() }
});
assert!(!o.abandoned, "{o:?}");
assert_eq!(o.result.as_deref(), Some("ran"));
// The script's own time counts from then.
let o = run_within_limits("test", ms(10_000), ms(200), move |started| {
started();
std::thread::sleep(ms(2_000));
XfaOutcome::default()
});
assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("ran longer")), "{o:?}");
// Start-up has a limit of its own, so nobody waits forever for an engine.
let o = run_within_limits("test", ms(200), ms(10_000), move |started| {
std::thread::sleep(ms(2_000));
started();
XfaOutcome::default()
});
assert!(o.abandoned && o.error.as_deref().is_some_and(|e| e.contains("to start")), "{o:?}");
// A script that finishes without saying it started (refused at once) isn't abandoned.
let o = run_within_limits("test", ms(10_000), ms(200), |_| XfaOutcome { error: Some("refused".into()), ..Default::default() });
assert!(!o.abandoned && o.error.as_deref() == Some("refused"), "{o:?}");
}
}
mod formcalc {
use super::xfa_model::form;
use crate::Limits;
use crate::formcalc::run_formcalc;
use crate::xfa::*;
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 };
run_formcalc(script, &ev, &doc, &form(), Limits::default())
}
/// The value of a script run as a calculate on `total`.
fn calc(script: &str) -> String {
let o = go(script, "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.error, None, "{script}");
o.result.unwrap_or_default()
}
/// A negative value that rounds to zero under the picture prints without a minus sign.
#[test]
fn format_does_not_print_negative_zero() {
assert_eq!(calc(r#"Format("9.9", -0.04)"#), "0.0");
assert_eq!(calc(r#"Format("99", -0.2)"#), "00");
assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5");
}
/// Assigning one long value to many fields at once stops at the run's byte budget and is
/// charged per copy, and a reset list names each field once.
#[test]
fn bulk_assignments_and_reset_lists_are_bounded() {
let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false);
let root = XfaNode {
name: "form1".into(),
som: "form1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![XfaNode {
name: "page1".into(),
som: "form1[0].page1[0]".into(),
kind: Some(XfaKind::Subform),
children: (0..1300).map(f).collect(),
..Default::default()
}],
..Default::default()
};
let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() };
let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default());
let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s");
assert_eq!(o.error, None, "{o:?}");
let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum();
assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept");
assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes);
// 1300 copies of a 1 MiB value are more work than a script may do.
let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s");
assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes);
let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)");
assert_eq!(o.error, None, "{o:?}");
let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum();
assert_eq!(names, 2);
}
/// FormCalc presence and access past the effect cap are left out of the snapshot too.
#[test]
fn formcalc_presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() {
let o = go(
"for i = 1 upto 20000 do $host.beep() endfor\nqty.presence = \"hidden\"\nqty.access = \"readOnly\"\nConcat(qty.presence, \":\", qty.access)",
"form1[0].page1[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.effects.len(), MAX_EFFECTS);
let before = go("Concat(qty.presence, \":\", qty.access)", "form1[0].page1[0]", "click");
assert_eq!(o.result, before.result);
}
/// FormCalc reads `formattedValue` as the field's picture shows it too, until the script
/// sets the value.
#[test]
fn formcalc_formatted_value_reads_the_shown_value_until_the_script_sets_one() {
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |script: &str| run_formcalc(script, &ev, &doc, &super::xfa_model::dated_form(), Limits::default());
let o = run("Concat($.formattedValue, \" \", $.rawValue)");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
let o = run("$.rawValue = \"2026-10-21\"\n$.formattedValue");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
}
/// FormCalc arithmetic, string conversion and comparisons follow FormCalc's own rules.
#[test]
fn arithmetic_strings_and_comparisons_follow_formcalc_rules() {
assert_eq!(calc("1 + 2 * 3"), "7");
assert_eq!(calc("(1 + 2) * 3"), "9");
assert_eq!(calc("10 / 4"), "2.5");
assert_eq!(calc("-3 + +4"), "1");
assert_eq!(calc(r#""12" + 3"#), "15");
assert_eq!(calc(r#""abc" + 3"#), "3");
assert_eq!(calc(r#"Concat("a", "b", 1.50)"#), "ab1.5");
assert_eq!(calc(r##""say ""hi""""##), "say \"hi\"");
assert_eq!(calc("1 < 2 and 2 <= 2"), "1");
assert_eq!(calc("1 == 1.0 & 3 <> 4"), "1");
assert_eq!(calc(r#""a" lt "b" | 0"#), "1");
assert_eq!(calc("not 0"), "1");
assert_eq!(calc("!0 + !1"), "1");
assert_eq!(calc("table.row[0]..amount"), "10");
assert_eq!(calc(r#""abc" == "ABC""#), "0");
// Logical operands are numbers: text that is not a number is false.
assert_eq!(calc(r#"if ("abc") then 1 else 0 endif"#), "0");
assert_eq!(calc(r#"if ("2") then 1 else 0 endif"#), "1");
assert_eq!(calc("null + 1"), "1");
assert_eq!(calc("null == null"), "1");
assert_eq!(calc("1 == null"), "0");
assert_eq!(calc("1 <> null"), "1");
assert!(go("1 / 0", "form1[0].page1[0].total[0]", "calculate").error.is_some());
// Comments and newlines inside brackets.
assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6");
}
#[test]
fn date_pictures_render_the_weekday_letters() {
assert_eq!(calc(r#"Num2Date(45350, DateFmt(4))"#), "Thursday, February 29, 2024");
assert_eq!(calc(r#"Num2Date(45350, "EEE, YYYY-MM-DD")"#), "Thu, 2024-02-29");
assert_eq!(calc(r#"Num2Date(45350, "EEEE")"#), "Thursday");
assert_eq!(calc(r#"Num2Date(45350, "E")"#), "5");
assert_eq!(calc(r#"Num2Date(45351, "EEEE")"#), "Friday");
assert_eq!(calc(r#"Num2Date(45352, "EEEE")"#), "Saturday");
assert_eq!(calc(r#"Num2Date(45353, "EEEE")"#), "Sunday");
assert_eq!(calc(r#"Num2Date(1, "EEEE")"#), "Monday");
assert_eq!(calc(r#"Format("EEEE, YYYY-MM-DD", 45350)"#), "Thursday, 2024-02-29");
// Other letters are still literal text.
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x");
}
/// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the
/// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format.
#[test]
fn date_numbers_beyond_chronos_range_keep_their_dates() {
assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16");
assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16");
assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11");
assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16");
assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20");
}
#[test]
fn str_and_format_round_exact_ties_away_from_zero() {
assert_eq!(calc("Str(2.5, 3, 0)"), " 3");
assert_eq!(calc("Str(0.5, 3, 0)"), " 1");
assert_eq!(calc("Str(-2.5, 4, 0)"), " -3");
assert_eq!(calc("Str(1.125, 6, 2)"), " 1.13");
assert_eq!(calc(r#"Format("9", 2.5)"#), "3");
assert_eq!(calc(r#"Format("9.99", 0.125)"#), "0.13");
assert_eq!(calc(r#"Format("9.9", 0.25)"#), "0.3");
assert_eq!(calc(r#"Format("9.9", -0.25)"#), "-0.3");
assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34");
}
#[test]
fn built_in_functions_cover_the_common_set() {
assert_eq!(calc("Abs(-2.5)"), "2.5");
assert_eq!(calc("Ceil(2.1)"), "3");
assert_eq!(calc("Floor(-2.1)"), "-3");
assert_eq!(calc("Mod(7, 3)"), "1");
assert_eq!(calc("Round(2.345, 2)"), "2.35");
assert_eq!(calc("Sum(1, 2, 3)"), "6");
assert_eq!(calc("Avg(1, 2, 3, null)"), "2");
assert_eq!(calc("Count(1, null, 3)"), "2");
assert_eq!(calc("Max(1, 9, 3)"), "9");
assert_eq!(calc("Min(4, 2, 3)"), "2");
assert_eq!(calc(r#"Choose(2, "a", "b", "c")"#), "b");
assert_eq!(calc(r#"Oneof(3, 1, 2, 3)"#), "1");
assert_eq!(calc(r#"Within(5, 1, 10)"#), "1");
assert_eq!(calc(r#"HasValue("")"#), "0");
assert_eq!(calc(r#"HasValue("x")"#), "1");
assert_eq!(calc(r#"At("abcd", "c")"#), "3");
assert_eq!(calc(r#"Left("hello", 2)"#), "he");
assert_eq!(calc(r#"Right("hello", 2)"#), "lo");
assert_eq!(calc(r#"Len("héllo")"#), "5");
assert_eq!(calc(r#"Lower("ABC")"#), "abc");
assert_eq!(calc(r#"Upper("abc")"#), "ABC");
assert_eq!(calc(r#"Ltrim(" a ")"#), "a ");
assert_eq!(calc(r#"Rtrim(" a ")"#), " a");
assert_eq!(calc(r#"Space(3)"#), " ");
assert_eq!(calc(r#"Str(3.14159, 6, 2)"#), " 3.14");
assert_eq!(calc(r#"Substr("abcdef", 2, 3)"#), "bcd");
assert_eq!(calc(r#"Stuff("abcdef", 2, 3, "XY")"#), "aXYef");
assert_eq!(calc(r#"Replace("a-b-c", "-", "+")"#), "a+b+c");
assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two");
assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents");
assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent");
assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50");
assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00");
assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024");
assert_eq!(calc(r#"Parse("zzz,zz9.99", "1,234.50")"#), "1234.5");
assert_eq!(calc(r#"Parse("MMM D, YYYY", "Mar 15, 1996")"#), "1996-03-15");
assert_eq!(calc(r#"Date2Num("1900-01-01")"#), "1");
assert_eq!(calc(r#"Date2Num("2024-02-29")"#), "45350");
assert_eq!(calc(r#"Date2Num("29/02/2024", "DD/MM/YYYY")"#), "45350");
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD")"#), "2024-02-29");
assert_eq!(calc(r#"Num2Date(Date2Num("2024-02-29") + 1, "MMMM D, YYYY")"#), "March 1, 2024");
assert_eq!(calc(r#"Time2Num("01:00:00")"#), "3600000");
assert_eq!(calc(r#"IsoTime2Num("12:30:45Z")"#), "45045000");
assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250");
assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000");
assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01");
assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49");
assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25");
assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51");
assert_eq!(calc(r#"UnitValue("2in", "mm")"#), "50.8");
assert_eq!(calc(r#"UnitType("2.5cm")"#), "cm");
assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1");
assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1");
assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72");
assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c");
assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b");
assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network"));
assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function"));
}
#[test]
fn format_nine_pads_the_integer_part() {
assert_eq!(calc(r#"Format("99999", 42)"#), "00042");
assert_eq!(calc(r#"Format("9999", 5)"#), "0005");
assert_eq!(calc(r#"Format("999.99", 4.5)"#), "004.50");
assert_eq!(calc(r#"Format("zzz9", 42)"#), "42");
assert_eq!(calc(r#"Format("99", 12345)"#), "12345");
}
#[test]
fn date2num_rejects_days_past_the_end_of_the_month() {
assert_eq!(calc(r#"Date2Num("2023-02-30")"#), "0");
assert_eq!(calc(r#"Date2Num("31/04/2024", "DD/MM/YYYY")"#), "0");
assert_eq!(calc(r#"Date2Num("2023-02-29")"#), "0");
assert_eq!(calc(r#"Date2Num("1900-02-29")"#), "0");
assert_eq!(calc(r#"Parse("YYYY-MM-DD", "2023-02-30") == null"#), "1");
// Valid month ends still work.
assert_eq!(calc(r#"Date2Num("2024-02-29")"#), "45350");
assert_eq!(calc(r#"Date2Num("2000-02-29") <> 0"#), "1");
assert_eq!(calc(r#"Date2Num("2024-04-30") <> 0"#), "1");
assert_eq!(calc(r#"Date2Num("2024-12-31") <> 0"#), "1");
}
#[test]
fn control_flow_variables_and_functions_work() {
assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10");
// An assignment is an expression: its value is the script's result.
assert_eq!(calc("qty = 7"), "7");
assert_eq!(calc("var y = 8"), "8");
assert_eq!(calc("var x = 1\nif (x > 2) then 10 elseif (x > 0) then 20 else 30 endif"), "20");
assert_eq!(calc("var i = 0\nvar s = 0\nwhile (i < 5) do\n i = i + 1\n if (i == 3) then continue endif\n s = s + i\nendwhile\ns"), "12");
assert_eq!(calc("var s = 0\nfor i = 1 upto 10 step 3 do s = s + i endfor\ns"), "22");
assert_eq!(calc("var s = 0\nfor i = 5 downto 1 do\n if (i == 2) then break endif\n s = s + i\nendfor\ns"), "12");
assert_eq!(
calc(
r#"var s = ""
foreach w in ("a", "b", "c") do s = Concat(s, w) endfor
s"#
),
"abc"
);
assert_eq!(calc("func sq(n) do n * n endfunc\nsq(4) + sq(3)"), "25");
assert_eq!(calc("func Sq(n) do n * n endfunc\nSQ(4)"), "16");
// `exit` inside a function ends the whole script.
let o = go("func f() do exit endfunc\nf()\n$host.messageBox(\"still ran\")\n5", "form1[0].page1[0].go[0]", "click");
assert_eq!(o.error, None);
assert!(o.effects.is_empty(), "{:?}", o.effects);
// Rows can be walked as objects.
assert_eq!(calc("var s = 0\nforeach r in (table.row[*]) do s = s + r.amount endfor\ns"), "15");
// A long sum is not deep nesting.
let long = format!("1{}", " + 1".repeat(500));
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
let o = run_formcalc(&long, &ev, &XfaDoc::default(), &form(), Limits { loop_iterations: 1000, recursion: 64 });
assert_eq!(o.result.as_deref(), Some("501"), "{o:?}");
// Null is an empty result with no truth value, as in JavaScript.
let o = go("if (0) then 5 endif", "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.result.as_deref(), Some(""));
assert_eq!(o.result_bool, None);
assert_eq!(calc("func f(n) do if (n < 2) then return 1 endif\n return n * f(n - 1) endfunc\nf(5)"), "120");
assert_eq!(calc("IF (1) THEN 5 ENDIF"), "5");
let o = go(r#"throw "stop here""#, "form1[0].page1[0].total[0]", "calculate");
assert_eq!(o.error.as_deref(), Some("stop here"));
let o = go("if (1) then 1", "form1[0].page1[0].total[0]", "calculate");
assert!(o.error.unwrap().contains("endif"));
}
#[test]
fn form_objects_resolve_assign_and_record_effects() {
// Fields deref to their values; `$` is the current field.
assert_eq!(calc("qty * price"), "10");
assert_eq!(calc("$.name"), "total");
assert_eq!(calc("$form.form1.page1.qty + 1"), "5");
assert_eq!(calc("xfa.form.form1.page1.qty"), "4");
assert_eq!(calc("$record.page1.qty"), "4");
assert_eq!(calc("Sum(table.row[*].amount)"), "15");
assert_eq!(calc("Count(table.row[*])"), "2");
assert_eq!(calc("Count(table.row[*].what)"), "1");
// Lists hold each object once, however they were reached.
assert_eq!(calc("Count((table.row[*].parent..row).parent..row)"), "2");
assert_eq!(calc("Count(xfa.resolveNodes(\"table.row[*].parent.row[*].parent.row[*]\"))"), "2");
// Bare names in a row see every sibling row, and `[-1]`/`[+1]` are relative to the row
// the script runs in.
let in_row = |s: &str| {
let o = go(s, "form1[0].page1[0].table[0].row[1].amount[0]", "calculate");
assert_eq!(o.error, None, "{s}");
o.result.unwrap_or_default()
};
assert_eq!(in_row("Count(row[*])"), "2");
assert_eq!(in_row("Sum(row[*].amount)"), "15");
assert_eq!(in_row("row[1].amount"), "5");
assert_eq!(in_row("row[-1].amount"), "10");
// Past the ends there is no object, so a property of it is an error (as in Acrobat).
let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate");
assert!(past.error.unwrap().contains("no object"));
assert_eq!(in_row("Exists(row[-5])"), "0");
let o = go("row[+1].amount", "form1[0].page1[0].table[0].row[0].amount[0]", "calculate");
assert_eq!(o.result.as_deref(), Some("5"));
assert_eq!(calc("table.row[1].amount"), "5");
assert_eq!(calc("table.row.amount"), "10");
assert_eq!(calc("$form..amount[1]"), "5");
assert_eq!(calc("table._row.count"), "2");
assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1");
assert_eq!(calc("HasValue(total)"), "0");
assert_eq!(calc("$host.numPages"), "3");
assert_eq!(calc("$layout.pageCount()"), "3");
assert_eq!(calc("$event.name"), "calculate");
assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5");
assert_eq!(calc("$.border.fill.color.value"), "");
assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name"));
// Assignment to a field (by name and by path), a property and a variable.
let o = go(
r#"page1.qty = 6
xfa.form.form1.page1.qty = 7
details.presence = "visible"
price.access = "readOnly"
$.border.fill.color.value = "255,0,0"
$host.messageBox(Concat("Total ", qty * price))
$host.setFocus("qty")
table._row.addInstance(1)
table._row.count"#,
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("3"));
assert_eq!(
o.effects,
vec![
XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() },
XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() },
XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() },
XfaEffect::MessageBox("Total 17.5".into()),
XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()),
XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() },
]
);
// Instance managers: remove, setInstances, count assignment, and the floor of one.
let o = go(
"table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count",
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("1"));
// Validate scripts give a truth value; a value the script sets wins over the result.
let o = go("$ == null or $ <= 100", "form1[0].page1[0].qty[0]", "validate");
assert_eq!(o.result_bool, Some(true));
let o = go("$ > 100", "form1[0].page1[0].qty[0]", "validate");
assert_eq!(o.result_bool, Some(false));
// Reset, print and URLs are recorded as requests, not performed.
let o = go(
r#"$host.resetData("qty, price")
$host.print()
$host.gotoURL("https://example.org")"#,
"form1[0].page1[0].go[0]",
"click",
);
assert_eq!(
o.effects,
vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())]
);
}
#[test]
fn formcalc_values_results_and_errors_stay_bounded() {
let total = "form1[0].page1[0].total[0]";
let fast = Limits { loop_iterations: 10_000, recursion: 32 };
let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast);
// A long value is cut where it is stored, so it reads back as saved.
let o = run("details.note = Space(100000)\nLen(details.note)");
assert_eq!(o.error, None);
assert_eq!(o.result.as_deref(), Some("65536"));
// A calculate's result is cut as values are, with a note.
let o = run("Space(100000)");
assert_eq!(o.result.map(|r| r.chars().count()), Some(65_536));
assert!(o.notes.iter().any(|n| n.contains("result")), "{:?}", o.notes);
// A thrown value is the error message: cut like one.
let e = run("throw Space(100000)").error.unwrap();
assert!(e.chars().count() <= 4_097, "{}", e.len());
// Copying and comparing long strings costs steps, so it ends without a clock (wasm).
let e = run("var s = Space(1000000)\nfor i = 1 upto 3000 do s endfor").error;
assert!(e.is_some_and(|e| e.contains("too long")));
let e = run("var s = Space(1000000)\nfor i = 1 upto 3000 do s == \"x\" endfor").error;
assert!(e.is_some_and(|e| e.contains("too long")));
// Each period IPmt and PPmt walk costs a step.
let e = run("for i = 1 upto 600 do IPmt(1e9, 0.01, 1, 1, 10000) endfor").error;
assert!(e.is_some_and(|e| e.contains("too long")));
// Exists answers no for a name that isn't there, but a limit inside it still stops the script.
assert_eq!(run("Exists(nothing.here)").result.as_deref(), Some("0"));
assert_eq!(run("Exists(qty)").result.as_deref(), Some("1"));
assert!(run("func f(n) do Exists(f(n + 1)) endfunc\nf(1)").error.unwrap().contains("nested"));
// An elseif condition's chain counts from zero, not from the last statement of the arm before.
let script = format!("if 0 then\nvar x = 1{}\nelseif 1{} then\n2\nendif", "+1".repeat(900), "+1".repeat(200));
assert_eq!(run(&script).result.as_deref(), Some("2"));
}
#[test]
fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() {
let total = "form1[0].page1[0].total[0]";
let fast = Limits { loop_iterations: 10_000, recursion: 32 };
let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast);
assert!(run("while (1) do endwhile").error.unwrap().contains("looped"));
assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped"));
assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested"));
assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some());
// A 120k-term chain is refused at parse time (its tree would be as deep as it is long).
assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long"));
assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long"));
assert!(run("nothing.x = 1").error.unwrap().contains("unknown name"));
assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing"));
// Values go into fields only: a subform or the form itself is refused, nothing is recorded.
let o = run("details = \"junk\"");
assert!(o.error.unwrap().contains("not a field"));
assert!(o.effects.is_empty());
assert!(run("$form = \"junk\"").error.unwrap().contains("not a field"));
// Hostile numbers into the string functions.
assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX"));
assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc"));
assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some(""));
assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some(""));
assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none());
assert!(run(r#"Round(1e300, 1e300)"#).error.is_none());
assert!(run(r#"Choose(1e300, 1)"#).error.is_none());
// Strings can't grow past the cap through any function, and messages and values are clipped.
assert!(
run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)")
.error
.unwrap()
.contains("too long")
);
assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long"));
let o = run("$host.messageBox(Space(100000))");
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len());
let o = run("qty = Space(100000)");
assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len());
assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes);
// A comment of dashes is not deep nesting.
assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2"));
// Parentheses don't reset the chain count: a tree is never deeper than the cap.
let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string();
for _ in 0..30 {
nested = format!("({nested}{})", "+1".repeat(40));
}
assert!(run(&nested).error.unwrap().contains("too long"));
assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32"));
// Nested statements inside recursion count against the depth limit.
let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20));
assert!(run(&deep).error.unwrap().contains("nested"));
// A deadline already passed stops the script at its first look at the clock.
let o = crate::formcalc::run_formcalc_at(
"for i = 1 upto 100000 do Eval(\"1\") endfor",
&ev,
&doc,
form(),
Limits { loop_iterations: u64::MAX, recursion: 32 },
Some(std::time::Duration::ZERO),
);
assert!(o.error.unwrap().contains("too long"));
assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some());
assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long"));
assert!(run("Space(1e12)").error.is_none());
assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long"));
assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None);
assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None);
assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None);
assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some(""));
assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name"));
assert!(run("\"unterminated").error.unwrap().contains("unterminated"));
assert!(run("1 ) 2").error.is_some());
assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none());
// Adding rows forever stops at the instance cap with the effects it managed.
let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count");
assert!(o.error.unwrap().contains("looped"));
assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len());
let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count");
assert_eq!(o.error, None);
assert_eq!(o.result.as_deref(), Some("1002"));
// Message boxes past the cap are dropped and noted.
let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor");
assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100);
assert!(!o.notes.is_empty());
// On a small stack (the wasm build's), the limits stop the script before the stack does.
let small = std::thread::Builder::new()
.stack_size(1 << 20)
.spawn(move || {
let fast = Limits { loop_iterations: 10_000, recursion: 256 };
let ev = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast);
assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested"));
let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20));
assert!(run(&deep).error.unwrap().contains("nested"));
let mut nested = "1".to_string();
for _ in 0..30 {
nested = format!("({nested}{})", "+1".repeat(40));
}
assert!(run(&nested).error.is_some());
let chain = format!("1{}", "+1".repeat(999));
assert_eq!(run(&chain).result.as_deref(), Some("1000"));
let dots = format!("$form{}", ".parent".repeat(60));
assert!(run(&dots).error.unwrap().contains("no object"));
let dots = format!("${}", ".parent".repeat(3));
assert_eq!(run(&dots).result.as_deref(), Some(""));
})
.unwrap();
small.join().expect("no stack overflow on a 1 MiB stack");
// Abandoned when it runs past the deadline.
let o = crate::formcalc::run_formcalc_within(
"while (1) do endwhile",
&ev,
&doc,
form(),
Limits { loop_iterations: u64::MAX, recursion: 32 },
std::time::Duration::from_millis(200),
);
assert!(o.abandoned || o.error.is_some(), "{o:?}");
}
}
mod session {
use super::xfa_model::form;
use crate::Limits;
use crate::xfa::*;
use crate::xfa_session::{Script, XfaSession};
/// A JavaScript or FormCalc script for `activity` on `target`, against the test form with
/// default limits and a 3 s timeout.
fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script {
Script {
source: source.into(),
formcalc,
event: XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() },
root: form(),
limits: Limits::default(),
timeout: std::time::Duration::from_secs(3),
}
}
/// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller
/// sends: nothing an earlier script changed or defined leaks into the next, a failing script
/// doesn't end the session, and hundreds of scripts run on its one thread.
#[test]
fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() {
let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() });
assert_eq!(s.dead(), None);
// JavaScript, then FormCalc, on the same thread.
let o = s.run(script("qty.rawValue = 10; qty.rawValue", false, "form1[0].page1[0].total[0]", "calculate"));
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.effects.len(), 1);
assert_eq!(o.result.as_deref(), Some("10"));
// The next script sees the form the caller sends, not what the last script did on
// its copy: nothing a script changed leaks past the caller.
let o = s.run(script("qty * price", true, "form1[0].page1[0].total[0]", "calculate"));
assert_eq!(o.result.as_deref(), Some("10"), "{o:?}");
let mut with = script("qty * price", true, "form1[0].page1[0].total[0]", "calculate");
with.root.children[0].children[0].value = "10".into();
assert_eq!(s.run(with).result.as_deref(), Some("25"));
// Nor do variables, globals, prototype changes or kept objects.
s.run(script("leak = 5; String.prototype.trim = function () { return 'P' }; kept = qty; 1", false, "form1[0].page1[0].qty[0]", "calculate"));
let o = s.run(script("typeof leak + ':' + ' x '.trim() + ':' + typeof kept", false, "form1[0].page1[0].qty[0]", "calculate"));
assert_eq!(o.result.as_deref(), Some("undefined:x:undefined"));
s.run(script("Object.freeze(globalThis); 1", false, "form1[0].page1[0].qty[0]", "calculate"));
let o = s.run(script("xfa.host.numPages", false, "form1[0].page1[0].qty[0]", "calculate"));
assert_eq!(o.result.as_deref(), Some("3"), "{o:?}");
// `this` and `event` follow each script's target.
let o = s.run(script("this.name + ':' + xfa.event.name", false, "form1[0].page1[0].price[0]", "change"));
assert_eq!(o.result.as_deref(), Some("price:change"));
// A failing script doesn't end the session.
let o = s.run(script("throw new Error('no')", false, "form1[0].page1[0].go[0]", "click"));
assert!(o.error.is_some() && !o.abandoned);
assert_eq!(s.run(script("1 + 1", true, "form1[0].page1[0].go[0]", "click")).result.as_deref(), Some("2"));
// Hundreds of scripts on the one thread.
let started = std::time::Instant::now();
for i in 0..300 {
let o = s.run(script(&format!("{i} + qty.rawValue"), i % 2 == 0, "form1[0].page1[0].total[0]", "calculate"));
assert_eq!(o.error, None, "{o:?}");
}
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
}
/// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's
/// time limit still runs and returns its result, and the session stays alive.
#[test]
fn a_scripts_time_starts_when_its_context_is_ready() {
// The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is
// not abandoned, its time starting only when the engine says it is ready.
use crate::xfa_session::START_DELAY_MS;
use std::sync::atomic::Ordering::Relaxed;
let mut s = XfaSession::new(XfaDoc::default());
START_DELAY_MS.store(300, Relaxed);
let mut quick = script("1", false, "form1[0].page1[0].go[0]", "click");
quick.timeout = std::time::Duration::from_millis(50);
let js = s.run(quick);
let mut quick = script("1", true, "form1[0].page1[0].go[0]", "click");
quick.timeout = std::time::Duration::from_millis(50);
let fc = s.run(quick);
START_DELAY_MS.store(0, Relaxed);
assert!(!js.abandoned && js.result.as_deref() == Some("1"), "{js:?}");
assert!(!fc.abandoned && fc.result.as_deref() == Some("1"), "{fc:?}");
assert_eq!(s.dead(), None);
}
/// An endless JavaScript loop past its 200 ms limit is abandoned within 2 s and leaves the
/// session dead, so nothing more runs in it; an endless FormCalc loop is abandoned or
/// reported as running too long.
#[test]
fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() {
let mut s = XfaSession::new(XfaDoc::default());
let mut slow = script("while (true) {}", false, "form1[0].page1[0].go[0]", "click");
slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 };
slow.timeout = std::time::Duration::from_millis(200);
let started = std::time::Instant::now();
let o = s.run(slow);
assert!(o.abandoned, "{o:?}");
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(s.dead().is_some_and(|w| w.contains("abandoned")));
let o = s.run(script("1", false, "form1[0].page1[0].go[0]", "click"));
assert!(o.abandoned && o.result.is_none(), "nothing more runs: {o:?}");
// FormCalc past its time too (it stops itself at the deadline and is reported).
let mut s = XfaSession::new(XfaDoc::default());
let mut slow = script("while (1) do endwhile", true, "form1[0].page1[0].go[0]", "click");
slow.limits = Limits { loop_iterations: u64::MAX, recursion: 32 };
slow.timeout = std::time::Duration::from_millis(200);
let o = s.run(slow);
assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}");
}
}
#[test]
fn shared_field_snapshots_isolate_writes_errors_and_script_globals() {
let initial = Arc::new(fields());
let before = initial.as_ref().clone();
let event = Event::field("Calculate", "total", "");
let write = run_shared(
"var scriptLocal = 1; getField('qty').value = 9; getField('agree').checkThisBox(0, true); event.value = getField('qty').value;",
&event,
&doc(),
Arc::clone(&initial),
&[],
Limits::default(),
);
assert_eq!(write.error, None);
assert_eq!(write.value, "9");
assert_eq!(write.changed.iter().find(|f| f.name == "qty").unwrap().value, ["9"]);
assert_eq!(write.changed.iter().find(|f| f.name == "agree").unwrap().value, ["Yes"]);
assert_eq!(*initial, before, "script writes cannot mutate the caller's snapshot");
let failed = run_shared("getField('qty').value = 99; throw new Error('rejected');", &event, &doc(), Arc::clone(&initial), &[], Limits::default());
assert!(failed.error.is_some());
assert_eq!(*initial, before, "an exception cannot leak partial writes into the initial snapshot");
let read = run_shared(
"event.value = typeof scriptLocal + ':' + getField('qty').value + ':' + getField('agree').isBoxChecked(0);",
&event,
&doc(),
initial,
&[],
Limits::default(),
);
assert_eq!(read.error, None);
assert_eq!(read.value, "undefined:4:false");
assert!(read.changed.is_empty());
}
mod scand {
use super::*;
use crate::xfa::{XfaDoc, XfaEvent, run_xfa};
/// Runs `util.scand(format, text)` for each case and checks the date it gives, written as
/// `yyyy-mm-dd HH:MM:ss`, or `null`.
fn assert_scand(cases: &[(&str, &str, &str)]) {
let list: Vec<String> = cases.iter().map(|(f, t, _)| format!("['{f}', '{t}']")).collect();
let script = format!(
"event.value = [{}].map(function (c) {{ var d = util.scand(c[0], c[1]); \
return d === null ? 'null' : util.printd('yyyy-mm-dd HH:MM:ss', d); }}).join('|');",
list.join(", ")
);
let o = go(&script, &Event::field("Calculate", "total", ""));
assert_eq!(o.error, None);
let got: Vec<&str> = o.value.split('|').collect();
assert_eq!(got.len(), cases.len());
for ((f, t, want), got) in cases.iter().zip(got) {
assert_eq!(got, *want, "util.scand('{f}', '{t}')");
}
}
// The cases below are another viewer's answers, observed black-box with a synthetic file.
/// Any separator may come between fields, whether the format has that one, another or none,
/// but a text with a space or a letter before or after the date reads nothing.
#[test]
fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() {
assert_scand(&[
("yyyymmdd", "20261018", "2026-10-18 00:00:00"),
("yyyymmdd", "2026-10-18", "2026-10-18 00:00:00"),
("yyyymmdd", "2026 10 18", "2026-10-18 00:00:00"),
("mm/dd/yyyy", "10/18/2026", "2026-10-18 00:00:00"),
("mm/dd/yyyy", "10-18-2026", "2026-10-18 00:00:00"),
("yyyymmdd", " 20261018 ", "null"),
("yyyymmdd", "20261018x", "null"),
("yyyymmdd", "x20261018", "null"),
]);
}
/// Digits typed together fill the joined fields with exactly as many digits as each field has
/// letters, so a run of any other length reads nothing.
#[test]
fn scand_splits_digits_typed_together_by_the_formats_letter_counts() {
assert_scand(&[
("yyyymmdd", "261018", "null"),
("yyyymmdd", "2026118", "null"),
("yyyymmdd", "202610181", "null"),
("yymmdd", "20261018", "null"),
("yyyymdd", "2026118", "2026-01-18 00:00:00"),
("yyyymmd", "2026118", "2026-11-08 00:00:00"),
("yyyymd", "2026118", "null"),
("yyyymd", "202618", "2026-01-08 00:00:00"),
("yyyymd", "20261018", "null"),
("yymd", "2618", "2026-01-08 00:00:00"),
("yymd", "261018", "null"),
]);
}
/// A month past 12, a day past the month's end and February 29 outside a leap year are
/// refused, while a month or day of 0 is read as 1.
#[test]
fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() {
assert_scand(&[
("yyyymmdd", "20261318", "null"),
("yyyymmdd", "20260230", "null"),
("yyyymmdd", "20260229", "null"),
("yyyymmdd", "20240229", "2024-02-29 00:00:00"),
("mm/dd/yyyy", "13/45/2026", "null"),
("yyyymmdd", "20260018", "2026-01-18 00:00:00"),
("yyyymmdd", "20261000", "2026-10-01 00:00:00"),
]);
}
/// A field typed by itself may have one or two digits, but a `yyyy` year needs four; `yy` reads
/// as 1950–2049; month names may be full or three letters in any case; weekday names are read.
#[test]
fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() {
assert_scand(&[
("mm/dd/yyyy", "1/5/2026", "2026-01-05 00:00:00"),
("m/d/yyyy", "1/5/2026", "2026-01-05 00:00:00"),
("m/d/yyyy", "01/05/2026", "2026-01-05 00:00:00"),
("m/d/yy", "1/5/26", "2026-01-05 00:00:00"),
("mm/dd/yyyy", "10/18/26", "null"),
("yyyy/mm/dd", "26/10/18", "null"),
("yymmdd", "261018", "2026-10-18 00:00:00"),
("yymmdd", "491018", "2049-10-18 00:00:00"),
("yymmdd", "501018", "1950-10-18 00:00:00"),
("mmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"),
("mmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"),
("mmm d, yyyy", "oct 18, 2026", "2026-10-18 00:00:00"),
("mmmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"),
("mmmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"),
("dd-mmm-yyyy", "18-Oct-2026", "2026-10-18 00:00:00"),
("dddd, mmmm d, yyyy", "Sunday, October 18, 2026", "2026-10-18 00:00:00"),
]);
}
/// A time is read along with a date, but a format with only time fields reads nothing.
#[test]
fn scand_reads_a_time_only_with_a_date() {
assert_scand(&[
("mm/dd/yyyy HH:MM:ss", "10/18/2026 21:07:05", "2026-10-18 21:07:05"),
("HH:MM", "09:07", "null"),
("HH:MM", "9:07", "null"),
("h:MM tt", "9:07 pm", "null"),
("h:MM tt", "9:07 am", "null"),
("h:MM tt", "12:07 am", "null"),
("h:MM tt", "9:07pm", "null"),
]);
}
/// An empty text reads as today at noon, and a text that is not a string (a number, or none
/// at all) reads nothing.
#[test]
fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() {
let o = go(
"var d = util.scand('yyyymmdd', ''), n = new Date(); \
event.value = [d.toDateString() == n.toDateString(), d.getHours(), d.getMinutes(), d.getSeconds(), \
util.scand('yyyymmdd', 20261018), util.scand(), util.scand('yyyymmdd')].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "true|12|0|0|||");
}
// Not observed: the choices made where the other viewer wasn't asked.
/// A year left out is this year, `h:MM tt` reads a 12-hour time and refuses hours it cannot
/// have, and a letter the format writes must be typed there.
#[test]
fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() {
assert_scand(&[
("mm/dd/yyyy h:MM tt", "10/18/2026 9:07 pm", "2026-10-18 21:07:00"),
("mm/dd/yyyy h:MM tt", "10/18/2026 12:07 AM", "2026-10-18 00:07:00"),
("mm/dd/yyyy h:MM tt", "10/18/2026 13:07 pm", "null"),
("mm/dd/yyyy HH:MM", "10/18/2026 24:00", "null"),
("yyyy-mm-ddTHH:MM", "2026-10-18T21:07", "2026-10-18 21:07:00"),
("yyyy-mm-ddTHH:MM", "2026-10-18X21:07", "null"),
]);
let o = go(
"var d = util.scand('mm/dd', '10/18'); event.value = [d.getFullYear() == new Date().getFullYear(), d.getMonth(), d.getDate()].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "true|9|18");
}
/// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read
/// in linear time. The limit is generous so a loaded runner passes (it also covers starting
/// the engine and building the strings); a quadratic scan of inputs this size takes minutes.
#[test]
fn scand_reads_long_formats_and_texts_in_linear_time() {
// Scripts can pass anything; native code isn't bounded by the loop limit.
let started = std::time::Instant::now();
let o = go(
"var x = new Array(50001).join('x'), md = new Array(50001).join('md'), ones = new Array(100000).join('1 ') + '1'; \
event.value = [util.scand(x + 'yyyy', x + '2026') !== null, util.scand('yyyy' + md, '2026 ' + ones) !== null].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "true|true");
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
}
/// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and
/// writes the first that reads the text back as `yyyymmdd`, or reports an invalid date.
#[test]
fn xfa_scripts_check_typed_dates_with_util_scand() {
// The check a form runs when a date field is left: the first of six formats that reads
// the typed text, written back as yyyymmdd; or an invalid date. Results as another viewer gives them.
let check = |typed: &str| {
let script = format!(
"var six = ['yyyymmdd', 'yymmdd', 'yyyymdd', 'yyyymmd', 'yyyymd', 'yymd'], out = 'invalid'; \
for (var i = 0; i < six.length; i++) {{ var d = util.scand(six[i], '{typed}'); \
if (d !== null) {{ out = util.printd('yyyymmdd', d); break; }} }} out"
);
let event = XfaEvent { activity: "exit".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
run_xfa(&script, &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default())
};
for (typed, want) in [
("20261018", "20261018"),
("261018", "20261018"),
("2026118", "20260118"),
("202618", "20260108"),
("2618", "20260108"),
("2026-10-18", "20261018"),
("20261318", "invalid"),
("20260230", "invalid"),
] {
let o = check(typed);
assert_eq!(o.error, None, "{typed}: {o:?}");
assert_eq!(o.result.as_deref(), Some(want), "{typed}");
}
}
}
mod menu_items {
use super::*;
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
/// Every side panel has a fixed id in automation output.
#[test]
fn each_panel_has_a_fixed_id_for_automation() {
let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id);
assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]);
}
/// Each side panel item, Print and SaveAs make their request, in order; an unknown item
/// makes none and is not an error.
#[test]
fn exec_menu_item_shows_side_panels_prints_and_saves() {
let o = go(
"app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \
app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \
app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');",
&Event::field("Mouse Up", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(
o.requests,
[
Request::ShowPanel(Panel::Attachments),
Request::ShowPanel(Panel::Bookmarks),
Request::ShowPanel(Panel::Pages),
Request::ShowPanel(Panel::Signatures),
Request::ShowPanel(Panel::Layers),
Request::Print,
Request::SaveAs,
]
);
}
/// `app.execMenuItem` works in XFA scripts too: a click shows the attachments panel.
#[test]
fn xfa_scripts_show_the_attachments_panel() {
let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default());
assert_eq!(o.error, None, "{o:?}");
assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects);
}
}
/// The check XFA forms run on opening: without both globals, or with a version below 2.8, it
/// alerts that the viewer can't show the form.
const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \
event.value = typeof xfa_installed + ' ' + typeof xfa_version + ' ' + xfa_version;";
/// An XFA form sees `xfa_installed` (true) and `xfa_version` (3.5), so the check passes.
#[test]
fn an_xfa_form_sees_the_xfa_plugin_installed() {
let xfa = DocInfo { xfa: true, ..doc() };
let o = run(XFA_CHECK, &Event::doc("Open"), &xfa, &fields(), &[], Limits::default());
assert_eq!(o.error, None);
assert_eq!(o.value, "boolean number 3.5");
assert!(o.alerts.is_empty(), "{:?}", o.alerts);
let o = run("event.value = String(xfa_installed);", &Event::doc("Open"), &xfa, &fields(), &[], Limits::default());
assert_eq!(o.value, "true");
}
/// A form without XFA sees `xfa_version` but no `xfa_installed`, so the check alerts.
#[test]
fn a_plain_form_sees_the_xfa_version_but_not_installed() {
let o = go(XFA_CHECK, &Event::doc("Open"));
assert_eq!(o.error, None);
assert_eq!(o.value, "undefined number 3.5");
assert_eq!(o.alerts, ["old viewer"]);
}