Files
pdfcraft/crates/js/src/host.rs
T
Dylan M. Taylor abec4e3fee fix(js): bound array text, month parsing, name registration and list work (#5883)
* fix(js): bound array text, month parsing, name registration and list work

- strings() (field value and colour setters, resetForm) copied every array
  entry into its own String with no total: one long string listed a million
  times was copied a million times. The copies together are now held to the
  run's MAX_PAYLOAD byte budget.
- FormCalc parse_date copied and lowercased the rest of the text for every
  month token of the picture, quadratic in both; it reads at most the longest
  month name now.
- XFA global registration checked each child name against a Vec of every
  name defined so far, quadratic in a wide form; it is a HashSet.
- FormCalc gather charged one step for a whole collection and list_push
  charged nothing for a duplicate it dropped, so .all on same-named siblings
  did quadratic work for one step a pair; every node visited costs a step.
- add_instance reserved only the new root's SOM before copying a subtree;
  the copy's nodes, names and SOMs are charged to the byte budget first.

* address review: gather charges each call; check an array entry's length before copying it

* Hold an array entry to its widest escaped size before copying it

The pre-copy check compared the payload budget against a JS string's UTF-16
length, which is shorter than the escaped UTF-8 form it is copied to: a
string of quotes or non-ASCII text could allocate up to six times the
budget before the post-check rejected it.

The check now uses the widest escaped width, so the copy cannot exceed the
budget it is checked against.

* review: measure an array entry's escaped width before refusing it

* address review: count an array entry's escaped width without copying it

* address review: charge retained field text and colours to a run-wide budget

* fmt

* fmt

* merge main, keep both
2026-11-11 15:50:29 -05:00

323 lines
13 KiB
Rust

use super::model::{DocInfo, FieldState, FieldType, Request};
use super::run::MAX_ARRAY_LENGTH;
use super::{
Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, Message, NativeFunction,
RefCell, xfa,
};
pub(crate) struct Host {
pub(crate) event_name: String,
pub(crate) fields: Arc<Vec<FieldState>>,
pub(crate) icons: Vec<(super::JsObject, u64)>,
pub(crate) objects: std::collections::HashMap<(String, Option<u64>, u64), super::JsObject>,
pub(crate) field_keys: std::collections::HashSet<(String, Option<u64>, u64)>,
pub(crate) changed: BTreeSet<usize>,
pub(crate) formatted: BTreeSet<usize>,
pub(crate) alerts: Vec<Message>,
pub(crate) console: Vec<String>,
pub(crate) requests: Vec<Request>,
pub(crate) doc: DocInfo,
pub(crate) calculations: Vec<(usize, String)>,
pub(crate) calculate_depth: usize,
pub(crate) calculation_evaluations: usize,
pub(crate) calculation_limit_exceeded: bool,
pub(crate) calculation_work: usize,
pub(crate) current_event: JsValue,
pub(crate) reset_calls: usize,
pub(crate) reset_limit_exceeded: bool,
/// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`].
pub(crate) kept: usize,
/// Bytes of text the script has added to the fields' values and colours, net of what it
/// replaced, against [`xfa::MAX_PAYLOAD`]: each assignment alone is capped, so without this
/// many fields could each hold a maximal array.
pub(crate) retained: usize,
/// Alerts, console lines and requests the limits left out.
pub(crate) dropped: usize,
}
impl Host {
/// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not.
fn room(&mut self, bytes: usize) -> bool {
match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) {
Some(k) => {
self.kept = k;
true
}
None => {
self.dropped = self.dropped.saturating_add(1);
false
}
}
}
/// Keep message `m`, its text and title charged to the run's byte budget, up to
/// [`xfa::MAX_ALERTS`] messages.
pub(crate) fn alert(&mut self, m: Message) {
if self.alerts.len() >= xfa::MAX_ALERTS {
self.dropped = self.dropped.saturating_add(1);
} else if self.room(m.bytes()) {
self.alerts.push(m);
}
}
pub(crate) fn print(&mut self, m: String) {
if self.console.len() >= xfa::MAX_CONSOLE {
self.dropped = self.dropped.saturating_add(1);
} else if self.room(m.len()) {
self.console.push(m);
}
}
/// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`]
/// requests and the run's byte budget.
pub(crate) fn request(&mut self, r: Request) -> bool {
if self.requests.len() >= xfa::MAX_EFFECTS {
self.dropped = self.dropped.saturating_add(1);
return false;
}
let r = match r {
Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()),
Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)),
Request::Submit(u) => Request::Submit(xfa::clip(u)),
Request::Focus(n) => Request::Focus(xfa::clip(n)),
Request::FocusField { name, object_id } => Request::FocusField { name: xfa::clip(name), object_id },
other => other,
};
let bytes = match &r {
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
Request::FocusField { name, .. } => name.len(),
Request::OpenAttachment { name, .. } => name.len(),
_ => 0,
};
let kept = self.room(bytes);
if kept {
self.requests.push(r);
}
kept
}
}
pub(crate) type Shared = RefCell<Host>;
/// The host state, installed before scripts run.
pub(crate) fn host(ctx: &Context) -> JsResult<&Shared> {
ctx.get_data::<Shared>().ok_or_else(|| error("the script host is not set up"))
}
pub(crate) fn s(v: &str) -> JsValue {
JsValue::from(JsString::from(v))
}
pub(crate) fn text(v: &JsValue, ctx: &mut Context) -> JsResult<String> {
if v.is_undefined() || v.is_null() {
return Ok(String::new());
}
Ok(v.to_string(ctx)?.to_std_string_escaped())
}
/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer
/// string.
pub(crate) fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult<String> {
if v.is_undefined() || v.is_null() {
return Ok(String::new());
}
let js = v.to_string(ctx)?;
// Two UTF-16 units per character at most: the clip still sees more than a line's worth.
let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2);
let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js };
Ok(xfa::clip(js.to_std_string_escaped()))
}
pub(crate) fn arg(args: &[JsValue], i: usize) -> JsValue {
args.get(i).cloned().unwrap_or_default()
}
pub(crate) fn error(msg: impl Into<String>) -> JsError {
JsNativeError::typ().with_message(msg.into()).into()
}
/// A JavaScript number for a text value that reads as one, as Acrobat's `field.value` gives.
pub(crate) fn as_number(t: &str) -> Option<f64> {
let t = t.trim();
if t.is_empty() || t.starts_with('+') || t.len() > 1 && t.starts_with('0') && !t.starts_with("0.") {
return None;
}
t.parse::<f64>().ok().filter(|v| v.is_finite())
}
pub(crate) fn function(ctx: &mut Context, f: NativeFunction) -> JsFunction {
FunctionObjectBuilder::new(ctx.realm(), f).build()
}
/// Format exactly the escaped UTF-8 representation used by `text`, refusing a
/// write before it exceeds the remaining aggregate budget. No full Rust copy of
/// an oversized JavaScript string is made just to discover its byte length.
pub(crate) fn text_with_budget(v: &JsValue, ctx: &mut Context, remaining: usize, limit_error: &str) -> JsResult<String> {
use std::fmt::Write;
if v.is_undefined() || v.is_null() {
return Ok(String::new());
}
let js = v.to_string(ctx)?;
let exceeded = || error(limit_error);
// Every UTF-16 unit contributes at least one byte in the escaped UTF-8 output.
if js.len() > remaining {
return Err(exceeded());
}
struct BoundedText {
text: String,
limit: usize,
}
impl std::fmt::Write for BoundedText {
fn write_str(&mut self, text: &str) -> std::fmt::Result {
if self.text.len().checked_add(text.len()).is_none_or(|len| len > self.limit) {
return Err(std::fmt::Error);
}
self.text.push_str(text);
Ok(())
}
}
let mut out = BoundedText { text: String::new(), limit: remaining };
write!(&mut out, "{}", js.display_escaped()).map_err(|_| exceeded())?;
Ok(out.text)
}
pub(crate) fn strings(v: &JsValue, ctx: &mut Context) -> JsResult<Vec<String>> {
if let Some(o) = v.as_object()
&& o.is_array()
{
let a = JsArray::from_object(o.clone())?;
let n = a.length(ctx)?;
if n > MAX_ARRAY_LENGTH {
return Err(error(format!("array length exceeds {MAX_ARRAY_LENGTH}")));
}
let mut out = Vec::new();
let mut remaining = xfa::MAX_PAYLOAD;
for i in 0..n {
let x = a.get(i, ctx)?;
let value = text_with_budget(&x, ctx, remaining, "string array byte limit exceeded")?;
remaining = remaining.saturating_sub(value.len());
out.push(value);
}
return Ok(out);
}
Ok(if v.is_undefined() || v.is_null() {
Vec::new()
} else {
vec![text_with_budget(v, ctx, xfa::MAX_PAYLOAD, "string array byte limit exceeded")?]
})
}
pub(crate) fn colour(c: Option<&[String]>, ctx: &mut Context) -> JsValue {
match c {
None => JsArray::from_iter([s("T")], ctx).into(),
Some(parts) => {
let vals: Vec<JsValue> =
parts.iter().enumerate().map(|(i, p)| if i == 0 { s(p) } else { JsValue::from(p.parse::<f64>().unwrap_or(0.0)) }).collect();
JsArray::from_iter(vals, ctx).into()
}
}
}
// ── field objects ───────────────────────────────────────────────────────────────────────────
pub(crate) type FieldKey = (JsString, Option<u64>, u64);
/// PDF identities survive renames; name-only synthetic fields retain name lookup semantics.
pub(crate) fn field_cache_key(name: &str, id: Option<u64>, token: u64) -> (String, Option<u64>, u64) {
(if id.is_some() { String::new() } else { name.to_string() }, id, token)
}
fn field_index(ctx: &Context, name: &FieldKey) -> JsResult<usize> {
let n = name.0.to_std_string_escaped();
host(ctx)?
.borrow()
.fields
.iter()
.position(|f| name.1.map_or(f.name == n, |id| f.object_id == Some(id) && f.instance_id == name.2))
.ok_or_else(|| error(format!("no field named {n}")))
}
pub(crate) fn read(ctx: &Context, name: &FieldKey) -> JsResult<FieldState> {
let i = field_index(ctx, name)?;
Ok(host(ctx)?.borrow().fields[i].clone())
}
pub(crate) fn write(ctx: &Context, name: &FieldKey, change: impl FnOnce(&mut FieldState)) -> JsResult<()> {
let i = field_index(ctx, name)?;
let mut h = host(ctx)?.borrow_mut();
let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?;
change(field);
h.changed.insert(i);
Ok(())
}
pub(crate) fn value_of(f: &FieldState, ctx: &mut Context) -> JsValue {
match f.kind {
FieldType::CheckBox | FieldType::RadioButton => s(f.value.first().map_or("Off", String::as_str)),
FieldType::ListBox if f.value.len() > 1 => {
let vals: Vec<JsValue> = f.value.iter().map(|v| s(v)).collect();
JsArray::from_iter(vals, ctx).into()
}
_ => {
let v = f.single();
as_number(&v).map_or_else(|| s(&v), JsValue::from)
}
}
}
/// Bytes of text in `v`, counting each string's heap size and its handle.
pub(crate) fn text_bytes(v: &[String]) -> usize {
v.iter().fold(0usize, |n, t| n.saturating_add(t.len()).saturating_add(std::mem::size_of::<String>()))
}
/// Charge the replacement of the text `old` selects from field `name` with `new_bytes` of text to
/// the run's retained-text budget; an assignment the budget has no room for is an error and
/// charges nothing.
pub(crate) fn charge(ctx: &Context, name: &FieldKey, old: impl FnOnce(&FieldState) -> usize, new_bytes: usize) -> JsResult<()> {
let i = field_index(ctx, name)?;
let mut h = host(ctx)?.borrow_mut();
let freed = old(&h.fields[i]);
let total = h.retained.saturating_sub(freed).saturating_add(new_bytes);
if total > xfa::MAX_PAYLOAD {
return Err(error(format!("the fields' values and colours hold more than {} bytes of text", xfa::MAX_PAYLOAD)));
}
h.retained = total;
Ok(())
}
pub(crate) fn kind_of(ctx: &Context, name: &FieldKey) -> JsResult<FieldType> {
let i = field_index(ctx, name)?;
Ok(host(ctx)?.borrow().fields[i].kind)
}
/// What a field of `kind` keeps of the values assigned to it.
pub(crate) fn value_for(kind: FieldType, vals: Vec<String>) -> Vec<String> {
match kind {
FieldType::CheckBox | FieldType::RadioButton => vals.into_iter().filter(|v| v != "Off" && !v.is_empty()).take(1).collect(),
FieldType::ListBox => vals,
_ => vals.into_iter().take(1).collect(),
}
}
pub(crate) fn set_value(f: &mut FieldState, vals: Vec<String>) {
f.value = value_for(f.kind, vals);
}
#[cfg(test)]
mod string_budget_review_tests {
use super::*;
#[test]
fn escaped_text_budget_counts_exact_utf8_bytes() {
let mut ctx = Context::default();
for (script, bytes) in [("'ascii'", 5), ("'é'", 2), ("'😀'", 4), ("'\\uD800'", 6)] {
let value = ctx.eval(boa_engine::Source::from_bytes(script)).unwrap();
let expected = text(&value, &mut ctx).unwrap();
assert_eq!(expected.len(), bytes);
assert_eq!(text_with_budget(&value, &mut ctx, bytes, "text limit").unwrap(), expected);
assert!(text_with_budget(&value, &mut ctx, bytes - 1, "text limit").is_err());
}
assert_eq!(text_with_budget(&JsValue::null(), &mut ctx, 0, "text limit").unwrap(), "");
}
}