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