rustenium-identity 0.1.11

A versatile stealth overlay for rustenium
Documentation
use crate::identity::{Browser, Identity, Os};
use crate::error::IdentityError;

/// Build the full version string from the version parts, e.g. [124, 0, 6367, 78] → "124.0.6367.78"
fn full_version(parts: &[u16]) -> String {
    parts.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(".")
}

/// The version as a Chromium UA string carries it: `MAJOR.0.0.0`.
///
/// UA reduction freezes the minor/build/patch at zero — measured on a real Chrome
/// 146, `navigator.userAgent` reports `Chrome/146.0.0.0` while the true build
/// `146.0.7680.153` is reachable only through the `uaFullVersion` client hint.
/// Putting the real quad in the UA string produces a value no Chrome emits, which
/// is exactly what a browser-version check is looking for.
fn reduced_version(parts: &[u16]) -> String {
    format!("{}.0.0.0", parts.first().copied().unwrap_or(0))
}

/// Extract the Chromium version parts from a UA string (`... Chrome/146.0.7444.60 ...`).
///
/// Used to pin a persona to the binary that is actually running. The UA is the only
/// part of a version claim we control: detectors feature-detect the build — which CSS
/// properties parse, which `window` members exist, which JS builtins are present — and
/// each maps to a release range no UA rewrite can move. Measured against browserscan,
/// a persona claiming 124 on a 146 binary came back `liedCSS`/`liedJS`/`liedWindow`
/// with an exact distance of 22.
pub fn parse_chrome_version(ua: &str) -> Option<Vec<u16>> {
    parse_version_parts(ua.split("Chrome/").nth(1)?.split_whitespace().next()?)
}

/// Parse a dotted numeric version (`146.0.7444.60`) into parts.
///
/// Prefer feeding this the `uaFullVersion` client hint over the UA string: UA
/// reduction freezes the UA's version at `MAJOR.0.0.0`, but real Chrome still
/// reports the true quad through `getHighEntropyValues`. Deriving `fullVersion`
/// from the reduced form would answer that probe with `146.0.0.0`, which no real
/// browser returns.
pub fn parse_version_parts(version: &str) -> Option<Vec<u16>> {
    let parts = version
        .split('.')
        .map(|p| p.parse::<u16>().ok())
        .collect::<Option<Vec<_>>>()?;
    if parts.is_empty() { None } else { Some(parts) }
}

#[cfg(test)]
mod tests {
    use super::{parse_chrome_version, reduced_version};

    #[test]
    fn ua_version_is_reduced_to_the_major() {
        // Real Chrome 146 reports `Chrome/146.0.0.0`; the true build reaches a page
        // only through `uaFullVersion`.
        assert_eq!(reduced_version(&[146, 0, 7680, 153]), "146.0.0.0");
    }

    #[test]
    fn parses_a_desktop_chrome_ua() {
        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
                  (KHTML, like Gecko) Chrome/146.0.7444.60 Safari/537.36";
        assert_eq!(parse_chrome_version(ua), Some(vec![146, 0, 7444, 60]));
    }

    #[test]
    fn parses_an_edge_ua_using_the_chromium_part() {
        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
                  (KHTML, like Gecko) Chrome/146.0.7444.60 Safari/537.36 Edg/146.0.3535.20";
        assert_eq!(parse_chrome_version(ua), Some(vec![146, 0, 7444, 60]));
    }

