rustenium-identity 0.1.11

A versatile stealth overlay for rustenium
Documentation
//! Shared harness for the detector tests.
//!
//! These drive the real sites, not a local reproduction of their checks — the
//! verdict that matters is the one the detector publishes, and several of them
//! compute it server-side from a payload we can only see by tapping the page's
//! own traffic.
//!
//! They are `#[ignore]`d because each launches a browser and talks to the
//! internet. Run them deliberately:
//!
//! ```text
//! cargo test --test creepjs    -- --ignored --nocapture
//! cargo test --test browserscan -- --ignored --nocapture
//! cargo test --test pixelscan  -- --ignored --nocapture
//! ```
//!
//! `--nocapture` matters: each test prints a report that is usually more useful
//! than the assertion it ends with.
#![allow(dead_code)]

use rustenium::browsers::cdp_browser::CdpBrowser;
use rustenium::browsers::chrome::browser::ChromeConfig;
use rustenium::cdp::target_manager::InitScript;
use rustenium_identity::{IdentityConfig, IdentitySession, preset};
use std::time::Duration;

/// Which Chrome the tests drive.
///
/// This matters more than it looks. rustenium's downloader pins a Chrome for
/// Testing build, and the personas claim a version — if the two disagree the
/// engine contradicts the UA on every feature-detection probe, which is what
/// pixelscan's `/s/api/cbv` reports. Point this at the same stock Chrome the
/// deployment ships so the catalogue has one binary to be right about.
///
///     CHROME_PATH=/path/to/chrome.exe cargo test --test creepjs -- --ignored
fn chrome_config() -> ChromeConfig {
    ChromeConfig {
        enable_bidi: false,
        enable_cdp: true,
        chrome_executable_path: std::env::var("CHROME_PATH").ok(),
        ..Default::default()
    }
}

/// Installed before any page script runs, so the detector's own requests are
/// captured. Several of these sites decide server-side and only render a
/// summary, so the request body and the response are the actual evidence.
pub const NETWORK_TAP: &str = r#"
window.__net = [];
(function () {
  var origFetch = window.fetch;
  window.fetch = function (input, init) {
    var url = (typeof input === 'string') ? input : (input && input.url) || String(input);
    var body = (init && init.body) || null;
    return origFetch.apply(this, arguments).then(function (res) {
      try {
        res.clone().text().then(function (txt) {
          window.__net.push({ url: url, req: body ? String(body) : null, status: res.status, res: txt });
        });
      } catch (e) {}
      return res;
    });
  };
  var origOpen = XMLHttpRequest.prototype.open;
  var origSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.open = function (m, u) { this.__u = u; return origOpen.apply(this, arguments); };
  XMLHttpRequest.prototype.send = function (b) {
    var self = this;
    this.addEventListener('load', function () {
      window.__net.push({ url: self.__u, req: b ? String(b) : null, status: self.status, res: self.responseText });
    });
    return origSend.apply(this, arguments);
  };
})();
"#;

/// A launched browser pointed at one detector, with or without an identity.
///
/// The baseline arm matters as much as the spoofed one. Several of these sites
/// react to things that have nothing to do with the persona — running headless is
/// enough to make pixelscan call a fingerprint inconsistent — so "the detector is
/// unhappy" only means something once you know what it says about the same
/// browser with nothing applied.
///
///     NO_IDENTITY=1 cargo test --test pixelscan -- --ignored --nocapture
pub enum Detector {
    Identity(IdentitySession),
    Baseline(rustenium::browsers::chrome::browser::ChromeBrowser),
}

