Skip to main content

proofsheet_core/
determinism.rs

1//! The determinism preamble.
2//!
3//! Injected via `Page.addScriptToEvaluateOnNewDocument`, so it runs before any
4//! page script on every document — including ones created by navigation.
5//!
6//! Why this exists: store screenshot sets rot because they are taken by hand
7//! at different moments. One shot says 3:47, the next says 9:12; a list
8//! reshuffles; "2 hours ago" becomes "3 days ago". Freezing the clock and
9//! seeding the PRNG makes a rerun reproduce the entire set byte for byte,
10//! so the only diffs are the ones you meant to make.
11//!
12//! Validated empirically before this code existed: with the preamble, two
13//! independent browser launches produced identical PNG hashes across five
14//! device sizes; with it disabled, the same page produced different bytes.
15//! The negative control is the part that makes that evidence rather than a
16//! green check.
17
18/// Knobs for the injected preamble.
19#[derive(Debug, Clone, PartialEq)]
20pub struct Determinism {
21    /// Seed for the replacement PRNG.
22    pub seed: u64,
23    /// The instant `Date.now()` reports, in milliseconds since the epoch.
24    pub epoch_ms: i64,
25    /// Fixed timezone, e.g. `UTC`. Applied via CDP, not script.
26    pub timezone: String,
27    /// Fixed locale, e.g. `en-US`. Applied via CDP, not script.
28    pub locale: String,
29    /// Virtual milliseconds advanced per animation frame.
30    pub frame_ms: f64,
31}
32
33impl Default for Determinism {
34    fn default() -> Self {
35        Determinism {
36            seed: 42,
37            // A fixed, boring instant. Chosen once and never changed, because
38            // changing it would churn every committed screenshot everywhere.
39            epoch_ms: 1_750_000_000_000,
40            timezone: "UTC".into(),
41            locale: "en-US".into(),
42            frame_ms: 1000.0 / 60.0,
43        }
44    }
45}
46
47impl Determinism {
48    pub fn with_seed(mut self, seed: u64) -> Self {
49        self.seed = seed;
50        self
51    }
52
53    pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
54        self.locale = locale.into();
55        self
56    }
57
58    /// Render the JavaScript preamble for these settings.
59    pub fn preamble(&self) -> String {
60        // mulberry32: tiny, fast, and stable across implementations, which
61        // matters because the Rust side may need to predict the same stream.
62        format!(
63            r#"(() => {{
64  let s = {seed} >>> 0;
65  Math.random = function () {{
66    s |= 0; s = (s + 0x6D2B79F5) | 0;
67    let t = Math.imul(s ^ (s >>> 15), 1 | s);
68    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
69    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
70  }};
71  const T0 = {epoch};
72  const RealDate = Date;
73  function FrozenDate(...a) {{
74    if (!(this instanceof FrozenDate)) return new RealDate(T0).toString();
75    return a.length ? new RealDate(...a) : new RealDate(T0);
76  }}
77  FrozenDate.prototype = RealDate.prototype;
78  FrozenDate.now = () => T0;
79  FrozenDate.parse = RealDate.parse;
80  FrozenDate.UTC = RealDate.UTC;
81  Object.defineProperty(FrozenDate, 'name', {{ value: 'Date' }});
82  window.Date = FrozenDate;
83
84  let perf = 0;
85  performance.now = () => perf;
86
87  const FRAME = {frame};
88  window.requestAnimationFrame = (cb) => {{
89    perf += FRAME;
90    const t = perf;
91    return setTimeout(() => cb(t), 0);
92  }};
93  window.cancelAnimationFrame = (h) => clearTimeout(h);
94
95  // crypto.getRandomValues is a second entropy source that would otherwise
96  // leak nondeterminism into anything generating ids.
97  if (window.crypto && crypto.getRandomValues) {{
98    crypto.getRandomValues = (arr) => {{
99      for (let i = 0; i < arr.length; i++) {{
100        arr[i] = Math.floor(Math.random() * 256);
101      }}
102      return arr;
103    }};
104  }}
105}})();"#,
106            seed = self.seed,
107            epoch = self.epoch_ms,
108            frame = self.frame_ms,
109        )
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn preamble_embeds_its_settings() {
119        let d = Determinism::default().with_seed(7);
120        let js = d.preamble();
121        assert!(js.contains("let s = 7 >>> 0"));
122        assert!(js.contains("const T0 = 1750000000000"));
123    }
124
125    #[test]
126    fn preamble_overrides_every_known_entropy_source() {
127        let js = Determinism::default().preamble();
128        for sym in [
129            "Math.random",
130            "Date.now",
131            "performance.now",
132            "requestAnimationFrame",
133            "getRandomValues",
134        ] {
135            assert!(js.contains(sym), "preamble does not override {sym}");
136        }
137    }
138
139    #[test]
140    fn default_epoch_is_pinned() {
141        // If this ever changes, every committed screenshot in every
142        // downstream repo churns. Treat a failure here as a deliberate
143        // decision, not a test to update.
144        assert_eq!(Determinism::default().epoch_ms, 1_750_000_000_000);
145    }
146}