Skip to main content

stygian_browser/
rects_noise.rs

1//! `ClientRects` and `TextMetrics` fingerprint noise injection.
2//!
3//! Overrides `getBoundingClientRect`, `getClientRects`, `Range` equivalents, and
4//! `CanvasRenderingContext2D.measureText` to inject deterministic sub-pixel noise
5//! that breaks font/layout fingerprinting while preserving `DOMRect` consistency.
6//!
7//! # Example
8//!
9//! ```
10//! use stygian_browser::rects_noise::rects_noise_script;
11//! use stygian_browser::noise::{NoiseEngine, NoiseSeed};
12//!
13//! let engine = NoiseEngine::new(NoiseSeed::from(42_u64));
14//! let js = rects_noise_script(&engine);
15//! assert!(js.contains("getBoundingClientRect"));
16//! assert!(js.contains("measureText"));
17//! ```
18
19use crate::noise::NoiseEngine;
20
21/// Generate the `ClientRects` and `TextMetrics` noise injection script.
22///
23/// Must be injected via `Page.addScriptToEvaluateOnNewDocument`.
24///
25/// Noise preserves `DOMRect` internal consistency: `right = x + width`,
26/// `bottom = y + height`.
27///
28/// # Example
29///
30/// ```
31/// use stygian_browser::rects_noise::rects_noise_script;
32/// use stygian_browser::noise::{NoiseEngine, NoiseSeed};
33///
34/// let js = rects_noise_script(&NoiseEngine::new(NoiseSeed::from(1_u64)));
35/// assert!(js.contains("getBoundingClientRect"));
36/// assert!(js.contains("getClientRects"));
37/// assert!(js.contains("measureText"));
38/// assert!(js.contains("__stygian_rect_noise"));
39/// ```
40#[must_use]
41pub fn rects_noise_script(engine: &NoiseEngine) -> String {
42    let noise_fn = engine.js_noise_fn();
43    format!(
44        r"(function() {{
45  'use strict';
46
47  // ── Noise helpers ──────────────────────────────────────────────────────
48  {noise_fn}
49
50  // ── Spoof toString ─────────────────────────────────────────────────────
51  function _nts(name) {{ return function toString() {{ return 'function ' + name + '() {{ [native code] }}'; }}; }}
52  function _def(obj, prop, fn) {{
53    fn.toString = _nts(prop);
54    Object.defineProperty(obj, prop, {{ value: fn, writable: false, configurable: false, enumerable: false }});
55  }}
56
57  // ── Element hash (stable, no DOM dependencies) ─────────────────────────
58  // Combines tagName, className, and textContent length into a u32 index
59  // for use as the noise key coordinate.
60  function _elemHash(el) {{
61    let h = 0;
62    const tag = (el.tagName || '');
63    const cls = (el.className && typeof el.className === 'string' ? el.className : '');
64    const tlen = (el.textContent ? el.textContent.length : 0);
65    for (let i = 0; i < tag.length; i++) h = ((h * 31) + tag.charCodeAt(i)) & 0xFFFFFFFF;
66    for (let i = 0; i < cls.length; i++) h = ((h * 31) + cls.charCodeAt(i)) & 0xFFFFFFFF;
67    h = ((h * 31) + tlen) & 0xFFFFFFFF;
68    return h >>> 0;
69  }}
70
71  // ── Noise a DOMRect preserving consistency ──────────────────────────────
72  function _noiseRect(rect, key, idx) {{
73    const [dx, dy, dw, dh] = __stygian_rect_noise(key, idx);
74    const x = rect.x + dx;
75    const y = rect.y + dy;
76    const w = rect.width  + dw;
77    const h = rect.height + dh;
78    return {{
79      x: x, y: y, width: w, height: h,
80      left: x, top: y, right: x + w, bottom: y + h,
81      toJSON: function() {{
82        return {{ x: x, y: y, width: w, height: h, left: x, top: y, right: x + w, bottom: y + h }};
83      }}
84    }};
85  }}
86
87  // ── Element.getBoundingClientRect ───────────────────────────────────────
88  if (typeof Element !== 'undefined') {{
89    const _origGBCR = Element.prototype.getBoundingClientRect;
90    _def(Element.prototype, 'getBoundingClientRect', function getBoundingClientRect() {{
91      const r = _origGBCR.call(this);
92      return _noiseRect(r, 'rects.getBCR', _elemHash(this));
93    }});
94
95    // Element.getClientRects
96    const _origGCR = Element.prototype.getClientRects;
97    _def(Element.prototype, 'getClientRects', function getClientRects() {{
98      const list = _origGCR.call(this);
99      const h = _elemHash(this);
100      const result = [];
101      for (let i = 0; i < list.length; i++) {{
102        result.push(_noiseRect(list[i], 'rects.getCR', (h + i) >>> 0));
103      }}
104      result[Symbol.iterator] = Array.prototype[Symbol.iterator];
105      result.item = function(idx) {{ return result[idx] || null; }};
106      result.length = result.length;
107      return result;
108    }});
109  }}
110
111  // ── Range.getBoundingClientRect ─────────────────────────────────────────
112  if (typeof Range !== 'undefined') {{
113    const _origRGBCR = Range.prototype.getBoundingClientRect;
114    _def(Range.prototype, 'getBoundingClientRect', function getBoundingClientRect() {{
115      const r = _origRGBCR.call(this);
116      return _noiseRect(r, 'rects.rangeBCR', 0);
117    }});
118
119    const _origRGCR = Range.prototype.getClientRects;
120    _def(Range.prototype, 'getClientRects', function getClientRects() {{
121      const list = _origRGCR.call(this);
122      const result = [];
123      for (let i = 0; i < list.length; i++) {{
124        result.push(_noiseRect(list[i], 'rects.rangeCR', i));
125      }}
126      result[Symbol.iterator] = Array.prototype[Symbol.iterator];
127      result.item = function(idx) {{ return result[idx] || null; }};
128      result.length = result.length;
129      return result;
130    }});
131  }}
132
133  // ── CanvasRenderingContext2D.measureText ────────────────────────────────
134  if (typeof CanvasRenderingContext2D !== 'undefined') {{
135    const _origMT = CanvasRenderingContext2D.prototype.measureText;
136    _def(CanvasRenderingContext2D.prototype, 'measureText', function measureText(text) {{
137      const m = _origMT.call(this, text);
138      // Hash the text for a stable noise index
139      let th = 0;
140      for (let i = 0; i < text.length; i++) th = ((th * 31) + text.charCodeAt(i)) & 0xFFFFFFFF;
141      const [dx, , dw, ] = __stygian_rect_noise('rects.measureText', th >>> 0);
142      const scale = 0.01; // ±0.001..0.01 pixels
143      return {{
144        width:                    m.width                    + dx * scale,
145        actualBoundingBoxLeft:    m.actualBoundingBoxLeft    + dx * scale,
146        actualBoundingBoxRight:   m.actualBoundingBoxRight   + dw * scale,
147        actualBoundingBoxAscent:  m.actualBoundingBoxAscent  + dx * scale,
148        actualBoundingBoxDescent: m.actualBoundingBoxDescent + dw * scale,
149        fontBoundingBoxAscent:    m.fontBoundingBoxAscent    + dx * scale,
150        fontBoundingBoxDescent:   m.fontBoundingBoxDescent   + dw * scale,
151        emHeightAscent:           m.emHeightAscent           + dx * scale,
152        emHeightDescent:          m.emHeightDescent          + dw * scale,
153        hangingBaseline:          m.hangingBaseline          + dx * scale,
154        alphabeticBaseline:       m.alphabeticBaseline       + dx * scale,
155        ideographicBaseline:      m.ideographicBaseline      + dx * scale,
156      }};
157    }});
158  }}
159
160}})();
161"
162    )
163}
164
165// ---------------------------------------------------------------------------
166// Tests
167// ---------------------------------------------------------------------------
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::noise::{NoiseEngine, NoiseSeed};
173
174    fn eng(seed: u64) -> NoiseEngine {
175        NoiseEngine::new(NoiseSeed::from(seed))
176    }
177
178    #[test]
179    fn script_overrides_all_methods() {
180        let js = rects_noise_script(&eng(1));
181        assert!(
182            js.contains("getBoundingClientRect"),
183            "missing getBoundingClientRect"
184        );
185        assert!(js.contains("getClientRects"), "missing getClientRects");
186        assert!(js.contains("Range"), "missing Range overrides");
187        assert!(js.contains("measureText"), "missing measureText");
188    }
189
190    #[test]
191    fn script_preserves_domrect_consistency() {
192        let js = rects_noise_script(&eng(1));
193        // The _noiseRect helper must set right = x + w and bottom = y + h
194        assert!(
195            js.contains("right: x + w"),
196            "DOMRect right not derived from x+w"
197        );
198        assert!(
199            js.contains("bottom: y + h"),
200            "DOMRect bottom not derived from y+h"
201        );
202    }
203
204    #[test]
205    fn text_metrics_covers_8_properties() {
206        let js = rects_noise_script(&eng(1));
207        let required = [
208            "width",
209            "actualBoundingBoxLeft",
210            "actualBoundingBoxRight",
211            "actualBoundingBoxAscent",
212            "actualBoundingBoxDescent",
213            "fontBoundingBoxAscent",
214            "fontBoundingBoxDescent",
215            "emHeightAscent",
216        ];
217        for prop in &required {
218            assert!(js.contains(prop), "TextMetrics missing {prop}");
219        }
220    }
221
222    #[test]
223    fn script_contains_rect_noise_fn() {
224        let js = rects_noise_script(&eng(1));
225        assert!(js.contains("__stygian_rect_noise"), "missing rect noise fn");
226    }
227
228    #[test]
229    fn element_hash_handles_null_classname() {
230        let js = rects_noise_script(&eng(1));
231        // JS handles missing className gracefully
232        assert!(
233            js.contains("typeof el.className === 'string'"),
234            "className guard missing"
235        );
236    }
237
238    #[test]
239    fn script_contains_native_tostring() {
240        let js = rects_noise_script(&eng(1));
241        assert!(js.contains("[native code]"), "missing toString spoof");
242    }
243}