use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use super::cdp_overrides::{build_cdp_emulation_commands, CdpCommand};
use super::init_script::{build_fingerprint_init_script, InitScriptOptions};
use super::profile::{resolve_fingerprint_profile, FingerprintProfile};
#[async_trait]
pub trait CdpTransport {
async fn send(&self, method: &str, params: Value) -> Result<Value>;
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ApplyOptions {
pub patch_webdriver: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppliedFingerprint {
pub profile: FingerprintProfile,
pub commands: Vec<CdpCommand>,
pub init_script: Option<String>,
}
pub async fn apply_fingerprint(
transport: &(impl CdpTransport + ?Sized),
profile: &FingerprintProfile,
options: ApplyOptions,
) -> Result<AppliedFingerprint> {
let profile = resolve_fingerprint_profile(profile)?;
let commands = build_cdp_emulation_commands(&profile);
let init_script = build_fingerprint_init_script(
&profile,
InitScriptOptions {
patch_webdriver: options.patch_webdriver,
patch_languages: false,
},
);
for command in &commands {
transport
.send(command.method, command.params.clone())
.await?;
}
if let Some(ref script) = init_script {
transport.send("Page.enable", json!({})).await?;
transport
.send(
"Page.addScriptToEvaluateOnNewDocument",
json!({ "source": script }),
)
.await?;
transport
.send(
"Runtime.evaluate",
json!({ "expression": script, "returnByValue": true }),
)
.await?;
}
Ok(AppliedFingerprint {
profile,
commands,
init_script,
})
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
use crate::fingerprint::presets::create_default_fingerprint_preset;
#[derive(Default)]
struct RecordingTransport {
sent: Mutex<Vec<(String, Value)>>,
fail_on: Option<&'static str>,
}
impl RecordingTransport {
fn methods(&self) -> Vec<String> {
self.sent
.lock()
.expect("lock")
.iter()
.map(|(method, _)| method.clone())
.collect()
}
fn params(&self, method: &str) -> Value {
self.sent
.lock()
.expect("lock")
.iter()
.find(|(sent, _)| sent == method)
.map(|(_, params)| params.clone())
.unwrap_or_else(|| panic!("{method} was not sent"))
}
}
#[async_trait]
impl CdpTransport for RecordingTransport {
async fn send(&self, method: &str, params: Value) -> Result<Value> {
if self.fail_on == Some(method) {
anyhow::bail!("Target closed");
}
self.sent
.lock()
.expect("lock")
.push((method.to_string(), params));
Ok(json!({}))
}
}
fn preset() -> FingerprintProfile {
create_default_fingerprint_preset("windows-chrome").expect("preset")
}
#[tokio::test]
async fn sends_the_emulation_commands_before_installing_the_init_script() {
let transport = RecordingTransport::default();
apply_fingerprint(&transport, &preset(), ApplyOptions::default())
.await
.expect("apply");
let methods = transport.methods();
let first_script_index = methods
.iter()
.position(|method| method == "Page.enable")
.expect("Page.enable was not sent");
assert!(first_script_index > 0);
assert!(methods[..first_script_index]
.iter()
.all(|method| method.starts_with("Emulation.")));
}
#[tokio::test]
async fn enables_the_page_domain_then_patches_the_open_document() {
let transport = RecordingTransport::default();
let applied = apply_fingerprint(
&transport,
&preset(),
ApplyOptions {
patch_webdriver: true,
},
)
.await
.expect("apply");
let script_methods: Vec<String> = transport
.methods()
.into_iter()
.filter(|method| !method.starts_with("Emulation."))
.collect();
assert_eq!(
script_methods,
vec![
"Page.enable",
"Page.addScriptToEvaluateOnNewDocument",
"Runtime.evaluate",
]
);
let script = applied.init_script.expect("init script");
assert_eq!(
transport.params("Page.addScriptToEvaluateOnNewDocument"),
json!({ "source": script })
);
assert_eq!(
transport.params("Runtime.evaluate"),
json!({ "expression": script, "returnByValue": true })
);
}
#[tokio::test]
async fn reports_the_commands_it_sent() {
let transport = RecordingTransport::default();
let profile: FingerprintProfile =
serde_json::from_value(json!({ "timezoneId": "Europe/Berlin", "deviceMemory": 8 }))
.expect("profile");
let applied = apply_fingerprint(&transport, &profile, ApplyOptions::default())
.await
.expect("apply");
assert_eq!(
applied.commands,
build_cdp_emulation_commands(&profile),
"the report has to be what was sent"
);
assert_eq!(
applied.profile.timezone_id.as_deref(),
Some("Europe/Berlin")
);
assert_eq!(transport.methods()[0], "Emulation.setTimezoneOverride");
assert!(applied
.init_script
.expect("script")
.contains("deviceMemory"));
}
#[tokio::test]
async fn skips_the_script_commands_when_the_browser_covers_everything() {
let transport = RecordingTransport::default();
let profile: FingerprintProfile =
serde_json::from_value(json!({ "timezoneId": "UTC" })).expect("profile");
let applied = apply_fingerprint(&transport, &profile, ApplyOptions::default())
.await
.expect("apply");
assert_eq!(applied.init_script, None);
assert_eq!(transport.methods(), vec!["Emulation.setTimezoneOverride"]);
}
#[tokio::test]
async fn sends_nothing_for_a_profile_that_describes_nothing() {
let transport = RecordingTransport::default();
let profile = FingerprintProfile::default();
let applied = apply_fingerprint(&transport, &profile, ApplyOptions::default())
.await
.expect("apply");
assert_eq!(applied.commands, Vec::new());
assert_eq!(applied.init_script, None);
assert!(transport.methods().is_empty());
}
#[tokio::test]
async fn refuses_a_profile_that_does_not_describe_a_real_machine() {
let transport = RecordingTransport::default();
let profile: FingerprintProfile =
serde_json::from_value(json!({ "hardwareConcurrency": 0 })).expect("profile");
let error = apply_fingerprint(&transport, &profile, ApplyOptions::default())
.await
.expect_err("apply must fail");
assert!(error.to_string().contains("hardwareConcurrency"));
assert!(transport.methods().is_empty());
}
#[tokio::test]
async fn stops_at_the_first_command_the_page_rejects() {
let transport = RecordingTransport {
fail_on: Some("Emulation.setTimezoneOverride"),
..Default::default()
};
let error = apply_fingerprint(&transport, &preset(), ApplyOptions::default())
.await
.expect_err("apply must fail");
assert!(error.to_string().contains("Target closed"));
assert!(!transport.methods().contains(&"Page.enable".to_string()));
}
}