//! Construção do script de inicialização do Chromium. //! //! A função [`build_init_script`] retorna um JavaScript único, interpolado a //! partir de uma [`Persona`], injetado via `Page.addScriptToEvaluateOnNewDocument` //! antes de qualquer recurso da página. O script padroniza propriedades do //! `navigator`, `screen` e os contextos de canvas/WebGL/áudio com os valores da //! persona, aplicando perturbação determinística (seed fixa por sessão) sobre os //! dados de canvas e áudio. O resultado é uma impressão digital estável dentro //! da sessão e distinta entre sessões, sem bloquear as APIs correspondentes. use crate::persona::Persona; /// Constrói o script de inicialização para a sessão. A string resultante é /// avaliada antes de qualquer outro script da página. pub fn build_init_script(persona: &Persona) -> String { let platform = persona.platform.js_platform(); let plugins = js_array_string(persona.plugins.iter().copied()); format!( r###"(function() {{ 'use strict'; const PERSONA = {{ platform: {platform_js}, languages: {languages}, locale: {locale_js}, hardwareConcurrency: {hw}, deviceMemory: {dm}, screenWidth: {sw}, screenHeight: {sh}, scale: {scale}, webglVendor: {webgl_vendor_js}, webglRenderer: {webgl_renderer_js}, audioSampleRate: {asr}, canvasSeed: {canvas_seed}, audioSeed: {audio_seed}, plugins: {plugins} }}; // --- navigator --- try {{ Object.defineProperty(navigator, 'webdriver', {{ get: () => false, configurable: true }}); }} catch (e) {{}} try {{ Object.defineProperty(navigator, 'platform', {{ get: () => PERSONA.platform, configurable: true }}); }} catch (e) {{}} try {{ Object.defineProperty(navigator, 'languages', {{ get: () => PERSONA.languages, configurable: true }}); }} catch (e) {{}} try {{ Object.defineProperty(navigator, 'language', {{ get: () => PERSONA.locale, configurable: true }}); }} catch (e) {{}} try {{ Object.defineProperty(navigator, 'hardwareConcurrency', {{ get: () => PERSONA.hardwareConcurrency, configurable: true }}); }} catch (e) {{}} try {{ Object.defineProperty(navigator, 'deviceMemory', {{ get: () => PERSONA.deviceMemory, configurable: true }}); }} catch (e) {{}} try {{ Object.defineProperty(navigator, 'plugins', {{ get: () => buildPlugins(PERSONA.plugins), configurable: true }}); }} catch (e) {{}} try {{ Object.defineProperty(navigator, 'mimeTypes', {{ get: () => buildMimeTypes(), configurable: true }}); }} catch (e) {{}} // --- screen --- try {{ Object.defineProperty(screen, 'width', {{ get: () => PERSONA.screenWidth, configurable: true }}); Object.defineProperty(screen, 'height', {{ get: () => PERSONA.screenHeight, configurable: true }}); Object.defineProperty(screen, 'availWidth', {{ get: () => PERSONA.screenWidth, configurable: true }}); Object.defineProperty(screen, 'availHeight', {{ get: () => PERSONA.screenHeight - 40, configurable: true }}); Object.defineProperty(window, 'outerWidth', {{ get: () => PERSONA.screenWidth, configurable: true }}); Object.defineProperty(window, 'outerHeight', {{ get: () => PERSONA.screenHeight, configurable: true }}); }} catch (e) {{}} // --- canvas: perturbação determinística de 1 bit por canal --- function mulberry32(seed) {{ let a = seed >>> 0; return function() {{ a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }}; }} const canvasRng = mulberry32(PERSONA.canvasSeed); function perturbPixels(imageData) {{ const data = imageData.data; for (let i = 0; i < data.length; i += 4) {{ const r = canvasRng(); if (r < 0.25) data[i] = data[i] ^ 1; if (r > 0.33 && r < 0.58) data[i + 1] = data[i + 1] ^ 1; if (r > 0.66 && r < 0.91) data[i + 2] = data[i + 2] ^ 1; }} return imageData; }} try {{ const origToDataURL = HTMLCanvasElement.prototype.toDataURL; HTMLCanvasElement.prototype.toDataURL = function(...args) {{ const ctx = this.getContext('2d'); if (ctx) {{ try {{ const w = this.width, h = this.height; const img = ctx.getImageData(0, 0, w, h); perturbPixels(img); ctx.putImageData(img, 0, 0); }} catch (e) {{}} }} return origToDataURL.apply(this, args); }}; }} catch (e) {{}} try {{ const origGetImageData = CanvasRenderingContext2D.prototype.getImageData; CanvasRenderingContext2D.prototype.getImageData = function(...args) {{ const img = origGetImageData.apply(this, args); return perturbPixels(img); }}; }} catch (e) {{}} // --- WebGL: vendor e renderer coerentes com a persona --- try {{ const overrideGetParameter = function(proto) {{ const orig = proto.getParameter; proto.getParameter = function(parameter) {{ if (parameter === 37445) return PERSONA.webglVendor; if (parameter === 37446) return PERSONA.webglRenderer; return orig.apply(this, [parameter]); }}; }}; if (typeof WebGLRenderingContext !== 'undefined') overrideGetParameter(WebGLRenderingContext.prototype); if (typeof WebGL2RenderingContext !== 'undefined') overrideGetParameter(WebGL2RenderingContext.prototype); }} catch (e) {{}} // --- AudioContext: ruído determinístico de baixa amplitude --- const audioRng = mulberry32(PERSONA.audioSeed); try {{ const origGetByteFrequencyData = AnalyserNode.prototype.getByteFrequencyData; AnalyserNode.prototype.getByteFrequencyData = function(array) {{ origGetByteFrequencyData.apply(this, [array]); for (let i = 0; i < array.length; i++) {{ const n = (audioRng() - 0.5) * 2; if (Math.abs(n) < 0.5) array[i] = Math.max(0, Math.min(255, array[i] + (n > 0 ? 1 : -1))); }} }}; }} catch (e) {{}} try {{ const origGetChannelData = AudioBuffer.prototype.getChannelData; AudioBuffer.prototype.getChannelData = function(channel) {{ const data = origGetChannelData.apply(this, [channel]); const noise = new Float32Array(data.length); for (let i = 0; i < data.length; i++) {{ noise[i] = (audioRng() - 0.5) * 1e-7; }} for (let i = 0; i < data.length; i++) {{ data[i] = data[i] + (i % 2 === 0 ? noise[i] : -noise[i]); }} return data; }}; }} catch (e) {{}} // --- helpers de plugins/mimeTypes --- function makePlugin(name) {{ const plugin = Object.create(Plugin.prototype); Object.defineProperties(plugin, {{ name: {{ value: name, enumerable: true }}, filename: {{ value: 'internal-pdf-viewer', enumerable: true }}, description: {{ value: 'Portable Document Format', enumerable: true }}, length: {{ value: 1, enumerable: true }}, 0: {{ value: {{ type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }}, enumerable: true }} }}); return plugin; }} function buildPlugins(names) {{ const arr = Object.create(PluginArray.prototype); names.forEach((name, idx) => {{ Object.defineProperty(arr, idx, {{ value: makePlugin(name), enumerable: true, configurable: true }}); }}); Object.defineProperty(arr, 'length', {{ value: names.length, enumerable: true }}); return arr; }} function makeMime() {{ const m = Object.create(MimeType.prototype); Object.defineProperties(m, {{ type: {{ value: 'application/pdf', enumerable: true }}, suffixes: {{ value: 'pdf', enumerable: true }}, description: {{ value: 'Portable Document Format', enumerable: true }} }}); return m; }} function buildMimeTypes() {{ const arr = Object.create(MimeTypeArray.prototype); const m = makeMime(); Object.defineProperty(arr, 0, {{ value: m, enumerable: true, configurable: true }}); Object.defineProperty(arr, 'length', {{ value: 1, enumerable: true }}); return arr; }} }})(); "###, platform_js = js_string(platform), languages = js_array_string([persona.locale.as_str(), "en-US", "en"]), locale_js = js_string(&persona.locale), hw = persona.hardware_concurrency, dm = persona.device_memory, sw = persona.screen.0, sh = persona.screen.1, scale = persona.device_scale_factor, webgl_vendor_js = js_string(persona.webgl_vendor), webgl_renderer_js = js_string(persona.webgl_renderer), asr = persona.audio_sample_rate, canvas_seed = persona.canvas_seed, audio_seed = persona.audio_seed, plugins = plugins ) } fn js_string(value: &str) -> String { let escaped = value .replace('\\', "\\\\") .replace('\'', "\\'") .replace('\n', "\\n") .replace('\r', "\\r"); format!("'{escaped}'") } fn js_array_string(items: I) -> String where I: IntoIterator, S: AsRef, { let parts: Vec = items .into_iter() .map(|item| js_string(item.as_ref())) .collect(); format!("[{}]", parts.join(", ")) } #[cfg(test)] mod tests { use super::*; use crate::persona; #[test] fn build_init_script_is_non_empty_and_contains_persona_values() { let persona = persona::generate("test", Some("br")); let script = build_init_script(&persona); assert!(script.len() > 1_000); assert!(script.contains(&format!( "hardwareConcurrency: {}", persona.hardware_concurrency ))); assert!(script.contains(&format!("deviceMemory: {}", persona.device_memory))); assert!(script.contains(&format!("screenWidth: {}", persona.screen.0))); assert!(script.contains(persona.webgl_vendor)); assert!(script.contains(persona.webgl_renderer)); assert!(script.contains(persona.platform.js_platform())); } /// Verifica sintaxe do JavaScript injetado via `node --check`. Ignorado /// por padrão (exige node no PATH); rodar com `cargo test -- --ignored`. #[test] #[ignore] fn build_init_script_is_valid_javascript_syntax() { let persona = persona::generate("test", Some("br")); let script = build_init_script(&persona); let mut node = match std::process::Command::new("node") .arg("--check") .arg("-") .stdin(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() { Ok(child) => child, Err(_) => { eprintln!("node não disponível; pulando validação de sintaxe JS"); return; } }; use std::io::Write; if let Some(stdin) = node.stdin.as_mut() { stdin .write_all(script.as_bytes()) .expect("escrita no stdin"); } let output = node.wait_with_output().expect("esperar node terminar"); assert!( output.status.success(), "Sintaxe JS inválida:\n--- stderr ---\n{}\n--- início do script ---\n{}", String::from_utf8_lossy(&output.stderr), &script[..script.len().min(800)] ); } }