rustenium-identity 0.1.9

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

use crate::script;
use crate::ua;

use rustenium::browsers::chrome::browser::ChromeBrowser;
use rustenium::browsers::cdp_browser::CdpBrowser;
use rustenium::cdp::target_manager::InitScript;
use rustenium_cdp_definitions::browser_protocol::emulation::commands::{
    SetDeviceMetricsOverride, SetEmitTouchEventsForMouse, SetEmitTouchEventsForMouseConfiguration,
    SetHardwareConcurrencyOverride, SetLocaleOverride, SetTimezoneOverride,
    SetTouchEmulationEnabled, SetUserAgentOverride,
};
use rustenium_cdp_definitions::browser_protocol::emulation::types::{
    UserAgentBrandVersion, UserAgentMetadata,
};
use rustenium_cdp_definitions::browser_protocol::network::commands::SetUserAgentOverride as NetworkSetUserAgentOverride;

/// Build UserAgentMetadata (Client Hints) for Chrome/Edge identities.
fn build_client_hints(identity: &Identity) -> UserAgentMetadata {
    let major = identity.browser_version.first().copied().unwrap_or(0).to_string();
    let full_version = identity
        .browser_version
        .iter()
        .map(|v| v.to_string())
        .collect::<Vec<_>>()
        .join(".");
    let is_mobile = identity.device_model.is_some();

    let (brand_name, brand_name_full) = match identity.browser {
        Browser::Edge => ("Microsoft Edge", "Microsoft Edge"),
        _ => ("Google Chrome", "Google Chrome"),
    };

    let grease_brand = match identity.os {
        Os::Android => "Not-A.Brand",
        _ => "Not;A=Brand",
    };

    let brands = vec![
        UserAgentBrandVersion::new("Chromium", &major),
        UserAgentBrandVersion::new(grease_brand, "24"),
        UserAgentBrandVersion::new(brand_name, &major),
    ];

    let full_version_list = vec![
        UserAgentBrandVersion::new("Chromium", &full_version),
        UserAgentBrandVersion::new(grease_brand, "24.0.0.0"),
        UserAgentBrandVersion::new(brand_name_full, &full_version),
    ];

    let platform = match identity.os {
        Os::Windows => "Windows",
        Os::Macintosh => "macOS",
        Os::Linux => "Linux",
        Os::Android => "Android",
        Os::Ios => "iOS",
    };

    // On mobile, architecture and bitness should be empty
    let (architecture, bitness) = if is_mobile {
        (String::new(), String::new())
    } else {
        (
            identity.platform.architecture.clone().unwrap_or_else(|| "x86".into()),
            identity.platform.bitness.clone().unwrap_or_else(|| "64".into()),
        )
    };
    let model = identity.device_model.clone().unwrap_or_default();

    UserAgentMetadata::builder()
        .brands(brands)
        .full_version_lists(full_version_list)
        .platform(platform)
        .platform_version(&identity.platform.version)
        .architecture(&architecture)
        .model(&model)
        .mobile(is_mobile)
        .bitness(bitness)
        .build()
        .unwrap()
}

