From 2ece498ffc1850cc7d5044d70a75d5bbf34310ba Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 20:21:21 +0000 Subject: [PATCH] fix(js): charge both copies of a written XFA value before keeping either set_value charged the snapshot's copy, changed the field, then charged the effect's copy in push. When only the first fit, the effect was dropped while later reads in the script saw the new value. Both copies are charged at once before the field changes. --- crates/js/src/tests.rs | 21 +++++++++++++++++++++ crates/js/src/xfa.rs | 28 +++++++++++++++++++--------- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 8ce338b2e..3201641b8 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -455,6 +455,27 @@ mod xfa_model { 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] diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index ae38658e0..99a1dd45c 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -315,7 +315,15 @@ impl XHost { /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] /// bytes of text) effects are counted and dropped. pub(crate) fn push(&mut self, e: XfaEffect) { - let e = match e { + let e = self.clip_effect(e); + if self.room(payload(&e)) { + self.record(e); + } + } + + /// `e` with its text cut to the lengths an effect keeps. + fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect { + match e { XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), @@ -328,10 +336,11 @@ impl XHost { XfaEffect::SetValue { som, value } } other => other, - }; - if !self.room(payload(&e)) { - return; } + } + + /// Keep `e`, whose bytes are already charged, against the count limits. + fn record(&mut self, e: XfaEffect) { let key = match &e { XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), @@ -415,16 +424,17 @@ impl XHost { if cut { self.clipped_values += 1; } - // The snapshot's copy counts as well as the effect's: past the budget the write is left - // out of both. - if !self.room(text.len()) { + // The snapshot's copy and the effect's are charged together, before either is kept: + // past the byte budget the write is left out of both. + let effect = XfaEffect::SetValue { som, value: text.clone() }; + if !self.room(text.len().saturating_add(payload(&effect))) { return true; } if let Some(n) = self.nodes.get_mut(i) { - n.value = text.clone(); + n.value = text; n.formatted = None; } - self.push(XfaEffect::SetValue { som, value: text }); + self.record(effect); true } }