Skip to main content

rustenium_identity/
ua.rs

1use crate::identity::{Browser, Identity, Os};
2use crate::error::IdentityError;
3
4/// Build the full version string from the version parts, e.g. [124, 0, 6367, 78] → "124.0.6367.78"
5fn full_version(parts: &[u16]) -> String {
6    parts.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(".")
7}
8
9/// The version as a Chromium UA string carries it: `MAJOR.0.0.0`.
10///
11/// UA reduction freezes the minor/build/patch at zero — measured on a real Chrome
12/// 146, `navigator.userAgent` reports `Chrome/146.0.0.0` while the true build
13/// `146.0.7680.153` is reachable only through the `uaFullVersion` client hint.
14/// Putting the real quad in the UA string produces a value no Chrome emits, which
15/// is exactly what a browser-version check is looking for.
16fn reduced_version(parts: &[u16]) -> String {
17    format!("{}.0.0.0", parts.first().copied().unwrap_or(0))
18}
19
20/// Extract the Chromium version parts from a UA string (`... Chrome/146.0.7444.60 ...`).
21///
22/// Used to pin a persona to the binary that is actually running. The UA is the only
23/// part of a version claim we control: detectors feature-detect the build — which CSS
24/// properties parse, which `window` members exist, which JS builtins are present — and
25/// each maps to a release range no UA rewrite can move. Measured against browserscan,
26/// a persona claiming 124 on a 146 binary came back `liedCSS`/`liedJS`/`liedWindow`
27/// with an exact distance of 22.
28pub fn parse_chrome_version(ua: &str) -> Option<Vec<u16>> {
29    parse_version_parts(ua.split("Chrome/").nth(1)?.split_whitespace().next()?)
30}
31
32/// Parse a dotted numeric version (`146.0.7444.60`) into parts.
33///
34/// Prefer feeding this the `uaFullVersion` client hint over the UA string: UA
35/// reduction freezes the UA's version at `MAJOR.0.0.0`, but real Chrome still
36/// reports the true quad through `getHighEntropyValues`. Deriving `fullVersion`
37/// from the reduced form would answer that probe with `146.0.0.0`, which no real
38/// browser returns.
39pub fn parse_version_parts(version: &str) -> Option<Vec<u16>> {
40    let parts = version
41        .split('.')
42        .map(|p| p.parse::<u16>().ok())
43        .collect::<Option<Vec<_>>>()?;
44    if parts.is_empty() { None } else { Some(parts) }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::{parse_chrome_version, reduced_version};
50
51    #[test]
52    fn ua_version_is_reduced_to_the_major() {
53        // Real Chrome 146 reports `Chrome/146.0.0.0`; the true build reaches a page
54        // only through `uaFullVersion`.
55        assert_eq!(reduced_version(&[146, 0, 7680, 153]), "146.0.0.0");
56    }
57
58    #[test]
59    fn parses_a_desktop_chrome_ua() {
60        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
61                  (KHTML, like Gecko) Chrome/146.0.7444.60 Safari/537.36";
62        assert_eq!(parse_chrome_version(ua), Some(vec![146, 0, 7444, 60]));
63    }
64
65    #[test]
66    fn parses_an_edge_ua_using_the_chromium_part() {
67        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
68                  (KHTML, like Gecko) Chrome/146.0.7444.60 Safari/537.36 Edg/146.0.3535.20";
69        assert_eq!(parse_chrome_version(ua), Some(vec![146, 0, 7444, 60]));
70    }
71
72    #[test]
73    fn rejects_a_ua_without_a_chromium_token() {
74        let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 \
75                  (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
76        assert_eq!(parse_chrome_version(ua), None);
77    }
78}
79
80/// Build the User-Agent string for a given identity.
81pub fn build_user_agent(identity: &Identity) -> Result<String, IdentityError> {
82    // Chromium UA strings are reduced; the full build only reaches a page through
83    // the client hints, which `apply_identity` sets separately. The iOS arms below
84    // are WebKit, not Chromium, and keep the version as given.
85    let chrome_ua = reduced_version(&identity.browser_version);
86    let chrome_full = full_version(&identity.browser_version);
87
88    match (&identity.os, &identity.browser) {
89        // §4.1 Chrome on Windows
90        (Os::Windows, Browser::Chrome) => {
91            let nt_version = windows_nt_version(&identity.os_version);
92            Ok(format!(
93                "Mozilla/5.0 (Windows NT {}; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
94                nt_version, chrome_ua
95            ))
96        }
97
98        // §4.2 Chrome on macOS
99        (Os::Macintosh, Browser::Chrome) => {
100            let osx_underscored = identity.os_version.replace('.', "_");
101            Ok(format!(
102                "Mozilla/5.0 (Macintosh; Intel Mac OS X {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
103                osx_underscored, chrome_ua
104            ))
105        }
106
107        // §4.3 Chrome on Android
108        (Os::Android, Browser::Chrome) => {
109            let device = identity.device_model.as_deref().unwrap_or("Pixel 7");
110            Ok(format!(
111                "Mozilla/5.0 (Linux; Android {}; {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Mobile Safari/537.36",
112                identity.os_version, device, chrome_ua
113            ))
114        }
115
116        // §4.3 Edge on Android
117        (Os::Android, Browser::Edge) => {
118            let device = identity.device_model.as_deref().unwrap_or("Pixel 7");
119            Ok(format!(
120                "Mozilla/5.0 (Linux; Android {}; {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Mobile EdgA/{} Safari/537.36",
121                identity.os_version, device, chrome_ua, chrome_ua
122            ))
123        }
124
125        // Safari on iOS
126        (Os::Ios, Browser::Safari) => {
127            let os_underscored = identity.os_version.replace('.', "_");
128            let nav_platform = identity.platform.navigator_platform.as_str();
129            Ok(format!(
130                "Mozilla/5.0 ({}; CPU iPhone OS {} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{} Mobile/15E148 Safari/604.1",
131                nav_platform, os_underscored, full_version(&identity.browser_version)
132            ))
133        }
134
135        // Chrome on iOS (WebKit-based, uses CriOS)
136        (Os::Ios, Browser::Chrome) => {
137            let os_underscored = identity.os_version.replace('.', "_");
138            let nav_platform = identity.platform.navigator_platform.as_str();
139            Ok(format!(
140                "Mozilla/5.0 ({}; CPU iPhone OS {} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/{} Mobile/15E148 Safari/604.1",
141                nav_platform, os_underscored, chrome_full
142            ))
143        }
144
145        // Edge on iOS (WebKit-based, uses EdgiOS)
146        (Os::Ios, Browser::Edge) => {
147            let os_underscored = identity.os_version.replace('.', "_");
148            let nav_platform = identity.platform.navigator_platform.as_str();
149            Ok(format!(
150                "Mozilla/5.0 ({}; CPU iPhone OS {} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{} EdgiOS/{} Mobile/15E148 Safari/604.1",
151                nav_platform, os_underscored, full_version(&identity.browser_version), chrome_full
152            ))
153        }
154
155        // Safari on macOS
156        (Os::Macintosh, Browser::Safari) => {
157            let osx_underscored = identity.os_version.replace('.', "_");
158            Ok(format!(
159                "Mozilla/5.0 (Macintosh; Intel Mac OS X {}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{} Safari/605.1.15",
160                osx_underscored, full_version(&identity.browser_version)
161            ))
162        }
163
164        // Chrome on Linux
165        (Os::Linux, Browser::Chrome) => {
166            Ok(format!(
167                "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
168                chrome_ua
169            ))
170        }
171
172        // Edge on Windows
173        (Os::Windows, Browser::Edge) => {
174            let nt_version = windows_nt_version(&identity.os_version);
175            Ok(format!(
176                "Mozilla/5.0 (Windows NT {}; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36 Edg/{}",
177                nt_version, chrome_ua, chrome_ua
178            ))
179        }
180
181        // Edge on macOS
182        (Os::Macintosh, Browser::Edge) => {
183            let osx_underscored = identity.os_version.replace('.', "_");
184            Ok(format!(
185                "Mozilla/5.0 (Macintosh; Intel Mac OS X {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36 Edg/{}",
186                osx_underscored, chrome_ua, chrome_ua
187            ))
188        }
189
190        (os, browser) => Err(IdentityError::UaError(format!(
191            "unsupported OS/browser combination: {:?}/{:?}",
192            os, browser
193        ))),
194    }
195}
196
197/// Map common Windows version strings to NT version numbers.
198fn windows_nt_version(os_version: &str) -> &str {
199    match os_version {
200        "11" => "10.0",
201        "10" => "10.0",
202        "8.1" => "6.3",
203        "8" => "6.2",
204        "7" => "6.1",
205        _ => "10.0",
206    }
207}