Skip to main content

browser_commander/fingerprint/
apply.rs

1//! Apply a fingerprint profile to a live page.
2//!
3//! Everything goes through CDP, including the init script, so a page controlled
4//! from Rust sees exactly what the JavaScript and Python implementations
5//! present. The transport is a trait rather than a concrete page type: this
6//! module stays free of any engine, and the recording transport in the tests
7//! below checks the exact command sequence without a browser.
8//!
9//! Unlike the JavaScript implementation there is no "apply to pages opened
10//! later" option, because chromiumoxide has no page-created event to hang it
11//! on. A page opened later has to be given the profile explicitly; the
12//! `Target.setAutoAttach` route is noted in the case study as the way to close
13//! that gap.
14
15use anyhow::Result;
16use async_trait::async_trait;
17use serde_json::{json, Value};
18
19use super::cdp_overrides::{build_cdp_emulation_commands, CdpCommand};
20use super::init_script::{build_fingerprint_init_script, InitScriptOptions};
21use super::profile::{resolve_fingerprint_profile, FingerprintProfile};
22
23/// Anything that can carry a CDP command to a page.
24///
25/// [`ChromiumoxidePage`](crate::browser::ChromiumoxidePage) implements this;
26/// so does any test double that records what it was asked to send.
27#[async_trait]
28pub trait CdpTransport {
29    /// Send one CDP command and return its result.
30    async fn send(&self, method: &str, params: Value) -> Result<Value>;
31}
32
33/// How much the page script has to do on top of the browser-side overrides.
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct ApplyOptions {
36    /// Force `navigator.webdriver` to `false` from JavaScript.
37    ///
38    /// Only needed when attaching to a browser somebody else launched with
39    /// automation switches that can no longer be changed; a browser launched by
40    /// this library does not need it, because
41    /// `--disable-blink-features=AutomationControlled` already covers it.
42    pub patch_webdriver: bool,
43}
44
45/// What [`apply_fingerprint`] sent.
46#[derive(Debug, Clone, PartialEq)]
47pub struct AppliedFingerprint {
48    /// The resolved profile the page is now presenting.
49    pub profile: FingerprintProfile,
50    /// The commands that were sent, in the order they were sent.
51    pub commands: Vec<CdpCommand>,
52    /// The init script that was injected, if any was needed.
53    pub init_script: Option<String>,
54}
55
56/// Apply a fingerprint profile to the page behind a transport.
57pub async fn apply_fingerprint(
58    transport: &(impl CdpTransport + ?Sized),
59    profile: &FingerprintProfile,
60    options: ApplyOptions,
61) -> Result<AppliedFingerprint> {
62    // Resolving is idempotent -- every derived field is also an accepted input
63    // field -- so an already-resolved profile can be passed straight back in.
64    let profile = resolve_fingerprint_profile(profile)?;
65    let commands = build_cdp_emulation_commands(&profile);
66    let init_script = build_fingerprint_init_script(
67        &profile,
68        InitScriptOptions {
69            patch_webdriver: options.patch_webdriver,
70            patch_languages: false,
71        },
72    );
73
74    for command in &commands {
75        transport
76            .send(command.method, command.params.clone())
77            .await?;
78    }
79
80    if let Some(ref script) = init_script {
81        // Measured: without Page.enable on this session, Chrome accepts
82        // addScriptToEvaluateOnNewDocument and returns an identifier, but never
83        // runs the script on any subsequent document. Enabling the domain is
84        // what makes the instrumentation take effect.
85        transport.send("Page.enable", json!({})).await?;
86        transport
87            .send(
88                "Page.addScriptToEvaluateOnNewDocument",
89                json!({ "source": script }),
90            )
91            .await?;
92        // A page that has already navigated will not replay the init script, so
93        // patch the current document too. The payload guards against running
94        // twice, which makes this safe on a brand new about:blank as well.
95        transport
96            .send(
97                "Runtime.evaluate",
98                json!({ "expression": script, "returnByValue": true }),
99            )
100            .await?;
101    }
102
103    Ok(AppliedFingerprint {
104        profile,
105        commands,
106        init_script,
107    })
108}
109
110#[cfg(test)]
111mod tests {
112    use std::sync::Mutex;
113
114    use super::*;
115    use crate::fingerprint::presets::create_default_fingerprint_preset;
116
117    /// A transport that records what was sent instead of talking to Chrome.
118    #[derive(Default)]
119    struct RecordingTransport {
120        sent: Mutex<Vec<(String, Value)>>,
121        fail_on: Option<&'static str>,
122    }
123
124    impl RecordingTransport {
125        fn methods(&self) -> Vec<String> {
126            self.sent
127                .lock()
128                .expect("lock")
129                .iter()
130                .map(|(method, _)| method.clone())
131                .collect()
132        }
133
134        fn params(&self, method: &str) -> Value {
135            self.sent
136                .lock()
137                .expect("lock")
138                .iter()
139                .find(|(sent, _)| sent == method)
140                .map(|(_, params)| params.clone())
141                .unwrap_or_else(|| panic!("{method} was not sent"))
142        }
143    }
144
145    #[async_trait]
146    impl CdpTransport for RecordingTransport {
147        async fn send(&self, method: &str, params: Value) -> Result<Value> {
148            if self.fail_on == Some(method) {
149                anyhow::bail!("Target closed");
150            }
151            self.sent
152                .lock()
153                .expect("lock")
154                .push((method.to_string(), params));
155            Ok(json!({}))
156        }
157    }
158
159    fn preset() -> FingerprintProfile {
160        create_default_fingerprint_preset("windows-chrome").expect("preset")
161    }
162
163    #[tokio::test]
164    async fn sends_the_emulation_commands_before_installing_the_init_script() {
165        let transport = RecordingTransport::default();
166
167        apply_fingerprint(&transport, &preset(), ApplyOptions::default())
168            .await
169            .expect("apply");
170
171        let methods = transport.methods();
172        let first_script_index = methods
173            .iter()
174            .position(|method| method == "Page.enable")
175            .expect("Page.enable was not sent");
176        assert!(first_script_index > 0);
177        assert!(methods[..first_script_index]
178            .iter()
179            .all(|method| method.starts_with("Emulation.")));
180    }
181
182    #[tokio::test]
183    async fn enables_the_page_domain_then_patches_the_open_document() {
184        let transport = RecordingTransport::default();
185
186        let applied = apply_fingerprint(
187            &transport,
188            &preset(),
189            ApplyOptions {
190                patch_webdriver: true,
191            },
192        )
193        .await
194        .expect("apply");
195
196        let script_methods: Vec<String> = transport
197            .methods()
198            .into_iter()
199            .filter(|method| !method.starts_with("Emulation."))
200            .collect();
201        assert_eq!(
202            script_methods,
203            vec![
204                "Page.enable",
205                "Page.addScriptToEvaluateOnNewDocument",
206                "Runtime.evaluate",
207            ]
208        );
209        let script = applied.init_script.expect("init script");
210        assert_eq!(
211            transport.params("Page.addScriptToEvaluateOnNewDocument"),
212            json!({ "source": script })
213        );
214        assert_eq!(
215            transport.params("Runtime.evaluate"),
216            json!({ "expression": script, "returnByValue": true })
217        );
218    }
219
220    #[tokio::test]
221    async fn reports_the_commands_it_sent() {
222        let transport = RecordingTransport::default();
223        let profile: FingerprintProfile =
224            serde_json::from_value(json!({ "timezoneId": "Europe/Berlin", "deviceMemory": 8 }))
225                .expect("profile");
226
227        let applied = apply_fingerprint(&transport, &profile, ApplyOptions::default())
228            .await
229            .expect("apply");
230
231        assert_eq!(
232            applied.commands,
233            build_cdp_emulation_commands(&profile),
234            "the report has to be what was sent"
235        );
236        assert_eq!(
237            applied.profile.timezone_id.as_deref(),
238            Some("Europe/Berlin")
239        );
240        assert_eq!(transport.methods()[0], "Emulation.setTimezoneOverride");
241        assert!(applied
242            .init_script
243            .expect("script")
244            .contains("deviceMemory"));
245    }
246
247    #[tokio::test]
248    async fn skips_the_script_commands_when_the_browser_covers_everything() {
249        let transport = RecordingTransport::default();
250        let profile: FingerprintProfile =
251            serde_json::from_value(json!({ "timezoneId": "UTC" })).expect("profile");
252
253        let applied = apply_fingerprint(&transport, &profile, ApplyOptions::default())
254            .await
255            .expect("apply");
256
257        assert_eq!(applied.init_script, None);
258        assert_eq!(transport.methods(), vec!["Emulation.setTimezoneOverride"]);
259    }
260
261    #[tokio::test]
262    async fn sends_nothing_for_a_profile_that_describes_nothing() {
263        let transport = RecordingTransport::default();
264        let profile = FingerprintProfile::default();
265
266        let applied = apply_fingerprint(&transport, &profile, ApplyOptions::default())
267            .await
268            .expect("apply");
269
270        assert_eq!(applied.commands, Vec::new());
271        assert_eq!(applied.init_script, None);
272        assert!(transport.methods().is_empty());
273    }
274
275    #[tokio::test]
276    async fn refuses_a_profile_that_does_not_describe_a_real_machine() {
277        // Sending a broken profile would leave the page half-overridden, so the
278        // validation the profile module owns has to run before the first send.
279        let transport = RecordingTransport::default();
280        let profile: FingerprintProfile =
281            serde_json::from_value(json!({ "hardwareConcurrency": 0 })).expect("profile");
282
283        let error = apply_fingerprint(&transport, &profile, ApplyOptions::default())
284            .await
285            .expect_err("apply must fail");
286
287        assert!(error.to_string().contains("hardwareConcurrency"));
288        assert!(transport.methods().is_empty());
289    }
290
291    #[tokio::test]
292    async fn stops_at_the_first_command_the_page_rejects() {
293        // A half-applied profile is worse than a failed one: the page would
294        // report a machine that does not exist, so the error has to surface.
295        let transport = RecordingTransport {
296            fail_on: Some("Emulation.setTimezoneOverride"),
297            ..Default::default()
298        };
299
300        let error = apply_fingerprint(&transport, &preset(), ApplyOptions::default())
301            .await
302            .expect_err("apply must fail");
303
304        assert!(error.to_string().contains("Target closed"));
305        assert!(!transport.methods().contains(&"Page.enable".to_string()));
306    }
307}