browser_commander/fingerprint/
apply.rs1use 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#[async_trait]
28pub trait CdpTransport {
29 async fn send(&self, method: &str, params: Value) -> Result<Value>;
31}
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct ApplyOptions {
36 pub patch_webdriver: bool,
43}
44
45#[derive(Debug, Clone, PartialEq)]
47pub struct AppliedFingerprint {
48 pub profile: FingerprintProfile,
50 pub commands: Vec<CdpCommand>,
52 pub init_script: Option<String>,
54}
55
56pub async fn apply_fingerprint(
58 transport: &(impl CdpTransport + ?Sized),
59 profile: &FingerprintProfile,
60 options: ApplyOptions,
61) -> Result<AppliedFingerprint> {
62 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 transport.send("Page.enable", json!({})).await?;
86 transport
87 .send(
88 "Page.addScriptToEvaluateOnNewDocument",
89 json!({ "source": script }),
90 )
91 .await?;
92 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 #[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 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 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}