use crate::identity::IdentityBundle;
use crate::impersonate::Profile;
pub const STEALTH_SHIM_TEMPLATE: &str = include_str!("stealth_shim.js");
#[derive(Debug, Clone, Default)]
pub struct CalibrationOverrides {
pub user_agent: Option<String>,
pub platform: Option<String>,
pub locale: Option<String>,
pub languages_json: Option<String>,
pub timezone: Option<String>,
pub tz_offset_min: Option<i32>,
pub webgl_unmasked_vendor: Option<String>,
pub webgl_unmasked_renderer: Option<String>,
pub webgpu_adapter_description: Option<String>,
pub heap_size_limit: Option<u64>,
pub media_mic_count: Option<u8>,
pub media_cam_count: Option<u8>,
pub media_speaker_count: Option<u8>,
}
impl CalibrationOverrides {
pub fn is_empty(&self) -> bool {
self.user_agent.is_none()
&& self.platform.is_none()
&& self.locale.is_none()
&& self.languages_json.is_none()
&& self.timezone.is_none()
&& self.tz_offset_min.is_none()
&& self.webgl_unmasked_vendor.is_none()
&& self.webgl_unmasked_renderer.is_none()
&& self.webgpu_adapter_description.is_none()
&& self.heap_size_limit.is_none()
&& self.media_mic_count.is_none()
&& self.media_cam_count.is_none()
&& self.media_speaker_count.is_none()
}
}
pub struct ShimVars<'a> {
pub profile: Profile,
pub locale: &'a str,
pub languages_json: &'a str,
pub timezone: &'a str,
pub tz_offset_min: i32,
pub platform: &'a str,
}
struct ShimSubstitutions<'a> {
user_agent: &'a str,
app_version: &'a str,
ua_brands: &'a str,
ua_full_version_list: &'a str,
ua_full_version: &'a str,
ua_major: &'a str,
platform: &'a str,
ua_platform: &'a str,
locale: &'a str,
languages_json: &'a str,
timezone: &'a str,
tz_offset_min: i32,
canvas_seed: u32,
webgl_unmasked_vendor: &'a str,
webgpu_adapter_description: &'a str,
scrollbar_width: u32,
heap_size_limit: u64,
device_memory: u32,
hardware_concurrency: u32,
max_texture_size: u32,
max_viewport_w: u32,
max_viewport_h: u32,
audio_sample_rate: u32,
fonts_json: &'a str,
gpu_vendor_keyword: &'a str,
media_mic_count: u8,
media_cam_count: u8,
media_speaker_count: u8,
expose_battery: bool,
}
fn js_str_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
_ => out.push(c),
}
}
out
}
fn apply(s: &ShimSubstitutions<'_>) -> String {
apply_to(STEALTH_SHIM_TEMPLATE, s)
}
fn apply_to(template: &str, s: &ShimSubstitutions<'_>) -> String {
template
.replace("{{USER_AGENT}}", s.user_agent)
.replace("{{APP_VERSION}}", s.app_version)
.replace("{{UA_BRANDS}}", s.ua_brands)
.replace("{{UA_FULL_VERSION_LIST}}", s.ua_full_version_list)
.replace("{{UA_FULL_VERSION}}", s.ua_full_version)
.replace("{{UA_MAJOR}}", s.ua_major)
.replace("{{PLATFORM}}", s.platform)
.replace("{{UA_PLATFORM}}", s.ua_platform)
.replace("{{LOCALE}}", s.locale)
.replace("{{LANGUAGES_JSON}}", s.languages_json)
.replace("{{TIMEZONE}}", s.timezone)
.replace("{{TZ_OFFSET_MIN}}", &s.tz_offset_min.to_string())
.replace("{{CANVAS_SEED}}", &s.canvas_seed.to_string())
.replace(
"{{WEBGL_UNMASKED_VENDOR}}",
&js_str_escape(s.webgl_unmasked_vendor),
)
.replace(
"{{WEBGPU_ADAPTER_DESCRIPTION}}",
&js_str_escape(s.webgpu_adapter_description),
)
.replace("{{SCROLLBAR_WIDTH}}", &s.scrollbar_width.to_string())
.replace("{{HEAP_SIZE_LIMIT}}", &s.heap_size_limit.to_string())
.replace("{{DEVICE_MEMORY}}", &s.device_memory.to_string())
.replace("{{HW_CONCURRENCY}}", &s.hardware_concurrency.to_string())
.replace("{{MAX_TEXTURE_SIZE}}", &s.max_texture_size.to_string())
.replace("{{MAX_VIEWPORT_W}}", &s.max_viewport_w.to_string())
.replace("{{MAX_VIEWPORT_H}}", &s.max_viewport_h.to_string())
.replace("{{AUDIO_SAMPLE_RATE}}", &s.audio_sample_rate.to_string())
.replace("{{FONTS_JSON}}", s.fonts_json)
.replace(
"{{GPU_VENDOR_KEYWORD}}",
&js_str_escape(s.gpu_vendor_keyword),
)
.replace("{{MEDIA_MIC_COUNT}}", &s.media_mic_count.to_string())
.replace("{{MEDIA_CAM_COUNT}}", &s.media_cam_count.to_string())
.replace(
"{{MEDIA_SPEAKER_COUNT}}",
&s.media_speaker_count.to_string(),
)
.replace(
"{{EXPOSE_BATTERY}}",
if s.expose_battery { "true" } else { "false" },
)
}
fn gpu_keyword_from_renderer(r: &str) -> &'static str {
let l = r.to_ascii_lowercase();
if l.contains("apple") {
"apple"
} else if l.contains("nvidia") {
"nvidia"
} else if l.contains("amd") || l.contains("radeon") {
"amd"
} else if l.contains("adreno") || l.contains("qualcomm") {
"adreno"
} else {
"intel"
}
}
fn seed_u31(raw: u64) -> u32 {
let mixed = raw ^ (raw >> 32);
(mixed as u32) & 0x7fff_ffff
}
#[deprecated(
note = "hardcodes Intel GPU strings; use render_shim_from_bundle with a validated IdentityBundle"
)]
pub fn render_shim(vars: &ShimVars<'_>) -> String {
let ua = vars.profile.user_agent();
let app_version = ua.strip_prefix("Mozilla/").unwrap_or(&ua).to_string();
let ua_brands = vars.profile.ua_brands_json();
let ua_full_version_list = vars.profile.fullversion_brands_json();
let ua_full_version = vars.profile.ua_full_version();
let ua_major = vars.profile.major_version().to_string();
let ua_platform = vars
.platform
.split_whitespace()
.next()
.unwrap_or("Linux")
.to_string();
apply(&ShimSubstitutions {
user_agent: &ua,
app_version: &app_version,
ua_brands: &ua_brands,
ua_full_version_list: &ua_full_version_list,
ua_full_version: &ua_full_version,
ua_major: &ua_major,
platform: vars.platform,
ua_platform: &ua_platform,
locale: vars.locale,
languages_json: vars.languages_json,
timezone: vars.timezone,
tz_offset_min: vars.tz_offset_min,
canvas_seed: seed_u31(
(vars.tz_offset_min as i64 as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ (vars.profile.major_version() as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9),
),
webgl_unmasked_vendor: "Google Inc. (Intel)",
webgpu_adapter_description:
"ANGLE (Intel, Mesa Intel(R) UHD Graphics 630 (CFL GT2), OpenGL 4.6)",
scrollbar_width: 15,
heap_size_limit: 2_147_483_648,
device_memory: 8,
hardware_concurrency: 8,
max_texture_size: 16384,
max_viewport_w: 32767,
max_viewport_h: 32767,
audio_sample_rate: 48000,
fonts_json: r#"["DejaVu Sans","Liberation Sans","Noto Sans","Ubuntu"]"#,
gpu_vendor_keyword: "intel",
media_mic_count: 2,
media_cam_count: 1,
media_speaker_count: 2,
expose_battery: false,
})
}
fn strip_worker_skip_blocks(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let mut in_skip = false;
for line in src.lines() {
let trimmed = line.trim();
if trimmed == "// @worker-skip-start" {
in_skip = true;
continue;
}
if trimmed == "// @worker-skip-end" {
in_skip = false;
continue;
}
if !in_skip {
out.push_str(line);
out.push('\n');
}
}
out
}
fn build_subs<'a>(bundle: &'a IdentityBundle, scratch: &'a ShimScratch) -> ShimSubstitutions<'a> {
ShimSubstitutions {
user_agent: &bundle.ua,
app_version: &scratch.app_version,
ua_brands: &bundle.ua_brands,
ua_full_version_list: &bundle.ua_full_version_list,
ua_full_version: &bundle.ua_full_version,
ua_major: &scratch.ua_major,
platform: &bundle.platform,
ua_platform: &scratch.ua_platform,
locale: &bundle.locale,
languages_json: &bundle.languages_json,
timezone: &bundle.timezone,
tz_offset_min: bundle.tz_offset_min,
canvas_seed: seed_u31(bundle.canvas_audio_seed),
webgl_unmasked_vendor: &bundle.webgl_unmasked_vendor,
webgpu_adapter_description: &bundle.webgpu_adapter_description,
scrollbar_width: bundle.scrollbar_width,
heap_size_limit: bundle.heap_size_limit,
device_memory: bundle.device_memory,
hardware_concurrency: bundle.hardware_concurrency,
max_texture_size: bundle.max_texture_size,
max_viewport_w: bundle.max_viewport_w,
max_viewport_h: bundle.max_viewport_h,
audio_sample_rate: bundle.audio_sample_rate,
fonts_json: &bundle.fonts_json,
gpu_vendor_keyword: gpu_keyword_from_renderer(&bundle.webgl_renderer),
media_mic_count: bundle.media_mic_count,
media_cam_count: bundle.media_cam_count,
media_speaker_count: bundle.media_speaker_count,
expose_battery: gpu_keyword_from_renderer(&bundle.webgl_renderer) == "adreno",
}
}
struct ShimScratch {
app_version: String,
ua_major: String,
ua_platform: String,
}
impl ShimScratch {
fn from_bundle(bundle: &IdentityBundle) -> Self {
Self {
app_version: bundle
.ua
.strip_prefix("Mozilla/")
.unwrap_or(&bundle.ua)
.to_string(),
ua_major: bundle.ua_major.to_string(),
ua_platform: bundle.ua_platform.trim_matches('"').to_string(),
}
}
}
#[derive(Default)]
struct OverrideScratch {
user_agent: Option<String>,
app_version: Option<String>,
platform: Option<String>,
ua_platform: Option<String>,
locale: Option<String>,
languages_json: Option<String>,
timezone: Option<String>,
webgl_unmasked_vendor: Option<String>,
webgpu_adapter_description: Option<String>,
gpu_vendor_keyword: Option<&'static str>,
}
impl OverrideScratch {
fn from_overrides(o: &CalibrationOverrides) -> Self {
let app_version = o
.user_agent
.as_deref()
.map(|ua| ua.strip_prefix("Mozilla/").unwrap_or(ua).to_string());
let gpu_vendor_keyword = o
.webgl_unmasked_renderer
.as_deref()
.map(gpu_keyword_from_renderer);
Self {
user_agent: o.user_agent.clone(),
app_version,
platform: o.platform.clone(),
ua_platform: o.platform.as_deref().map(|p| {
p.split_whitespace()
.next()
.unwrap_or(p)
.trim_matches('"')
.to_string()
}),
locale: o.locale.clone(),
languages_json: o.languages_json.clone(),
timezone: o.timezone.clone(),
webgl_unmasked_vendor: o.webgl_unmasked_vendor.clone(),
webgpu_adapter_description: o.webgpu_adapter_description.clone(),
gpu_vendor_keyword,
}
}
}
fn apply_overrides<'a>(
subs: &mut ShimSubstitutions<'a>,
overrides: &'a CalibrationOverrides,
scratch: &'a OverrideScratch,
) {
if let Some(s) = scratch.user_agent.as_deref() {
subs.user_agent = s;
}
if let Some(s) = scratch.app_version.as_deref() {
subs.app_version = s;
}
if let Some(s) = scratch.platform.as_deref() {
subs.platform = s;
}
if let Some(s) = scratch.ua_platform.as_deref() {
subs.ua_platform = s;
}
if let Some(s) = scratch.locale.as_deref() {
subs.locale = s;
}
if let Some(s) = scratch.languages_json.as_deref() {
subs.languages_json = s;
}
if let Some(s) = scratch.timezone.as_deref() {
subs.timezone = s;
}
if let Some(n) = overrides.tz_offset_min {
subs.tz_offset_min = n;
}
if let Some(s) = scratch.webgl_unmasked_vendor.as_deref() {
subs.webgl_unmasked_vendor = s;
}
if let Some(s) = scratch.webgpu_adapter_description.as_deref() {
subs.webgpu_adapter_description = s;
}
if let Some(n) = overrides.heap_size_limit {
subs.heap_size_limit = n;
}
if let Some(kw) = scratch.gpu_vendor_keyword {
subs.gpu_vendor_keyword = kw;
}
if let Some(n) = overrides.media_mic_count {
subs.media_mic_count = n;
}
if let Some(n) = overrides.media_cam_count {
subs.media_cam_count = n;
}
if let Some(n) = overrides.media_speaker_count {
subs.media_speaker_count = n;
}
}
pub fn render_worker_shim_from_bundle(bundle: &IdentityBundle) -> String {
render_worker_shim_from_bundle_with_calibration(bundle, None)
}
pub fn render_worker_shim_from_bundle_with_calibration(
bundle: &IdentityBundle,
overrides: Option<&CalibrationOverrides>,
) -> String {
let stripped = strip_worker_skip_blocks(STEALTH_SHIM_TEMPLATE);
let scratch = ShimScratch::from_bundle(bundle);
let mut subs = build_subs(bundle, &scratch);
let ov_scratch;
if let Some(o) = overrides {
if !o.is_empty() {
ov_scratch = OverrideScratch::from_overrides(o);
apply_overrides(&mut subs, o, &ov_scratch);
}
}
apply_to(&stripped, &subs)
}
pub fn render_shim_from_bundle(bundle: &IdentityBundle) -> String {
render_shim_from_bundle_with_calibration(bundle, None)
}
pub fn render_shim_from_bundle_with_calibration(
bundle: &IdentityBundle,
overrides: Option<&CalibrationOverrides>,
) -> String {
let scratch = ShimScratch::from_bundle(bundle);
let mut subs = build_subs(bundle, &scratch);
let ov_scratch;
if let Some(o) = overrides {
if !o.is_empty() {
ov_scratch = OverrideScratch::from_overrides(o);
apply_overrides(&mut subs, o, &ov_scratch);
}
}
apply(&subs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::IdentityBundle;
#[test]
fn bundle_substitutions_fill_every_placeholder() {
let b = IdentityBundle::from_chromium(131, 42);
let js = render_shim_from_bundle(&b);
assert!(
!js.contains("{{"),
"template has unsubstituted tokens: {}",
js.lines().find(|l| l.contains("{{")).unwrap_or("")
);
assert!(js.contains(&b.ua), "UA missing");
assert!(js.contains(&b.timezone), "timezone missing");
assert!(js.contains(&b.locale), "locale missing");
assert!(js.contains(&b.ua_full_version), "full version missing");
}
#[test]
fn canvas_seed_is_deterministic_for_same_bundle() {
let a = IdentityBundle::from_chromium(131, 0xdead_beef);
let b = IdentityBundle::from_chromium(131, 0xdead_beef);
let ja = render_shim_from_bundle(&a);
let jb = render_shim_from_bundle(&b);
let needle = "window.__crawlex_seed__ = (";
let ea = ja.find(needle).expect("seed literal present");
let eb = jb.find(needle).expect("seed literal present");
assert_eq!(&ja[ea..ea + 80], &jb[eb..eb + 80]);
}
#[test]
fn canvas_seed_differs_across_sessions() {
let a = IdentityBundle::from_chromium(131, 1);
let b = IdentityBundle::from_chromium(131, 2);
let ja = render_shim_from_bundle(&a);
let jb = render_shim_from_bundle(&b);
assert_ne!(
ja.split("window.__crawlex_seed__ = (").nth(1).unwrap_or(""),
jb.split("window.__crawlex_seed__ = (").nth(1).unwrap_or(""),
);
}
#[test]
fn no_date_now_in_seed_block() {
let b = IdentityBundle::from_chromium(131, 42);
let js = render_shim_from_bundle(&b);
let seed_block = js
.split("typeof window.__crawlex_seed__ !== 'number'")
.nth(1)
.expect("seed block present");
let window = &seed_block[..seed_block.len().min(400)];
assert!(
!window.contains("Date.now"),
"seed init must not depend on Date.now(): {}",
window
);
}
#[test]
fn permissions_query_handles_notifications_and_push() {
let b = IdentityBundle::from_chromium(131, 7);
let js = render_shim_from_bundle(&b);
assert!(js.contains("leaky = { notifications: 1, push: 1 }"));
assert!(js.contains("s === 'default' ? 'prompt' : s"));
assert!(js.contains("return orig(p);"));
}
#[test]
fn notification_request_permission_is_coerced() {
let b = IdentityBundle::from_chromium(131, 11);
let js = render_shim_from_bundle(&b);
assert!(
js.contains("Notification.requestPermission = wrapped"),
"Notification.requestPermission override missing"
);
assert!(
js.contains("(raw === 'denied') ? 'default' : raw"),
"denied → default coercion missing"
);
assert!(
js.contains("callback(result)"),
"callback invocation path missing"
);
assert!(
js.contains("Promise.resolve(result)"),
"Promise return path missing"
);
let notif_section = js
.split("13b. Notification.requestPermission")
.nth(1)
.expect("notification section present");
let notif_window = ¬if_section[..notif_section.len().min(5000)];
assert!(
notif_window.contains("__crawlex_reg_target__"),
"wrapped function not registered with toString proxy"
);
assert!(
notif_window.contains("lateReg(wrapped)"),
"registrar not called on wrapped ref"
);
}
#[test]
fn battery_hidden_for_desktop_personas() {
let b = IdentityBundle::from_chromium(131, 99);
let js = render_shim_from_bundle(&b);
assert!(
js.contains("const EXPOSE_BATTERY = false"),
"EXPOSE_BATTERY should be false on desktop persona"
);
assert!(
js.contains("const EXPOSE_BATTERY_S17 = false"),
"Section 17 gate should be false on desktop"
);
}
#[test]
fn battery_curve_present_in_template() {
let b = IdentityBundle::from_chromium(131, 7);
let js = render_shim_from_bundle(&b);
assert!(
js.contains("0.85 - (0.85 - 0.20) * t"),
"discharging formula 85→20% missing"
);
assert!(
js.contains("0.20 + (0.85 - 0.20) * t"),
"charging formula 20→85% missing"
);
assert!(
js.contains("if (level > 0.85) level = 0.85"),
"upper clamp at 85% missing — must never expose 100%"
);
assert!(
js.contains("if (level < 0.20) level = 0.20"),
"lower clamp at 20% missing"
);
assert!(
js.contains("new Date().getTimezoneOffset()"),
"timezone anchoring missing — curve must follow local midnight"
);
}
#[test]
fn calibration_overrides_none_matches_stock_output() {
let b = IdentityBundle::from_chromium(131, 42);
let stock = render_shim_from_bundle(&b);
let via_calibration = render_shim_from_bundle_with_calibration(&b, None);
assert_eq!(stock, via_calibration);
let empty = CalibrationOverrides::default();
assert!(empty.is_empty());
let via_empty = render_shim_from_bundle_with_calibration(&b, Some(&empty));
assert_eq!(stock, via_empty);
}
#[test]
fn calibration_overrides_reflected_in_shim() {
let b = IdentityBundle::from_chromium(131, 42);
let overrides = CalibrationOverrides {
user_agent: Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) Chrome/149.0".into()),
platform: Some("MacIntel".into()),
locale: Some("pt-PT".into()),
languages_json: Some(r#"["pt-PT","pt","en"]"#.into()),
timezone: Some("Europe/Lisbon".into()),
tz_offset_min: Some(-60),
webgl_unmasked_vendor: Some("Apple Inc.".into()),
webgl_unmasked_renderer: Some("Apple M2".into()),
webgpu_adapter_description: Some("Apple M2 GPU".into()),
heap_size_limit: Some(4_294_967_296),
media_mic_count: Some(3),
media_cam_count: Some(2),
media_speaker_count: Some(4),
};
let js = render_shim_from_bundle_with_calibration(&b, Some(&overrides));
assert!(!js.contains("{{"), "unsubstituted token leaked");
assert!(js.contains("Chrome/149.0"), "calibrated UA missing");
assert!(js.contains("MacIntel"), "calibrated platform missing");
assert!(js.contains("pt-PT"), "calibrated locale missing");
assert!(js.contains(r#"["pt-PT","pt","en"]"#));
assert!(js.contains("Europe/Lisbon"));
assert!(js.contains("-60"), "calibrated tz offset missing");
assert!(js.contains("Apple Inc."), "calibrated WebGL vendor missing");
assert!(
js.contains("Apple M2 GPU"),
"calibrated WebGPU adapter missing"
);
assert!(
js.contains("'apple'") || js.contains("\"apple\""),
"GPU keyword not derived from calibrated renderer"
);
assert!(js.contains("4294967296"), "calibrated heap limit missing");
assert!(js.contains("MIC_COUNT = 3"), "calibrated mic count missing");
}
#[test]
fn partial_overrides_only_replace_listed_fields() {
let b = IdentityBundle::from_chromium(131, 42);
let stock = render_shim_from_bundle(&b);
let mut overrides = CalibrationOverrides::default();
overrides.timezone = Some("Asia/Tokyo".into());
let mixed = render_shim_from_bundle_with_calibration(&b, Some(&overrides));
assert!(mixed.contains("Asia/Tokyo"));
assert!(mixed.contains(&b.ua), "non-overridden UA dropped");
assert!(mixed.contains(&b.locale), "non-overridden locale dropped");
assert_ne!(stock, mixed);
}
#[test]
fn worker_shim_honours_calibration_overrides() {
let b = IdentityBundle::from_chromium(131, 7);
let stock = render_worker_shim_from_bundle(&b);
let via_none = render_worker_shim_from_bundle_with_calibration(&b, None);
assert_eq!(stock, via_none, "worker stock path must be preserved");
let overrides = CalibrationOverrides {
timezone: Some("Asia/Tokyo".into()),
webgl_unmasked_vendor: Some("Apple Inc.".into()),
webgl_unmasked_renderer: Some("Apple M2".into()),
..Default::default()
};
let calibrated = render_worker_shim_from_bundle_with_calibration(&b, Some(&overrides));
assert!(calibrated.contains("Asia/Tokyo"));
assert!(calibrated.contains("Apple Inc."));
assert!(!calibrated.contains("@worker-skip-start"));
}
#[test]
#[allow(deprecated)] fn legacy_profile_path_still_works() {
let vars = ShimVars {
profile: Profile::Chrome131Stable,
locale: "en-US",
languages_json: r#"["en-US","en"]"#,
timezone: "UTC",
tz_offset_min: 0,
platform: "Linux x86_64",
};
let js = render_shim(&vars);
assert!(!js.contains("{{"));
assert!(js.contains("Linux"));
}
}