use serde::{Deserialize, Serialize};
use crate::Platform;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Brand {
pub brand: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct UserAgentMetadata {
pub brands: Vec<Brand>,
#[serde(rename = "fullVersionList")]
pub full_version_list: Vec<Brand>,
pub platform: String,
#[serde(rename = "platformVersion")]
pub platform_version: String,
pub architecture: String,
pub bitness: String,
pub wow64: bool,
pub mobile: bool,
pub model: String,
}
impl UserAgentMetadata {
pub fn realistic(platform: Platform, chrome_major: u32, chrome_full: &str) -> Self {
let brands = vec![
Brand {
brand: "Not_A Brand".into(),
version: "8".into(),
},
Brand {
brand: "Chromium".into(),
version: chrome_major.to_string(),
},
Brand {
brand: "Google Chrome".into(),
version: chrome_major.to_string(),
},
];
let full_version_list = vec![
Brand {
brand: "Not_A Brand".into(),
version: "8.0.0.0".into(),
},
Brand {
brand: "Chromium".into(),
version: chrome_full.to_string(),
},
Brand {
brand: "Google Chrome".into(),
version: chrome_full.to_string(),
},
];
let (platform_version, architecture, bitness) = match platform {
Platform::Win32 => ("15.0.0", "x86", "64"),
Platform::MacIntel => ("10.15.7", "x86", "64"),
Platform::LinuxX86_64 => ("5.15.0", "x86", "64"),
};
Self {
brands,
full_version_list,
platform: platform.ch_platform().to_string(),
platform_version: platform_version.to_string(),
architecture: architecture.to_string(),
bitness: bitness.to_string(),
wow64: false,
mobile: false,
model: String::new(),
}
}
}
use std::path::Path;
#[cfg(not(windows))]
use std::process::Command;
use crate::error::StealthError;
const FALLBACK_CHROME_FULL: &str = "148.0.7778.181";
const FALLBACK_CHROME_MAJOR: u32 = 148;
#[derive(Debug, Clone, Serialize)]
pub struct Fingerprint {
pub platform: Platform,
pub chrome_major: u32,
pub chrome_full: String,
pub cpu_count: u32,
pub memory_gb: u32,
pub ua_string: String,
pub ua_metadata: UserAgentMetadata,
pub timezone: Option<String>,
pub locale: Option<String>,
pub languages: Option<Vec<String>>,
pub screen: Option<crate::persona::specs::ScreenSpec>,
}
impl Fingerprint {
#[allow(clippy::result_large_err)]
pub fn auto_detect(chrome_executable: &Path) -> Result<Self, StealthError> {
let platform = detect_platform();
let (chrome_major, chrome_full) =
probe_chrome_version(chrome_executable).unwrap_or_else(|e| {
tracing::warn!("chrome version probe failed: {e}; using fallback");
(FALLBACK_CHROME_MAJOR, FALLBACK_CHROME_FULL.to_string())
});
let cpu_count = clamp_cpu_count(num_cpus::get() as u32);
let memory_gb = detect_memory_gb()?;
let ua_string = crate::ua::compose_ua_string(platform, &chrome_full);
let ua_metadata = UserAgentMetadata::realistic(platform, chrome_major, &chrome_full);
Ok(Self {
platform,
chrome_major,
chrome_full,
cpu_count,
memory_gb,
ua_string,
ua_metadata,
timezone: None,
locale: None,
languages: None,
screen: None,
})
}
pub fn recompose(&mut self) {
self.ua_string = crate::ua::compose_ua_string(self.platform, &self.chrome_full);
self.ua_metadata =
UserAgentMetadata::realistic(self.platform, self.chrome_major, &self.chrome_full);
}
}
pub(crate) fn detect_platform() -> Platform {
#[cfg(target_os = "windows")]
{
Platform::Win32
}
#[cfg(target_os = "macos")]
{
Platform::MacIntel
}
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))]
{
Platform::LinuxX86_64
}
#[cfg(not(any(
target_os = "windows",
target_os = "macos",
target_os = "linux",
target_os = "freebsd",
target_os = "openbsd"
)))]
{
Platform::LinuxX86_64 }
}
#[cfg(not(windows))]
#[allow(clippy::result_large_err)]
fn parse_version_banner(stdout: &str) -> Result<(u32, String), StealthError> {
let full = stdout
.split_whitespace()
.find(|tok| tok.chars().next().is_some_and(|c| c.is_ascii_digit()))
.ok_or_else(|| StealthError::ChromeVersionDetect(format!("no version token in: {stdout}")))?
.to_string();
let major: u32 = full
.split('.')
.next()
.and_then(|s| s.parse().ok())
.ok_or_else(|| StealthError::ChromeVersionDetect(format!("bad major in: {full}")))?;
Ok((major, full))
}
#[cfg(not(windows))]
#[allow(clippy::result_large_err)]
pub(crate) fn probe_chrome_version(exe: &Path) -> Result<(u32, String), StealthError> {
use std::io::Read;
use std::process::Stdio;
use std::time::{Duration, Instant};
const PROBE_TIMEOUT: Duration = Duration::from_secs(10);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
let mut child = Command::new(exe)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| StealthError::ChromeVersionDetect(format!("spawn failed: {e}")))?;
let deadline = Instant::now() + PROBE_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(StealthError::ChromeVersionDetect(format!(
"`--version` did not exit within {PROBE_TIMEOUT:?}"
)));
}
std::thread::sleep(POLL_INTERVAL);
}
Err(e) => {
return Err(StealthError::ChromeVersionDetect(format!(
"wait failed: {e}"
)));
}
}
};
if !status.success() {
return Err(StealthError::ChromeVersionDetect(format!(
"exit {:?}",
status.code()
)));
}
let mut stdout = String::new();
child
.stdout
.take()
.ok_or_else(|| StealthError::ChromeVersionDetect("no stdout pipe".to_string()))?
.read_to_string(&mut stdout)
.map_err(|e| StealthError::ChromeVersionDetect(format!("read stdout: {e}")))?;
parse_version_banner(&stdout)
}
#[cfg(windows)]
#[allow(clippy::result_large_err)]
pub(crate) fn probe_chrome_version(exe: &Path) -> Result<(u32, String), StealthError> {
let bytes = std::fs::read(exe)
.map_err(|e| StealthError::ChromeVersionDetect(format!("read {}: {e}", exe.display())))?;
parse_pe_file_version(&bytes).ok_or_else(|| {
StealthError::ChromeVersionDetect(format!(
"no VS_FIXEDFILEINFO version resource in {}",
exe.display()
))
})
}
#[cfg(windows)]
fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
#[cfg(windows)]
fn parse_pe_file_version(bytes: &[u8]) -> Option<(u32, String)> {
const SIGNATURE: [u8; 4] = 0xFEEF_04BDu32.to_le_bytes();
const SIG_SEARCH_WINDOW: usize = 64;
let key: Vec<u8> = "VS_VERSION_INFO"
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect();
let mut search_from = 0usize;
while let Some(rel) = find_bytes(bytes.get(search_from..)?, &key) {
let anchor = search_from + rel + key.len();
let window_end = anchor.saturating_add(SIG_SEARCH_WINDOW).min(bytes.len());
if let Some(sig) = bytes
.get(anchor..window_end)
.and_then(|w| find_bytes(w, &SIGNATURE))
.map(|sig_rel| anchor + sig_rel)
{
let word = |off: usize| -> Option<u32> {
Some(u32::from_le_bytes(
bytes.get(sig + off..sig + off + 4)?.try_into().ok()?,
))
};
let ms = word(8)?;
let ls = word(12)?;
let major = ms >> 16;
if major != 0 {
let full = format!("{}.{}.{}.{}", major, ms & 0xFFFF, ls >> 16, ls & 0xFFFF);
return Some((major, full));
}
}
search_from += rel + 1;
}
None
}
pub(crate) fn clamp_cpu_count(n: u32) -> u32 {
n.clamp(2, 32)
}
#[allow(clippy::result_large_err)]
pub(crate) fn detect_memory_gb() -> Result<u32, StealthError> {
let mut sys = sysinfo::System::new();
sys.refresh_memory();
let total_bytes = sys.total_memory();
if total_bytes == 0 {
return Err(StealthError::SystemInfo("total_memory returned 0".into()));
}
let total_gb = (total_bytes / 1_073_741_824) as u32;
Ok(round_to_navigator_memory(total_gb))
}
fn round_to_navigator_memory(gb: u32) -> u32 {
if gb >= 8 { 8 } else { 4 }
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn probes_windows_version_from_pe_resource_without_executing() {
let exe = std::env::current_exe().unwrap();
let bytes = std::fs::read(&exe).unwrap();
if let Some((major, full)) = parse_pe_file_version(&bytes) {
assert!(
full.split('.').count() == 4,
"expected a 4-part version, got {full:?}"
);
assert_eq!(
major,
full.split('.').next().unwrap().parse::<u32>().unwrap(),
"major must be the first component of {full:?}"
);
}
}
#[cfg(windows)]
#[test]
fn parses_fixed_file_info_field_packing() {
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(&[0xAA; 128]); buf.extend("VS_VERSION_INFO".encode_utf16().flat_map(u16::to_le_bytes));
buf.extend_from_slice(&[0x00, 0x00]); buf.extend_from_slice(&0xFEEF_04BDu32.to_le_bytes()); buf.extend_from_slice(&0x0001_0000u32.to_le_bytes()); buf.extend_from_slice(&(150u32 << 16).to_le_bytes());
buf.extend_from_slice(&((7871u32 << 16) | 114).to_le_bytes());
assert_eq!(
parse_pe_file_version(&buf),
Some((150, "150.0.7871.114".to_string()))
);
}
#[cfg(windows)]
#[test]
fn pe_version_parse_rejects_image_without_version_resource() {
assert_eq!(parse_pe_file_version(&[0x00; 512]), None);
}
#[cfg(windows)]
#[test]
fn pe_version_parse_skips_decoy_key_without_signature() {
let key: Vec<u8> = "VS_VERSION_INFO"
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect();
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(&key);
buf.extend_from_slice(b"\0\0unexpected VS_VERSIONINFO in \0");
buf.extend_from_slice(&[0xCC; 96]);
buf.extend_from_slice(&key);
buf.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); buf.extend_from_slice(&0xFEEF_04BDu32.to_le_bytes());
buf.extend_from_slice(&0x0001_0000u32.to_le_bytes());
buf.extend_from_slice(&(150u32 << 16).to_le_bytes());
buf.extend_from_slice(&((7871u32 << 16) | 114).to_le_bytes());
assert_eq!(
parse_pe_file_version(&buf),
Some((150, "150.0.7871.114".to_string())),
"must skip the decoy key and find the real resource behind it"
);
}
#[cfg(windows)]
#[test]
fn pe_version_parse_ignores_stray_signature_without_key() {
let mut buf: Vec<u8> = Vec::new();
buf.extend_from_slice(&0xFEEF_04BDu32.to_le_bytes());
buf.extend_from_slice(&[0xEF; 32]);
assert_eq!(parse_pe_file_version(&buf), None);
}
#[cfg(not(windows))]
#[test]
fn parses_chrome_and_chromium_version_banners() {
assert_eq!(
parse_version_banner("Google Chrome 120.0.6099.234\n").unwrap(),
(120, "120.0.6099.234".to_string())
);
assert_eq!(
parse_version_banner("Chromium 120.0.6099.0\n").unwrap(),
(120, "120.0.6099.0".to_string())
);
assert!(parse_version_banner("Opening in existing browser session.\n").is_err());
}
#[test]
fn realistic_uam_macintel_chrome_120_matches_snapshot() {
let uam = UserAgentMetadata::realistic(Platform::MacIntel, 120, "120.0.6099.234");
insta::assert_json_snapshot!("uam_macintel_chrome_120", uam);
}
#[test]
fn realistic_uam_win32_chrome_120_matches_snapshot() {
let uam = UserAgentMetadata::realistic(Platform::Win32, 120, "120.0.6099.234");
insta::assert_json_snapshot!("uam_win32_chrome_120", uam);
}
#[test]
fn realistic_uam_linux_chrome_120_matches_snapshot() {
let uam = UserAgentMetadata::realistic(Platform::LinuxX86_64, 120, "120.0.6099.234");
insta::assert_json_snapshot!("uam_linux_chrome_120", uam);
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::unwrap_used)]
mod fingerprint_tests {
use super::*;
#[test]
fn clamp_cpu_count_floors_at_two() {
assert_eq!(clamp_cpu_count(1), 2);
assert_eq!(clamp_cpu_count(0), 2);
}
#[test]
fn clamp_cpu_count_caps_at_thirty_two() {
assert_eq!(clamp_cpu_count(64), 32);
assert_eq!(clamp_cpu_count(128), 32);
}
#[test]
fn clamp_cpu_count_preserves_normal_values() {
assert_eq!(clamp_cpu_count(8), 8);
assert_eq!(clamp_cpu_count(16), 16);
}
#[test]
fn round_navigator_memory_caps_at_eight() {
assert_eq!(round_to_navigator_memory(16), 8);
assert_eq!(round_to_navigator_memory(64), 8);
}
#[test]
fn round_navigator_memory_floors_at_four() {
assert_eq!(round_to_navigator_memory(1), 4);
assert_eq!(round_to_navigator_memory(3), 4);
}
#[test]
fn round_navigator_memory_eight_stays_eight() {
assert_eq!(round_to_navigator_memory(8), 8);
}
#[test]
fn detect_memory_gb_works_on_real_system() {
let gb = detect_memory_gb().expect("real system should have RAM");
assert!(gb == 4 || gb == 8, "got {gb}");
}
#[test]
fn detect_platform_returns_expected_for_host() {
let p = detect_platform();
#[cfg(target_os = "macos")]
assert_eq!(p, Platform::MacIntel);
#[cfg(target_os = "linux")]
assert_eq!(p, Platform::LinuxX86_64);
#[cfg(target_os = "windows")]
assert_eq!(p, Platform::Win32);
}
#[test]
fn fingerprint_recompose_updates_ua_and_uam() {
let mut fp = Fingerprint {
platform: Platform::Win32,
chrome_major: 120,
chrome_full: "120.0.6099.234".into(),
cpu_count: 8,
memory_gb: 8,
ua_string: String::new(),
ua_metadata: UserAgentMetadata::realistic(Platform::Win32, 120, "120.0.6099.234"),
timezone: None,
locale: None,
languages: None,
screen: None,
};
fp.recompose();
assert!(fp.ua_string.contains("Windows NT 10.0"));
assert!(fp.ua_string.contains("Chrome/120.0.6099.234"));
}
#[test]
fn fallback_chrome_is_not_ancient() {
const {
assert!(
FALLBACK_CHROME_MAJOR >= 144,
"FALLBACK_CHROME_MAJOR is stale; bump it (and this floor) to current stable"
)
};
}
}