1use std::sync::Mutex;
5
6use jsdet_core::bridge::Bridge;
7use jsdet_core::observation::Value;
8
9use crate::canvas;
10use crate::css::ComputedStyle;
11use crate::dom::{Dom, NodeId};
12use crate::navigator;
13use crate::network::{self, ResponseRegistry};
14use crate::persona::Persona;
15use crate::storage::{CookieJar, WebStorage};
16use crate::window::{History, Location};
17
18pub struct BrowserBridge {
20 dom: Mutex<Dom>,
22 location: Mutex<Location>,
24 #[allow(dead_code)]
26 history: Mutex<History>,
27 persona: Persona,
29 local_storage: WebStorage,
31 session_storage: WebStorage,
33 cookies: CookieJar,
35 responses: ResponseRegistry,
37 redirect_targets: Mutex<Vec<String>>,
39 popup_urls: Mutex<Vec<String>>,
41}
42
43impl BrowserBridge {
44 pub fn new(url: &str, html: &str, persona: Persona) -> Self {
46 Self {
47 dom: Mutex::new(Dom::parse(html)),
48 location: Mutex::new(Location::from_url(url)),
49 history: Mutex::new(History::new(url)),
50 persona,
51 local_storage: WebStorage::new(5000),
52 session_storage: WebStorage::new(5000),
53 cookies: CookieJar::new(),
54 responses: ResponseRegistry::new(),
55 redirect_targets: Mutex::new(Vec::new()),
56 popup_urls: Mutex::new(Vec::new()),
57 }
58 }
59
60 pub fn add_response(&mut self, url: &str, response: network::FakeResponse) {
62 self.responses.add_exact(url, response);
63 }
64
65 pub fn add_cookie(&self, name: &str, value: &str, domain: &str) {
67 self.cookies.add(name, value, domain);
68 }
69
70 pub fn redirect_targets(&self) -> Vec<String> {
72 self.redirect_targets
73 .lock()
74 .map(|v| v.clone())
75 .unwrap_or_default()
76 }
77
78 pub fn popup_urls(&self) -> Vec<String> {
80 self.popup_urls
81 .lock()
82 .map(|v| v.clone())
83 .unwrap_or_default()
84 }
85
86 fn handle_document(&self, method: &str, args: &[Value]) -> Result<Value, String> {
87 match method {
88 "createElement" => {
89 let tag = args.first().and_then(|v| v.as_str()).unwrap_or("div");
90 let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
91 let id = dom.create_element(tag);
92 Ok(Value::Int(id.0 as i64))
93 }
94 "getElementById" => {
95 let id = args.first().and_then(|v| v.as_str()).unwrap_or("");
96 let dom = self.dom.lock().map_err(|e| e.to_string())?;
97 match dom.get_element_by_id(id) {
98 Some(node_id) => Ok(Value::Int(node_id.0 as i64)),
99 None => Ok(Value::Null),
100 }
101 }
102 "querySelector" => {
103 let selector = args.first().and_then(|v| v.as_str()).unwrap_or("");
104 let dom = self.dom.lock().map_err(|e| e.to_string())?;
105 match dom.query_selector(selector) {
106 Some(node_id) => Ok(Value::Int(node_id.0 as i64)),
107 None => Ok(Value::Null),
108 }
109 }
110 "querySelectorAll" => {
111 let selector = args.first().and_then(|v| v.as_str()).unwrap_or("");
112 let dom = self.dom.lock().map_err(|e| e.to_string())?;
113 let results = dom.query_selector_all(selector);
114 let ids: Vec<i64> = results.iter().map(|n| n.0 as i64).collect();
115 Ok(Value::json(serde_json::to_string(&ids).unwrap_or_default()))
116 }
117 "write" | "writeln" => {
118 Ok(Value::Undefined)
122 }
123 _ => Err(format!("document.{method} is not defined")),
124 }
125 }
126
127 fn handle_element(&self, method: &str, args: &[Value]) -> Result<Value, String> {
128 let node_id = args
129 .first()
130 .and_then(|v| match v {
131 Value::Int(n) => Some(NodeId(*n as u32)),
132 _ => None,
133 })
134 .ok_or("missing node ID")?;
135
136 match method {
137 "getAttribute" => {
138 let name = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
139 let dom = self.dom.lock().map_err(|e| e.to_string())?;
140 match dom.get_attribute(node_id, name) {
141 Some(v) => Ok(Value::string(v.to_string())),
142 None => Ok(Value::Null),
143 }
144 }
145 "setAttribute" => {
146 let name = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
147 let value = args.get(2).and_then(|v| v.as_str()).unwrap_or("");
148 let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
149 dom.set_attribute(node_id, name, value);
150 Ok(Value::Undefined)
151 }
152 "removeAttribute" => {
153 let name = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
154 let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
155 dom.remove_attribute(node_id, name);
156 Ok(Value::Undefined)
157 }
158 "appendChild" => {
159 let child_id = args
160 .get(1)
161 .and_then(|v| match v {
162 Value::Int(n) => Some(NodeId(*n as u32)),
163 _ => None,
164 })
165 .ok_or("missing child ID")?;
166 let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
167 dom.append_child(node_id, child_id);
168 Ok(Value::Int(child_id.0 as i64))
169 }
170 "innerHTML" => {
171 let dom = self.dom.lock().map_err(|e| e.to_string())?;
172 Ok(Value::string(dom.inner_html(node_id)))
173 }
174 "setInnerHTML" => {
175 let html = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
176 let mut dom = self.dom.lock().map_err(|e| e.to_string())?;
177 dom.set_inner_html(node_id, html);
178 Ok(Value::Undefined)
179 }
180 _ => Err(format!("element.{method} is not defined")),
181 }
182 }
183
184 fn handle_window(&self, method: &str, args: &[Value]) -> Result<Value, String> {
185 match method {
186 "open" => {
187 let url = args
188 .first()
189 .and_then(|v| v.as_str())
190 .unwrap_or("")
191 .to_string();
192 if !url.is_empty()
193 && let Ok(mut urls) = self.popup_urls.lock()
194 {
195 urls.push(url);
196 }
197 Ok(Value::Null) }
199 "alert" | "confirm" | "prompt" => Ok(Value::Undefined),
200 "postMessage" => Ok(Value::Undefined),
201 _ => Err(format!("window.{method} is not defined")),
202 }
203 }
204
205 fn handle_fetch(&self, args: &[Value]) -> Result<Value, String> {
206 let url = args.first().and_then(|v| v.as_str()).unwrap_or("");
207 let method = args.get(1).and_then(|v| v.as_str()).unwrap_or("GET");
208 Ok(network::handle_fetch(url, method, &self.responses))
209 }
210
211 fn handle_storage(&self, area: &str, method: &str, args: &[Value]) -> Result<Value, String> {
212 let storage = match area {
213 "localStorage" => &self.local_storage,
214 "sessionStorage" => &self.session_storage,
215 _ => return Err(format!("{area} is not defined")),
216 };
217
218 match method {
219 "getItem" => {
220 let key = args.first().and_then(|v| v.as_str()).unwrap_or("");
221 match storage.get_item(key) {
222 Some(v) => Ok(Value::string(v)),
223 None => Ok(Value::Null),
224 }
225 }
226 "setItem" => {
227 let key = args.first().and_then(|v| v.as_str()).unwrap_or("");
228 let value = args.get(1).and_then(|v| v.as_str()).unwrap_or("");
229 storage.set_item(key, value)?;
230 Ok(Value::Undefined)
231 }
232 "removeItem" => {
233 let key = args.first().and_then(|v| v.as_str()).unwrap_or("");
234 storage.remove_item(key);
235 Ok(Value::Undefined)
236 }
237 "clear" => {
238 storage.clear();
239 Ok(Value::Undefined)
240 }
241 "length" => Ok(Value::Int(storage.length() as i64)),
242 "key" => {
243 let index = args
244 .first()
245 .and_then(|v| match v {
246 Value::Int(n) => Some(*n as usize),
247 _ => None,
248 })
249 .unwrap_or(0);
250 match storage.key(index) {
251 Some(k) => Ok(Value::string(k)),
252 None => Ok(Value::Null),
253 }
254 }
255 _ => Err(format!("{area}.{method} is not defined")),
256 }
257 }
258}
259
260impl Bridge for BrowserBridge {
261 fn call(&self, api: &str, args: &[Value]) -> Result<Value, String> {
262 let parts: Vec<&str> = api.splitn(3, '.').collect();
263
264 match parts.as_slice() {
265 ["document", method] => self.handle_document(method, args),
266 ["element", method] => self.handle_element(method, args),
267 ["window", method] => self.handle_window(method, args),
268 ["fetch"] => self.handle_fetch(args),
269 ["localStorage", method] => self.handle_storage("localStorage", method, args),
270 ["sessionStorage", method] => self.handle_storage("sessionStorage", method, args),
271 ["getComputedStyle"] => {
272 let node_id = args
273 .first()
274 .and_then(|v| match v {
275 Value::Int(n) => Some(NodeId(*n as u32)),
276 _ => None,
277 })
278 .unwrap_or(NodeId::ROOT);
279 let dom = self.dom.lock().map_err(|e| e.to_string())?;
280 let style = ComputedStyle::for_node(&dom, node_id);
281 Ok(Value::json(style.to_json()))
282 }
283 ["canvas", "toDataURL"] => Ok(Value::string(canvas::handle_to_data_url(
284 &self.persona.user_agent,
285 ))),
286 ["webgl", "getParameter"] => {
287 let param = args.first().and_then(|v| v.as_str()).unwrap_or("");
288 match canvas::handle_webgl_parameter(&self.persona.user_agent, param) {
289 Some(v) => Ok(Value::string(v)),
290 None => Ok(Value::Null),
291 }
292 }
293 ["probe_html_forms"] | ["probe_static_forms"] => {
298 let dom = self.dom.lock().map_err(|e| e.to_string())?;
299 let password_inputs = dom.extract_password_inputs();
300
301 if !password_inputs.is_empty() {
302 let form_actions = dom.extract_form_actions();
303 let action = form_actions.first().cloned().unwrap_or_default();
304 let field_count = password_inputs.len();
305 return Ok(Value::string(format!(
308 "CREDENTIAL_FORM:action={};fields=password({})",
309 action, field_count
310 )));
311 }
312 Ok(Value::Null)
313 }
314 _ => Err(format!("{api} is not defined")),
315 }
316 }
317
318 fn get_property(&self, object: &str, property: &str) -> Result<Value, String> {
319 match object {
320 "navigator" => Ok(navigator::get_navigator_property(&self.persona, property)),
321 "location" | "window.location" => {
322 let loc = self.location.lock().map_err(|e| e.to_string())?;
323 Ok(loc.get_property(property))
324 }
325 "document" => match property {
326 "cookie" => Ok(Value::string(self.cookies.to_cookie_string())),
327 "referrer" => Ok(Value::string(self.persona.referrer.clone())),
328 "title" => Ok(Value::string(String::new())),
329 "readyState" => Ok(Value::string("complete")),
330 "domain" => {
331 let loc = self.location.lock().map_err(|e| e.to_string())?;
332 Ok(Value::string(loc.hostname.clone()))
333 }
334 _ => Err(format!("document.{property} is not defined")),
335 },
336 "screen" => match property {
337 "width" => Ok(Value::Int(self.persona.screen_width as i64)),
338 "height" => Ok(Value::Int(self.persona.screen_height as i64)),
339 "colorDepth" | "pixelDepth" => Ok(Value::Int(self.persona.color_depth as i64)),
340 "availWidth" => Ok(Value::Int(self.persona.screen_width as i64)),
341 "availHeight" => Ok(Value::Int((self.persona.screen_height - 40) as i64)),
342 _ => Err(format!("screen.{property} is not defined")),
343 },
344 _ => Err(format!("{object}.{property} is not defined")),
345 }
346 }
347
348 fn set_property(&self, object: &str, property: &str, value: &Value) -> Result<(), String> {
349 match (object, property) {
350 ("document", "cookie") => {
351 if let Some(cookie_str) = value.as_str() {
352 self.cookies.set_from_string(cookie_str);
353 }
354 Ok(())
355 }
356 ("location" | "window.location", "href") => {
357 if let Some(url) = value.as_str() {
358 if let Ok(mut targets) = self.redirect_targets.lock() {
359 targets.push(url.to_string());
360 }
361 if let Ok(mut loc) = self.location.lock() {
362 *loc = Location::from_url(url);
363 }
364 }
365 Ok(())
366 }
367 ("document", "title") => Ok(()),
368 _ => Ok(()),
369 }
370 }
371
372 fn provided_globals(&self) -> Vec<String> {
373 vec![
374 "document".into(),
375 "window".into(),
376 "navigator".into(),
377 "location".into(),
378 "screen".into(),
379 "localStorage".into(),
380 "sessionStorage".into(),
381 "fetch".into(),
382 "XMLHttpRequest".into(),
383 "getComputedStyle".into(),
384 ]
385 }
386
387 fn bootstrap_js(&self) -> String {
388 static TEMPLATE: &str = include_str!("../js/bootstrap.js");
391
392 let loc = self.location.lock().map(|l| l.clone()).unwrap_or_default();
393 TEMPLATE
394 .replace("{{JSDET_URL}}", &loc.href)
395 .replace("{{JSDET_PROTOCOL}}", &loc.protocol)
396 .replace("{{JSDET_HOSTNAME}}", &loc.hostname)
397 .replace("{{JSDET_PATHNAME}}", &loc.pathname)
398 .replace("{{JSDET_SEARCH}}", &loc.search)
399 .replace("{{JSDET_HASH}}", &loc.hash)
400 .replace("{{JSDET_ORIGIN}}", &loc.origin)
401 .replace("{{JSDET_REFERRER}}", &self.persona.referrer)
402 .replace("{{JSDET_UA}}", &self.persona.user_agent)
403 .replace("{{JSDET_PLATFORM}}", &self.persona.platform)
404 .replace("{{JSDET_LANGUAGE}}", &self.persona.language)
405 .replace(
406 "{{JSDET_LANGUAGES_JSON}}",
407 &serde_json::to_string(&self.persona.languages).unwrap_or_default(),
408 )
409 .replace(
410 "{{JSDET_HW_CONCURRENCY}}",
411 &self.persona.hardware_concurrency.to_string(),
412 )
413 .replace(
414 "{{JSDET_DEVICE_MEMORY}}",
415 &self.persona.device_memory.to_string(),
416 )
417 .replace(
418 "{{JSDET_MAX_TOUCH}}",
419 &self.persona.max_touch_points.to_string(),
420 )
421 .replace("{{JSDET_VENDOR}}", &self.persona.vendor)
422 .replace("{{JSDET_APP_NAME}}", &self.persona.app_name)
423 .replace("{{JSDET_APP_VERSION}}", &self.persona.app_version)
424 .replace("{{JSDET_SCREEN_W}}", &self.persona.screen_width.to_string())
425 .replace(
426 "{{JSDET_SCREEN_H_TASKBAR}}",
427 &self.persona.screen_height.saturating_sub(40).to_string(),
428 )
429 .replace(
430 "{{JSDET_SCREEN_H}}",
431 &self.persona.screen_height.to_string(),
432 )
433 .replace(
434 "{{JSDET_COLOR_DEPTH}}",
435 &self.persona.color_depth.to_string(),
436 )
437 }
438}
439
440#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn bridge_provides_navigator() {
454 let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
455 let ua = bridge.get_property("navigator", "userAgent").unwrap();
456 assert!(matches!(ua, Value::String(s, _) if s.contains("Chrome")));
457 }
458
459 #[test]
460 fn bridge_provides_location() {
461 let bridge = BrowserBridge::new(
462 "https://example.com/path?q=1",
463 "<html></html>",
464 Persona::default(),
465 );
466 let host = bridge.get_property("location", "hostname").unwrap();
467 assert_eq!(host, Value::string("example.com"));
468 }
469
470 #[test]
471 fn bridge_creates_elements() {
472 let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
473 let id = bridge.call("document.createElement", &[Value::string("div")]);
474 assert!(matches!(id, Ok(Value::Int(_))));
475 }
476
477 #[test]
478 fn bridge_handles_cookies() {
479 let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
480 bridge
481 .set_property("document", "cookie", &Value::string("test=hello"))
482 .unwrap();
483 let cookies = bridge.get_property("document", "cookie").unwrap();
484 assert!(matches!(cookies, Value::String(s, _) if s.contains("test=hello")));
485 }
486
487 #[test]
488 fn bridge_handles_storage() {
489 let bridge = BrowserBridge::new("https://example.com", "<html></html>", Persona::default());
490 bridge
491 .call(
492 "localStorage.setItem",
493 &[Value::string("key"), Value::string("val")],
494 )
495 .unwrap();
496 let result = bridge
497 .call("localStorage.getItem", &[Value::string("key")])
498 .unwrap();
499 assert_eq!(result, Value::string("val"));
500 }
501}