    #[test]
    fn rejects_a_ua_without_a_chromium_token() {
        let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 \
                  (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
        assert_eq!(parse_chrome_version(ua), None);
    }
}

/// Build the User-Agent string for a given identity.
pub fn build_user_agent(identity: &Identity) -> Result<String, IdentityError> {
    // Chromium UA strings are reduced; the full build only reaches a page through
    // the client hints, which `apply_identity` sets separately. The iOS arms below
    // are WebKit, not Chromium, and keep the version as given.
    let chrome_ua = reduced_version(&identity.browser_version);
    let chrome_full = full_version(&identity.browser_version);

    match (&identity.os, &identity.browser) {
        // §4.1 Chrome on Windows
        (Os::Windows, Browser::Chrome) => {
            let nt_version = windows_nt_version(&identity.os_version);
            Ok(format!(
                "Mozilla/5.0 (Windows NT {}; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
                nt_version, chrome_ua
            ))
        }

        // §4.2 Chrome on macOS
        (Os::Macintosh, Browser::Chrome) => {
            let osx_underscored = identity.os_version.replace('.', "_");
            Ok(format!(
                "Mozilla/5.0 (Macintosh; Intel Mac OS X {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
                osx_underscored, chrome_ua
            ))
        }

        // §4.3 Chrome on Android
        (Os::Android, Browser::Chrome) => {
            let device = identity.device_model.as_deref().unwrap_or("Pixel 7");
            Ok(format!(
                "Mozilla/5.0 (Linux; Android {}; {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Mobile Safari/537.36",
                identity.os_version, device, chrome_ua
            ))
        }

        // §4.3 Edge on Android
        (Os::Android, Browser::Edge) => {
            let device = identity.device_model.as_deref().unwrap_or("Pixel 7");
            Ok(format!(
                "Mozilla/5.0 (Linux; Android {}; {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Mobile EdgA/{} Safari/537.36",
                identity.os_version, device, chrome_ua, chrome_ua
            ))
        }

        // Safari on iOS
        (Os::Ios, Browser::Safari) => {
            let os_underscored = identity.os_version.replace('.', "_");
            let nav_platform = identity.platform.navigator_platform.as_str();
            Ok(format!(
                "Mozilla/5.0 ({}; CPU iPhone OS {} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{} Mobile/15E148 Safari/604.1",
                nav_platform, os_underscored, full_version(&identity.browser_version)
            ))
        }

        // Chrome on iOS (WebKit-based, uses CriOS)
        (Os::Ios, Browser::Chrome) => {
            let os_underscored = identity.os_version.replace('.', "_");
            let nav_platform = identity.platform.navigator_platform.as_str();
            Ok(format!(
                "Mozilla/5.0 ({}; CPU iPhone OS {} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/{} Mobile/15E148 Safari/604.1",
                nav_platform, os_underscored, chrome_full
            ))
        }

        // Edge on iOS (WebKit-based, uses EdgiOS)
        (Os::Ios, Browser::Edge) => {
            let os_underscored = identity.os_version.replace('.', "_");
            let nav_platform = identity.platform.navigator_platform.as_str();
            Ok(format!(
                "Mozilla/5.0 ({}; CPU iPhone OS {} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{} EdgiOS/{} Mobile/15E148 Safari/604.1",
                nav_platform, os_underscored, full_version(&identity.browser_version), chrome_full
            ))
        }

        // Safari on macOS
        (Os::Macintosh, Browser::Safari) => {
            let osx_underscored = identity.os_version.replace('.', "_");
            Ok(format!(
                "Mozilla/5.0 (Macintosh; Intel Mac OS X {}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/{} Safari/605.1.15",
                osx_underscored, full_version(&identity.browser_version)
            ))
        }

        // Chrome on Linux
        (Os::Linux, Browser::Chrome) => {
            Ok(format!(
                "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
                chrome_ua
            ))
        }

        // Edge on Windows
        (Os::Windows, Browser::Edge) => {
            let nt_version = windows_nt_version(&identity.os_version);
            Ok(format!(
                "Mozilla/5.0 (Windows NT {}; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36 Edg/{}",
                nt_version, chrome_ua, chrome_ua
            ))
        }

        // Edge on macOS
        (Os::Macintosh, Browser::Edge) => {
            let osx_underscored = identity.os_version.replace('.', "_");
            Ok(format!(
                "Mozilla/5.0 (Macintosh; Intel Mac OS X {}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36 Edg/{}",
                osx_underscored, chrome_ua, chrome_ua
            ))
        }

        (os, browser) => Err(IdentityError::UaError(format!(
            "unsupported OS/browser combination: {:?}/{:?}",
            os, browser
        ))),
    }
}

/// Map common Windows version strings to NT version numbers.
fn windows_nt_version(os_version: &str) -> &str {
    match os_version {
        "11" => "10.0",
        "10" => "10.0",
        "8.1" => "6.3",
        "8" => "6.2",
        "7" => "6.1",
        _ => "10.0",
    }
}