use veilus_fingerprint_core::{BrowserFamily, BrowserProfile, FingerprintError, OsFamily};
use veilus_fingerprint_data::loader::{get_fingerprint_network, get_header_network};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use crate::assembler::assemble_profile;
use crate::engine::constraints::{sample_constrained, Constraints};
use crate::engine::sampler::sample_ancestral;
const IMPOSSIBLE_COMBOS: &[(&str, &str)] = &[
("safari", "windows"),
("safari", "linux"),
("safari", "android"),
];
#[must_use]
#[derive(Debug, Default)]
pub struct FingerprintGenerator {
browser: Option<BrowserFamily>,
os: Option<OsFamily>,
locale: Option<String>,
seed: Option<u64>,
strict: bool,
}
impl FingerprintGenerator {
pub fn new() -> Self {
Self::default()
}
pub fn random() -> Result<BrowserProfile, FingerprintError> {
Self::new().generate()
}
pub fn browser(mut self, family: BrowserFamily) -> Self {
self.browser = Some(family);
self
}
pub fn os(mut self, family: OsFamily) -> Self {
self.os = Some(family);
self
}
pub fn locale(mut self, locale: impl Into<String>) -> Self {
self.locale = Some(locale.into());
self
}
pub fn seeded(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
pub fn generate(self) -> Result<BrowserProfile, FingerprintError> {
self.validate_constraints()?;
let header_constraints = self.build_header_constraints();
let fp_constraints = self.build_fp_constraints();
let mut rng = self.build_rng();
let fp_network = get_fingerprint_network()?;
let header_network = get_header_network()?;
let fp_assignment = if fp_constraints.is_empty() {
sample_ancestral(fp_network, &mut rng)?
} else {
match sample_constrained(fp_network, &fp_constraints, &mut rng) {
Ok(a) => a,
Err(e) if !self.strict => {
tracing::warn!("fp constraints failed, relaxing: {e}");
sample_ancestral(fp_network, &mut rng)?
}
Err(e) => return Err(e),
}
};
let header_assignment = if header_constraints.is_empty() {
sample_ancestral(header_network, &mut rng)?
} else {
match sample_constrained(header_network, &header_constraints, &mut rng) {
Ok(a) => a,
Err(e) if !self.strict => {
tracing::warn!("header constraints failed, relaxing: {e}");
sample_ancestral(header_network, &mut rng)?
}
Err(e) => return Err(e),
}
};
assemble_profile(&fp_assignment, &header_assignment, &mut rng)
}
fn validate_constraints(&self) -> Result<(), FingerprintError> {
if let (Some(browser), Some(os)) = (&self.browser, &self.os) {
let browser_key = browser_family_to_key(browser);
let os_key = os_family_to_key(os);
for (b, o) in IMPOSSIBLE_COMBOS {
if &browser_key == b && &os_key == o {
return Err(FingerprintError::ConstraintConflict {
browser: browser_key,
os: os_key,
});
}
}
}
Ok(())
}
fn build_header_constraints(&self) -> Constraints {
let mut c = Constraints::new();
if let Some(browser) = &self.browser {
let prefix = browser_family_to_key(browser);
if let Ok(network) = get_header_network() {
if let Some(browser_node) = network.nodes.iter().find(|n| n.name == "*BROWSER") {
let matching: Vec<String> = browser_node
.possible_values
.iter()
.filter(|v| v.to_lowercase().starts_with(&prefix))
.cloned()
.collect();
if !matching.is_empty() {
c.insert("*BROWSER".to_string(), matching);
}
}
}
}
if let Some(os) = &self.os {
c.insert(
"*OPERATING_SYSTEM".to_string(),
vec![os_family_to_key(os)],
);
}
c
}
fn build_fp_constraints(&self) -> Constraints {
Constraints::new()
}
fn build_rng(&self) -> ChaCha8Rng {
if let Some(seed) = self.seed {
ChaCha8Rng::seed_from_u64(seed)
} else {
ChaCha8Rng::from_entropy()
}
}
}
fn browser_family_to_key(family: &BrowserFamily) -> String {
match family {
BrowserFamily::Chrome => "chrome".to_string(),
BrowserFamily::Firefox => "firefox".to_string(),
BrowserFamily::Safari => "safari".to_string(),
BrowserFamily::Edge => "edge".to_string(),
BrowserFamily::Other(s) => s.to_lowercase(),
}
}
fn os_family_to_key(family: &OsFamily) -> String {
match family {
OsFamily::Windows => "windows".to_string(),
OsFamily::MacOs => "macos".to_string(),
OsFamily::Linux => "linux".to_string(),
OsFamily::Android => "android".to_string(),
OsFamily::Ios => "ios".to_string(),
OsFamily::Other(s) => s.to_lowercase(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn random_succeeds() {
let profile = FingerprintGenerator::random().expect("random generation must succeed");
assert!(!profile.fingerprint.navigator.user_agent.is_empty());
assert!(!profile.fingerprint.navigator.webdriver, "webdriver must always be false");
assert!(profile.generated_at > 0, "generated_at must be set");
assert_ne!(profile.id, [0u8; 16], "id must be non-zero");
}
#[test]
fn seeded_produces_identical_user_agents() {
let profile1 = FingerprintGenerator::new()
.seeded(42)
.generate()
.expect("seeded must succeed");
let profile2 = FingerprintGenerator::new()
.seeded(42)
.generate()
.expect("seeded must succeed again");
assert_eq!(
profile1.fingerprint.navigator.user_agent,
profile2.fingerprint.navigator.user_agent,
"same seed must produce identical user_agent"
);
assert_eq!(
profile1.fingerprint.screen.width,
profile2.fingerprint.screen.width,
"same seed must produce identical screen width"
);
assert_ne!(profile1.id, [0u8; 16]);
assert_ne!(profile2.id, [0u8; 16]);
}
#[test]
fn different_seeds_produce_different_profiles() {
let p1 = FingerprintGenerator::new()
.seeded(1)
.generate()
.expect("must succeed");
let p2 = FingerprintGenerator::new()
.seeded(999)
.generate()
.expect("must succeed");
let _ = (p1, p2); }
#[test]
fn safari_windows_conflict_is_rejected() {
let result = FingerprintGenerator::new()
.browser(BrowserFamily::Safari)
.os(OsFamily::Windows)
.strict()
.generate();
assert!(
matches!(
result,
Err(FingerprintError::ConstraintConflict { .. })
),
"Safari+Windows must fail with ConstraintConflict, got: {:?}",
result.err()
);
}
#[test]
fn os_windows_constraint_populates_header() {
let gen = FingerprintGenerator::new().os(OsFamily::Windows);
let constraints = gen.build_header_constraints();
assert!(
constraints.contains_key("*OPERATING_SYSTEM"),
"Windows constraint must target *OPERATING_SYSTEM"
);
assert_eq!(constraints["*OPERATING_SYSTEM"], vec!["windows"]);
}
#[test]
fn builder_chaining_compiles() {
let _gen = FingerprintGenerator::new()
.browser(BrowserFamily::Chrome)
.os(OsFamily::Windows)
.locale("en-US")
.seeded(42)
.strict();
}
#[test]
fn dataset_version_is_populated() {
let profile = FingerprintGenerator::random().expect("must succeed");
assert_eq!(
profile.dataset_version,
veilus_fingerprint_data::DATASET_VERSION,
"dataset_version must match embedded constant"
);
}
#[test]
fn screen_dimensions_are_positive() {
let profile = FingerprintGenerator::random().expect("must succeed");
assert!(
profile.fingerprint.screen.width > 0,
"screen width must be positive"
);
assert!(
profile.fingerprint.screen.height > 0,
"screen height must be positive"
);
}
#[test]
fn headers_do_not_contain_meta_keys() {
for seed in [10, 20, 30, 40, 50] {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
for key in profile.headers.keys() {
assert!(
!key.starts_with('*'),
"Header key '{key}' starts with '*' — internal meta-key leaked to output"
);
}
}
}
#[test]
fn navigator_extended_fields_populated() {
let profile = FingerprintGenerator::new()
.seeded(42)
.generate()
.expect("must succeed");
let nav = &profile.fingerprint.navigator;
assert_eq!(
nav.app_code_name.as_deref(),
Some("Mozilla"),
"appCodeName must be 'Mozilla'"
);
assert_eq!(
nav.app_name.as_deref(),
Some("Netscape"),
"appName must be 'Netscape'"
);
assert_eq!(
nav.product.as_deref(),
Some("Gecko"),
"product must be 'Gecko'"
);
assert!(
!nav.webdriver,
"webdriver must always be false"
);
}
#[test]
fn chrome_user_agent_data_has_high_entropy_fields() {
let mut found_chrome_with_uad = false;
for seed in 0..50 {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if profile.browser.family == BrowserFamily::Chrome {
if let Some(uad) = &profile.fingerprint.navigator.user_agent_data {
if !uad.brands.is_empty()
&& (uad.architecture.is_some() || uad.bitness.is_some())
{
found_chrome_with_uad = true;
break;
}
}
}
}
assert!(
found_chrome_with_uad,
"At least one Chrome profile out of 50 should have high-entropy UAD fields"
);
}
#[test]
fn non_chromium_profiles_have_no_user_agent_data() {
let mut found_non_chrome = false;
for seed in 0..50 {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if profile.browser.family != BrowserFamily::Chrome
&& profile.browser.family != BrowserFamily::Edge
{
found_non_chrome = true;
}
}
assert!(
found_non_chrome,
"Should find at least one non-Chrome profile in 50 samples"
);
}
#[test]
fn screen_extended_fields_populated() {
let mut any_outer = false;
let mut any_avail_top = false;
for seed in [10, 50, 100] {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
let screen = &profile.fingerprint.screen;
if screen.outer_width.is_some() {
any_outer = true;
}
if screen.avail_top.is_some() {
any_avail_top = true;
}
}
assert!(
any_outer,
"At least one profile should have outerWidth populated"
);
assert!(
any_avail_top,
"At least one profile should have availTop populated"
);
}
#[test]
fn video_card_is_populated() {
let mut any_video_card = false;
for seed in [10, 50, 100] {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if let Some(vc) = &profile.fingerprint.video_card {
assert!(!vc.renderer.is_empty(), "videoCard.renderer must not be empty");
assert!(!vc.vendor.is_empty(), "videoCard.vendor must not be empty");
any_video_card = true;
}
}
assert!(
any_video_card,
"At least one profile should have videoCard populated"
);
}
#[test]
fn battery_handles_null_charging_time() {
for seed in 0..10 {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if let Some(bat) = &profile.fingerprint.battery {
assert!(bat.level >= 0.0 && bat.level <= 1.0, "battery level must be 0.0..1.0, got {}", bat.level);
}
}
}
#[test]
fn mock_web_rtc_true_for_chrome() {
let profile = FingerprintGenerator::new()
.browser(BrowserFamily::Chrome)
.os(OsFamily::Windows)
.seeded(50042)
.generate()
.expect("Chrome+Windows must succeed");
assert_eq!(
profile.fingerprint.mock_web_rtc,
Some(true),
"Chrome should have mockWebRTC=true"
);
}
#[test]
fn mock_web_rtc_false_for_firefox() {
let mut found_firefox = false;
for seed in 0..100 {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if profile.browser.family == BrowserFamily::Firefox {
assert_eq!(
profile.fingerprint.mock_web_rtc,
Some(false),
"Firefox should have mockWebRTC=false"
);
found_firefox = true;
break;
}
}
let _ = found_firefox;
}
#[test]
fn different_seeds_produce_different_user_agents() {
let p1 = FingerprintGenerator::new()
.seeded(1)
.generate()
.expect("must succeed");
let p2 = FingerprintGenerator::new()
.seeded(12345)
.generate()
.expect("must succeed");
assert_ne!(
p1.fingerprint.navigator.user_agent,
p2.fingerprint.navigator.user_agent,
"Very different seeds should produce different user agents"
);
}
#[test]
fn audio_codecs_populated() {
let mut any_codecs = false;
for seed in [10, 50, 100] {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if let Some(ac) = &profile.fingerprint.audio_codecs {
let has_support = [&ac.ogg, &ac.mp3, &ac.wav, &ac.m4a, &ac.aac]
.iter()
.any(|v| !v.is_empty());
assert!(has_support, "audioCodecs should have at least one non-empty value");
any_codecs = true;
}
}
assert!(any_codecs, "At least one profile should have audioCodecs");
}
#[test]
fn video_codecs_populated() {
let mut any_codecs = false;
for seed in [10, 50, 100] {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if let Some(vc) = &profile.fingerprint.video_codecs {
let has_support = [&vc.ogg, &vc.h264, &vc.webm]
.iter()
.any(|v| !v.is_empty());
assert!(has_support, "videoCodecs should have at least one non-empty value");
any_codecs = true;
}
}
assert!(any_codecs, "At least one profile should have videoCodecs");
}
#[test]
fn fonts_list_populated() {
let mut any_fonts = false;
for seed in 0..20 {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if let Some(fonts) = &profile.fingerprint.fonts {
if !fonts.is_empty() {
any_fonts = true;
break;
}
}
}
assert!(any_fonts, "At least one profile out of 20 should have non-empty fonts");
}
#[test]
fn plugins_data_parseable() {
let mut any_plugins = false;
for seed in 0..30 {
let profile = FingerprintGenerator::new()
.seeded(seed)
.generate()
.expect("must succeed");
if let Some(pd) = &profile.fingerprint.plugins_data {
if !pd.plugins.is_empty() {
for plugin in &pd.plugins {
assert!(!plugin.name.is_empty(), "plugin name must not be empty");
}
any_plugins = true;
break;
}
}
}
let _ = any_plugins;
}
#[test]
fn slim_is_always_false() {
let profile = FingerprintGenerator::new()
.seeded(42)
.generate()
.expect("must succeed");
assert_eq!(
profile.fingerprint.slim,
Some(false),
"slim must always be false"
);
}
#[test]
fn webdriver_always_false_with_constraints() {
for (browser, os) in [
(BrowserFamily::Chrome, OsFamily::Windows),
(BrowserFamily::Firefox, OsFamily::Linux),
(BrowserFamily::Safari, OsFamily::MacOs),
] {
let profile = FingerprintGenerator::new()
.browser(browser.clone())
.os(os)
.seeded(42)
.generate()
.expect("must succeed");
assert!(
!profile.fingerprint.navigator.webdriver,
"webdriver must always be false for {:?}",
browser
);
}
}
}