1use serde::{Deserialize, Serialize};
23
24use crate::noise::{NoiseEngine, NoiseSeed};
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct TimingNoiseConfig {
44 pub enabled: bool,
46 pub jitter_ms: f64,
48 pub seed: NoiseSeed,
50}
51
52impl Default for TimingNoiseConfig {
53 fn default() -> Self {
55 Self {
56 enabled: true,
57 jitter_ms: 0.3,
58 seed: NoiseSeed::random(),
59 }
60 }
61}
62
63#[must_use]
85pub fn timing_noise_script(config: &TimingNoiseConfig) -> String {
86 if !config.enabled {
87 return String::new();
88 }
89
90 let engine = NoiseEngine::new(config.seed);
91 let noise_fn = engine.js_noise_fn();
92 let jitter_ms = config.jitter_ms;
93
94 let origin_shift = {
97 let h = engine.float_noise("timing.origin", 0);
98 h * 1_000_000_000.0 };
101 let origin_shift_ms = origin_shift.clamp(-10.0, 10.0);
103
104 format!(
105 r"(function() {{
106 'use strict';
107
108 // ── Noise helpers ──────────────────────────────────────────────────────
109 {noise_fn}
110
111 const _JITTER_MS = {jitter_ms};
112 // Fixed origin shift for this session (±{origin_shift_ms:.4} ms)
113 const _ORIGIN_SHIFT = {origin_shift_ms:.6};
114
115 // ── performance.now() — monotonic jitter accumulator ──────────────────
116 let __stygian_time_offset = 0.0;
117 let __stygian_pnow_counter = 0;
118 let __stygian_pnow_last = 0.0;
119
120 const _origPerfNow = performance.now.bind(performance);
121
122 Object.defineProperty(performance, 'now', {{
123 value: function now() {{
124 const base = _origPerfNow();
125 const noiseFraction = __stygian_float_noise('timing.now', __stygian_pnow_counter++);
126 // noiseFraction is in [-1e-5, 1e-5]; scale to [-jitter_ms/2, jitter_ms/2]
127 const delta = noiseFraction * (_JITTER_MS * 50000.0);
128 // Accumulate only positive deltas to keep monotonicity
129 const positive = Math.max(0.0, delta);
130 __stygian_time_offset += positive;
131 const result = Math.max(__stygian_pnow_last, base + __stygian_time_offset);
132 __stygian_pnow_last = result;
133 return result;
134 }},
135 writable: false,
136 configurable: false,
137 enumerable: true,
138 }});
139
140 // ── performance.timeOrigin — fixed per-session shift ──────────────────
141 const _origTimeOrigin = performance.timeOrigin;
142 Object.defineProperty(performance, 'timeOrigin', {{
143 get: function() {{ return _origTimeOrigin + _ORIGIN_SHIFT; }},
144 configurable: false,
145 enumerable: true,
146 }});
147
148 // ── Date.now() — apply same origin shift ─────────────────────────────
149 const _origDateNow = Date.now.bind(Date);
150 (function() {{
151 const shifted = function now() {{
152 return _origDateNow() + _ORIGIN_SHIFT;
153 }};
154 shifted.toString = function toString() {{ return 'function now() {{ [native code] }}'; }};
155 try {{
156 Date.now = shifted;
157 }} catch(e) {{
158 Object.defineProperty(Date, 'now', {{
159 value: shifted, writable: false, configurable: false, enumerable: false
160 }});
161 }}
162 }})();
163
164 // ── performance.getEntries* — noise on timing fields ─────────────────
165 function _noiseEntry(entry, idx) {{
166 const delta = __stygian_float_noise('timing.entry', idx) * (_JITTER_MS * 50000.0);
167 // Preserve ordering: only add positive deltas
168 const d = Math.abs(delta);
169 // Build a plain-object copy with shifted timings; preserve startTime ordering
170 return {{
171 name: entry.name,
172 entryType: entry.entryType,
173 startTime: entry.startTime + d,
174 duration: entry.duration,
175 // Resource / Navigation fields (may be undefined on other entry types)
176 // We only copy defined fields to avoid breaking typed PerformanceEntry comparisons
177 toJSON: function() {{
178 const j = entry.toJSON ? entry.toJSON() : {{}};
179 j.startTime = entry.startTime + d;
180 return j;
181 }},
182 }};
183 }}
184
185 const _origGetEntries = performance.getEntries.bind(performance);
186 Object.defineProperty(performance, 'getEntries', {{
187 value: function getEntries() {{
188 return _origGetEntries().map(function(e, i) {{ return _noiseEntry(e, i); }});
189 }},
190 writable: false, configurable: false, enumerable: true,
191 }});
192
193 const _origGetEntriesByType = performance.getEntriesByType.bind(performance);
194 Object.defineProperty(performance, 'getEntriesByType', {{
195 value: function getEntriesByType(type) {{
196 return _origGetEntriesByType(type).map(function(e, i) {{ return _noiseEntry(e, i); }});
197 }},
198 writable: false, configurable: false, enumerable: true,
199 }});
200
201 const _origGetEntriesByName = performance.getEntriesByName.bind(performance);
202 Object.defineProperty(performance, 'getEntriesByName', {{
203 value: function getEntriesByName(name, type) {{
204 const args = type !== undefined ? [name, type] : [name];
205 return _origGetEntriesByName.apply(performance, args)
206 .map(function(e, i) {{ return _noiseEntry(e, i); }});
207 }},
208 writable: false, configurable: false, enumerable: true,
209 }});
210
211}})();
212",
213 )
214}
215
216#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::noise::NoiseSeed;
224
225 fn cfg(enabled: bool, jitter: f64, seed: u64) -> TimingNoiseConfig {
226 TimingNoiseConfig {
227 enabled,
228 jitter_ms: jitter,
229 seed: NoiseSeed::from(seed),
230 }
231 }
232
233 #[test]
234 fn disabled_returns_empty() {
235 assert!(timing_noise_script(&cfg(false, 0.3, 1)).is_empty());
236 }
237
238 #[test]
239 fn script_overrides_perf_now() {
240 let js = timing_noise_script(&cfg(true, 0.3, 1));
241 assert!(
242 js.contains("performance.now"),
243 "missing performance.now override"
244 );
245 }
246
247 #[test]
248 fn script_overrides_time_origin() {
249 let js = timing_noise_script(&cfg(true, 0.3, 1));
250 assert!(js.contains("timeOrigin"), "missing timeOrigin override");
251 }
252
253 #[test]
254 fn script_overrides_date_now() {
255 let js = timing_noise_script(&cfg(true, 0.3, 1));
256 assert!(js.contains("Date.now"), "missing Date.now override");
257 }
258
259 #[test]
260 fn script_overrides_get_entries() {
261 let js = timing_noise_script(&cfg(true, 0.3, 1));
262 assert!(js.contains("getEntries"), "missing getEntries override");
263 assert!(js.contains("getEntriesByType"), "missing getEntriesByType");
264 assert!(js.contains("getEntriesByName"), "missing getEntriesByName");
265 }
266
267 #[test]
268 fn script_has_monotonicity_accumulator() {
269 let js = timing_noise_script(&cfg(true, 0.3, 1));
270 assert!(
271 js.contains("__stygian_time_offset"),
272 "missing monotonicity accumulator"
273 );
274 }
275
276 #[test]
277 fn default_jitter_in_reasonable_range() {
278 let c = TimingNoiseConfig::default();
279 assert!(
280 c.jitter_ms >= 0.01 && c.jitter_ms <= 1.0,
281 "jitter_ms out of range"
282 );
283 }
284
285 #[test]
286 fn serde_round_trip() {
287 let c = TimingNoiseConfig {
288 enabled: true,
289 jitter_ms: 0.25,
290 seed: NoiseSeed::from(98765_u64),
291 };
292 let json_result = serde_json::to_string(&c);
293 assert!(json_result.is_ok(), "serialize failed: {json_result:?}");
294 let Ok(json) = json_result else {
295 return;
296 };
297 let cfg_result: Result<TimingNoiseConfig, _> = serde_json::from_str(&json);
298 assert!(cfg_result.is_ok(), "deserialize failed: {cfg_result:?}");
299 let Ok(c2) = cfg_result else {
300 return;
301 };
302 assert_eq!(c2.enabled, c.enabled);
303 assert!((c2.jitter_ms - c.jitter_ms).abs() < f64::EPSILON);
304 assert_eq!(c2.seed.as_u64(), c.seed.as_u64());
305 }
306
307 #[test]
308 fn different_seeds_produce_different_scripts() {
309 let js1 = timing_noise_script(&cfg(true, 0.3, 1));
310 let js2 = timing_noise_script(&cfg(true, 0.3, 2));
311 assert_ne!(js1, js2);
312 }
313}