use crate::identity::IdentityBundle;
use crate::impersonate::Profile;
pub const STEALTH_SHIM_TEMPLATE: &str = include_str!("stealth_shim.js");
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(),
}
}
}
pub fn render_worker_shim_from_bundle(bundle: &IdentityBundle) -> String {
let stripped = strip_worker_skip_blocks(STEALTH_SHIM_TEMPLATE);
let scratch = ShimScratch::from_bundle(bundle);
let subs = build_subs(bundle, &scratch);
apply_to(&stripped, &subs)
}
pub fn render_shim_from_bundle(bundle: &IdentityBundle) -> String {
let ua_platform_raw = bundle.ua_platform.trim_matches('"').to_string();
let ua_major = bundle.ua_major.to_string();
let app_version = bundle
.ua
.strip_prefix("Mozilla/")
.unwrap_or(&bundle.ua)
.to_string();
apply(&ShimSubstitutions {
user_agent: &bundle.ua,
app_version: &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: &ua_major,
platform: &bundle.platform,
ua_platform: &ua_platform_raw,
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",
})
}
#[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]
#[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"));
}
}