Skip to main content

stygian_browser/
diagnostic.rs

1//! Stealth self-diagnostic — JavaScript detection checks.
2//!
3//! Defines a catalogue of JavaScript snippets that detect common browser-
4//! automation telltales when evaluated inside a live browser context via
5//! CDP `Runtime.evaluate`.
6//!
7//! Each check script evaluates to a JSON string:
8//!
9//! ```json
10//! { "passed": true, "details": "..." }
11//! ```
12//!
13//! # Usage
14//!
15//! 1. Iterate [`all_checks`] to get the built-in check catalogue.
16//! 2. For each [`DetectionCheck`], send `check.script` to the browser via
17//!    CDP and collect the returned JSON string.
18//! 3. Call [`DetectionCheck::parse_output`] to get a [`CheckResult`].
19//! 4. Aggregate with [`DiagnosticReport::new`].
20//!
21//! # Example
22//!
23//! ```
24//! use stygian_browser::diagnostic::{all_checks, DiagnosticReport};
25//!
26//! // Simulate every check returning a passing result
27//! let results = all_checks()
28//!     .iter()
29//!     .map(|check| check.parse_output(r#"{"passed":true,"details":"ok"}"#))
30//!     .collect::<Vec<_>>();
31//!
32//! let report = DiagnosticReport::new(results);
33//! assert!(report.is_clean());
34//! ```
35
36use serde::{Deserialize, Serialize};
37
38// ── CheckId ───────────────────────────────────────────────────────────────────
39
40/// Stable identifier for a built-in stealth detection check.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum CheckId {
44    /// `navigator.webdriver` must be `undefined` or `false`.
45    WebDriverFlag,
46    /// `window.chrome.runtime` must be present (absent in headless by default).
47    ChromeObject,
48    /// `navigator.plugins` must have at least one entry.
49    PluginCount,
50    /// `navigator.languages` must be non-empty.
51    LanguagesPresent,
52    /// Canvas `toDataURL()` must return non-trivial image data.
53    CanvasConsistency,
54    /// WebGL vendor/renderer must not contain the `SwiftShader` software-renderer marker.
55    WebGlVendor,
56    /// No automation-specific globals (`__puppeteer__`, `__playwright`, etc.) must be present.
57    AutomationGlobals,
58    /// `window.outerWidth` and `window.outerHeight` must be non-zero.
59    OuterWindowSize,
60    /// `navigator.userAgent` must not contain the `"HeadlessChrome"` substring.
61    HeadlessUserAgent,
62    /// `Notification.permission` must not be pre-granted (automation artefact).
63    NotificationPermission,
64    /// `window.matchMedia` must be a function (PX env-bitmask bit 0).
65    MatchMediaPresent,
66    /// `document.elementFromPoint` must be a function (PX env-bitmask bit 1).
67    ElementFromPointPresent,
68    /// `window.requestAnimationFrame` must be a function (PX env-bitmask bit 2).
69    RequestAnimationFramePresent,
70    /// `window.getComputedStyle` must be a function (PX env-bitmask bit 3).
71    GetComputedStylePresent,
72    /// `CSS.supports` must exist and be callable (PX env-bitmask bit 4).
73    CssSupportsPresent,
74    /// `navigator.sendBeacon` must be a function (PX env-bitmask bit 5).
75    SendBeaconPresent,
76    /// `document.execCommand` must be a function (PX env-bitmask bit 6).
77    ExecCommandPresent,
78    /// `process.versions.node` must be absent — not a Node.js environment (PX env-bitmask bit 7).
79    NodeJsAbsent,
80}
81
82// ── CheckResult ───────────────────────────────────────────────────────────────
83
84/// The outcome of running a single detection check in the browser.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct CheckResult {
87    /// Which check produced this result.
88    pub id: CheckId,
89    /// Human-readable description of what was tested.
90    pub description: String,
91    /// `true` if the browser appears legitimate for this check.
92    pub passed: bool,
93    /// Diagnostic detail returned by the JavaScript evaluation.
94    pub details: String,
95}
96
97/// Optional observed transport fingerprints to compare against expected values.
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct TransportObservations {
100    /// Observed JA3 hash (lower/upper hex accepted).
101    pub ja3_hash: Option<String>,
102    /// Observed JA4 fingerprint string.
103    pub ja4: Option<String>,
104    /// Observed HTTP/3 perk text (`SETTINGS|PSEUDO_HEADERS`).
105    pub http3_perk_text: Option<String>,
106    /// Observed HTTP/3 perk hash.
107    pub http3_perk_hash: Option<String>,
108}
109
110/// Transport-level diagnostics emitted alongside JavaScript stealth checks.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct TransportDiagnostic {
113    /// User-Agent sampled from the live page.
114    pub user_agent: String,
115    /// Built-in profile name inferred from User-Agent, if any.
116    pub expected_profile: Option<String>,
117    /// Expected JA3 raw string from the inferred profile.
118    pub expected_ja3_raw: Option<String>,
119    /// Expected JA3 hash from the inferred profile.
120    pub expected_ja3_hash: Option<String>,
121    /// Expected JA4 fingerprint from the inferred profile.
122    pub expected_ja4: Option<String>,
123    /// Expected HTTP/3 perk text derived from User-Agent.
124    pub expected_http3_perk_text: Option<String>,
125    /// Expected HTTP/3 perk hash derived from User-Agent.
126    pub expected_http3_perk_hash: Option<String>,
127    /// Caller-supplied observed transport values.
128    pub observed: TransportObservations,
129    /// `true` when all supplied observations match expected fingerprints.
130    /// `None` when no observations were supplied.
131    pub transport_match: Option<bool>,
132    /// Human-readable mismatch reasons.
133    pub mismatches: Vec<String>,
134}
135
136impl TransportDiagnostic {
137    /// Build transport diagnostics from `user_agent` and optional observations.
138    #[must_use]
139    pub fn from_user_agent_and_observations(
140        user_agent: &str,
141        observed: Option<&TransportObservations>,
142    ) -> Self {
143        let observed = observed.cloned().unwrap_or_default();
144
145        // Resolve profile once; derive all fingerprints from it to avoid repeated UA parsing.
146        let expected_profile = crate::tls::expected_tls_profile_from_user_agent(user_agent);
147        let expected_ja3 = expected_profile.map(crate::tls::TlsProfile::ja3);
148        let expected_ja4 = expected_profile.map(crate::tls::TlsProfile::ja4);
149        let expected_http3 = expected_profile.and_then(crate::tls::TlsProfile::http3_perk);
150
151        let mut mismatches = Vec::new();
152
153        if let (Some(expected), Some(observed_hash)) = (
154            expected_ja3.as_ref().map(|j| j.hash.as_str()),
155            observed.ja3_hash.as_deref(),
156        ) && !observed_hash.eq_ignore_ascii_case(expected)
157        {
158            mismatches.push(format!(
159                "ja3_hash mismatch: expected '{expected}', observed '{observed_hash}'"
160            ));
161        }
162
163        if let (Some(expected), Some(observed_ja4)) = (
164            expected_ja4.as_ref().map(|j| j.fingerprint.as_str()),
165            observed.ja4.as_deref(),
166        ) && observed_ja4 != expected
167        {
168            mismatches.push(format!(
169                "ja4 mismatch: expected '{expected}', observed '{observed_ja4}'"
170            ));
171        }
172
173        if let Some(expected) = expected_http3.as_ref() {
174            let cmp = expected.compare(
175                observed.http3_perk_text.as_deref(),
176                observed.http3_perk_hash.as_deref(),
177            );
178            mismatches.extend(cmp.mismatches);
179        }
180
181        // If callers supplied observed transport fields that cannot be compared
182        // due to missing expectations, surface that explicitly instead of
183        // reporting a false positive match.
184        if observed.ja3_hash.is_some() && expected_ja3.is_none() {
185            mismatches.push(
186                "ja3_hash was provided but no expected JA3 could be derived from user-agent"
187                    .to_string(),
188            );
189        }
190        if observed.ja4.is_some() && expected_ja4.is_none() {
191            mismatches.push(
192                "ja4 was provided but no expected JA4 could be derived from user-agent".to_string(),
193            );
194        }
195        if (observed.http3_perk_text.is_some() || observed.http3_perk_hash.is_some())
196            && expected_http3.is_none()
197        {
198            mismatches.push(
199                "http3 perk observation was provided but no expected HTTP/3 fingerprint could be derived from user-agent"
200                    .to_string(),
201            );
202        }
203
204        let has_observed = observed.ja3_hash.is_some()
205            || observed.ja4.is_some()
206            || observed.http3_perk_text.is_some()
207            || observed.http3_perk_hash.is_some();
208
209        Self {
210            user_agent: user_agent.to_string(),
211            expected_profile: expected_profile.map(|p| p.name.clone()),
212            expected_ja3_raw: expected_ja3.as_ref().map(|j| j.raw.clone()),
213            expected_ja3_hash: expected_ja3.as_ref().map(|j| j.hash.clone()),
214            expected_ja4: expected_ja4.as_ref().map(|j| j.fingerprint.clone()),
215            expected_http3_perk_text: expected_http3
216                .as_ref()
217                .map(crate::tls::Http3Perk::perk_text),
218            expected_http3_perk_hash: expected_http3
219                .as_ref()
220                .map(crate::tls::Http3Perk::perk_hash),
221            observed,
222            transport_match: has_observed.then_some(mismatches.is_empty()),
223            mismatches,
224        }
225    }
226}
227
228// ── DiagnosticReport ──────────────────────────────────────────────────────────
229
230/// Aggregate result from running all detection checks.
231///
232/// # Example
233///
234/// ```
235/// use stygian_browser::diagnostic::{all_checks, DiagnosticReport};
236///
237/// let results = all_checks()
238///     .iter()
239///     .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
240///     .collect::<Vec<_>>();
241/// let report = DiagnosticReport::new(results);
242/// assert!(report.is_clean());
243/// assert!((report.coverage_pct() - 100.0).abs() < 0.001);
244/// ```
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct DiagnosticReport {
247    /// Individual check results in catalogue order.
248    pub checks: Vec<CheckResult>,
249    /// Number of checks where `passed == true`.
250    pub passed_count: usize,
251    /// Number of checks where `passed == false`.
252    pub failed_count: usize,
253    /// Optional transport-layer diagnostics (JA3/JA4/HTTP3 perk).
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub transport: Option<TransportDiagnostic>,
256}
257
258impl DiagnosticReport {
259    /// Build a report from an ordered list of check results.
260    pub fn new(checks: Vec<CheckResult>) -> Self {
261        let passed_count = checks.iter().filter(|r| r.passed).count();
262        let failed_count = checks.len() - passed_count;
263        Self {
264            checks,
265            passed_count,
266            failed_count,
267            transport: None,
268        }
269    }
270
271    /// Attach transport diagnostics to this report.
272    #[must_use]
273    pub fn with_transport(mut self, transport: TransportDiagnostic) -> Self {
274        self.transport = Some(transport);
275        self
276    }
277
278    /// Returns `true` when every check passed.
279    #[must_use]
280    pub const fn is_clean(&self) -> bool {
281        self.failed_count == 0
282    }
283
284    /// Percentage of checks that passed (0.0–100.0).
285    #[allow(clippy::cast_precision_loss)]
286    pub fn coverage_pct(&self) -> f64 {
287        if self.checks.is_empty() {
288            return 0.0;
289        }
290        self.passed_count as f64 / self.checks.len() as f64 * 100.0
291    }
292
293    /// Iterate over all checks that returned `passed: false`.
294    pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
295        self.checks.iter().filter(|r| !r.passed)
296    }
297}
298
299// ── DetectionCheck ────────────────────────────────────────────────────────────
300
301/// A single stealth detection check: identifier, description, and JavaScript
302/// to evaluate via CDP `Runtime.evaluate`.
303pub struct DetectionCheck {
304    /// Stable identifier.
305    pub id: CheckId,
306    /// Human-readable description of what this check tests.
307    pub description: &'static str,
308    /// Self-contained JavaScript expression that **must** evaluate to a JSON
309    /// string with shape `'{"passed":true|false,"details":"..."}'`.
310    ///
311    /// The expression is sent verbatim to CDP `Runtime.evaluate`.  Use IIFEs
312    /// (`(function(){ ... })()`) for multi-statement scripts.
313    pub script: &'static str,
314}
315
316impl DetectionCheck {
317    /// Parse the JSON string returned by the CDP evaluation of [`script`](Self::script).
318    ///
319    /// If the JSON is invalid (e.g. the script threw an exception), returns a
320    /// failing [`CheckResult`] with the raw output in `details` (conservative
321    /// fallback — avoids silently hiding problems).
322    pub fn parse_output(&self, json: &str) -> CheckResult {
323        #[derive(Deserialize)]
324        struct Output {
325            passed: bool,
326            #[serde(default)]
327            details: String,
328        }
329
330        match serde_json::from_str::<Output>(json) {
331            Ok(o) => CheckResult {
332                id: self.id,
333                description: self.description.to_string(),
334                passed: o.passed,
335                details: o.details,
336            },
337            Err(e) => CheckResult {
338                id: self.id,
339                description: self.description.to_string(),
340                passed: false,
341                details: format!("parse error: {e} | raw: {json}"),
342            },
343        }
344    }
345}
346
347// ── Built-in JavaScript scripts ───────────────────────────────────────────────
348
349const SCRIPT_WEBDRIVER: &str = concat!(
350    "JSON.stringify({",
351    "passed:navigator.webdriver===false||navigator.webdriver===undefined,",
352    "details:String(navigator.webdriver)",
353    "})"
354);
355
356const SCRIPT_CHROME_OBJECT: &str = concat!(
357    "JSON.stringify({",
358    "passed:typeof window.chrome!=='undefined'&&window.chrome!==null",
359    "&&typeof window.chrome.runtime!=='undefined',",
360    "details:typeof window.chrome",
361    "})"
362);
363
364const SCRIPT_PLUGIN_COUNT: &str = concat!(
365    "JSON.stringify({",
366    "passed:navigator.plugins.length>0,",
367    "details:navigator.plugins.length+' plugins'",
368    "})"
369);
370
371const SCRIPT_LANGUAGES: &str = concat!(
372    "JSON.stringify({",
373    "passed:Array.isArray(navigator.languages)&&navigator.languages.length>0,",
374    "details:JSON.stringify(navigator.languages)",
375    "})"
376);
377
378const SCRIPT_CANVAS: &str = concat!(
379    "(function(){",
380    "var c=document.createElement('canvas');",
381    "c.width=200;c.height=50;",
382    "var ctx=c.getContext('2d');",
383    "ctx.fillStyle='#1a2b3c';ctx.fillRect(0,0,200,50);",
384    "ctx.font='16px Arial';ctx.fillStyle='#fafafa';",
385    "ctx.fillText('stygian-diag',10,30);",
386    "var d=c.toDataURL();",
387    "return JSON.stringify({passed:d.length>200,details:'len='+d.length});",
388    "})()"
389);
390
391const SCRIPT_WEBGL_VENDOR: &str = concat!(
392    "(function(){",
393    "var gl=document.createElement('canvas').getContext('webgl');",
394    "if(!gl)return JSON.stringify({passed:false,details:'webgl unavailable'});",
395    "var ext=gl.getExtension('WEBGL_debug_renderer_info');",
396    "if(!ext)return JSON.stringify({passed:true,details:'debug ext absent (normal)'});",
397    "var v=gl.getParameter(ext.UNMASKED_VENDOR_WEBGL)||'';",
398    "var r=gl.getParameter(ext.UNMASKED_RENDERER_WEBGL)||'';",
399    "var sw=v.includes('SwiftShader')||r.includes('SwiftShader');",
400    "return JSON.stringify({passed:!sw,details:v+'/'+r});",
401    "})()"
402);
403
404const SCRIPT_AUTOMATION_GLOBALS: &str = concat!(
405    "JSON.stringify({",
406    "passed:typeof window.__puppeteer__==='undefined'",
407    "&&typeof window.__playwright==='undefined'",
408    "&&typeof window.__webdriverFunc==='undefined'",
409    "&&typeof window._phantom==='undefined',",
410    "details:'automation globals checked'",
411    "})"
412);
413
414const SCRIPT_OUTER_WINDOW: &str = concat!(
415    "JSON.stringify({",
416    "passed:window.outerWidth>0&&window.outerHeight>0,",
417    "details:window.outerWidth+'x'+window.outerHeight",
418    "})"
419);
420
421const SCRIPT_HEADLESS_UA: &str = concat!(
422    "JSON.stringify({",
423    "passed:!navigator.userAgent.includes('HeadlessChrome'),",
424    "details:navigator.userAgent.substring(0,100)",
425    "})"
426);
427
428const SCRIPT_NOTIFICATION: &str = concat!(
429    "JSON.stringify({",
430    "passed:typeof Notification==='undefined'||Notification.permission!=='granted',",
431    "details:typeof Notification!=='undefined'?Notification.permission:'unavailable'",
432    "})"
433);
434
435const SCRIPT_MATCH_MEDIA: &str = concat!(
436    "JSON.stringify({",
437    "passed:typeof window.matchMedia==='function',",
438    "details:typeof window.matchMedia",
439    "})"
440);
441
442const SCRIPT_ELEMENT_FROM_POINT: &str = concat!(
443    "JSON.stringify({",
444    "passed:typeof document.elementFromPoint==='function',",
445    "details:typeof document.elementFromPoint",
446    "})"
447);
448
449const SCRIPT_RAF: &str = concat!(
450    "JSON.stringify({",
451    "passed:typeof window.requestAnimationFrame==='function',",
452    "details:typeof window.requestAnimationFrame",
453    "})"
454);
455
456const SCRIPT_GET_COMPUTED_STYLE: &str = concat!(
457    "JSON.stringify({",
458    "passed:typeof window.getComputedStyle==='function',",
459    "details:typeof window.getComputedStyle",
460    "})"
461);
462
463const SCRIPT_CSS_SUPPORTS: &str = concat!(
464    "JSON.stringify({",
465    "passed:typeof CSS!=='undefined'&&typeof CSS.supports==='function',",
466    "details:typeof CSS!=='undefined'?typeof CSS.supports:'undefined'",
467    "})"
468);
469
470const SCRIPT_SEND_BEACON: &str = concat!(
471    "JSON.stringify({",
472    "passed:typeof navigator.sendBeacon==='function',",
473    "details:typeof navigator.sendBeacon",
474    "})"
475);
476
477const SCRIPT_EXEC_COMMAND: &str = concat!(
478    "JSON.stringify({",
479    "passed:typeof document.execCommand==='function',",
480    "details:typeof document.execCommand",
481    "})"
482);
483
484const SCRIPT_NODEJS_ABSENT: &str = concat!(
485    "JSON.stringify({",
486    "passed:typeof process==='undefined'",
487    "||process.versions==null",
488    "||typeof process.versions.node==='undefined',",
489    "details:typeof process",
490    "})"
491);
492
493// ── Static check catalogue ────────────────────────────────────────────────────
494
495/// Return all built-in stealth detection checks.
496///
497/// Iterate the slice, send each `check.script` to the browser via CDP, then
498/// call [`DetectionCheck::parse_output`] with the returned JSON string.
499pub fn all_checks() -> &'static [DetectionCheck] {
500    CHECKS
501}
502
503static CHECKS: &[DetectionCheck] = &[
504    DetectionCheck {
505        id: CheckId::WebDriverFlag,
506        description: "navigator.webdriver must be false/undefined",
507        script: SCRIPT_WEBDRIVER,
508    },
509    DetectionCheck {
510        id: CheckId::ChromeObject,
511        description: "window.chrome.runtime must exist",
512        script: SCRIPT_CHROME_OBJECT,
513    },
514    DetectionCheck {
515        id: CheckId::PluginCount,
516        description: "navigator.plugins must be non-empty",
517        script: SCRIPT_PLUGIN_COUNT,
518    },
519    DetectionCheck {
520        id: CheckId::LanguagesPresent,
521        description: "navigator.languages must be non-empty",
522        script: SCRIPT_LANGUAGES,
523    },
524    DetectionCheck {
525        id: CheckId::CanvasConsistency,
526        description: "canvas toDataURL must return non-trivial image data",
527        script: SCRIPT_CANVAS,
528    },
529    DetectionCheck {
530        id: CheckId::WebGlVendor,
531        description: "WebGL vendor must not be SwiftShader (software renderer)",
532        script: SCRIPT_WEBGL_VENDOR,
533    },
534    DetectionCheck {
535        id: CheckId::AutomationGlobals,
536        description: "automation globals (Puppeteer/Playwright) must be absent",
537        script: SCRIPT_AUTOMATION_GLOBALS,
538    },
539    DetectionCheck {
540        id: CheckId::OuterWindowSize,
541        description: "window.outerWidth/outerHeight must be non-zero",
542        script: SCRIPT_OUTER_WINDOW,
543    },
544    DetectionCheck {
545        id: CheckId::HeadlessUserAgent,
546        description: "User-Agent must not contain 'HeadlessChrome'",
547        script: SCRIPT_HEADLESS_UA,
548    },
549    DetectionCheck {
550        id: CheckId::NotificationPermission,
551        description: "Notification.permission must not be pre-granted",
552        script: SCRIPT_NOTIFICATION,
553    },
554    DetectionCheck {
555        id: CheckId::MatchMediaPresent,
556        description: "window.matchMedia must be a function (PX env-bitmask bit 0)",
557        script: SCRIPT_MATCH_MEDIA,
558    },
559    DetectionCheck {
560        id: CheckId::ElementFromPointPresent,
561        description: "document.elementFromPoint must be a function (PX env-bitmask bit 1)",
562        script: SCRIPT_ELEMENT_FROM_POINT,
563    },
564    DetectionCheck {
565        id: CheckId::RequestAnimationFramePresent,
566        description: "window.requestAnimationFrame must be a function (PX env-bitmask bit 2)",
567        script: SCRIPT_RAF,
568    },
569    DetectionCheck {
570        id: CheckId::GetComputedStylePresent,
571        description: "window.getComputedStyle must be a function (PX env-bitmask bit 3)",
572        script: SCRIPT_GET_COMPUTED_STYLE,
573    },
574    DetectionCheck {
575        id: CheckId::CssSupportsPresent,
576        description: "CSS.supports must exist and be callable (PX env-bitmask bit 4)",
577        script: SCRIPT_CSS_SUPPORTS,
578    },
579    DetectionCheck {
580        id: CheckId::SendBeaconPresent,
581        description: "navigator.sendBeacon must be a function (PX env-bitmask bit 5)",
582        script: SCRIPT_SEND_BEACON,
583    },
584    DetectionCheck {
585        id: CheckId::ExecCommandPresent,
586        description: "document.execCommand must be a function (PX env-bitmask bit 6)",
587        script: SCRIPT_EXEC_COMMAND,
588    },
589    DetectionCheck {
590        id: CheckId::NodeJsAbsent,
591        description: "process.versions.node must be absent — not a Node.js environment (PX env-bitmask bit 7)",
592        script: SCRIPT_NODEJS_ABSENT,
593    },
594];
595
596// ── tests ─────────────────────────────────────────────────────────────────────
597
598#[cfg(test)]
599#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
600mod tests {
601    use super::*;
602    use std::collections::HashSet;
603
604    #[test]
605    fn all_checks_returns_eighteen_entries() {
606        assert_eq!(all_checks().len(), 18);
607    }
608
609    #[test]
610    fn all_checks_have_unique_ids() {
611        let ids: HashSet<_> = all_checks().iter().map(|c| c.id).collect();
612        assert_eq!(
613            ids.len(),
614            all_checks().len(),
615            "duplicate check ids detected"
616        );
617    }
618
619    #[test]
620    fn all_checks_have_non_empty_scripts_with_json_stringify() {
621        for check in all_checks() {
622            assert!(
623                !check.script.is_empty(),
624                "check {:?} has empty script",
625                check.id
626            );
627            assert!(
628                check.script.contains("JSON.stringify"),
629                "check {:?} script must produce a JSON string",
630                check.id
631            );
632        }
633    }
634
635    #[test]
636    fn parse_output_valid_passing_json() {
637        let check = &all_checks()[0]; // WebDriverFlag
638        let result = check.parse_output(r#"{"passed":true,"details":"undefined"}"#);
639        assert!(result.passed);
640        assert_eq!(result.id, CheckId::WebDriverFlag);
641        assert_eq!(result.details, "undefined");
642    }
643
644    #[test]
645    fn parse_output_valid_failing_json() {
646        let check = &all_checks()[0];
647        let result = check.parse_output(r#"{"passed":false,"details":"true"}"#);
648        assert!(!result.passed);
649    }
650
651    #[test]
652    fn parse_output_invalid_json_returns_fail_with_details() {
653        let check = &all_checks()[0];
654        let result = check.parse_output("not json at all");
655        assert!(!result.passed);
656        assert!(result.details.contains("parse error"));
657    }
658
659    #[test]
660    fn parse_output_preserves_check_id() {
661        let check = all_checks()
662            .iter()
663            .find(|c| c.id == CheckId::ChromeObject)
664            .unwrap();
665        let result = check.parse_output(r#"{"passed":true,"details":"object"}"#);
666        assert_eq!(result.id, CheckId::ChromeObject);
667        assert_eq!(result.description, check.description);
668    }
669
670    #[test]
671    fn parse_output_missing_details_defaults_to_empty() {
672        let check = &all_checks()[0];
673        let result = check.parse_output(r#"{"passed":true}"#);
674        assert!(result.passed);
675        assert!(result.details.is_empty());
676    }
677
678    #[test]
679    fn diagnostic_report_all_passing() {
680        let results: Vec<CheckResult> = all_checks()
681            .iter()
682            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
683            .collect();
684        let report = DiagnosticReport::new(results);
685        assert!(report.is_clean());
686        assert_eq!(report.passed_count, 18);
687        assert_eq!(report.failed_count, 0);
688        assert!((report.coverage_pct() - 100.0).abs() < 0.001);
689        assert_eq!(report.failures().count(), 0);
690    }
691
692    #[test]
693    fn diagnostic_report_some_failing() {
694        let mut results: Vec<CheckResult> = all_checks()
695            .iter()
696            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
697            .collect();
698        results[0].passed = false;
699        results[2].passed = false;
700        let report = DiagnosticReport::new(results);
701        assert!(!report.is_clean());
702        assert_eq!(report.failed_count, 2);
703        assert_eq!(report.passed_count, 16);
704        assert_eq!(report.failures().count(), 2);
705    }
706
707    #[test]
708    fn diagnostic_report_empty_checks() {
709        let report = DiagnosticReport::new(Vec::new());
710        assert!(report.is_clean()); // vacuously true
711        assert!((report.coverage_pct()).abs() < 0.001);
712    }
713
714    #[test]
715    fn check_result_serializes_with_snake_case_id() {
716        let result = CheckResult {
717            id: CheckId::WebDriverFlag,
718            description: "test".to_string(),
719            passed: true,
720            details: "ok".to_string(),
721        };
722        let json = serde_json::to_string(&result).unwrap();
723        assert!(json.contains("\"web_driver_flag\""), "got: {json}");
724        assert!(json.contains("\"passed\":true"));
725    }
726
727    #[test]
728    fn diagnostic_report_serializes_and_deserializes() {
729        let results: Vec<CheckResult> = all_checks()
730            .iter()
731            .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
732            .collect();
733        let report = DiagnosticReport::new(results);
734        let json = serde_json::to_string(&report).unwrap();
735        let restored: DiagnosticReport = serde_json::from_str(&json).unwrap();
736        assert_eq!(restored.passed_count, report.passed_count);
737        assert!(restored.is_clean());
738    }
739
740    #[test]
741    fn transport_diagnostic_reports_match_for_matching_observations() {
742        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";
743        let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
744
745        // Ensure test UA resolves at least one expected fingerprint.
746        assert!(
747            expected.expected_profile.is_some()
748                || expected.expected_ja3_hash.is_some()
749                || expected.expected_ja4.is_some()
750                || expected.expected_http3_perk_text.is_some()
751        );
752
753        let observed = TransportObservations {
754            ja3_hash: expected.expected_ja3_hash.clone(),
755            ja4: expected.expected_ja4.clone(),
756            http3_perk_text: expected.expected_http3_perk_text.clone(),
757            http3_perk_hash: expected.expected_http3_perk_hash,
758        };
759        let diagnostic =
760            TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
761
762        assert_eq!(diagnostic.transport_match, Some(true));
763        assert!(diagnostic.mismatches.is_empty());
764    }
765
766    #[test]
767    fn transport_diagnostic_reports_mismatch_for_mismatching_observations() {
768        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";
769        let expected = TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
770
771        assert!(expected.expected_ja3_hash.is_some());
772
773        let observed = TransportObservations {
774            ja3_hash: Some("definitely-not-the-expected-ja3".to_string()),
775            ja4: expected.expected_ja4.clone(),
776            http3_perk_text: expected.expected_http3_perk_text.clone(),
777            http3_perk_hash: expected.expected_http3_perk_hash,
778        };
779        let diagnostic =
780            TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
781
782        assert_eq!(diagnostic.transport_match, Some(false));
783        assert!(!diagnostic.mismatches.is_empty());
784        assert!(
785            diagnostic
786                .mismatches
787                .iter()
788                .any(|m| m.contains("ja3_hash mismatch"))
789        );
790    }
791
792    #[test]
793    fn transport_diagnostic_flags_observations_when_no_expectations_derivable() {
794        let user_agent = "UnknownBrowser/0.0";
795        let diagnostic_without_observed =
796            TransportDiagnostic::from_user_agent_and_observations(user_agent, None);
797
798        // Unknown UA should not resolve any expectations.
799        assert_eq!(diagnostic_without_observed.expected_profile, None);
800        assert_eq!(diagnostic_without_observed.expected_ja3_hash, None);
801        assert_eq!(diagnostic_without_observed.expected_ja4, None);
802        assert_eq!(diagnostic_without_observed.expected_http3_perk_text, None);
803
804        let observed = TransportObservations {
805            ja3_hash: Some("some-observed-ja3".to_string()),
806            ja4: Some("some-observed-ja4".to_string()),
807            http3_perk_text: Some("some-observed-http3-perk-text".to_string()),
808            http3_perk_hash: Some("some-observed-http3-perk-hash".to_string()),
809        };
810
811        let diagnostic =
812            TransportDiagnostic::from_user_agent_and_observations(user_agent, Some(&observed));
813
814        // With observations but no expectations, mismatches should be flagged.
815        assert_eq!(diagnostic.transport_match, Some(false));
816        assert!(!diagnostic.mismatches.is_empty());
817        assert!(
818            diagnostic
819                .mismatches
820                .iter()
821                .any(|m| m.contains("no expected JA3 could be derived"))
822        );
823    }
824}