mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 06:12:19 +00:00
A FormCalc script under a time limit is watched twice: the caller waits `timeout` for it, and the interpreter reads its own deadline, set to the same `timeout`, every 256 steps. Which one notices first is down to the scheduler, and the two reported different things: the caller's outcome is abandoned with no changes (the form's scripts go off), while the interpreter's kept every change the script made before the deadline. For a click script that loops on addInstance, that difference is 1000 rows (the most one run may add). Since repeating subforms lay out up to 1000 rows instead of 50 (#4608, landed with #6716), keeping them means laying out a 1000-row form again, twice, which takes most of a minute in the test build. The engine test hostile_formcalc_scripts_cannot_hang_or_bloat_the_document took 3.0 s when the caller noticed first and 46-61 s when the script did, so it failed its 60 s bound at random. Now a run stopped by its own clock reports what an abandoned run does: the same error, no changes, abandoned. XfaSession treats that outcome like a wait that ran out (the session is dead after it). Limits other than time (steps, loops, depth) keep their changes as before. Co-authored-by: FredApps <FredApps@users.noreply.github.com>
306 lines
14 KiB
Rust
306 lines
14 KiB
Rust
//! FormCalc (XFA 3.3 part 8), the default scripting language of older Designer forms: a lexer,
|
|
//! a parser and a tree-walking evaluator over the same form table and effects as the JavaScript
|
|
//! model ([`crate::xfa`]), so the engine treats both languages alike.
|
|
//!
|
|
//! Covered: expressions with FormCalc's operators and coercions, `var`, assignment to fields
|
|
//! and properties, `if`/`elseif`/`else`, `while`, `for … upto/downto … step`, `foreach`,
|
|
//! `func … endfunc`, `break`/`continue`/`return`/`exit`, accessors (`$`, `$host`, `$form`,
|
|
//! `$record`, `$layout`, `$event`, `xfa.…`, `..name`, `[n]`, `[*]`, `_name` instance managers),
|
|
//! and the arithmetic, logical, string, date/time, financial and unit built-in functions.
|
|
//! `Get`, `Post`, `Put` (network) refuse.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::rc::Rc;
|
|
|
|
use crate::xfa::{
|
|
HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances,
|
|
reset_names, resolve_som, run_within,
|
|
};
|
|
use crate::{Limits, Message, MessageButtons, MessageIcon};
|
|
|
|
mod access;
|
|
mod ast;
|
|
mod builtins;
|
|
mod dates;
|
|
mod eval;
|
|
mod lexer;
|
|
mod parser;
|
|
mod value;
|
|
mod words;
|
|
|
|
use ast::Stmt;
|
|
pub(crate) use dates::days_in;
|
|
#[cfg(test)]
|
|
use dates::{civil_from_days, days_from_civil, format_date, parse_date};
|
|
use parser::parse;
|
|
use value::Val;
|
|
#[cfg(test)]
|
|
use value::{fixed, num_text, parse_num};
|
|
#[cfg(test)]
|
|
use words::word_num;
|
|
|
|
/// Deepest nesting the parser follows.
|
|
const MAX_PARSE_DEPTH: usize = 64;
|
|
/// Most statements and expressions evaluated in one run (loops included).
|
|
const MAX_STEPS: u64 = 5_000_000;
|
|
/// Longest string a script may build.
|
|
const MAX_STRING: usize = 1 << 20;
|
|
/// Most elements a list may hold.
|
|
const MAX_LIST: usize = 100_000;
|
|
/// Most operators and accessors in one expression (`a + b + …`, `a.b.c…`, brackets included):
|
|
/// the tree is as deep as the chain, and dropping or cloning it recurses.
|
|
const MAX_CHAIN: usize = 1000;
|
|
// The errors a run stops with when it reaches a limit (rather than a fault in the script).
|
|
const RAN_TOO_LONG: &str = "the script ran too long";
|
|
const TOO_DEEP: &str = "the script is nested too deeply";
|
|
const CALLS_TOO_DEEP: &str = "functions are nested too deeply";
|
|
const LOOPED_TOO_MUCH: &str = "the script looped too many times";
|
|
const LIST_TOO_LONG: &str = "a list is too long";
|
|
const STRING_TOO_LONG: &str = "a string is too long";
|
|
|
|
/// Whether `e` is a limit the run reached: those stop the script even where a fault is
|
|
/// otherwise an answer (`Exists`).
|
|
fn is_limit(e: &str) -> bool {
|
|
[RAN_TOO_LONG, TOO_DEEP, CALLS_TOO_DEEP, LOOPED_TOO_MUCH, LIST_TOO_LONG, STRING_TOO_LONG].contains(&e)
|
|
}
|
|
|
|
/// Steps between looks at the clock.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const CLOCK_EVERY: u64 = 256;
|
|
|
|
// ── the evaluator ───────────────────────────────────────────────────────────────────────────
|
|
|
|
enum Flow {
|
|
Next,
|
|
Break,
|
|
Continue,
|
|
Return(Val),
|
|
Exit,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
struct Func {
|
|
params: Vec<String>,
|
|
body: Vec<Stmt>,
|
|
}
|
|
|
|
struct Interp<'h> {
|
|
h: &'h mut XHost,
|
|
scopes: Vec<HashMap<String, Val>>,
|
|
/// User functions by lower-case name (FormCalc names are case-insensitive).
|
|
funcs: HashMap<String, Rc<Func>>,
|
|
event: XfaEvent,
|
|
steps: u64,
|
|
loops_left: u64,
|
|
depth: usize,
|
|
max_depth: usize,
|
|
/// `exit` ran: every block ends at once.
|
|
exited: bool,
|
|
/// When the caller stops waiting: the script stops itself soon after (no thread is left
|
|
/// burning a core to the loop limit).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
deadline: Option<std::time::Instant>,
|
|
/// `deadline` passed and stopped the script: the run reports as abandoned.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
out_of_time: bool,
|
|
}
|
|
|
|
type R<T> = Result<T, String>;
|
|
|
|
// ── entry points ────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Run a FormCalc `script` for `event` on the form `root`, as [`crate::xfa::run_xfa`] runs a
|
|
/// JavaScript one.
|
|
pub fn run_formcalc(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome {
|
|
run_formcalc_at(script, event, doc, std::sync::Arc::new(root.clone()), false, limits, None)
|
|
}
|
|
|
|
/// [`run_formcalc`] on an owned form, giving up at `deadline` (the clock is read every
|
|
/// [`CLOCK_EVERY`] steps; unused on wasm, which has no clock).
|
|
pub(crate) fn run_formcalc_at(
|
|
script: &str,
|
|
event: &XfaEvent,
|
|
doc: &XfaDoc,
|
|
root: std::sync::Arc<XfaNode>,
|
|
reuse: bool,
|
|
limits: Limits,
|
|
deadline: Option<std::time::Duration>,
|
|
) -> XfaOutcome {
|
|
// FormCalc has its own nesting limits (the shared `refuse` reads JavaScript syntax).
|
|
if script.len() > crate::MAX_SCRIPT_BYTES {
|
|
return XfaOutcome {
|
|
error: Some(format!("the script is too long ({} KiB; the limit is {} KiB)", script.len() / 1024, crate::MAX_SCRIPT_BYTES / 1024)),
|
|
..Default::default()
|
|
};
|
|
}
|
|
let body = match parse(script) {
|
|
Ok(b) => b,
|
|
Err(e) => return XfaOutcome { error: Some(e), ..Default::default() },
|
|
};
|
|
let mut host = XHost::new(root, reuse, &event.target, doc);
|
|
#[cfg(target_arch = "wasm32")]
|
|
let _ = deadline;
|
|
let mut interp = Interp {
|
|
h: &mut host,
|
|
scopes: vec![HashMap::new()],
|
|
funcs: HashMap::new(),
|
|
event: event.clone(),
|
|
steps: 0,
|
|
loops_left: limits.loop_iterations,
|
|
depth: 0,
|
|
// The JavaScript recursion limit, kept to 8..=128: statement nesting counts against it
|
|
// too, and the interpreter's frames are large.
|
|
max_depth: limits.recursion.clamp(8, 128),
|
|
exited: false,
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
deadline: deadline.and_then(|d| std::time::Instant::now().checked_add(d)),
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
out_of_time: false,
|
|
};
|
|
let mut out = XfaOutcome::default();
|
|
match interp.block(&body) {
|
|
Ok((_, last)) => {
|
|
// As the JavaScript path reports: null is an empty result (a calculate clears the
|
|
// field) with no truth value.
|
|
let last = interp.deref(last);
|
|
out.result = Some(interp.to_str(&last));
|
|
if !matches!(last, Val::Null) {
|
|
out.result_bool = Some(interp.truthy(&last));
|
|
}
|
|
}
|
|
Err(e) => out.error = Some(e),
|
|
}
|
|
// The caller stops waiting at the same time limit, so which of the two notices first is
|
|
// down to the scheduler. The outcome must not depend on it: stopped by the clock, the run
|
|
// reports what an abandoned one does, the error and no changes. Keeping the changes made
|
|
// it a race: a loop adding rows until the clock stopped it left the form unchanged on one
|
|
// run and 1000 rows longer on the next (each laid out again, tens of seconds).
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
if let (true, Some(d)) = (interp.out_of_time, deadline) {
|
|
return XfaOutcome {
|
|
error: Some(format!("the script ran longer than {:.1} s and was abandoned", d.as_secs_f64())),
|
|
abandoned: true,
|
|
..Default::default()
|
|
};
|
|
}
|
|
out.notes = host.notes();
|
|
out.effects = host.effects;
|
|
out.console = host.console;
|
|
clip_result(&mut out);
|
|
out
|
|
}
|
|
|
|
/// [`run_formcalc`] on its own thread, abandoned after `timeout` (and told to stop itself then).
|
|
pub fn run_formcalc_within(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, timeout: std::time::Duration) -> XfaOutcome {
|
|
let (script, event, doc) = (script.to_string(), event.clone(), doc.clone());
|
|
// FormCalc has no engine to start: its time (and its own deadline) begins at once.
|
|
run_within("pdfcraft-formcalc", timeout, move |started| {
|
|
started();
|
|
run_formcalc_at(&script, &event, &doc, std::sync::Arc::new(root), false, limits, Some(timeout))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn fixed_does_not_print_negative_zero() {
|
|
assert_eq!(fixed(-0.4, 0), "0");
|
|
assert_eq!(fixed(-0.004, 2), "0.00");
|
|
assert_eq!(fixed(-0.0, 0), "0");
|
|
assert_eq!(fixed(-1.5, 0), "-2");
|
|
}
|
|
|
|
#[test]
|
|
fn dates_round_trip_through_formcalc_day_numbers() {
|
|
assert_eq!(days_from_civil(1900, 1, 1), 1);
|
|
assert_eq!(days_from_civil(1970, 1, 1), 25_568);
|
|
assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29));
|
|
// Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono
|
|
// formula, both directions, both sides of the epoch.
|
|
for (n, date) in [
|
|
(99_000_000, (272_952, 10, 16)),
|
|
(99_999_999, (275_690, 9, 11)),
|
|
(-99_000_000, (-269_153, 3, 16)),
|
|
(-99_999_999, (-271_891, 4, 20)),
|
|
(72_354_905, (200_000, 12, 31)),
|
|
(-73_742_460, (-200_000, 1, 1)),
|
|
] {
|
|
assert_eq!(civil_from_days(n), date, "{n}");
|
|
assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}");
|
|
}
|
|
assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year");
|
|
assert_eq!(days_in(-269_153, 2), 28);
|
|
assert_eq!(days_in(i64::MAX, 12), 31);
|
|
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1));
|
|
assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3)));
|
|
assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3)));
|
|
assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3)));
|
|
assert_eq!(format_date(2001, 2, 3, "MMMM D, YYYY"), "February 3, 2001");
|
|
assert_eq!(word_num(1234.5, true), "One Thousand Two Hundred Thirty-Four Dollars And 50 Cents");
|
|
assert_eq!(word_num(999_999_999_999.999, true), "");
|
|
assert_eq!(num_text(3.0), "3");
|
|
assert_eq!(num_text(2.5), "2.5");
|
|
assert_eq!(num_text(0.1 + 0.2), "0.3");
|
|
assert_eq!(parse_num("12abc"), Some(12.0));
|
|
assert_eq!(parse_num("abc"), None);
|
|
}
|
|
|
|
/// A number prints to at most fifteen significant digits, so the binary noise of a value
|
|
/// with six digits before the point (279893.88 is 279893.880000000004656… as a double) stays
|
|
/// hidden, while small values keep their twelve decimals.
|
|
#[test]
|
|
fn num_text_prints_at_most_fifteen_significant_digits() {
|
|
assert_eq!(num_text(279_893.88), "279893.88");
|
|
assert_eq!(num_text(-279_893.88), "-279893.88");
|
|
assert_eq!(num_text(279_893.88 + 291_774.12 + 304_171.06 + 317_107.44 + 273_970.85), "1466917.35");
|
|
assert_eq!(num_text(1_000_000.0 / 3.0), "333333.333333333");
|
|
assert_eq!(num_text(1.0 / 3.0), "0.333333333333");
|
|
assert_eq!(num_text(0.3 - 0.1 - 0.2), "0", "noise below twelve decimals is zero");
|
|
// Past fifteen digits before the point no decimals are left, and the integer keeps its zeros.
|
|
assert_eq!(num_text(1e15 + 0.5), "1000000000000000");
|
|
assert_eq!(num_text(1e20), "100000000000000000000");
|
|
assert_eq!(num_text(-1e20), "-100000000000000000000");
|
|
assert_eq!(num_text(f64::MAX).len(), 309);
|
|
}
|
|
|
|
#[test]
|
|
fn decode_xml_and_html_entities() {
|
|
let eval = |script: &str| {
|
|
let root = XfaNode::default();
|
|
let out = run_formcalc(script, &XfaEvent::default(), &XfaDoc::default(), &root, Limits::default());
|
|
assert!(out.error.is_none(), "{:?}", out.error);
|
|
out.result.unwrap_or_default()
|
|
};
|
|
|
|
// `'` in both xml and html
|
|
assert_eq!(eval(r#"Decode("It's <ok> & "fine"", "xml")"#), "It's <ok> & \"fine\"");
|
|
assert_eq!(eval(r#"Decode("It's", "html")"#), "It's");
|
|
|
|
// Decimal and hex numeric character references (case-insensitive #x/#X and hex digits, leading zeros)
|
|
assert_eq!(eval(r#"Decode("ABCDe€😀", "xml")"#), "ABCDe€😀");
|
|
assert_eq!(eval(r#"Decode("ABCé", "html")"#), "ABCé");
|
|
|
|
// Invalid / surrogate / out-of-range / zero / malformed numeric references stay literal without panicking
|
|
assert_eq!(
|
|
eval(r#"Decode("� � � � � &#; &#x; A G; &;", "xml")"#),
|
|
"� � � � � &#; &#x; A G; &;"
|
|
);
|
|
|
|
// Single-pass decoding: `&lt;` and `&lt;` must decode to `<`, never `<`
|
|
assert_eq!(eval(r#"Decode("&lt; &#65; &lt; &apos;", "xml")"#), "< A < '");
|
|
assert_eq!(eval(r#"Decode("&nbsp; &eacute;", "html")"#), " é");
|
|
|
|
// HTML named entities decode in "html" mode but remain literal in "xml" mode
|
|
assert_eq!(eval(r#"Decode("café ©€—αΩ♥", "html")"#), "café\u{00A0}©€—αΩ♥");
|
|
assert_eq!(eval(r#"Decode("café ©€", "xml")"#), "café ©€");
|
|
// Case sensitivity of HTML named entities (`É` vs `é`, `″` vs `′`, `‡` vs `†`)
|
|
assert_eq!(eval(r#"Decode("Éé″′‡†&bogus;", "html")"#), "Éé″′‡†&bogus;");
|
|
// Non-ASCII characters after `&` or `&#` and long runs of `&` without `;`
|
|
assert_eq!(eval(r#"Decode("&é; &#é; &#xé; &é", "html")"#), "&é; &#é; &#xé; &é");
|
|
let amps = "&".repeat(5_000);
|
|
assert_eq!(eval(&format!(r#"Decode("{amps}'", "xml")"#)), format!("{amps}'"));
|
|
}
|
|
}
|