Skip to main content

millipede_fingerprint/
header_generator.rs

1use std::{
2    collections::hash_map::DefaultHasher,
3    hash::{Hash, Hasher},
4    sync::OnceLock,
5};
6
7const HEADER_PROFILES_JSON: &str = include_str!("../data/header_profiles.json");
8
9/// A browser user agent and its ordered companion HTTP headers.
10#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11pub struct HeaderProfile {
12    /// Browser user-agent value.
13    pub user_agent: String,
14    /// Ordered header name and value pairs associated with the user agent.
15    pub headers: Vec<(String, String)>,
16}
17
18fn bundled_profiles() -> &'static [HeaderProfile] {
19    static PROFILES: OnceLock<Vec<HeaderProfile>> = OnceLock::new();
20    PROFILES
21        .get_or_init(|| {
22            serde_json::from_str(HEADER_PROFILES_JSON)
23                .expect("bundled header_profiles.json must parse")
24        })
25        .as_slice()
26}
27
28/// Deterministically selects browser-like header profiles from a curated dataset.
29#[derive(Debug, Clone)]
30pub struct HeaderGenerator {
31    profiles: Vec<HeaderProfile>,
32}
33
34impl HeaderGenerator {
35    /// Creates a generator backed by the bundled profile dataset.
36    pub fn new() -> Self {
37        Self {
38            profiles: bundled_profiles().to_vec(),
39        }
40    }
41
42    /// Creates a generator backed by caller-supplied profiles.
43    ///
44    /// # Panics
45    ///
46    /// Panics if `profiles` is empty.
47    pub fn from_profiles(profiles: Vec<HeaderProfile>) -> Self {
48        assert!(!profiles.is_empty(), "header profiles must not be empty");
49        Self { profiles }
50    }
51
52    /// Returns the profile selected deterministically by `seed`.
53    pub fn generate(&self, seed: &str) -> HeaderProfile {
54        let mut hasher = DefaultHasher::new();
55        seed.as_bytes().hash(&mut hasher);
56        let index = hasher.finish() as usize % self.profiles.len();
57        self.profiles[index].clone()
58    }
59}
60
61impl Default for HeaderGenerator {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use std::collections::HashSet;
70
71    use super::HeaderGenerator;
72
73    #[test]
74    fn same_seed_is_deterministic_across_fresh_instances() {
75        assert_eq!(
76            HeaderGenerator::new().generate("session-A"),
77            HeaderGenerator::new().generate("session-A")
78        );
79    }
80
81    #[test]
82    fn same_seed_is_byte_identical_when_serialized() {
83        let first = serde_json::to_vec(&HeaderGenerator::new().generate("session-A")).unwrap();
84        let second = serde_json::to_vec(&HeaderGenerator::new().generate("session-A")).unwrap();
85
86        assert_eq!(first, second);
87    }
88
89    #[test]
90    fn different_seeds_can_select_different_profiles() {
91        let generator = HeaderGenerator::new();
92        let profiles = (0..20)
93            .map(|index| generator.generate(&format!("session-{index}")))
94            .collect::<Vec<_>>();
95
96        assert!(profiles.iter().any(|profile| profile != &profiles[0]));
97    }
98
99    #[test]
100    fn bundled_dataset_has_multiple_distinct_profiles() {
101        let generator = HeaderGenerator::new();
102        let user_agents = (0..200)
103            .map(|index| generator.generate(&format!("session-{index}")).user_agent)
104            .collect::<HashSet<_>>();
105
106        assert!(user_agents.len() >= 6);
107    }
108
109    #[test]
110    #[should_panic(expected = "header profiles must not be empty")]
111    fn from_profiles_rejects_empty() {
112        HeaderGenerator::from_profiles(vec![]);
113    }
114
115    #[test]
116    fn generated_profile_is_well_formed() {
117        let profile = HeaderGenerator::new().generate("well-formed");
118
119        assert!(!profile.user_agent.is_empty());
120        assert!(!profile.headers.is_empty());
121        assert!(
122            profile
123                .headers
124                .iter()
125                .all(|(name, value)| !name.is_empty() && !value.is_empty())
126        );
127    }
128}