impl Detector {
    /// Launch `preset_id` with the stealth identity applied, tap the page's
    /// traffic, and navigate to `url`.
    pub async fn open(preset_id: u64, url: &str) -> Detector {
        // A real identity record beats a preset when you want to test the thing
        // that will actually ship:
        //   IDENTITY_JSON=/path/to/identity.json cargo test ... -- --ignored
        let mut identity = match std::env::var("IDENTITY_JSON") {
            Ok(path) => {
                let raw = std::fs::read_to_string(&path)
                    .unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
                println!("identity from {path}");
                rustenium_identity::Identity::from_json(&raw)
                    .unwrap_or_else(|e| panic!("cannot parse {path}: {e}"))
            }
            Err(_) => preset::get_by_id(preset_id).expect("preset id out of range"),
        };

        // Override the claimed version without editing the catalogue, so a run
        // can be pointed at whatever binary is being driven:
        //   PERSONA_VERSION=151.0.7922.138 cargo test ... -- --ignored
        if let Ok(v) = std::env::var("PERSONA_VERSION") {
            if let Some(parts) = rustenium_identity::ua::parse_version_parts(&v) {
                identity.browser_version = parts;
            }
        }
        println!(
            "launching preset #{preset_id}: {:?} {} / {:?} {:?} / {}",
            identity.os,
            identity.os_version,
            identity.browser,
            identity.browser_version,
            identity.gpu.webgl_renderer,
        );

        let mut d = if std::env::var("NO_IDENTITY").is_ok() {
            println!("  NO_IDENTITY set — launching a plain browser, nothing applied");
            // Still hide the automation flag. Without it `navigator.webdriver`
            // is true and pixelscan simply refuses to scan, which makes the
            // control useless — it has to differ from the spoofed run only in
            // the identity, not in whether it looks automated.
            let mut cfg = chrome_config();
            cfg.browser_flags = Some(vec![
                "--disable-blink-features=AutomationControlled".to_string(),
            ]);
            Detector::Baseline(rustenium::browsers::chrome::browser::ChromeBrowser::new(cfg).await)
        } else {
            Detector::Identity(
                IdentitySession::launch(IdentityConfig::new(identity, chrome_config()))
                    .await
                    .expect("failed to launch identity session"),
            )
        };

        d.browser_mut()
            .add_init_script(InitScript { page: Some(NETWORK_TAP.into()), worker: None })
            .await;

        d.browser_mut()
            .navigate(url)
            .await
            .unwrap_or_else(|e| panic!("navigate to {url} failed: {e:?}"));

        d
    }

    fn browser_mut(&mut self) -> &mut rustenium::browsers::chrome::browser::ChromeBrowser {
        match self {
            Detector::Identity(s) => s.browser_mut(),
            Detector::Baseline(b) => b,
        }
    }

    /// Evaluate a synchronous `expr` and return whatever it produced, as JSON.
    ///
    /// The expression should return a string; every scrape here builds one with
    /// `JSON.stringify` so the shape survives the CDP boundary unambiguously.
    pub async fn eval(&mut self, expr: &str) -> serde_json::Value {
        self.evaluate(expr, false).await
    }

    /// As `eval`, for an expression that returns a promise.
    ///
    /// Without `awaitPromise` the result is the pending promise itself, which
    /// arrives as a null value — a silent empty report rather than an error.
    pub async fn eval_async(&mut self, expr: &str) -> serde_json::Value {
        self.evaluate(expr, true).await
    }

    async fn evaluate(&mut self, expr: &str, await_promise: bool) -> serde_json::Value {
        let raw = self
            .browser_mut()
            .evaluate_script(expr, await_promise)
            .await
            .map(|v| v.result.value.and_then(|x| x.as_str().map(str::to_string)))
            .unwrap_or(None)
            .unwrap_or_default();
        serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null)
    }

    /// Poll `expr` (which must return the string `"yes"`) until it is ready.
    ///
    /// Returns false on timeout rather than panicking: a detector that never
    /// settles is itself a result worth printing, and the caller can dump what
    /// it managed to render.
    pub async fn wait_until(&mut self, expr: &str, timeout: Duration) -> bool {
        let deadline = std::time::Instant::now() + timeout;
        while std::time::Instant::now() < deadline {
            tokio::time::sleep(Duration::from_secs(2)).await;
            if let Ok(v) = self.browser_mut().evaluate_script(expr, false).await
                && v.result.value.as_ref().and_then(|x| x.as_str()) == Some("yes")
            {
                return true;
            }
            print!(".");
        }
        println!();
        false
    }

    /// Every request the page made, with bodies. This is where the verdict lives
    /// for the detectors that score server-side.
    pub async fn traffic(&mut self) -> serde_json::Value {
        self.eval(
            r#"JSON.stringify((window.__net || []).map(n => ({
                url: n.url, status: n.status,
                req: n.req ? n.req.slice(0, 4000) : null,
                res: n.res ? n.res.slice(0, 4000) : null,
            })))"#,
        )
        .await
    }

    /// The identity that was applied, so a test can assert the page reports it
    /// rather than only that nothing was flagged.
    pub fn identity(&self) -> &rustenium_identity::Identity {
        match self {
            Detector::Identity(s) => s.identity(),
            Detector::Baseline(_) => panic!("no identity was applied in this run"),
        }
    }

    pub async fn close(self) {
        match self {
            Detector::Identity(s) => {
                s.close().await;
            }
            Detector::Baseline(b) => {
                use rustenium::browsers::BidiBrowser;
                let _ = b.close().await;
            }
        }
    }
}

/// Print a JSON value indented, under a heading.
pub fn dump(heading: &str, value: &serde_json::Value) {
    println!("\n--- {heading} ---");
    println!("{}", serde_json::to_string_pretty(value).unwrap_or_default());
}