mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 06:12:19 +00:00
* 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
323 lines
13 KiB
Rust
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(), "");
|
|
}
|
|
}
|