1use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum CheckId {
44 WebDriverFlag,
46 ChromeObject,
48 PluginCount,
50 LanguagesPresent,
52 CanvasConsistency,
54 WebGlVendor,
56 AutomationGlobals,
58 OuterWindowSize,
60 HeadlessUserAgent,
62 NotificationPermission,
64 MatchMediaPresent,
66 ElementFromPointPresent,
68 RequestAnimationFramePresent,
70 GetComputedStylePresent,
72 CssSupportsPresent,
74 SendBeaconPresent,
76 ExecCommandPresent,
78 NodeJsAbsent,
80 WebDriverDescriptorShape,
82 UserAgentDataPresent,
84 ConnectionPresent,
86 HiddenFontProbeRect,
88 ScreenMetricsCoherent,
90 AudioContextPresent,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum LimitationId {
98 WebGpuSurface,
100 PerformanceMemorySurface,
102 OpaqueOriginStorage,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct CheckResult {
111 pub id: CheckId,
113 pub description: String,
115 pub passed: bool,
117 pub details: String,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct KnownLimitation {
124 pub id: LimitationId,
126 pub description: String,
128 pub details: String,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134pub struct TransportObservations {
135 pub ja3_hash: Option<String>,
137 pub ja4: Option<String>,
139 pub http3_perk_text: Option<String>,
141 pub http3_perk_hash: Option<String>,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct TransportDiagnostic {
148 pub user_agent: String,
150 pub expected_profile: Option<String>,
152 pub expected_ja3_raw: Option<String>,
154 pub expected_ja3_hash: Option<String>,
156 pub expected_ja4: Option<String>,
158 pub expected_http3_perk_text: Option<String>,
160 pub expected_http3_perk_hash: Option<String>,
162 pub observed: TransportObservations,
164 pub transport_match: Option<bool>,
167 pub mismatches: Vec<String>,
169}
170
171impl TransportDiagnostic {
172 #[must_use]
174 pub fn from_user_agent_and_observations(
175 user_agent: &str,
176 observed: Option<&TransportObservations>,
177 ) -> Self {
178 let observed = observed.cloned().unwrap_or_default();
179
180 let expected_profile = crate::tls::expected_tls_profile_from_user_agent(user_agent);
182 let expected_ja3 = expected_profile.map(crate::tls::TlsProfile::ja3);
183 let expected_ja4 = expected_profile.map(crate::tls::TlsProfile::ja4);
184 let expected_http3 = expected_profile.and_then(crate::tls::TlsProfile::http3_perk);
185
186 let mut mismatches = Vec::new();
187
188 if let (Some(expected), Some(observed_hash)) = (
189 expected_ja3.as_ref().map(|j| j.hash.as_str()),
190 observed.ja3_hash.as_deref(),
191 ) && !observed_hash.eq_ignore_ascii_case(expected)
192 {
193 mismatches.push(format!(
194 "ja3_hash mismatch: expected '{expected}', observed '{observed_hash}'"
195 ));
196 }
197
198 if let (Some(expected), Some(observed_ja4)) = (
199 expected_ja4.as_ref().map(|j| j.fingerprint.as_str()),
200 observed.ja4.as_deref(),
201 ) && observed_ja4 != expected
202 {
203 mismatches.push(format!(
204 "ja4 mismatch: expected '{expected}', observed '{observed_ja4}'"
205 ));
206 }
207
208 if let Some(expected) = expected_http3.as_ref() {
209 let cmp = expected.compare(
210 observed.http3_perk_text.as_deref(),
211 observed.http3_perk_hash.as_deref(),
212 );
213 mismatches.extend(cmp.mismatches);
214 }
215
216 if observed.ja3_hash.is_some() && expected_ja3.is_none() {
220 mismatches.push(
221 "ja3_hash was provided but no expected JA3 could be derived from user-agent"
222 .to_string(),
223 );
224 }
225 if observed.ja4.is_some() && expected_ja4.is_none() {
226 mismatches.push(
227 "ja4 was provided but no expected JA4 could be derived from user-agent".to_string(),
228 );
229 }
230 if (observed.http3_perk_text.is_some() || observed.http3_perk_hash.is_some())
231 && expected_http3.is_none()
232 {
233 mismatches.push(
234 "http3 perk observation was provided but no expected HTTP/3 fingerprint could be derived from user-agent"
235 .to_string(),
236 );
237 }
238
239 let has_observed = observed.ja3_hash.is_some()
240 || observed.ja4.is_some()
241 || observed.http3_perk_text.is_some()
242 || observed.http3_perk_hash.is_some();
243
244 Self {
245 user_agent: user_agent.to_string(),
246 expected_profile: expected_profile.map(|p| p.name.clone()),
247 expected_ja3_raw: expected_ja3.as_ref().map(|j| j.raw.clone()),
248 expected_ja3_hash: expected_ja3.as_ref().map(|j| j.hash.clone()),
249 expected_ja4: expected_ja4.as_ref().map(|j| j.fingerprint.clone()),
250 expected_http3_perk_text: expected_http3
251 .as_ref()
252 .map(crate::tls::Http3Perk::perk_text),
253 expected_http3_perk_hash: expected_http3
254 .as_ref()
255 .map(crate::tls::Http3Perk::perk_hash),
256 observed,
257 transport_match: has_observed.then_some(mismatches.is_empty()),
258 mismatches,
259 }
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct DiagnosticReport {
282 pub checks: Vec<CheckResult>,
284 pub passed_count: usize,
286 pub failed_count: usize,
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
290 pub known_limitations: Vec<KnownLimitation>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub transport: Option<TransportDiagnostic>,
294}
295
296impl DiagnosticReport {
297 pub fn new(checks: Vec<CheckResult>) -> Self {
299 let passed_count = checks.iter().filter(|r| r.passed).count();
300 let failed_count = checks.len() - passed_count;
301 Self {
302 checks,
303 passed_count,
304 failed_count,
305 known_limitations: Vec::new(),
306 transport: None,
307 }
308 }
309
310 #[must_use]
312 pub fn with_known_limitations(mut self, known_limitations: Vec<KnownLimitation>) -> Self {
313 self.known_limitations = known_limitations;
314 self
315 }
316
317 #[must_use]
319 pub fn with_transport(mut self, transport: TransportDiagnostic) -> Self {
320 self.transport = Some(transport);
321 self
322 }
323
324 #[must_use]
326 pub const fn is_clean(&self) -> bool {
327 self.failed_count == 0
328 }
329
330 #[allow(clippy::cast_precision_loss)]
332 pub fn coverage_pct(&self) -> f64 {
333 if self.checks.is_empty() {
334 return 0.0;
335 }
336 self.passed_count as f64 / self.checks.len() as f64 * 100.0
337 }
338
339 pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
341 self.checks.iter().filter(|r| !r.passed)
342 }
343}
344
345pub struct DetectionCheck {
350 pub id: CheckId,
352 pub description: &'static str,
354 pub script: &'static str,
360}
361
362pub struct LimitationProbe {
364 pub id: LimitationId,
366 pub description: &'static str,
368 pub script: &'static str,
371}
372
373impl DetectionCheck {
374 pub fn parse_output(&self, json: &str) -> CheckResult {
380 #[derive(Deserialize)]
381 struct Output {
382 passed: bool,
383 #[serde(default)]
384 details: String,
385 }
386
387 match serde_json::from_str::<Output>(json) {
388 Ok(o) => CheckResult {
389 id: self.id,
390 description: self.description.to_string(),
391 passed: o.passed,
392 details: o.details,
393 },
394 Err(e) => CheckResult {
395 id: self.id,
396 description: self.description.to_string(),
397 passed: false,
398 details: format!("parse error: {e} | raw: {json}"),
399 },
400 }
401 }
402}
403
404impl LimitationProbe {
405 fn limitation(&self, details: String) -> KnownLimitation {
406 KnownLimitation {
407 id: self.id,
408 description: self.description.to_string(),
409 details,
410 }
411 }
412
413 pub fn parse_output(&self, json: &str) -> Option<KnownLimitation> {
415 #[derive(Deserialize)]
416 struct Output {
417 limited: bool,
418 #[serde(default)]
419 details: String,
420 }
421
422 match serde_json::from_str::<Output>(json) {
423 Ok(output) => output.limited.then(|| self.limitation(output.details)),
424 Err(error) => Some(self.limitation(format!("parse error: {error} | raw: {json}"))),
425 }
426 }
427}
428
429const SCRIPT_WEBDRIVER: &str = concat!(
432 "JSON.stringify({",
433 "passed:navigator.webdriver===false||navigator.webdriver===undefined,",
434 "details:String(navigator.webdriver)",
435 "})"
436);
437
438const SCRIPT_CHROME_OBJECT: &str = concat!(
439 "JSON.stringify({",
440 "passed:typeof window.chrome!=='undefined'&&window.chrome!==null",
441 "&&typeof window.chrome.runtime!=='undefined',",
442 "details:typeof window.chrome",
443 "})"
444);
445
446const SCRIPT_PLUGIN_COUNT: &str = concat!(
447 "JSON.stringify({",
448 "passed:navigator.plugins.length>0,",
449 "details:navigator.plugins.length+' plugins'",
450 "})"
451);
452
453const SCRIPT_LANGUAGES: &str = concat!(
454 "JSON.stringify({",
455 "passed:Array.isArray(navigator.languages)&&navigator.languages.length>0,",
456 "details:JSON.stringify(navigator.languages)",
457 "})"
458);
459
460const SCRIPT_CANVAS: &str = concat!(
461 "(function(){",
462 "var c=document.createElement('canvas');",
463 "c.width=200;c.height=50;",
464 "var ctx=c.getContext('2d');",
465 "ctx.fillStyle='#1a2b3c';ctx.fillRect(0,0,200,50);",
466 "ctx.font='16px Arial';ctx.fillStyle='#fafafa';",
467 "ctx.fillText('stygian-diag',10,30);",
468 "var d=c.toDataURL();",
469 "return JSON.stringify({passed:d.length>200,details:'len='+d.length});",
470 "})()"
471);
472
473const SCRIPT_WEBGL_VENDOR: &str = concat!(
474 "(function(){",
475 "var gl=document.createElement('canvas').getContext('webgl');",
476 "if(!gl)return JSON.stringify({passed:false,details:'webgl unavailable'});",
477 "var ext=gl.getExtension('WEBGL_debug_renderer_info');",
478 "if(!ext)return JSON.stringify({passed:true,details:'debug ext absent (normal)'});",
479 "var v=gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)||'';",
480 "var r=gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)||'';",
481 "var sw=v.includes('SwiftShader')||r.includes('SwiftShader');",
482 "return JSON.stringify({passed:!sw,details:v+'/'+r});",
483 "})()"
484);
485
486const SCRIPT_AUTOMATION_GLOBALS: &str = concat!(
487 "JSON.stringify({",
488 "passed:typeof window.__puppeteer__==='undefined'",
489 "&&typeof window.__playwright==='undefined'",
490 "&&typeof window.__webdriverFunc==='undefined'",
491 "&&typeof window._phantom==='undefined',",
492 "details:'automation globals checked'",
493 "})"
494);
495
496const SCRIPT_OUTER_WINDOW: &str = concat!(
497 "JSON.stringify({",
498 "passed:window.outerWidth>0&&window.outerHeight>0,",
499 "details:window.outerWidth+'x'+window.outerHeight",
500 "})"
501);
502
503const SCRIPT_HEADLESS_UA: &str = concat!(
504 "JSON.stringify({",
505 "passed:!navigator.userAgent.includes('HeadlessChrome'),",
506 "details:navigator.userAgent.substring(0,100)",
507 "})"
508);
509
510const SCRIPT_NOTIFICATION: &str = concat!(
511 "JSON.stringify({",
512 "passed:typeof Notification==='undefined'||Notification.permission!=='granted',",
513 "details:typeof Notification!=='undefined'?Notification.permission:'unavailable'",
514 "})"
515);
516
517const SCRIPT_MATCH_MEDIA: &str = concat!(
518 "JSON.stringify({",
519 "passed:typeof window.matchMedia==='function',",
520 "details:typeof window.matchMedia",
521 "})"
522);
523
524const SCRIPT_ELEMENT_FROM_POINT: &str = concat!(
525 "JSON.stringify({",
526 "passed:typeof document.elementFromPoint==='function',",
527 "details:typeof document.elementFromPoint",
528 "})"
529);
530
531const SCRIPT_RAF: &str = concat!(
532 "JSON.stringify({",
533 "passed:typeof window.requestAnimationFrame==='function',",
534 "details:typeof window.requestAnimationFrame",
535 "})"
536);
537
538const SCRIPT_GET_COMPUTED_STYLE: &str = concat!(
539 "JSON.stringify({",
540 "passed:typeof window.getComputedStyle==='function',",
541 "details:typeof window.getComputedStyle",
542 "})"
543);
544
545const SCRIPT_CSS_SUPPORTS: &str = concat!(
546 "JSON.stringify({",
547 "passed:typeof CSS!=='undefined'&&typeof CSS.supports==='function',",
548 "details:typeof CSS!=='undefined'?typeof CSS.supports:'undefined'",
549 "})"
550);
551
552const SCRIPT_SEND_BEACON: &str = concat!(
553 "JSON.stringify({",
554 "passed:typeof navigator.sendBeacon==='function',",
555 "details:typeof navigator.sendBeacon",
556 "})"
557);
558
559const SCRIPT_EXEC_COMMAND: &str = concat!(
560 "JSON.stringify({",
561 "passed:typeof document.execCommand==='function',",
562 "details:typeof document.execCommand",
563 "})"
564);
565
566const SCRIPT_NODEJS_ABSENT: &str = concat!(
567 "JSON.stringify({",
568 "passed:typeof process==='undefined'",
569 "||process.versions==null",
570 "||typeof process.versions.node==='undefined',",
571 "details:typeof process",
572 "})"
573);
574
575const SCRIPT_WEBDRIVER_DESCRIPTOR: &str = concat!(
576 "(function(){",
577 "var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'webdriver');",
578 "var ok=typeof d==='undefined'||(typeof d.get==='function'&&d.set===undefined&&d.configurable===true);",
579 "var detail=d?('getter='+typeof d.get+',set='+typeof d.set+',configurable='+String(d.configurable)+',enumerable='+String(d.enumerable)):'missing';",
580 "return JSON.stringify({passed:ok,details:detail});",
581 "})()"
582);
583
584const SCRIPT_USER_AGENT_DATA: &str = concat!(
585 "(function(){",
586 "var d=navigator.userAgentData;",
587 "var ok=typeof d==='undefined'||(Array.isArray(d.brands)&&d.brands.length>0&&typeof d.mobile==='boolean'&&typeof d.getHighEntropyValues==='function');",
588 "var detail=typeof d==='undefined'?'undefined':('brands='+(Array.isArray(d.brands)?d.brands.length:0)+',mobile='+String(d.mobile)+',platform='+(d.platform||''));",
589 "return JSON.stringify({passed:ok,details:detail});",
590 "})()"
591);
592
593const SCRIPT_CONNECTION: &str = concat!(
594 "(function(){",
595 "var c=navigator.connection;",
596 "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;",
597 "var detail=typeof c==='undefined'?'undefined':('rtt='+String(c.rtt)+',downlink='+String(c.downlink)+',effectiveType='+(c.effectiveType||''));",
598 "return JSON.stringify({passed:ok,details:detail});",
599 "})()"
600);
601
602const SCRIPT_STORAGE_ESTIMATE: &str = concat!(
603 "(function(){",
604 "var s=navigator.storage;",
605 "var limited=!s||typeof s.estimate!=='function';",
606 "var detail=!s?'storage unavailable':typeof s.estimate;",
607 "return JSON.stringify({limited:limited,details:detail});",
608 "})()"
609);
610
611const SCRIPT_HIDDEN_FONT_PROBE: &str = concat!(
612 "(function(){",
613 "var root=document.body||document.documentElement;",
614 "if(!root){return JSON.stringify({passed:false,details:'no root element available'});}",
615 "var probe=document.createElement('div');",
616 "probe.textContent='mmmmmmmmmlli';",
617 "probe.setAttribute('aria-hidden','true');",
618 "probe.style.position='absolute';",
619 "probe.style.visibility='hidden';",
620 "probe.style.font='16px Arial';",
621 "root.appendChild(probe);",
622 "var rect=probe.getBoundingClientRect();",
623 "probe.remove();",
624 "var ok=rect.width>0&&rect.height>0;",
625 "return JSON.stringify({passed:ok,details:'width='+rect.width+',height='+rect.height});",
626 "})()"
627);
628
629const SCRIPT_SCREEN_METRICS: &str = concat!(
630 "JSON.stringify({",
631 "passed:screen.width>0&&screen.height>0&&screen.availWidth>0&&screen.availHeight>0&&screen.availWidth<=screen.width&&screen.availHeight<=screen.height&&window.devicePixelRatio>0,",
632 "details:'screen='+screen.width+'x'+screen.height+',avail='+screen.availWidth+'x'+screen.availHeight+',dpr='+window.devicePixelRatio",
633 "})"
634);
635
636const SCRIPT_AUDIO_CONTEXT: &str = concat!(
637 "(function(){",
638 "var C=window.AudioContext||window.webkitAudioContext;",
639 "if(!C)return JSON.stringify({passed:false,details:'AudioContext unavailable'});",
640 "var ctx=new C();",
641 "var sampleRate=ctx.sampleRate||0;",
642 "var baseLatency=typeof ctx.baseLatency==='number'?ctx.baseLatency:-1;",
643 "if(typeof ctx.close==='function'){ctx.close();}",
644 "return JSON.stringify({passed:sampleRate>0,details:'sampleRate='+sampleRate+',baseLatency='+baseLatency});",
645 "})()"
646);
647
648const SCRIPT_WEBGPU_LIMITATION: &str = concat!(
649 "JSON.stringify({",
650 "limited:'gpu' in navigator,",
651 "details:typeof navigator.gpu",
652 "})"
653);
654
655const SCRIPT_PERFORMANCE_MEMORY_LIMITATION: &str = concat!(
656 "JSON.stringify({",
657 "limited:typeof performance.memory!=='undefined',",
658 "details:typeof performance.memory",
659 "})"
660);
661
662pub fn all_checks() -> &'static [DetectionCheck] {
669 CHECKS
670}
671
672pub fn all_limitation_probes() -> &'static [LimitationProbe] {
674 LIMITATION_PROBES
675}
676
677static CHECKS: &[DetectionCheck] = &[
678 DetectionCheck {
679 id: CheckId::WebDriverFlag,
680 description: "navigator.webdriver must be false/undefined",
681 script: SCRIPT_WEBDRIVER,
682 },
683 DetectionCheck {
684 id: CheckId::ChromeObject,
685 description: "window.chrome.runtime must exist",
686 script: SCRIPT_CHROME_OBJECT,
687 },
688 DetectionCheck {
689 id: CheckId::PluginCount,
690 description: "navigator.plugins must be non-empty",
691 script: SCRIPT_PLUGIN_COUNT,
692 },
693 DetectionCheck {
694 id: CheckId::LanguagesPresent,
695 description: "navigator.languages must be non-empty",
696 script: SCRIPT_LANGUAGES,
697 },
698 DetectionCheck {
699 id: CheckId::CanvasConsistency,
700 description: "canvas toDataURL must return non-trivial image data",
701 script: SCRIPT_CANVAS,
702 },
703 DetectionCheck {
704 id: CheckId::WebGlVendor,
705 description: "WebGL vendor must not be SwiftShader (software renderer)",
706 script: SCRIPT_WEBGL_VENDOR,
707 },
708 DetectionCheck {
709 id: CheckId::AutomationGlobals,
710 description: "automation globals (Puppeteer/Playwright) must be absent",
711 script: SCRIPT_AUTOMATION_GLOBALS,
712 },
713 DetectionCheck {
714 id: CheckId::OuterWindowSize,
715 description: "window.outerWidth/outerHeight must be non-zero",
716 script: SCRIPT_OUTER_WINDOW,
717 },
718 DetectionCheck {
719 id: CheckId::HeadlessUserAgent,
720 description: "User-Agent must not contain 'HeadlessChrome'",
721 script: SCRIPT_HEADLESS_UA,
722 },
723 DetectionCheck {
724 id: CheckId::NotificationPermission,
725 description: "Notification.permission must not be pre-granted",
726 script: SCRIPT_NOTIFICATION,
727 },
728 DetectionCheck {
729 id: CheckId::MatchMediaPresent,
730 description: "window.matchMedia must be a function (PX env-bitmask bit 0)",
731 script: SCRIPT_MATCH_MEDIA,
732 },
733 DetectionCheck {
734 id: CheckId::ElementFromPointPresent,
735 description: "document.elementFromPoint must be a function (PX env-bitmask bit 1)",
736 script: SCRIPT_ELEMENT_FROM_POINT,
737 },
738 DetectionCheck {
739 id: CheckId::RequestAnimationFramePresent,
740 description: "window.requestAnimationFrame must be a function (PX env-bitmask bit 2)",
741 script: SCRIPT_RAF,
742 },
743 DetectionCheck {
744 id: CheckId::GetComputedStylePresent,
745 description: "window.getComputedStyle must be a function (PX env-bitmask bit 3)",
746 script: SCRIPT_GET_COMPUTED_STYLE,
747 },
748 DetectionCheck {
749 id: CheckId::CssSupportsPresent,
750 description: "CSS.supports must exist and be callable (PX env-bitmask bit 4)",
751 script: SCRIPT_CSS_SUPPORTS,
752 },
753 DetectionCheck {
754 id: CheckId::SendBeaconPresent,
755 description: "navigator.sendBeacon must be a function (PX env-bitmask bit 5)",
756 script: SCRIPT_SEND_BEACON,
757 },
758 DetectionCheck {
759 id: CheckId::ExecCommandPresent,
760 description: "document.execCommand must be a function (PX env-bitmask bit 6)",
761 script: SCRIPT_EXEC_COMMAND,
762 },
763 DetectionCheck {
764 id: CheckId::NodeJsAbsent,
765 description: "process.versions.node must be absent — not a Node.js environment (PX env-bitmask bit 7)",
766 script: SCRIPT_NODEJS_ABSENT,
767 },
768 DetectionCheck {
769 id: CheckId::WebDriverDescriptorShape,
770 description: "Navigator.prototype.webdriver must look like an accessor descriptor",
771 script: SCRIPT_WEBDRIVER_DESCRIPTOR,
772 },
773 DetectionCheck {
774 id: CheckId::UserAgentDataPresent,
775 description: "navigator.userAgentData must expose coherent client hints",
776 script: SCRIPT_USER_AGENT_DATA,
777 },
778 DetectionCheck {
779 id: CheckId::ConnectionPresent,
780 description: "navigator.connection must expose plausible network information",
781 script: SCRIPT_CONNECTION,
782 },
783 DetectionCheck {
784 id: CheckId::HiddenFontProbeRect,
785 description: "hidden font probes must yield non-zero layout measurements",
786 script: SCRIPT_HIDDEN_FONT_PROBE,
787 },
788 DetectionCheck {
789 id: CheckId::ScreenMetricsCoherent,
790 description: "screen metrics and devicePixelRatio must be coherent",
791 script: SCRIPT_SCREEN_METRICS,
792 },
793 DetectionCheck {
794 id: CheckId::AudioContextPresent,
795 description: "AudioContext must expose a non-zero sample rate",
796 script: SCRIPT_AUDIO_CONTEXT,
797 },
798];
799
800static LIMITATION_PROBES: &[LimitationProbe] = &[
801 LimitationProbe {
802 id: LimitationId::WebGpuSurface,
803 description: "navigator.gpu / WebGPU is exposed but not yet spoofed or validated",
804 script: SCRIPT_WEBGPU_LIMITATION,
805 },
806 LimitationProbe {
807 id: LimitationId::PerformanceMemorySurface,
808 description: "performance.memory is exposed but not yet spoofed or validated",
809 script: SCRIPT_PERFORMANCE_MEMORY_LIMITATION,
810 },
811 LimitationProbe {
812 id: LimitationId::OpaqueOriginStorage,
813 description: "navigator.storage is unavailable or incomplete on this origin",
814 script: SCRIPT_STORAGE_ESTIMATE,
815 },
816];
817
818#[cfg(test)]
821#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
822mod tests {
823 use super::*;
824 use std::collections::HashSet;
825
826 #[test]
827 fn all_checks_returns_eighteen_entries() {
828 assert_eq!(all_checks().len(), 24);
829 }
830
831 #[test]
832 fn all_limitation_probes_returns_two_entries() {
833 assert_eq!(all_limitation_probes().len(), 3);
834 }
835
836 #[test]
837 fn all_checks_have_unique_ids() {
838 let ids: HashSet<_> = all_checks().iter().map(|c| c.id).collect();
839 assert_eq!(
840 ids.len(),
841 all_checks().len(),
842 "duplicate check ids detected"
843 );
844 }
845
846 #[test]
847 fn all_checks_have_non_empty_scripts_with_json_stringify() {
848 for check in all_checks() {
849 assert!(
850 !check.script.is_empty(),
851 "check {:?} has empty script",
852 check.id
853 );
854 assert!(
855 check.script.contains("JSON.stringify"),
856 "check {:?} script must produce a JSON string",
857 check.id
858 );
859 }
860 }
861
862 #[test]
863 fn parse_output_valid_passing_json() {
864 let check = &all_checks()[0]; let result = check.parse_output(r#"{"passed":true,"details":"undefined"}"#);
866 assert!(result.passed);
867 assert_eq!(result.id, CheckId::WebDriverFlag);
868 assert_eq!(result.details, "undefined");
869 }
870
871 #[test]
872 fn parse_output_valid_failing_json() {
873 let check = &all_checks()[0];
874 let result = check.parse_output(r#"{"passed":false,"details":"true"}"#);
875 assert!(!result.passed);
876 }
877
878 #[test]
879 fn parse_output_invalid_json_returns_fail_with_details() {
880 let check = &all_checks()[0];
881 let result = check.parse_output("not json at all");
882 assert!(!result.passed);
883 assert!(result.details.contains("parse error"));
884 }
885
886 #[test]
887 fn parse_output_preserves_check_id() {
888 let check = all_checks()
889 .iter()
890 .find(|c| c.id == CheckId::ChromeObject)
891 .unwrap();
892 let result = check.parse_output(r#"{"passed":true,"details":"object"}"#);
893 assert_eq!(result.id, CheckId::ChromeObject);
894 assert_eq!(result.description, check.description);
895 }
896
897 #[test]
898 fn parse_output_missing_details_defaults_to_empty() {
899 let check = &all_checks()[0];
900 let result = check.parse_output(r#"{"passed":true}"#);
901 assert!(result.passed);
902 assert!(result.details.is_empty());
903 }
904
905 #[test]
906 fn diagnostic_report_all_passing() {
907 let results: Vec<CheckResult> = all_checks()
908 .iter()
909 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
910 .collect();
911 let report = DiagnosticReport::new(results);
912 assert!(report.is_clean());
913 assert_eq!(report.passed_count, 24);
914 assert!(report.known_limitations.is_empty());
915 assert_eq!(report.failed_count, 0);
916 assert!((report.coverage_pct() - 100.0).abs() < 0.001);
917 assert_eq!(report.failures().count(), 0);
918 }
919
920 #[test]
921 fn diagnostic_report_some_failing() {
922 let mut results: Vec<CheckResult> = all_checks()
923 .iter()
924 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
925 .collect();
926 results[0].passed = false;
927 results[2].passed = false;
928 let report = DiagnosticReport::new(results);
929 assert!(!report.is_clean());
930 assert_eq!(report.failed_count, 2);
931 assert_eq!(report.passed_count, 22);
932 assert_eq!(report.failures().count(), 2);
933 }
934
935 #[test]
936 fn diagnostic_report_empty_checks() {
937 let report = DiagnosticReport::new(Vec::new());
938 assert!(report.is_clean()); assert!((report.coverage_pct()).abs() < 0.001);
940 }
941
942 #[test]
943 fn check_result_serializes_with_snake_case_id() {
944 let result = CheckResult {
945 id: CheckId::WebDriverFlag,
946 description: "test".to_string(),
947 passed: true,
948 details: "ok".to_string(),
949 };
950 let json = serde_json::to_string(&result).unwrap();
951 assert!(json.contains("\"web_driver_flag\""), "got: {json}");
952 assert!(json.contains("\"passed\":true"));
953 }
954
955 #[test]
956 fn diagnostic_report_serializes_and_deserializes() {
957 let results: Vec<CheckResult> = all_checks()
958 .iter()
959 .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
960 .collect();
961 let report = DiagnosticReport::new(results).with_known_limitations(vec![KnownLimitation {
962 id: LimitationId::WebGpuSurface,
963 description: "navigator.gpu / WebGPU is exposed but not yet spoofed or validated"
964 .to_string(),
965 details: "object".to_string(),
966 }]);
967 let json = serde_json::to_string(&report).unwrap();
968 let restored: DiagnosticReport = serde_json::from_str(&json).unwrap();
969 assert_eq!(restored.passed_count, report.passed_count);
970 assert_eq!(restored.known_limitations.len(), 1);
971 assert!(restored.is_clean());
972 }
973
974 #[test]
975 fn limitation_probe_reports_surface_when_limited() {
976 let probe = &all_limitation_probes()[0];
977 let limitation = probe
978 .parse_output(r#"{"limited":true,"details":"object"}"#)
979 .unwrap();
980 assert_eq!(limitation.id, LimitationId::WebGpuSurface);
981 assert_eq!(limitation.details, "object");
982 }
983
984 #[test]
985 fn limitation_probe_returns_none_when_surface_not_limited() {
986 let probe = &all_limitation_probes()[0];
987 assert!(
988 probe
989 .parse_output(r#"{"limited":false,"details":"undefined"}"#)
990 .is_none()
991 );
992 }
993
994 #[test]
995 fn transport_diagnostic_reports_match_for_matching_observations() {
996 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";
997 let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
998
999 assert!(
1001 expected.expected_profile.is_some()
1002 || expected.expected_ja3_hash.is_some()
1003 || expected.expected_ja4.is_some()
1004 || expected.expected_http3_perk_text.is_some()
1005 );
1006
1007 let observed = TransportObservations {
1008 ja3_hash: expected.expected_ja3_hash.clone(),
1009 ja4: expected.expected_ja4.clone(),
1010 http3_perk_text: expected.expected_http3_perk_text.clone(),
1011 http3_perk_hash: expected.expected_http3_perk_hash,
1012 };
1013 let diagnostic =
1014 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1015
1016 assert_eq!(diagnostic.transport_match, Some(true));
1017 assert!(diagnostic.mismatches.is_empty());
1018 }
1019
1020 #[test]
1021 fn transport_diagnostic_reports_mismatch_for_mismatching_observations() {
1022 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";
1023 let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
1024
1025 assert!(expected.expected_ja3_hash.is_some());
1026
1027 let observed = TransportObservations {
1028 ja3_hash: Some("definitely-not-the-expected-ja3".to_string()),
1029 ja4: expected.expected_ja4.clone(),
1030 http3_perk_text: expected.expected_http3_perk_text.clone(),
1031 http3_perk_hash: expected.expected_http3_perk_hash,
1032 };
1033 let diagnostic =
1034 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1035
1036 assert_eq!(diagnostic.transport_match, Some(false));
1037 assert!(!diagnostic.mismatches.is_empty());
1038 assert!(
1039 diagnostic
1040 .mismatches
1041 .iter()
1042 .any(|m| m.contains("ja3_hash mismatch"))
1043 );
1044 }
1045
1046 #[test]
1047 fn transport_diagnostic_flags_observations_when_no_expectations_derivable() {
1048 let user_agent = "UnknownBrowser/0.0";
1049 let diagnostic_without_observed =
1050 TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
1051
1052 assert_eq!(diagnostic_without_observed.expected_profile, None);
1054 assert_eq!(diagnostic_without_observed.expected_ja3_hash, None);
1055 assert_eq!(diagnostic_without_observed.expected_ja4, None);
1056 assert_eq!(diagnostic_without_observed.expected_http3_perk_text, None);
1057
1058 let observed = TransportObservations {
1059 ja3_hash: Some("some-observed-ja3".to_string()),
1060 ja4: Some("some-observed-ja4".to_string()),
1061 http3_perk_text: Some("some-observed-http3-perk-text".to_string()),
1062 http3_perk_hash: Some("some-observed-http3-perk-hash".to_string()),
1063 };
1064
1065 let diagnostic =
1066 TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
1067
1068 assert_eq!(diagnostic.transport_match, Some(false));
1070 assert!(!diagnostic.mismatches.is_empty());
1071 assert!(
1072 diagnostic
1073 .mismatches
1074 .iter()
1075 .any(|m| m.contains("no expected JA3 could be derived"))
1076 );
1077 }
1078}