stygian_browser/coherence/
probes.rs1use serde::{Deserialize, Serialize};
36
37use crate::error::{BrowserError, Result};
38use crate::page::PageHandle;
39
40use super::report::{ContextObservation, IdentitySurface};
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
60struct ProbeOutput {
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 skipped: Option<String>,
64 #[serde(default, flatten)]
67 surface: IdentitySurface,
68}
69
70impl ProbeOutput {
71 fn into_observation(self) -> ContextObservation {
72 match self.skipped {
73 Some(reason) => ContextObservation::skipped(reason),
74 None => ContextObservation::observed(self.surface),
75 }
76 }
77}
78
79const TOP_LEVEL_PROBE: &str = concat!(
82 "(function(){",
83 "var r={};",
84 "try{r.userAgent=String(navigator.userAgent||'');}catch(e){r.userAgent='';}",
85 "try{r.platform=String(navigator.platform||'');}catch(e){r.platform='';}",
86 "try{r.languages=Array.isArray(navigator.languages)?navigator.languages.join(','):'';}catch(e){r.languages='';}",
87 "try{r.hardware_concurrency=typeof navigator.hardwareConcurrency==='number'?navigator.hardwareConcurrency:null;}catch(e){r.hardware_concurrency=null;}",
88 "try{r.device_memory=typeof navigator.deviceMemory==='number'?navigator.deviceMemory:null;}catch(e){r.device_memory=null;}",
89 "try{r.timezone=String(Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(e){r.timezone='';}",
90 "try{r.screen_width=typeof screen==='object'&&screen&&typeof screen.width==='number'?screen.width:null;}catch(e){r.screen_width=null;}",
91 "try{r.screen_height=typeof screen==='object'&&screen&&typeof screen.height==='number'?screen.height:null;}catch(e){r.screen_height=null;}",
92 "try{r.color_depth=typeof screen==='object'&&screen&&typeof screen.colorDepth==='number'?screen.colorDepth:null;}catch(e){r.color_depth=null;}",
93 "try{r.webdriver=typeof navigator.webdriver==='boolean'?navigator.webdriver:null;}catch(e){r.webdriver=null;}",
94 "return JSON.stringify(r);",
95 "})()"
96);
97
98const IFRAME_PROBE: &str = concat!(
99 "(function(){",
100 "try{",
101 "var doc=document;",
102 "var body=doc.body||doc.documentElement;",
103 "if(!body){return JSON.stringify({skipped:'no document body'});}",
104 "var f=doc.createElement('iframe');",
105 "f.setAttribute('aria-hidden','true');",
106 "f.style.cssText='position:absolute;width:0;height:0;border:0;visibility:hidden;';",
107 "f.srcdoc='<!doctype html><html><head></head><body></body></html>';",
108 "body.appendChild(f);",
109 "var w=f.contentWindow;",
110 "if(!w){try{body.removeChild(f);}catch(_e){} return JSON.stringify({skipped:'iframe contentWindow unavailable'});}",
111 "var n=w.navigator;",
112 "if(!n){try{body.removeChild(f);}catch(_e){} return JSON.stringify({skipped:'iframe navigator unavailable'});}",
113 "var r={};",
114 "try{r.user_agent=String(n.userAgent||'');}catch(e){r.user_agent='';}",
115 "try{r.platform=String(n.platform||'');}catch(e){r.platform='';}",
116 "try{r.languages=Array.isArray(n.languages)?n.languages.join(','):'';}catch(e){r.languages='';}",
117 "try{r.hardware_concurrency=typeof n.hardwareConcurrency==='number'?n.hardwareConcurrency:null;}catch(e){r.hardware_concurrency=null;}",
118 "try{r.device_memory=typeof n.deviceMemory==='number'?n.deviceMemory:null;}catch(e){r.device_memory=null;}",
119 "try{r.timezone=String(w.Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(e){r.timezone='';}",
120 "try{r.screen_width=w.screen&&typeof w.screen.width==='number'?w.screen.width:null;}catch(e){r.screen_width=null;}",
121 "try{r.screen_height=w.screen&&typeof w.screen.height==='number'?w.screen.height:null;}catch(e){r.screen_height=null;}",
122 "try{r.color_depth=w.screen&&typeof w.screen.colorDepth==='number'?w.screen.colorDepth:null;}catch(e){r.color_depth=null;}",
123 "try{r.webdriver=typeof n.webdriver==='boolean'?n.webdriver:null;}catch(e){r.webdriver=null;}",
124 "try{body.removeChild(f);}catch(_e){}",
125 "return JSON.stringify(r);",
126 "}catch(e){",
127 "return JSON.stringify({skipped:'iframe probe failed: '+(e&&e.message?e.message:String(e))});",
128 "}",
129 "})()"
130);
131
132const WORKER_PROBE: &str = r#"(function(){
138try{
139if(typeof Worker==='undefined'){return JSON.stringify({skipped:'Worker unsupported'});}
140if(typeof Blob==='undefined'||typeof URL==='undefined'||typeof URL.createObjectURL!=='function'){return JSON.stringify({skipped:'Blob/URL.createObjectURL unsupported'});}
141var src="self.onmessage=function(_e){var r={};try{r.user_agent=String(navigator.userAgent||'');}catch(_err){r.user_agent='';}try{r.platform=String(navigator.platform||'');}catch(_err){r.platform='';}try{r.languages=Array.isArray(navigator.languages)?navigator.languages.join(','):'';}catch(_err){r.languages='';}try{r.hardware_concurrency=typeof navigator.hardwareConcurrency==='number'?navigator.hardwareConcurrency:null;}catch(_err){r.hardware_concurrency=null;}try{r.device_memory=typeof navigator.deviceMemory==='number'?navigator.deviceMemory:null;}catch(_err){r.device_memory=null;}try{r.timezone=String(Intl.DateTimeFormat().resolvedOptions().timeZone||'');}catch(_err){r.timezone='';}try{r.webdriver=typeof navigator.webdriver==='boolean'?navigator.webdriver:null;}catch(_err){r.webdriver=null;}try{self.postMessage(JSON.stringify(r));self.close();}catch(_err){self.postMessage('SKIP:'+(_err&&_err.message?_err.message:String(_err)));self.close();}};";
142var blob;
143try{blob=new Blob([src],{type:'application/javascript'});}catch(e){return JSON.stringify({skipped:'Blob construction failed: '+(e&&e.message?e.message:String(e))});}
144var url;
145try{url=URL.createObjectURL(blob);}catch(e){return JSON.stringify({skipped:'createObjectURL failed: '+(e&&e.message?e.message:String(e))});}
146var worker;
147try{worker=new Worker(url);}catch(e){try{URL.revokeObjectURL(url);}catch(_e){} return JSON.stringify({skipped:'new Worker failed: '+(e&&e.message?e.message:String(e))});}
148var result=null;
149var errorReason='';
150worker.onmessage=function(ev){result=ev&&typeof ev.data==='string'?ev.data:null;};
151worker.onerror=function(ev){errorReason=(ev&&ev.message)?ev.message:'unknown worker error';};
152try{worker.postMessage('probe');}catch(e){try{worker.terminate();}catch(_e){} try{URL.revokeObjectURL(url);}catch(_e){} return JSON.stringify({skipped:'postMessage failed: '+(e&&e.message?e.message:String(e))});}
153var deadline=Date.now()+2000;
154while(result===null&&errorReason===''&&Date.now()<deadline){}
155try{worker.terminate();}catch(_e){}
156try{URL.revokeObjectURL(url);}catch(_e){}
157if(errorReason!==''){return JSON.stringify({skipped:'worker error: '+errorReason});}
158if(result===null){return JSON.stringify({skipped:'worker probe timeout'});}
159if(typeof result==='string'&&result.indexOf('SKIP:')===0){return JSON.stringify({skipped:result.substring(5)});}
160return result;
161}catch(e){
162return JSON.stringify({skipped:'worker probe failed: '+(e&&e.message?e.message:String(e))});
163}
164})()"#;
165
166#[derive(Debug, Clone, Default)]
225pub struct CoherenceProbe {
226 _private: (),
227}
228
229impl CoherenceProbe {
230 #[must_use]
232 pub const fn new() -> Self {
233 Self { _private: () }
234 }
235
236 pub async fn run(&self, page: &PageHandle) -> Result<super::CoherenceDriftReport> {
249 let top = self.probe_top(page).await;
250 let iframe = self.probe_iframe(page).await;
251 let worker = self.probe_worker(page).await;
252 Ok(super::report::build_report(top, iframe, worker, None))
253 }
254
255 pub async fn run_with_freshness(
268 &self,
269 page: &PageHandle,
270 contract: &crate::freshness::FreshnessContract,
271 ) -> Result<super::CoherenceDriftReport> {
272 let top = self.probe_top(page).await;
273 let iframe = self.probe_iframe(page).await;
274 let worker = self.probe_worker(page).await;
275
276 let freshness_report = match &top {
279 ContextObservation::Observed { surface } => {
280 let observed_signature = super::report::surface_signature(surface);
281 let input = crate::freshness::FreshnessCheckInput::new(
282 &contract.domain,
283 Some(observed_signature.as_str()),
284 crate::freshness::unix_epoch_ms(),
285 );
286 let report = crate::freshness::FreshnessReport::evaluate(contract, &input);
287 report.log();
288 Some(report)
289 }
290 ContextObservation::Skipped { .. } => None,
291 };
292
293 Ok(super::report::build_report(
294 top,
295 iframe,
296 worker,
297 freshness_report,
298 ))
299 }
300
301 pub async fn probe_top(&self, page: &PageHandle) -> ContextObservation {
312 run_probe(page, TOP_LEVEL_PROBE, "top-level").await
313 }
314
315 pub async fn probe_iframe(&self, page: &PageHandle) -> ContextObservation {
323 run_probe(page, IFRAME_PROBE, "iframe").await
324 }
325
326 pub async fn probe_worker(&self, page: &PageHandle) -> ContextObservation {
334 run_probe(page, WORKER_PROBE, "worker").await
335 }
336}
337
338async fn run_probe(page: &PageHandle, script: &str, label: &'static str) -> ContextObservation {
341 let json: String = match page.eval(script).await {
342 Ok(s) => s,
343 Err(err) => {
344 tracing::warn!(
345 context = label,
346 error = %err,
347 "coherence probe CDP evaluation failed",
348 );
349 return ContextObservation::skipped(format!("{label} CDP failure: {err}"));
350 }
351 };
352 match serde_json::from_str::<ProbeOutput>(&json) {
353 Ok(out) => out.into_observation(),
354 Err(err) => {
355 tracing::warn!(
356 context = label,
357 error = %err,
358 raw = %json,
359 "coherence probe returned invalid JSON",
360 );
361 ContextObservation::skipped(format!("{label} JSON decode failed: {err}"))
362 }
363 }
364}
365
366#[allow(dead_code)]
370const _: fn() -> Option<BrowserError> = || None;
371
372#[cfg(test)]
375#[allow(
376 clippy::unwrap_used,
377 clippy::expect_used,
378 clippy::panic,
379 clippy::indexing_slicing
380)]
381mod tests {
382 use super::*;
383 use crate::coherence::report::{ContextKind, ContextPair, DriftSeverity, build_report};
384
385 #[test]
386 fn top_level_probe_uses_old_style_function_and_json_stringify() {
387 assert!(TOP_LEVEL_PROBE.starts_with("(function(){"));
389 assert!(TOP_LEVEL_PROBE.contains("var r={};"));
390 assert!(TOP_LEVEL_PROBE.contains("JSON.stringify(r)"));
391 assert!(!TOP_LEVEL_PROBE.contains("=>"), "no arrow functions");
392 assert!(!TOP_LEVEL_PROBE.contains("let "), "no `let` declarations");
393 assert!(
394 !TOP_LEVEL_PROBE.contains("const "),
395 "no `const` declarations"
396 );
397 }
398
399 #[test]
400 fn iframe_probe_creates_srcdoc_iframe() {
401 assert!(IFRAME_PROBE.contains("srcdoc="));
402 assert!(IFRAME_PROBE.contains("createElement('iframe')"));
403 assert!(IFRAME_PROBE.contains("appendChild"));
404 assert!(IFRAME_PROBE.contains("removeChild"));
406 }
407
408 #[test]
409 fn worker_probe_uses_blob_url_and_terminates() {
410 assert!(WORKER_PROBE.contains("new Worker("));
411 assert!(WORKER_PROBE.contains("createObjectURL"));
412 assert!(WORKER_PROBE.contains("terminate"));
413 assert!(WORKER_PROBE.contains("revokeObjectURL"));
414 assert!(WORKER_PROBE.contains("Date.now()+2000"));
416 }
417
418 #[test]
419 fn all_probes_wrap_in_iife() {
420 for (name, script) in [
421 ("top", TOP_LEVEL_PROBE),
422 ("iframe", IFRAME_PROBE),
423 ("worker", WORKER_PROBE),
424 ] {
425 let trimmed = script.trim_start();
426 assert!(
427 trimmed.starts_with("(function(){"),
428 "{name} probe must be a self-invoking function expression"
429 );
430 assert!(
431 script.trim_end().ends_with(")()"),
432 "{name} probe must end with the IIFE invocation"
433 );
434 }
435 }
436
437 #[test]
438 fn probe_output_skipped_decodes_to_observation() {
439 let raw = r#"{"skipped":"Worker unsupported"}"#;
440 let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
441 let obs = parsed.into_observation();
442 assert!(obs.is_skipped());
443 assert!(!obs.is_observed());
444 }
445
446 #[test]
447 fn probe_output_observed_decodes_to_observation() {
448 let raw = r#"{"user_agent":"Mozilla/5.0","platform":"MacIntel","languages":"en-US","hardware_concurrency":8,"device_memory":8,"timezone":"UTC","screen_width":1920,"screen_height":1080,"color_depth":24,"webdriver":false}"#;
449 let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
450 let obs = parsed.into_observation();
451 let surface = obs.surface().expect("surface present");
452 assert_eq!(surface.user_agent.as_deref(), Some("Mozilla/5.0"));
453 assert_eq!(surface.hardware_concurrency, Some(8));
454 assert_eq!(surface.webdriver, Some(false));
455 }
456
457 #[test]
458 fn probe_output_partial_surface_decodes_cleanly() {
459 let raw = r#"{"user_agent":"Mozilla/5.0"}"#;
461 let parsed: ProbeOutput = serde_json::from_str(raw).expect("decode");
462 let surface = parsed
463 .into_observation()
464 .surface()
465 .expect("surface")
466 .clone();
467 assert_eq!(surface.user_agent.as_deref(), Some("Mozilla/5.0"));
468 assert!(surface.platform.is_none());
469 assert!(surface.webdriver.is_none());
470 }
471
472 #[test]
473 fn probe_output_invalid_falls_back_to_observed_with_empty_surface() {
474 let raw = "null";
478 let parsed: serde_json::Result<ProbeOutput> = serde_json::from_str(raw);
479 assert!(parsed.is_err());
480
481 let parsed: ProbeOutput = serde_json::from_str("{}").expect("decode empty object");
485 let obs = parsed.into_observation();
486 assert!(obs.is_observed());
487 assert!(obs.surface().expect("surface").is_empty());
488 }
489
490 #[test]
491 fn build_report_with_three_skipped_contexts_emits_no_drift() {
492 let report = build_report(
493 ContextObservation::skipped("a"),
494 ContextObservation::skipped("b"),
495 ContextObservation::skipped("c"),
496 None,
497 );
498 assert!(report.is_coherent());
499 assert_eq!(report.observed_context_count(), 0);
500 assert_eq!(report.skipped_context_count(), 3);
501 }
502
503 #[test]
504 fn coherence_probe_default_is_constructible() {
505 let _ = CoherenceProbe::default();
506 let _ = CoherenceProbe::new();
507 }
508
509 #[test]
510 fn drift_severity_classifies_hard_fields() {
511 assert_eq!(
512 super::super::report::field_severity("user_agent"),
513 DriftSeverity::Hard
514 );
515 assert_eq!(
516 super::super::report::field_severity("platform"),
517 DriftSeverity::Hard
518 );
519 assert_eq!(
520 super::super::report::field_severity("languages"),
521 DriftSeverity::Hard
522 );
523 assert_eq!(
524 super::super::report::field_severity("webdriver"),
525 DriftSeverity::Hard
526 );
527 assert_eq!(
528 super::super::report::field_severity("hardware_concurrency"),
529 DriftSeverity::KnownLimitation
530 );
531 assert_eq!(
532 super::super::report::field_severity("device_memory"),
533 DriftSeverity::KnownLimitation
534 );
535 }
536
537 #[test]
538 fn context_kind_constants_resolve_internally() {
539 assert_eq!(
543 ContextPair::TopIframe.sides(),
544 (ContextKind::Top, ContextKind::Iframe)
545 );
546 assert_eq!(
547 ContextPair::TopWorker.sides(),
548 (ContextKind::Top, ContextKind::Worker)
549 );
550 assert_eq!(
551 ContextPair::IframeWorker.sides(),
552 (ContextKind::Iframe, ContextKind::Worker)
553 );
554 }
555}