use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::{Rng, SeedableRng};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum BrowserFamily {
Chrome,
Firefox,
Safari,
Edge,
}
impl BrowserFamily {
pub fn as_str(self) -> &'static str {
match self {
Self::Chrome => "chrome",
Self::Firefox => "firefox",
Self::Safari => "safari",
Self::Edge => "edge",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Platform {
Windows,
MacOS,
Linux,
}
impl Platform {
pub fn as_str(self) -> &'static str {
match self {
Self::Windows => "windows",
Self::MacOS => "macos",
Self::Linux => "linux",
}
}
}
#[derive(Debug, Clone)]
pub struct IdentityProfile {
pub family: BrowserFamily,
pub platform: Platform,
pub user_agent: String,
pub major_version: u32,
pub ua_platform: &'static str,
pub seed: u64,
}
impl IdentityProfile {
pub fn shuffled_headers(&self, language: &str, country: &str) -> Vec<(&'static str, String)> {
let mut rng = StdRng::seed_from_u64(self.seed);
let accept = self.accept_header();
let accept_language = self.accept_language(language, country, &mut rng);
let accept_encoding = "gzip, deflate, br".to_string();
let platform_arc = self.platform_arch(&mut rng);
let mut all: Vec<(&'static str, String)> = vec![
("accept", accept),
("accept-language", accept_language),
("accept-encoding", accept_encoding),
("upgrade-insecure-requests", "1".to_string()),
("sec-fetch-dest", "document".to_string()),
("sec-fetch-mode", "navigate".to_string()),
("sec-fetch-site", "none".to_string()),
("sec-fetch-user", "?1".to_string()),
];
if matches!(self.family, BrowserFamily::Chrome | BrowserFamily::Edge) {
let sec_ch_ua = match self.family {
BrowserFamily::Edge => format!(
r#""Chromium";v="{v}", "Microsoft Edge";v="{v}", "Not-A.Brand";v="99""#,
v = self.major_version
),
_ => format!(
r#""Chromium";v="{v}", "Google Chrome";v="{v}", "Not-A.Brand";v="99""#,
v = self.major_version
),
};
let platform_quoted = format!(r#""{}""#, self.ua_platform);
all.push(("sec-ch-ua", sec_ch_ua));
all.push(("sec-ch-ua-mobile", "?0".to_string()));
all.push(("sec-ch-ua-platform", platform_quoted));
all.push(("sec-ch-ua-arch", platform_arc));
all.push(("cache-control", "max-age=0".to_string()));
}
all.shuffle(&mut rng);
all
}
fn accept_header(&self) -> String {
match self.family {
BrowserFamily::Chrome | BrowserFamily::Edge => {
"text/html,application/xhtml+xml,application/xml;q=0.9,\
image/avif,image/webp,image/apng,*/*;q=0.8"
}
BrowserFamily::Firefox => {
"text/html,application/xhtml+xml,application/xml;q=0.9,\
image/avif,image/webp,*/*;q=0.8"
}
BrowserFamily::Safari => {
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
}
}
.to_string()
}
fn accept_language(&self, language: &str, country: &str, rng: &mut StdRng) -> String {
let lang = language.to_ascii_lowercase();
let ctry = country.to_ascii_uppercase();
let variant: u8 = rng.gen_range(0..=1);
if lang == "en" {
match variant {
0 => "en-US,en;q=0.9".to_string(),
_ => "en-US,en;q=0.9,en-GB;q=0.8".to_string(),
}
} else {
match variant {
0 => format!("{lang}-{ctry},{lang};q=0.9,en-US;q=0.8,en;q=0.7"),
_ => format!("{lang}-{ctry},{lang};q=0.9,en;q=0.8"),
}
}
}
fn platform_arch(&self, rng: &mut StdRng) -> String {
if rng.gen_bool(0.85) {
"x86_64".to_string()
} else if rng.gen_bool(0.5) {
"x86".to_string()
} else {
String::new()
}
}
pub fn tag(&self) -> String {
format!(
"{}-{}-{:016x}",
self.family.as_str(),
self.platform.as_str(),
self.seed
)
}
}
#[derive(Debug)]
pub struct IdentityPool {
identities: Vec<IdentityProfile>,
rng: StdRng,
level: u32,
current: usize,
}
impl IdentityPool {
pub fn new(seed: Option<u64>) -> Self {
let identities = build_default_identities();
let s = seed.unwrap_or_else(|| {
let mut r = rand::thread_rng();
r.gen::<u64>()
});
let rng = StdRng::seed_from_u64(s);
Self {
identities,
rng,
level: 0,
current: 0,
}
}
pub fn current(&self) -> &IdentityProfile {
&self.identities[self.current]
}
pub fn level(&self) -> u32 {
self.level
}
pub fn reset(&mut self) {
self.level = 0;
}
pub fn rotate_on_block(&mut self) -> &IdentityProfile {
self.level = self.level.saturating_add(1);
let next = match self.level {
1 => self.pick_same_family_different_platform(),
2 => self.pick_different_family_same_platform(),
3 => self.pick_different_family_and_platform(),
4.. => self.pick_random(),
_ => self.current,
};
self.current = next;
&self.identities[self.current]
}
pub fn active_tag(&self) -> String {
self.current().tag()
}
fn pick_same_family_different_platform(&mut self) -> usize {
let current = &self.identities[self.current];
let candidates: Vec<usize> = self
.identities
.iter()
.enumerate()
.filter(|(i, p)| *i != self.current && p.family == current.family)
.map(|(i, _)| i)
.collect();
self.choose_from(&candidates)
}
fn pick_different_family_same_platform(&mut self) -> usize {
let current = &self.identities[self.current];
let candidates: Vec<usize> = self
.identities
.iter()
.enumerate()
.filter(|(i, p)| *i != self.current && p.platform == current.platform)
.map(|(i, _)| i)
.collect();
self.choose_from(&candidates)
}
fn pick_different_family_and_platform(&mut self) -> usize {
let current = &self.identities[self.current];
let candidates: Vec<usize> = self
.identities
.iter()
.enumerate()
.filter(|(i, p)| {
*i != self.current && p.family != current.family && p.platform != current.platform
})
.map(|(i, _)| i)
.collect();
self.choose_from(&candidates)
}
fn pick_random(&mut self) -> usize {
let n = self.identities.len();
self.rng.gen_range(0..n)
}
fn choose_from(&mut self, candidates: &[usize]) -> usize {
if candidates.is_empty() {
return self.pick_random();
}
candidates
.choose(&mut self.rng)
.copied()
.unwrap_or(self.current)
}
}
fn build_default_identities() -> Vec<IdentityProfile> {
vec![
IdentityProfile {
family: BrowserFamily::Chrome,
platform: Platform::Windows,
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36".into(),
major_version: 146,
ua_platform: "Windows",
seed: 0x1111_1111_aaaa_0001,
},
IdentityProfile {
family: BrowserFamily::Chrome,
platform: Platform::MacOS,
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36".into(),
major_version: 146,
ua_platform: "macOS",
seed: 0x2222_2222_bbbb_0002,
},
IdentityProfile {
family: BrowserFamily::Chrome,
platform: Platform::Linux,
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36".into(),
major_version: 146,
ua_platform: "Linux",
seed: 0x3333_3333_cccc_0003,
},
IdentityProfile {
family: BrowserFamily::Edge,
platform: Platform::Windows,
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.3800.97".into(),
major_version: 145,
ua_platform: "Windows",
seed: 0x4444_4444_dddd_0004,
},
IdentityProfile {
family: BrowserFamily::Edge,
platform: Platform::MacOS,
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.3800.97".into(),
major_version: 145,
ua_platform: "macOS",
seed: 0x5555_5555_eeee_0005,
},
IdentityProfile {
family: BrowserFamily::Edge,
platform: Platform::Linux,
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.3800.97".into(),
major_version: 145,
ua_platform: "Linux",
seed: 0x6666_6666_ffff_0006,
},
IdentityProfile {
family: BrowserFamily::Firefox,
platform: Platform::Windows,
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0".into(),
major_version: 134,
ua_platform: "Windows",
seed: 0x7777_7777_aaaa_0007,
},
IdentityProfile {
family: BrowserFamily::Firefox,
platform: Platform::MacOS,
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.6; rv:134.0) Gecko/20100101 Firefox/134.0".into(),
major_version: 134,
ua_platform: "macOS",
seed: 0x8888_8888_bbbb_0008,
},
IdentityProfile {
family: BrowserFamily::Firefox,
platform: Platform::Linux,
user_agent: "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0".into(),
major_version: 134,
ua_platform: "Linux",
seed: 0x9999_9999_cccc_0009,
},
IdentityProfile {
family: BrowserFamily::Safari,
platform: Platform::Windows,
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15".into(),
major_version: 17,
ua_platform: "Windows",
seed: 0xaaaa_aaaa_dddd_000a,
},
IdentityProfile {
family: BrowserFamily::Safari,
platform: Platform::MacOS,
user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15".into(),
major_version: 17,
ua_platform: "macOS",
seed: 0xbbbb_bbbb_eeee_000b,
},
IdentityProfile {
family: BrowserFamily::Safari,
platform: Platform::Linux,
user_agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15".into(),
major_version: 17,
ua_platform: "Linux",
seed: 0xcccc_cccc_ffff_000c,
},
]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeReport {
pub endpoint: String,
pub status: u16,
pub latency_ms: u64,
pub has_set_cookie: bool,
pub identity: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pool_has_twelve_identities() {
let pool = IdentityPool::new(Some(42));
let _ = pool.current();
}
#[test]
fn rotation_advances_cascade_level() {
let mut pool = IdentityPool::new(Some(42));
assert_eq!(pool.level(), 0);
let first = pool.active_tag();
pool.rotate_on_block();
assert_eq!(pool.level(), 1);
assert_ne!(pool.active_tag(), first);
}
#[test]
fn deterministic_seed_produces_same_sequence() {
let mut a = IdentityPool::new(Some(99));
let mut b = IdentityPool::new(Some(99));
for _ in 0..3 {
let ta = a.active_tag();
let tb = b.active_tag();
assert_eq!(ta, tb, "deterministic seed must produce same tag");
a.rotate_on_block();
b.rotate_on_block();
}
}
#[test]
fn shuffled_headers_include_family_specific_values() {
let pool = IdentityPool::new(Some(7));
let headers = pool.current().shuffled_headers("pt", "br");
let names: Vec<&str> = headers.iter().map(|(n, _)| *n).collect();
assert!(names.contains(&"accept"));
assert!(names.contains(&"accept-language"));
assert!(names.contains(&"sec-fetch-dest"));
if matches!(
pool.current().family,
BrowserFamily::Chrome | BrowserFamily::Edge
) {
assert!(names.contains(&"sec-ch-ua"));
assert!(names.contains(&"sec-ch-ua-platform"));
}
}
#[test]
fn tag_format_is_stable() {
let pool = IdentityPool::new(Some(1));
let tag = pool.active_tag();
let parts: Vec<&str> = tag.split('-').collect();
assert_eq!(parts.len(), 3, "tag must have 3 parts: {tag}");
assert_eq!(parts[2].len(), 16, "seed part must be 16 hex chars: {tag}");
}
}