From 6a78a9ce9973a2997fcbe9fe7789935cc6260b76 Mon Sep 17 00:00:00 2001 From: bonfim6P Date: Thu, 15 Oct 2026 02:53:57 -0400 Subject: [PATCH] fix(engine): give a host with no clock or time zone a real one (Session::with_wall_clock) (#2317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On wasm32-unknown-unknown the engine has neither a clock (`now()` is empty) nor a time zone (`local_utc_offset()` is 0), so in a browser Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written. The one way to inject a clock, `with_clock`, is a test shortcut: it takes the injected instant AS local time and zeroes the zone, so a host that injects only that gets either the local dates or the UTC ones wrong by its offset (21:30 in São Paulo, UTC-3, is already the next day in UTC). `Session::with_wall_clock(clock, utc_offset)` keeps the two separate: `clock` is the UTC instant (what the file records) and `utc_offset` is asked on every use, because the zone changes with daylight saving. `Automation::with_wall_clock` forwards it. Nothing changes for a native build, or for `with_clock`. The test pins the contrast: at 21:30 in São Paulo the stamped day is the 14th, `signing_date` carries `-03'00'`, the file's instant stays 00:30Z on the 15th, and the plain test clock still answers the 15th. Co-authored-by: Paulo Junior <195025778+bonfim6P@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 --- crates/automation/src/lib.rs | 6 ++++++ crates/engine/src/dates.rs | 20 ++++++++++++++++++++ crates/engine/src/lib.rs | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index a832d0685a..3e2dde314b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -140,6 +140,12 @@ impl Automation { self } + /// The clock and time zone of a host without its own. See [`Session::with_wall_clock`]. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset); + self + } + pub fn session(&self) -> &Session { &self.session } diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index c3f9a50861..8c14a5b1b3 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -669,6 +669,26 @@ mod tests { assert_eq!(s.date_format(), "dd.mm.yyyy", "a rejected pattern keeps the previous one"); } + /// 21:30 on 14 Oct 2026 in São Paulo (UTC-3) is 00:30Z on the 15th. The date Fill & Sign + /// stamps is the wall-clock day (14th); the time the file records (`/M`, `/ModDate`) is UTC. + /// The test shortcut (`with_clock`) takes the injected time as local and says the 15th, which + /// is why it cannot serve a host that knows both the UTC time and the zone. + #[test] + fn a_wall_clock_gives_the_local_day_and_keeps_utc_for_the_file() { + let sao_paulo = Session::new().with_wall_clock(|| 1_792_024_200, || -3 * 3600); + assert_eq!(sao_paulo.today(), (2026, 10, 14)); + assert_eq!(sao_paulo.today_text(None, None).as_deref(), Ok("10/14/2026")); + assert_eq!(sao_paulo.signing_date(), "D:20261014213000-03'00'"); + assert_eq!(sao_paulo.stamp_by_line("Ada"), "By Ada at 9:30 pm, Oct 14, 2026"); + assert_eq!(sao_paulo.now_secs(), 1_792_024_200, "the file's instant stays in UTC"); + + let utc = Session::new().with_wall_clock(|| 1_792_024_200, || 0); + assert_eq!(utc.today(), (2026, 10, 15)); + + let clock_only = Session::new().with_clock(|| 1_792_024_200); + assert_eq!(clock_only.today(), (2026, 10, 15), "on its own, an injected clock counts as local time"); + } + #[test] fn names_follow_the_date_language() { assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1058a8e46b..23df757f8e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2762,6 +2762,11 @@ pub struct Session { next_id: u64, /// Seconds since the Unix epoch, injected so saves are deterministic in tests. clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, /// Certificates trusted for signing (Acrobat: Trusted Certificates). trust: Arc, /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). @@ -3100,6 +3105,28 @@ impl Session { self } + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + /// Seconds since the Unix epoch from the session clock (0 when unknown). pub fn now_secs(&self) -> i64 { self.now().unwrap_or(0) @@ -3119,7 +3146,7 @@ impl Session { /// is taken as local time. /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). pub fn stamp_by_line(&self, author: &str) -> String { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); @@ -3130,7 +3157,7 @@ impl Session { } pub fn today(&self) -> (i64, u32, u32) { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) } @@ -4580,7 +4607,7 @@ impl Session { /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). pub fn signing_date(&self) -> String { - let offset = if self.clock.is_some() { 0 } else { local_utc_offset() }; + let offset = self.local_offset(); let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); let stamp = local.trim_end_matches('Z'); if offset == 0 {