/// Apply all CDP emulation commands and register the stealth script.
pub async fn apply_identity(
    browser: &mut ChromeBrowser,
    identity: &Identity,
    timezone: &str,
) -> Result<(), IdentityError> {
    let user_agent = ua::build_user_agent(identity)?;
    let lang0 = identity
        .language
        .first()
        .cloned()
        .unwrap_or_else(|| "en-US".into());
    let is_mobile = identity.device_model.is_some();

    // Build client hints for Chrome/Edge (skip iOS — all iOS browsers are WebKit-based)
    let client_hints = if matches!(identity.browser, Browser::Chrome | Browser::Edge)
        && !matches!(identity.os, Os::Ios)
    {
        Some(build_client_hints(identity))
    } else {
        None
    };

    // Note the emulation overrides below are session-scoped: rustenium replicates
    // them to every target automatically, so a worker's timezone, locale and UA-CH
    // stay consistent with the page's rather than leaking the host's.

    // Emulation.setUserAgentOverride
    let mut ua_builder = SetUserAgentOverride::builder()
        .user_agent(&user_agent)
        .accept_language(&lang0)
        .platform(identity.platform.navigator_platform.as_str());
    if let Some(ref metadata) = client_hints {
        ua_builder = ua_builder.user_agent_metadata(metadata.clone());
    }
    let ua_cmd = ua_builder.build().unwrap();

    // `uaFullVersion` has no non-deprecated way to be set. Chrome derives it from
    // the legacy scalar `fullVersion`, not from `fullVersionList`, and CDP dropped
    // that field from the published protocol — so the generated UserAgentMetadata
    // cannot express it and Chrome falls back to the *real* browser version. Left
    // alone, getHighEntropyValues(['uaFullVersion']) reports the host's Chrome next
    // to the persona's everywhere else.
    //
    // Measured: the browser still honours the field, so send it alongside.
    let full_version = identity
        .browser_version
        .iter()
        .map(|v| v.to_string())
        .collect::<Vec<_>>()
        .join(".");
    let ua_extensions = serde_json::json!({
        "userAgentMetadata": { "fullVersion": full_version }
    });
    browser
        .send_command_extended(ua_cmd.into(), ua_extensions.clone())
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // Network.setUserAgentOverride (belt-and-suspenders, also with client hints)
    let mut net_builder = NetworkSetUserAgentOverride::builder()
        .user_agent(&user_agent)
        .accept_language(&lang0);
    if let Some(ref metadata) = client_hints {
        net_builder = net_builder.user_agent_metadata(metadata.clone());
    }
    let net_ua_cmd = net_builder.build().unwrap();
    // Carries its own copy of the metadata, so it needs the same extension —
    // otherwise it lands second and resets `fullVersion` to the real version.
    browser
        .send_command_extended(net_ua_cmd.into(), ua_extensions.clone())
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // Emulation.setDeviceMetricsOverride — only spoof display dimensions for mobile
    if is_mobile {
        let screen_width = (identity.screen.logical_width as f64 * identity.screen.density_pixel_ratio as f64) as i64;
        let screen_height = (identity.screen.logical_height as f64 * identity.screen.density_pixel_ratio as f64) as i64;
        let device_cmd = SetDeviceMetricsOverride::builder()
            .width(identity.screen.logical_width as i64)
            .height(identity.screen.logical_height as i64)
            .device_scale_factor(identity.screen.density_pixel_ratio as f64)
            .mobile(true)
            .screen_width(screen_width)
            .screen_height(screen_height)
            .build()
            .unwrap();
        browser
            .send_command(device_cmd.into())
            .await
            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
    }

    // Emulation.setTouchEmulationEnabled
    if identity.has_touch {
        let touch_cmd = SetTouchEmulationEnabled::builder()
            .enabled(true)
            .max_touch_points(5i64)
            .build()
            .unwrap();
        browser
            .send_command(touch_cmd.into())
            .await
            .map_err(|e| IdentityError::CdpError(e.to_string()))?;

        // Emulation.setEmitTouchEventsForMouse — translate the automation's mouse
        // input into synthetic touch events so a touch device receives touchstart/
        // move/end (as a real finger would) instead of mouse events.
        let configuration = if is_mobile {
            SetEmitTouchEventsForMouseConfiguration::Mobile
        } else {
            SetEmitTouchEventsForMouseConfiguration::Desktop
        };
        let emit_touch_cmd = SetEmitTouchEventsForMouse::builder()
            .enabled(true)
            .configuration(configuration)
            .build()
            .unwrap();
        browser
            .send_command(emit_touch_cmd.into())
            .await
            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
    }

    // Emulation.setLocaleOverride
    let locale_cmd = SetLocaleOverride::builder()
        .locale(&lang0)
        .build();
    browser
        .send_command(locale_cmd.into())
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // Emulation.setTimezoneOverride
    let tz_cmd = SetTimezoneOverride::builder()
        .timezone_id(timezone)
        .build()
        .unwrap();
    browser
        .send_command(tz_cmd.into())
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // Emulation.setHardwareConcurrencyOverride
    let hc_cmd = SetHardwareConcurrencyOverride::builder()
        .hardware_concurrency(identity.hardware_concurrency as i64)
        .build()
        .unwrap();
    browser
        .send_command(hc_cmd.into())
        .await
        .map_err(|e| IdentityError::CdpError(e.to_string()))?;

    // One registration covers every realm. The target manager applies it to each
    // target already attached and to every one that appears later, installing it
    // while the target is still frozen — before it has run a statement.
    //
    // This is what reaches workers and cross-origin iframes. They are separate
    // targets with their own realms, so without it they report the host's real
    // values while the page reports the persona, and comparing the two is a
    // one-line detection. Service workers especially: they belong to no tab, so
    // only a browser-scoped registration can reach them at all.
    //
    // Two sources because a worker realm has no window or document and uses
    // WorkerNavigator, so the document script throws on its first statement there.
    browser
        .add_init_script(InitScript {
            page: Some(script::build_stealth_script(identity)),
            worker: Some(script::build_worker_script(identity)),
        })
        .await;
    Ok(())
}