1use serde::{Deserialize, Serialize};
37
38use crate::integrity_canary::IntegrityCanaryReport;
39use crate::transport_realism::TransportRealismReport;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum CheckId {
47 WebDriverFlag,
49 ChromeObject,
51 PluginCount,
53 LanguagesPresent,
55 CanvasConsistency,
57 WebGlVendor,
59 AutomationGlobals,
61 OuterWindowSize,
63 HeadlessUserAgent,
65 NotificationPermission,
67 MatchMediaPresent,
69 ElementFromPointPresent,
71 RequestAnimationFramePresent,
73 GetComputedStylePresent,
75 CssSupportsPresent,
77 SendBeaconPresent,
79 ExecCommandPresent,
81 NodeJsAbsent,
83 WebDriverDescriptorShape,
85 UserAgentDataPresent,
87 ConnectionPresent,
89 HiddenFontProbeRect,
91 ScreenMetricsCoherent,
93 AudioContextPresent,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum LimitationId {
101 WebGpuSurface,
103 PerformanceMemorySurface,
105 OpaqueOriginStorage,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct CheckResult {
114 pub id: CheckId,
116 pub description: String,
118 pub passed: bool,
120 pub details: String,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct KnownLimitation {
127 pub id: LimitationId,
129 pub description: String,
131 pub details: String,
133}
134
135#[derive(Debug, Clone, Default, Serialize, Deserialize)]
137pub struct TransportObservations {
138 pub ja3_hash: Option<String>,
140 pub ja4: Option<String>,
142 pub http3_perk_text: Option<String>,
144 pub http3_perk_hash: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct TransportDiagnostic {
151 pub user_agent: String,
153 pub expected_profile: Option<String>,
155 pub expected_ja3_raw: Option<String>,
157 pub expected_ja3_hash: Option<String>,
159 pub expected_ja4: Option<String>,
161 pub expected_http3_perk_text: Option<String>,
163 pub expected_http3_perk_hash: Option<String>,
165 pub observed: TransportObservations,
167 pub transport_match: Option<bool>,
170 pub mismatches: Vec<String>,
172}
173
174impl TransportDiagnostic {
175 #[must_use]
177 pub fn from_user_agent_and_observations(
178 user_agent: &str,
179 observed: Option<&TransportObservations>,
180 ) -> Self {
181 let observed = observed.cloned().unwrap_or_default();
182
183 let expected_profile = crate::tls::expected_tls_profile_from_user_agent(user_agent);
185 let expected_ja3 = expected_profile.map(crate::tls::TlsProfile::ja3);
186 let expected_ja4 = expected_profile.map(crate::tls::TlsProfile::ja4);
187 let expected_http3 = expected_profile.and_then(crate::tls::TlsProfile::http3_perk);
188
189 let mut mismatches = Vec::new();
190
191 if let (Some(expected), Some(observed_hash)) = (
192 expected_ja3.as_ref().map(|j| j.hash.as_str()),
193 observed.ja3_hash.as_deref(),
194 ) && !observed_hash.eq_ignore_ascii_case(expected)
195 {
196 mismatches.push(format!(
197 "ja3_hash mismatch: expected '{expected}', observed '{observed_hash}'"
198 ));
199 }
200
201 if let (Some(expected), Some(observed_ja4)) = (
202 expected_ja4.as_ref().map(|j| j.fingerprint.as_str()),
203 observed.ja4.as_deref(),
204 ) && observed_ja4 != expected
205 {
206 mismatches.push(format!(
207 "ja4 mismatch: expected '{expected}', observed '{observed_ja4}'"
208 ));
209 }
210
211 if let Some(expected) = expected_http3.as_ref() {
212 let cmp = expected.compare(
213 observed.http3_perk_text.as_deref(),
214 observed.http3_perk_hash.as_deref(),
215 );
216 mismatches.extend(cmp.mismatches);
217 }
218
219 if observed.ja3_hash.is_some() && expected_ja3.is_none() {
223 mismatches.push(
224 "ja3_hash was provided but no expected JA3 could be derived from user-agent"
225 .to_string(),
226 );
227 }
228 if observed.ja4.is_some() && expected_ja4.is_none() {
229 mismatches.push(
230 "ja4 was provided but no expected JA4 could be derived from user-agent".to_string(),
231 );
232 }
233 if (observed.http3_perk_text.is_some() || observed.http3_perk_hash.is_some())
234 && expected_http3.is_none()
235 {
236 mismatches.push(
237 "http3 perk observation was provided but no expected HTTP/3 fingerprint could be derived from user-agent"
238 .to_string(),
239 );
240 }
241
242 let has_observed = observed.ja3_hash.is_some()
243 || observed.ja4.is_some()
244 || observed.http3_perk_text.is_some()
245 || observed.http3_perk_hash.is_some();
246
247 Self {
248 user_agent: user_agent.to_string(),
249 expected_profile: expected_profile.map(|p| p.name.clone()),
250 expected_ja3_raw: expected_ja3.as_ref().map(|j| j.raw.clone()),
251 expected_ja3_hash: expected_ja3.as_ref().map(|j| j.hash.clone()),
252 expected_ja4: expected_ja4.as_ref().map(|j| j.fingerprint.clone()),
253 expected_http3_perk_text: expected_http3
254 .as_ref()
255 .map(crate::tls::Http3Perk::perk_text),
256 expected_http3_perk_hash: expected_http3
257 .as_ref()
258 .map(crate::tls::Http3Perk::perk_hash),
259 observed,
260 transport_match: has_observed.then_some(mismatches.is_empty()),
261 mismatches,
262 }
263 }
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct DiagnosticReport {
285 pub checks: Vec<CheckResult>,
287 pub passed_count: usize,
289 pub failed_count: usize,
291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
293 pub known_limitations: Vec<KnownLimitation>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub transport: Option<TransportDiagnostic>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
303 pub transport_realism: Option<TransportRealismReport>,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub integrity_canary: Option<IntegrityCanaryReport>,
313}
314
315impl DiagnosticReport {
316 #[must_use]
318 pub fn new(checks: Vec<CheckResult>) -> Self {
319 let passed_count = checks.iter().filter(|r| r.passed).count();
320 let failed_count = checks.len() - passed_count;
321 Self {
322 checks,
323 passed_count,
324 failed_count,
325 known_limitations: Vec::new(),
326 transport: None,
327 transport_realism: None,
328 integrity_canary: None,
329 }
330 }
331
332 #[must_use]
334 pub fn with_known_limitations(mut self, known_limitations: Vec<KnownLimitation>) -> Self {
335 self.known_limitations = known_limitations;
336 self
337 }
338
339 #[must_use]
341 pub fn with_transport(mut self, transport: TransportDiagnostic) -> Self {
342 self.transport = Some(transport);
343 self
344 }
345
346 #[must_use]
352 pub fn with_transport_realism(mut self, transport_realism: TransportRealismReport) -> Self {
353 self.transport_realism = Some(transport_realism);
354 self
355 }
356
357 #[must_use]
363 pub fn with_integrity_canary(mut self, integrity_canary: IntegrityCanaryReport) -> Self {
364 self.integrity_canary = Some(integrity_canary);
365 self
366 }
367
368 #[must_use]
370 pub const fn is_clean(&self) -> bool {
371 self.failed_count == 0
372 }
373
374 #[allow(clippy::cast_precision_loss)]
376 #[must_use]
377 pub fn coverage_pct(&self) -> f64 {
378 if self.checks.is_empty() {
379 return 0.0;
380 }
381 self.passed_count as f64 / self.checks.len() as f64 * 100.0
382 }
383
384 pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
386 self.checks.iter().filter(|r| !r.passed)
387 }
388}
389
390pub struct DetectionCheck {
395 pub id: CheckId,
397 pub description: &'static str,
399 pub script: &'static str,
405}
406
407pub struct LimitationProbe {
409 pub id: LimitationId,
411 pub description: &'static str,
413 pub script: &'static str,
416}
417
418impl DetectionCheck {
419 #[must_use]
425 pub fn parse_output(&self, json: &str) -> CheckResult {
426 #[derive(Deserialize)]
427 struct Output {
428 passed: bool,
429 #[serde(default)]
430 details: String,
431 }
432
433 match serde_json::from_str::<Output>(json) {
434 Ok(o) => CheckResult {
435 id: self.id,
436 description: self.description.to_string(),
437 passed: o.passed,
438 details: o.details,
439 },
440 Err(e) => CheckResult {
441 id: self.id,
442 description: self.description.to_string(),
443 passed: false,
444 details: format!("parse error: {e} | raw: {json}"),
445 },
446 }
447 }
448}
449
450impl LimitationProbe {
451 fn limitation(&self, details: String) -> KnownLimitation {
452 KnownLimitation {
453 id: self.id,
454 description: self.description.to_string(),
455 details,
456 }
457 }
458
459 #[must_use]
461 pub fn parse_output(&self, json: &str) -> Option<KnownLimitation> {
462 #[derive(Deserialize)]
463 struct Output {
464 limited: bool,
465 #[serde(default)]
466 details: String,
467 }
468
469 match serde_json::from_str::<Output>(json) {
470 Ok(output) => output.limited.then(|| self.limitation(output.details)),
471 Err(error) => Some(self.limitation(format!("parse error: {error} | raw: {json}"))),
472 }
473 }
474}
475
476const SCRIPT_WEBDRIVER: &str = concat!(
479 "JSON.stringify({",
480 "passed:navigator.webdriver===false||navigator.webdriver===undefined,",
481 "details:String(navigator.webdriver)",
482 "})"
483);
484
485const SCRIPT_CHROME_OBJECT: &str = concat!(
486 "JSON.stringify({",
487 "passed:typeof window.chrome!=='undefined'&&window.chrome!==null",
488 "&&typeof window.chrome.runtime!=='undefined',",
489 "details:typeof window.chrome",
490 "})"
491);
492
493const SCRIPT_PLUGIN_COUNT: &str = concat!(
494 "JSON.stringify({",
495 "passed:navigator.plugins.length>0,",
496 "details:navigator.plugins.length+' plugins'",
497 "})"
498);
499
500const SCRIPT_LANGUAGES: &str = concat!(
501 "JSON.stringify({",
502 "passed:Array.isArray(navigator.languages)&&navigator.languages.length>0,",
503 "details:JSON.stringify(navigator.languages)",
504 "})"
505);
506
507const SCRIPT_CANVAS: &str = concat!(
508 "(function(){",
509 "var c=document.createElement('canvas');",
510 "c.width=200;c.height=50;",
511 "var ctx=c.getContext('2d');",
512 "ctx.fillStyle='#1a2b3c';ctx.fillRect(0,0,200,50);",
513 "ctx.font='16px Arial';ctx.fillStyle='#fafafa';",
514 "ctx.fillText('stygian-diag',10,30);",
515 "var d=c.toDataURL();",
516 "return JSON.stringify({passed:d.length>200,details:'len='+d.length});",
517 "})()"
518);
519
520const SCRIPT_WEBGL_VENDOR: &str = concat!(
521 "(function(){",
522 "var gl=document.createElement('canvas').getContext('webgl');",
523 "if(!gl)return JSON.stringify({passed:false,details:'webgl unavailable'});",
524 "var ext=gl.getExtension('WEBGL_debug_renderer_info');",
525 "if(!ext)return JSON.stringify({passed:true,details:'debug ext absent (normal)'});",
526 "var v=gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)||'';",
527 "var r=gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)||'';",
528 "var sw=v.includes('SwiftShader')||r.includes('SwiftShader');",
529 "return JSON.stringify({passed:!sw,details:v+'/'+r});",
530 "})()"
531);
532
533const SCRIPT_AUTOMATION_GLOBALS: &str = concat!(
534 "JSON.stringify({",
535 "passed:typeof window.__puppeteer__==='undefined'",
536 "&&typeof window.__playwright==='undefined'",
537 "&&typeof window.__webdriverFunc==='undefined'",
538 "&&typeof window._phantom==='undefined',",
539 "details:'automation globals checked'",
540 "})"
541);
542
543const SCRIPT_OUTER_WINDOW: &str = concat!(
544 "JSON.stringify({",
545 "passed:window.outerWidth>0&&window.outerHeight>0,",
546 "details:window.outerWidth+'x'+window.outerHeight",
547 "})"
548);
549
550const SCRIPT_HEADLESS_UA: &str = concat!(
551 "JSON.stringify({",
552 "passed:!navigator.userAgent.includes('HeadlessChrome'),",
553 "details:navigator.userAgent.substring(0,100)",
554 "})"
555);
556
557const SCRIPT_NOTIFICATION: &str = concat!(
558 "JSON.stringify({",
559 "passed:typeof Notification==='undefined'||Notification.permission!=='granted',",
560 "details:typeof Notification!=='undefined'?Notification.permission:'unavailable'",
561 "})"
562);
563
564const SCRIPT_MATCH_MEDIA: &str = concat!(
565 "JSON.stringify({",
566 "passed:typeof window.matchMedia==='function',",
567 "details:typeof window.matchMedia",
568 "})"
569);
570
571const SCRIPT_ELEMENT_FROM_POINT: &str = concat!(
572 "JSON.stringify({",
573 "passed:typeof document.elementFromPoint==='function',",
574 "details:typeof document.elementFromPoint",
575 "})"
576);
577
578const SCRIPT_RAF: &str = concat!(
579 "JSON.stringify({",
580 "passed:typeof window.requestAnimationFrame==='function',",
581 "details:typeof window.requestAnimationFrame",
582 "})"
583);
584
585const SCRIPT_GET_COMPUTED_STYLE: &str = concat!(
586 "JSON.stringify({",
587 "passed:typeof window.getComputedStyle==='function',",
588 "details:typeof window.getComputedStyle",
589 "})"
590);
591
592const SCRIPT_CSS_SUPPORTS: &str = concat!(
593 "JSON.stringify({",
594 "passed:typeof CSS!=='undefined'&&typeof CSS.supports==='function',",
595 "details:typeof CSS!=='undefined'?typeof CSS.supports:'undefined'",
596 "})"
597);
598
599const SCRIPT_SEND_BEACON: &str = concat!(
600 "JSON.stringify({",
601 "passed:typeof navigator.sendBeacon==='function',",
602 "details:typeof navigator.sendBeacon",
603 "})"
604);
605
606const SCRIPT_EXEC_COMMAND: &str = concat!(
607 "JSON.stringify({",
608 "passed:typeof document.execCommand==='function',",
609 "details:typeof document.execCommand",
610 "})"
611);
612
613const SCRIPT_NODEJS_ABSENT: &str = concat!(
614 "JSON.stringify({",
615 "passed:typeof process==='undefined'",
616 "||process.versions==null",
617 "||typeof process.versions.node==='undefined',",
618 "details:typeof process",
619 "})"
620);
621
622const SCRIPT_WEBDRIVER_DESCRIPTOR: &str = concat!(
623 "(function(){",
624 "var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'webdriver');",
625 "var ok=typeof d==='undefined'||(typeof d.get==='function'&&d.set===undefined&&d.configurable===true);",
626 "var detail=d?('getter='+typeof d.get+',set='+typeof d.set+',configurable='+String(d.configurable)+',enumerable='+String(d.enumerable)):'missing';",
627 "return JSON.stringify({passed:ok,details:detail});",
628 "})()"
629);
630
631const SCRIPT_USER_AGENT_DATA: &str = concat!(
632 "(function(){",
633 "var d=navigator.userAgentData;",
634 "var ok=typeof d==='undefined'||(Array.isArray(d.brands)&&d.brands.length>0&&typeof d.mobile==='boolean'&&typeof d.getHighEntropyValues==='function');",
635 "var detail=typeof d==='undefined'?'undefined':('brands='+(Array.isArray(d.brands)?d.brands.length:0)+',mobile='+String(d.mobile)+',platform='+(d.platform||''));",
636 "return JSON.stringify({passed:ok,details:detail});",
637 "})()"
638);
639
640const SCRIPT_CONNECTION: &str = concat!(
641 "(function(){",
642 "var c=navigator.connection;",
643 "var ok=typeof c!=='undefined'&&typeof c.rtt==='number'&&c.rtt>=0&&typeof c.downlink==='number'&&c.downlink>=0&&typeof c.effectiveType==='string'&&c.effectiveType.length>0;",
644 "var detail=typeof c==='undefined'?'undefined':('rtt='+String(c.rtt)+',downlink='+String(c.downlink)+',effectiveType='+(c.effectiveType||''));",
645 "return JSON.stringify({passed:ok,details:detail});",
646 "})()"
647);
648
649const SCRIPT_STORAGE_ESTIMATE: &str = concat!(
650 "(function(){",
651 "var s=navigator.storage;",
652 "var limited=!s||typeof s.estimate!=='function';",
653 "var detail=!s?'storage unavailable':typeof s.estimate;",
654 "return JSON.stringify({limited:limited,details:detail});",
655 "})()"
656);
657
658const SCRIPT_HIDDEN_FONT_PROBE: &str = concat!(
659 "(function(){",
660 "var root=document.body||document.documentElement;",
661 "if(!root){return JSON.stringify({passed:false,details:'no root element available'});}",
662 "var probe=document.createElement('div');",
663 "probe.textContent='mmmmmmmmmlli';",
664 "probe.setAttribute('aria-hidden','true');",
665 "probe.style.position='absolute';",
666 "probe.style.visibility='hidden';",
667 "probe.style.font='16px Arial';",
668 "root.appendChild(probe);",
669 "var rect=probe.getBoundingClientRect();",
670 "probe.remove();",
671 "var ok=rect.width>0&&rect.height>0;",
672 "return JSON.stringify({passed:ok,details:'width='+rect.width+',height='+rect.height});",
673 "})()"
674);
675
676const SCRIPT_SCREEN_METRICS: &str = concat!(
677 "JSON.stringify({",
678 "passed:screen.width>0&&screen.height>0&&screen.availWidth>0&&screen.availHeight>0&&screen.availWidth<=screen.width&&screen.availHeight<=screen.height&&window.devicePixelRatio>0,",
679 "details:'screen='+screen.width+'x'+screen.height+',avail='+screen.availWidth+'x'+screen.availHeight+',dpr='+window.devicePixelRatio",
680 "})"
681);
682
683const SCRIPT_AUDIO_CONTEXT: &str = concat!(
684 "(function(){",
685 "var C=window.AudioContext||window.webkitAudioContext;",
686 "if(!C)return JSON.stringify({passed:false,details:'AudioContext unavailable'});",
687 "var ctx=new C();",
688 "var sampleRate=ctx.sampleRate||0;",
689 "var baseLatency=typeof ctx.baseLatency==='number'?ctx.baseLatency:-1;",
690 "if(typeof ctx.close==='function'){ctx.close();}",
691 "return JSON.stringify({passed:sampleRate>0,details:'sampleRate='+sampleRate+',baseLatency='+baseLatency});",
692 "})()"
693);
694
695const SCRIPT_WEBGPU_LIMITATION: &str = concat!(
696 "JSON.stringify({",
697 "limited:'gpu' in navigator,",
698 "details:typeof navigator.gpu",
699 "})"
700);
701
702const SCRIPT_PERFORMANCE_MEMORY_LIMITATION: &str = concat!(
703 "JSON.stringify({",
704 "limited:typeof performance.memory!=='undefined',",
705 "details:typeof performance.memory",
706 "})"
707);
708
709#[must_use]
716pub fn all_checks() -> &'static [DetectionCheck] {
717 CHECKS
718}
719
720#[must_use]
722pub fn all_limitation_probes() -> &'static [LimitationProbe] {
723 LIMITATION_PROBES
724}
725
726static CHECKS: &[DetectionCheck] = &[
727 DetectionCheck {
728 id: CheckId::WebDriverFlag,
729 description: "navigator.webdriver must be false/undefined",
730 script: SCRIPT_WEBDRIVER,
731 },
732 DetectionCheck {
733 id: CheckId::ChromeObject,
734 description: "window.chrome.runtime must exist",
735 script: SCRIPT_CHROME_OBJECT,
736 },
737 DetectionCheck {
738 id: CheckId::PluginCount,
739 description: "navigator.plugins must be non-empty",
740 script: SCRIPT_PLUGIN_COUNT,
741 },
742 DetectionCheck {
743 id: CheckId::LanguagesPresent,
744 description: "navigator.languages must be non-empty",
745 script: SCRIPT_LANGUAGES,
746 },
747 DetectionCheck {
748 id: CheckId::CanvasConsistency,
749 description: "canvas toDataURL must return non-trivial image data",
750 script: SCRIPT_CANVAS,
751 },
752 DetectionCheck {
753 id: CheckId::WebGlVendor,
754 description: "WebGL vendor must not be SwiftShader (software renderer)",
755 script: SCRIPT_WEBGL_VENDOR,
756 },
757 DetectionCheck {
758 id: CheckId::AutomationGlobals,
759 description: "automation globals (Puppeteer/Playwright) must be absent",
760 script: SCRIPT_AUTOMATION_GLOBALS,
761 },
762 DetectionCheck {
763 id: CheckId::OuterWindowSize,
764 description: "window.outerWidth/outerHeight must be non-zero",
765 script: SCRIPT_OUTER_WINDOW,
766 },
767 DetectionCheck {
768 id: CheckId::HeadlessUserAgent,
769 description: "User-Agent must not contain 'HeadlessChrome'",
770 script: SCRIPT_HEADLESS_UA,
771 },
772 DetectionCheck {
773 id: CheckId::NotificationPermission,
774 description: "Notification.permission must not be pre-granted",
775 script: SCRIPT_NOTIFICATION,
776 },
777 DetectionCheck {
778 id: CheckId::MatchMediaPresent,
779 description: "window.matchMedia must be a function (PX env-bitmask bit 0)",
780 script: SCRIPT_MATCH_MEDIA,
781 },
782 DetectionCheck {
783 id: CheckId::ElementFromPointPresent,
784 description: "document.elementFromPoint must be a function (PX env-bitmask bit 1)",
785 script: SCRIPT_ELEMENT_FROM_POINT,
786 },
787 DetectionCheck {
788 id: CheckId::RequestAnimationFramePresent,
789 description: "window.requestAnimationFrame must be a function (PX env-bitmask bit 2)",
790 script: SCRIPT_RAF,
791 },
792 DetectionCheck {
793 id: CheckId::GetComputedStylePresent,
794 description: "window.getComputedStyle must be a function (PX env-bitmask bit 3)",
795 script: SCRIPT_GET_COMPUTED_STYLE,
796 },
797 DetectionCheck {
798 id: CheckId::CssSupportsPresent,
799 description: "CSS.supports must exist and be callable (PX env-bitmask bit 4)",
800 script: SCRIPT_CSS_SUPPORTS,
801 },
802 DetectionCheck {
803 id: CheckId::SendBeaconPresent,
804 description: "navigator.sendBeacon must be a function (PX env-bitmask bit 5)",
805 script: SCRIPT_SEND_BEACON,
806 },
807 DetectionCheck {
808 id: CheckId::ExecCommandPresent,
809 description: "document.execCommand must be a function (PX env-bitmask bit 6)",
810 script: SCRIPT_EXEC_COMMAND,
811 },
812 DetectionCheck {
813 id: CheckId::NodeJsAbsent,
814 description: "process.versions.node must be absent — not a Node.js environment (PX env-bitmask bit 7)",
815 script: SCRIPT_NODEJS_ABSENT,
816 },
817 DetectionCheck {
818 id: CheckId::WebDriverDescriptorShape,
819 description: "Navigator.prototype.webdriver must look like an accessor descriptor",
820 script: SCRIPT_WEBDRIVER_DESCRIPTOR,
821 },
822 DetectionCheck {
823 id: CheckId::UserAgentDataPresent,
824 description: "navigator.userAgentData must expose coherent client hints",
825 script: SCRIPT_USER_AGENT_DATA,
826 },
827 DetectionCheck {
828 id: CheckId::ConnectionPresent,
829 description: "navigator.connection must expose plausible network information",
830 script: SCRIPT_CONNECTION,
831 },
832 DetectionCheck {
833 id: CheckId::HiddenFontProbeRect,
834 description: "hidden font probes must yield non-zero layout measurements",
835 script: SCRIPT_HIDDEN_FONT_PROBE,
836 },
837 DetectionCheck {
838 id: CheckId::ScreenMetricsCoherent,
839 description: "screen metrics and devicePixelRatio must be coherent",
840 script: SCRIPT_SCREEN_METRICS,
841 },
842 DetectionCheck {
843 id: CheckId::AudioContextPresent,
844 description: "AudioContext must expose a non-zero sample rate",
845 script: SCRIPT_AUDIO_CONTEXT,
846 },
847];
848
849static LIMITATION_PROBES: &[LimitationProbe] = &[
850 LimitationProbe {
851 id: LimitationId::WebGpuSurface,
852 description: "navigator.gpu / WebGPU is exposed but not yet spoofed or validated",
853 script: SCRIPT_WEBGPU_LIMITATION,
854 },
855 LimitationProbe {
856 id: LimitationId::PerformanceMemorySurface,
857 description: "performance.memory is exposed but not yet spoofed or validated",
858 script: SCRIPT_PERFORMANCE_MEMORY_LIMITATION,
859 },
860 LimitationProbe {
861 id: LimitationId::OpaqueOriginStorage,
862 description: "navigator.storage is unavailable or incomplete on this origin",
863 script: SCRIPT_STORAGE_ESTIMATE,
864 },
865];
866
867#[cfg(test)]
870#[allow(
871 clippy::unwrap_used,
872 clippy::expect_used,
873 clippy::panic,
874 clippy::indexing_slicing
875)]
876mod tests {
877 use super::*;
878 use std::collections::HashSet;
879
880 #[test]
881 fn all_checks_returns_eighteen_entries() {
882 assert_eq!(all_checks().len(), 24);
883 }
884
885 #[test]
886 fn all_limitation_probes_returns_two_entries() {
887 assert_eq!(all_limitation_probes().len(), 3);
888 }
889
890 #[test]
891 fn all_checks_have_unique_ids() {
892 let ids: HashSet<_> = all_checks().iter().map(|c| c.id).collect();
893 assert_eq!(
894 ids.len(),
895 all_checks().len(),
896 "duplicate check ids detected"
897 );
898 }
899
900 #[test]
901 fn all_checks_have_non_empty_scripts_with_json_stringify() {
902 for check in all_checks() {
903 assert!(
904 !check.script.is_empty(),
905 "check {:?} has empty script",
906 check.id
907 );
908 assert!(
909 check.script.contains("JSON.stringify"),
910 "check {:?} script must produce a JSON string",
911 check.id
912 );
913 }
914 }
915
916 #[test]
917 fn parse_output_valid_passing_json() {
918 let check = &all_checks()[0]; let result = check.parse_output(r#"{"passed":true,"details":"undefined"}"#);
920 assert!(result.passed);
921 assert_eq!(result.id, CheckId::WebDriverFlag);
922 assert_eq!(result.details, "undefined");
923 }
924
925 #[test]
926 fn parse_output_valid_failing_json() {
927 let check = &all_checks()[0];
928 let result = check.parse_output(r#"{"passed":false,"details":"true"}"#);
929 assert!(!result.passed);
930 }
931
932 #[test]
933 fn parse_output_invalid_json_returns_fail_with_details() {
934 let check = &all_checks()[0];
935 let result = check.parse_output("not json at all");
936 assert!(!result.passed);
937 assert!(result.details.contains("parse error"));
938 }
939
940 #[test]
941 fn parse_output_preserves_check_id() {
942 let check = all_checks()
943 .iter()
944 .find(|c| c.id == CheckId::ChromeObject)
945 .unwrap();
946 let result = check.parse_output(r#"{"passed":true,"details":"object"}"#);
947 assert_eq!(result.id, CheckId::ChromeObject);
948 assert_eq!(result.description, check.description);
949 }
950
951 #[test]
952 fn parse_output_missing_details_defaults_to_empty() {
953 let check = &all_checks()[0];
954 let result = check.parse_output(r#"{"passed":true}"#);
955 assert!(result.passed);
956 assert!(result.details.is_empty());
957 }
958
959 #[test]
960 fn diagnostic_report_all_passing() {
961 let results: Vec<CheckResult> = all_checks()
962 .iter()
963 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
964 .collect();
965 let report = DiagnosticReport::new(results);
966 assert!(report.is_clean());
967 assert_eq!(report.passed_count, 24);
968 assert!(report.known_limitations.is_empty());
969 assert_eq!(report.failed_count, 0);
970 assert!((report.coverage_pct() - 100.0).abs() < 0.001);
971 assert_eq!(report.failures().count(), 0);
972 }
973
974 #[test]
975 fn diagnostic_report_some_failing() {
976 let mut results: Vec<CheckResult> = all_checks()
977 .iter()
978 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
979 .collect();
980 results[0].passed = false;
981 results[2].passed = false;
982 let report = DiagnosticReport::new(results);
983 assert!(!report.is_clean());
984 assert_eq!(report.failed_count, 2);
985 assert_eq!(report.passed_count, 22);
986 assert_eq!(report.failures().count(), 2);
987 }
988
989 #[test]
990 fn diagnostic_report_empty_checks() {
991 let report = DiagnosticReport::new(Vec::new());
992 assert!(report.is_clean()); assert!((report.coverage_pct()).abs() < 0.001);
994 }
995
996 #[test]
997 fn check_result_serializes_with_snake_case_id() {
998 let result = CheckResult {
999 id: CheckId::WebDriverFlag,
1000 description: "test".to_string(),
1001 passed: true,
1002 details: "ok".to_string(),
1003 };
1004 let json = serde_json::to_string(&result).unwrap();
1005 assert!(json.contains("\"web_driver_flag\""), "got: {json}");
1006 assert!(json.contains("\"passed\":true"));
1007 }
1008
1009 #[test]
1010 fn diagnostic_report_serializes_and_deserializes() {
1011 let results: Vec<CheckResult> = all_checks()
1012 .iter()
1013 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
1014 .collect();
1015 let report = DiagnosticReport::new(results).with_known_limitations(vec![KnownLimitation {
1016 id: LimitationId::WebGpuSurface,
1017 description: "navigator.gpu / WebGPU is exposed but not yet spoofed or validated"
1018 .to_string(),
1019 details: "object".to_string(),
1020 }]);
1021 let json = serde_json::to_string(&report).unwrap();
1022 let restored: DiagnosticReport = serde_json::from_str(&json).unwrap();
1023 assert_eq!(restored.passed_count, report.passed_count);
1024 assert_eq!(restored.known_limitations.len(), 1);
1025 assert!(restored.is_clean());
1026 }
1027
1028 #[test]
1029 fn limitation_probe_reports_surface_when_limited() {
1030 let probe = &all_limitation_probes()[0];
1031 let limitation = probe
1032 .parse_output(r#"{"limited":true,"details":"object"}"#)
1033 .unwrap();
1034 assert_eq!(limitation.id, LimitationId::WebGpuSurface);
1035 assert_eq!(limitation.details, "object");
1036 }
1037
1038 #[test]
1039 fn limitation_probe_returns_none_when_surface_not_limited() {
1040 let probe = &all_limitation_probes()[0];
1041 assert!(
1042 probe
1043 .parse_output(r#"{"limited":false,"details":"undefined"}"#)
1044 .is_none()
1045 );
1046 }
1047
1048 #[test]
1049 fn transport_diagnostic_reports_match_for_matching_observations() {
1050 let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
1051 let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
1052
1053 assert!(
1055 expected.expected_profile.is_some()
1056 || expected.expected_ja3_hash.is_some()
1057 || expected.expected_ja4.is_some()
1058 || expected.expected_http3_perk_text.is_some()
1059 );
1060
1061 let observed = TransportObservations {
1062 ja3_hash: expected.expected_ja3_hash.clone(),
1063 ja4: expected.expected_ja4.clone(),
1064 http3_perk_text: expected.expected_http3_perk_text.clone(),
1065 http3_perk_hash: expected.expected_http3_perk_hash,
1066 };
1067 let diagnostic =
1068 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1069
1070 assert_eq!(diagnostic.transport_match, Some(true));
1071 assert!(diagnostic.mismatches.is_empty());
1072 }
1073
1074 #[test]
1075 fn transport_diagnostic_reports_mismatch_for_mismatching_observations() {
1076 let user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
1077 let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
1078
1079 assert!(expected.expected_ja3_hash.is_some());
1080
1081 let observed = TransportObservations {
1082 ja3_hash: Some("definitely-not-the-expected-ja3".to_string()),
1083 ja4: expected.expected_ja4.clone(),
1084 http3_perk_text: expected.expected_http3_perk_text.clone(),
1085 http3_perk_hash: expected.expected_http3_perk_hash,
1086 };
1087 let diagnostic =
1088 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1089
1090 assert_eq!(diagnostic.transport_match, Some(false));
1091 assert!(!diagnostic.mismatches.is_empty());
1092 assert!(
1093 diagnostic
1094 .mismatches
1095 .iter()
1096 .any(|m| m.contains("ja3_hash mismatch"))
1097 );
1098 }
1099
1100 #[test]
1101 fn transport_diagnostic_flags_observations_when_no_expectations_derivable() {
1102 let user_agent = "UnknownBrowser/0.0";
1103 let diagnostic_without_observed =
1104 TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
1105
1106 assert_eq!(diagnostic_without_observed.expected_profile, None);
1108 assert_eq!(diagnostic_without_observed.expected_ja3_hash, None);
1109 assert_eq!(diagnostic_without_observed.expected_ja4, None);
1110 assert_eq!(diagnostic_without_observed.expected_http3_perk_text, None);
1111
1112 let observed = TransportObservations {
1113 ja3_hash: Some("some-observed-ja3".to_string()),
1114 ja4: Some("some-observed-ja4".to_string()),
1115 http3_perk_text: Some("some-observed-http3-perk-text".to_string()),
1116 http3_perk_hash: Some("some-observed-http3-perk-hash".to_string()),
1117 };
1118
1119 let diagnostic =
1120 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1121
1122 assert_eq!(diagnostic.transport_match, Some(false));
1124 assert!(!diagnostic.mismatches.is_empty());
1125 assert!(
1126 diagnostic
1127 .mismatches
1128 .iter()
1129 .any(|m| m.contains("no expected JA3 could be derived"))
1130 );
1131 }
1132
1133 #[test]
1136 fn diagnostic_report_omits_transport_realism_field_when_unset() {
1137 let results: Vec<CheckResult> = all_checks()
1138 .iter()
1139 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
1140 .collect();
1141 let report = DiagnosticReport::new(results);
1142 let json = serde_json::to_string(&report).expect("serialize");
1143 assert!(
1148 !json.contains("transport_realism"),
1149 "transport_realism must be omitted when None, got: {json}"
1150 );
1151 }
1152
1153 #[test]
1154 fn diagnostic_report_includes_transport_realism_when_attached() {
1155 use crate::transport_realism::{
1156 TransportObservation, TransportProfile, score as score_transport_realism,
1157 };
1158
1159 let results: Vec<CheckResult> = all_checks()
1160 .iter()
1161 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
1162 .collect();
1163 let report = DiagnosticReport::new(results);
1164 let realism_report = score_transport_realism(
1165 &TransportProfile::default(),
1166 &TransportObservation::chrome_136_reference(),
1167 );
1168 let report = report.with_transport_realism(realism_report.clone());
1169 let json = serde_json::to_string(&report).expect("serialize");
1170 assert!(
1171 json.contains("\"transport_realism\""),
1172 "transport_realism must appear in JSON when set, got: {json}"
1173 );
1174 let restored: DiagnosticReport = serde_json::from_str(&json).expect("deserialize");
1175 let restored_realism = restored
1176 .transport_realism
1177 .as_ref()
1178 .expect("transport_realism attached");
1179 assert_eq!(restored_realism.profile_name, realism_report.profile_name);
1180 }
1181
1182 #[test]
1183 fn diagnostic_report_transport_realism_backward_compat_omission() {
1184 let legacy_payload = serde_json::json!({
1188 "checks": [],
1189 "passed_count": 0,
1190 "failed_count": 0,
1191 });
1192 let report: DiagnosticReport =
1193 serde_json::from_value(legacy_payload).expect("legacy payload deserializes");
1194 assert!(report.transport_realism.is_none());
1195 assert!(report.transport.is_none());
1196 }
1197
1198 #[test]
1199 fn transport_realism_report_serializes_with_snake_case_keys() {
1200 let report = crate::transport_realism::score(
1203 &crate::transport_realism::TransportProfile::default(),
1204 &crate::transport_realism::TransportObservation::chrome_136_reference(),
1205 );
1206 let json = serde_json::to_string(&report).expect("serialize");
1207 assert!(json.contains("\"profile_name\""), "got: {json}");
1208 assert!(json.contains("\"compatibility\""), "got: {json}");
1209 assert!(json.contains("\"score\""), "got: {json}");
1210 assert!(json.contains("\"confidence\""), "got: {json}");
1211 assert!(json.contains("\"coverage\""), "got: {json}");
1212 assert!(json.contains("\"matched_count\""), "got: {json}");
1213 assert!(json.contains("\"total_checks\""), "got: {json}");
1214
1215 let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
1218 let restored: TransportRealismReport = serde_json::from_value(value).expect("deserialize");
1219 assert_eq!(restored.profile_name, report.profile_name);
1220 let score_diff = (restored.compatibility.score - report.compatibility.score).abs();
1221 assert!(
1222 score_diff < 1e-9,
1223 "score round-trip must preserve value, got {restored_score} vs {original_score}",
1224 restored_score = restored.compatibility.score,
1225 original_score = report.compatibility.score,
1226 );
1227 }
1228
1229 #[test]
1232 fn diagnostic_report_omits_integrity_canary_field_when_unset() {
1233 let results: Vec<CheckResult> = all_checks()
1234 .iter()
1235 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
1236 .collect();
1237 let report = DiagnosticReport::new(results);
1238 let json = serde_json::to_string(&report).expect("serialize");
1239 assert!(
1244 !json.contains("integrity_canary"),
1245 "integrity_canary must be omitted when None, got: {json}"
1246 );
1247 }
1248
1249 #[test]
1250 fn diagnostic_report_includes_integrity_canary_when_attached() {
1251 use crate::integrity_canary::{IntegrityCanaryReport, IntegrityProbe};
1252
1253 let results: Vec<CheckResult> = all_checks()
1254 .iter()
1255 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
1256 .collect();
1257 let report = DiagnosticReport::new(results);
1258 let canary = IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
1259 "webdriver_descriptor_native",
1260 0.20,
1261 "x",
1262 )]);
1263 let report = report.with_integrity_canary(canary.clone());
1264 let json = serde_json::to_string(&report).expect("serialize");
1265 assert!(
1266 json.contains("\"integrity_canary\""),
1267 "integrity_canary must appear in JSON when set, got: {json}"
1268 );
1269 let restored: DiagnosticReport = serde_json::from_str(&json).expect("deserialize");
1270 let restored_canary = restored
1271 .integrity_canary
1272 .as_ref()
1273 .expect("integrity_canary attached");
1274 assert_eq!(
1275 restored_canary.score.classification,
1276 canary.score.classification
1277 );
1278 assert_eq!(restored_canary.findings.len(), 1);
1279 }
1280
1281 #[test]
1282 fn diagnostic_report_integrity_canary_backward_compat_omission() {
1283 let legacy_payload = serde_json::json!({
1287 "checks": [],
1288 "passed_count": 0,
1289 "failed_count": 0,
1290 });
1291 let report: DiagnosticReport =
1292 serde_json::from_value(legacy_payload).expect("legacy payload deserializes");
1293 assert!(report.integrity_canary.is_none());
1294 assert!(report.transport_realism.is_none());
1295 assert!(report.transport.is_none());
1296 }
1297}