1use crate::profile::{BrowserKind, FingerprintProfile};
23
24#[must_use]
44pub fn navigator_coherence_script(profile: &FingerprintProfile) -> String {
45 let cores = profile.hardware.cores;
46 let memory = profile.hardware.memory_gb;
47 let rtt = profile.network.rtt;
48 let downlink = profile.network.downlink;
49 let effective_type = &profile.network.effective_type;
50 let save_data = profile.network.save_data;
51 let max_touch = profile.platform.max_touch_points;
52 let pdf_viewer_enabled = matches!(
53 profile.browser.kind,
54 BrowserKind::Chrome | BrowserKind::Edge
55 );
56
57 let languages_js = build_languages_js(profile);
59 let plugins_js = build_plugins_js(profile);
60 let ua_data_js = build_ua_data_js(profile);
61 let user_agent = &profile.browser.user_agent;
62 let platform_string = &profile.platform.platform_string;
63
64 format!(
65 r"(function() {{
66 'use strict';
67
68 // ── toString spoof utility ───────────────────────────────────────────────
69 function _nts(name) {{ return function toString() {{ return 'function ' + name + '() {{ [native code] }}'; }}; }}
70 function _def(obj, prop, val) {{
71 Object.defineProperty(obj, prop, {{ value: val, writable: false, configurable: false, enumerable: false }});
72 }}
73 function _defGetter(obj, prop, getter) {{
74 getter.toString = _nts('get ' + prop);
75 Object.defineProperty(obj, prop, {{ get: getter, configurable: false, enumerable: true }});
76 }}
77
78 // ── 1. hardwareConcurrency ───────────────────────────────────────────────
79 _defGetter(Navigator.prototype, 'hardwareConcurrency', function() {{ return {cores}; }});
80
81 // ── 2. deviceMemory ─────────────────────────────────────────────────────
82 if ('deviceMemory' in navigator) {{
83 _defGetter(Navigator.prototype, 'deviceMemory', function() {{ return {memory}; }});
84 }}
85
86 // ── 3. connection (NetworkInformation) ──────────────────────────────────
87 const _conn = Object.create(EventTarget.prototype);
88 _def(_conn, 'rtt', {rtt});
89 _def(_conn, 'downlink', {downlink});
90 _def(_conn, 'effectiveType', '{effective_type}');
91 _def(_conn, 'saveData', {save_data_js});
92 _def(_conn, 'onchange', null);
93 _defGetter(Navigator.prototype, 'connection', function() {{ return _conn; }});
94
95 // ── 4. maxTouchPoints ───────────────────────────────────────────────────
96 _defGetter(Navigator.prototype, 'maxTouchPoints', function() {{ return {max_touch}; }});
97
98 // ── 5. languages ─────────────────────────────────────────────────────────
99 const _langs = {languages_js};
100 _defGetter(Navigator.prototype, 'languages', function() {{ return _langs; }});
101 _defGetter(Navigator.prototype, 'language', function() {{ return _langs[0] || 'en-US'; }});
102
103 // ── 6. pdfViewerEnabled ──────────────────────────────────────────────────
104 _defGetter(Navigator.prototype, 'pdfViewerEnabled', function() {{ return {pdf_viewer_enabled_js}; }});
105
106 // ── 7. plugins + mimeTypes ───────────────────────────────────────────────
107 {plugins_js}
108
109 // ── 8. userAgent + platform ─────────────────────────────────────────────
110 _defGetter(Navigator.prototype, 'userAgent', function() {{ return '{user_agent}'; }});
111 _defGetter(Navigator.prototype, 'platform', function() {{ return '{platform_string}'; }});
112 _defGetter(Navigator.prototype, 'appVersion', function() {{
113 return '{user_agent}'.replace('Mozilla/', '');
114 }});
115
116 // ── 9. userAgentData (Client Hints) ─────────────────────────────────────
117 {ua_data_js}
118
119}})();
120",
121 cores = cores,
122 memory = memory,
123 rtt = rtt,
124 downlink = downlink,
125 effective_type = effective_type,
126 save_data_js = if save_data { "true" } else { "false" },
127 max_touch = max_touch,
128 languages_js = languages_js,
129 pdf_viewer_enabled_js = if pdf_viewer_enabled { "true" } else { "false" },
130 plugins_js = plugins_js,
131 user_agent = user_agent.replace('\'', "\\'"),
132 platform_string = platform_string.replace('\'', "\\'"),
133 ua_data_js = ua_data_js,
134 )
135}
136
137fn build_languages_js(profile: &FingerprintProfile) -> String {
142 let _ = profile.browser.sec_ch_ua_mobile == "?1";
145 "['en-US', 'en']".into()
146}
147
148fn build_plugins_js(profile: &FingerprintProfile) -> String {
149 let is_chrome_like = matches!(
150 profile.browser.kind,
151 BrowserKind::Chrome | BrowserKind::Edge
152 );
153 if !is_chrome_like {
154 return String::new();
155 }
156 r"
158 (function() {
159 const _mimeTypes = [
160 { type: 'application/pdf', suffixes: 'pdf', description: '' },
161 { type: 'text/pdf', suffixes: 'pdf', description: '' },
162 ];
163 const _pluginData = [
164 'PDF Viewer',
165 'Chrome PDF Viewer',
166 'Chromium PDF Viewer',
167 'Microsoft Edge PDF Viewer',
168 'WebKit built-in PDF',
169 ];
170 function _makePlugin(name) {
171 const p = Object.create(Plugin.prototype);
172 Object.defineProperty(p, 'name', { value: name, enumerable: true });
173 Object.defineProperty(p, 'description', { value: '', enumerable: true });
174 Object.defineProperty(p, 'filename', { value: 'internal-pdf-viewer', enumerable: true });
175 Object.defineProperty(p, 'length', { value: _mimeTypes.length, enumerable: true });
176 _mimeTypes.forEach(function(mt, i) {
177 Object.defineProperty(p, i, { value: mt, enumerable: true });
178 });
179 p.item = function(i) { return _mimeTypes[i] || null; };
180 p.namedItem = function(n) { return _mimeTypes.find(function(m) { return m.type === n; }) || null; };
181 return p;
182 }
183 const _plugins = _pluginData.map(_makePlugin);
184 const _pluginArray = Object.create(PluginArray.prototype);
185 Object.defineProperty(_pluginArray, 'length', { value: _plugins.length, enumerable: true });
186 _plugins.forEach(function(p, i) { Object.defineProperty(_pluginArray, i, { value: p, enumerable: true }); });
187 _pluginArray.item = function(i) { return _plugins[i] || null; };
188 _pluginArray.namedItem = function(n) { return _plugins.find(function(p) { return p.name === n; }) || null; };
189 _pluginArray.refresh = function() {};
190 Object.defineProperty(Navigator.prototype, 'plugins', {
191 get: function() { return _pluginArray; }, configurable: false, enumerable: true
192 });
193 })();".into()
194}
195
196fn build_ua_data_js(profile: &FingerprintProfile) -> String {
197 let brands = parse_sec_ch_ua_brands(&profile.browser.sec_ch_ua);
198 let mobile = profile.browser.sec_ch_ua_mobile == "?1";
199 let platform = strip_quotes(&profile.browser.sec_ch_ua_platform);
200 let os_version = &profile.platform.os_version;
201
202 format!(
203 r"
204 if (typeof NavigatorUAData !== 'undefined' || 'userAgentData' in navigator) {{
205 const _brands = {brands};
206 const _uaData = {{
207 brands: _brands,
208 mobile: {mobile_js},
209 platform: '{platform}',
210 getHighEntropyValues: function(hints) {{
211 return Promise.resolve({{
212 architecture: 'x86',
213 model: '',
214 platform: '{platform}',
215 platformVersion: '{os_version}',
216 fullVersionList: _brands,
217 mobile: {mobile_js},
218 bitness: '64',
219 wow64: false,
220 }});
221 }},
222 toJSON: function() {{
223 return {{ brands: _brands, mobile: {mobile_js}, platform: '{platform}' }};
224 }},
225 }};
226 Object.defineProperty(Navigator.prototype, 'userAgentData', {{
227 get: function() {{ return _uaData; }}, configurable: false, enumerable: true
228 }});
229 }}",
230 brands = brands,
231 mobile_js = if mobile { "true" } else { "false" },
232 platform = platform,
233 os_version = os_version,
234 )
235}
236
237fn parse_sec_ch_ua_brands(sec_ch_ua: &str) -> String {
242 use std::fmt::Write;
243
244 let mut result = String::from('[');
245 for part in sec_ch_ua.split(',') {
246 let part = part.trim();
247 let mut iter = part.splitn(2, ";v=");
249 let brand_raw = iter.next().unwrap_or("").trim().trim_matches('"');
250 let version_raw = iter.next().unwrap_or("\"\"").trim().trim_matches('"');
251 if !brand_raw.is_empty() {
252 if result.len() > 1 {
253 result.push(',');
254 }
255 let _ = write!(
256 result,
257 "{{brand:\"{brand_raw}\",version:\"{version_raw}\"}}",
258 );
259 }
260 }
261 result.push(']');
262 result
263}
264
265fn strip_quotes(s: &str) -> String {
267 s.trim_matches('"').to_string()
268}
269
270#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::profile::FingerprintProfile;
278
279 fn script_for(p: &FingerprintProfile) -> String {
280 navigator_coherence_script(p)
281 }
282
283 #[test]
284 fn script_overrides_all_nine_groups() {
285 let p = FingerprintProfile::windows_chrome_136_rtx3060();
286 let js = script_for(&p);
287 assert!(
288 js.contains("hardwareConcurrency"),
289 "missing hardwareConcurrency"
290 );
291 assert!(js.contains("deviceMemory"), "missing deviceMemory");
292 assert!(js.contains("connection"), "missing connection");
293 assert!(js.contains("maxTouchPoints"), "missing maxTouchPoints");
294 assert!(js.contains("languages"), "missing languages");
295 assert!(js.contains("pdfViewerEnabled"), "missing pdfViewerEnabled");
296 assert!(js.contains("plugins"), "missing plugins");
297 assert!(js.contains("userAgentData"), "missing userAgentData");
298 assert!(js.contains("userAgent"), "missing userAgent");
299 }
300
301 #[test]
302 fn hardware_concurrency_matches_profile() {
303 let p = FingerprintProfile::windows_chrome_136_rtx3060();
304 let js = script_for(&p);
305 assert!(
306 js.contains(&format!("return {};", p.hardware.cores)),
307 "hardwareConcurrency value not found"
308 );
309 }
310
311 #[test]
312 fn device_memory_matches_profile() {
313 let p = FingerprintProfile::windows_chrome_136_rtx3060();
314 let js = script_for(&p);
315 assert!(
316 js.contains(&format!("return {};", p.hardware.memory_gb)),
317 "deviceMemory value not found"
318 );
319 }
320
321 #[test]
322 fn connection_has_all_four_properties() {
323 let js = script_for(&FingerprintProfile::windows_chrome_136_rtx3060());
324 assert!(js.contains("rtt"), "missing rtt");
325 assert!(js.contains("downlink"), "missing downlink");
326 assert!(js.contains("effectiveType"), "missing effectiveType");
327 assert!(js.contains("saveData"), "missing saveData");
328 }
329
330 #[test]
331 fn plugins_count_five_for_chrome() {
332 let js = script_for(&FingerprintProfile::windows_chrome_136_rtx3060());
333 assert!(js.contains("PDF Viewer"), "missing PDF Viewer plugin");
334 assert!(
335 js.contains("Chrome PDF Viewer"),
336 "missing Chrome PDF Viewer"
337 );
338 assert!(
339 js.contains("Chromium PDF Viewer"),
340 "missing Chromium PDF Viewer"
341 );
342 assert!(
343 js.contains("Microsoft Edge PDF Viewer"),
344 "missing Edge plugin"
345 );
346 assert!(js.contains("WebKit built-in PDF"), "missing WebKit plugin");
347 }
348
349 #[test]
350 fn ua_data_brands_match_sec_ch_ua() {
351 let p = FingerprintProfile::windows_chrome_136_rtx3060();
352 let js = script_for(&p);
353 assert!(js.contains("Google Chrome"), "missing Google Chrome brand");
354 assert!(js.contains("Chromium"), "missing Chromium brand");
355 }
356
357 #[test]
358 fn max_touch_points_desktop_is_zero() {
359 let p = FingerprintProfile::windows_chrome_136_rtx3060();
360 assert_eq!(p.platform.max_touch_points, 0);
361 let js = script_for(&p);
362 assert!(js.contains("return 0;"), "maxTouchPoints not 0 on desktop");
363 }
364
365 #[test]
366 fn max_touch_points_mobile_is_nonzero() {
367 let p = FingerprintProfile::android_chrome_136_pixel();
368 assert!(p.platform.max_touch_points > 0);
369 let js = script_for(&p);
370 assert!(
371 js.contains(&format!("return {};", p.platform.max_touch_points)),
372 "maxTouchPoints not > 0 on mobile"
373 );
374 }
375
376 #[test]
377 fn parse_sec_ch_ua_brands_roundtrip() {
378 let input = r#""Chromium";v="136", "Google Chrome";v="136", "Not-A.Brand";v="99""#;
379 let out = parse_sec_ch_ua_brands(input);
380 assert!(out.contains("Chromium"), "Chromium not parsed");
381 assert!(out.contains("136"), "version 136 not parsed");
382 }
383}