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};
37use std::fmt;
38
39use crate::integrity_canary::IntegrityCanaryReport;
40use crate::transport_realism::TransportRealismReport;
41
42// ── CheckId ───────────────────────────────────────────────────────────────────
43
44/// Stable identifier for a built-in stealth detection check.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum CheckId {
48    /// `navigator.webdriver` must be `undefined` or `false`.
49    WebDriverFlag,
50    /// `window.chrome.runtime` must be present (absent in headless by default).
51    ChromeObject,
52    /// `navigator.plugins` must have at least one entry.
53    PluginCount,
54    /// `navigator.languages` must be non-empty.
55    LanguagesPresent,
56    /// Canvas `toDataURL()` must return non-trivial image data.
57    CanvasConsistency,
58    /// WebGL vendor/renderer must not contain the `SwiftShader` software-renderer marker.
59    WebGlVendor,
60    /// No automation-specific globals (`__puppeteer__`, `__playwright`, etc.) must be present.
61    AutomationGlobals,
62    /// `window.outerWidth` and `window.outerHeight` must be non-zero.
63    OuterWindowSize,
64    /// `navigator.userAgent` must not contain the `"HeadlessChrome"` substring.
65    HeadlessUserAgent,
66    /// `Notification.permission` must not be pre-granted (automation artefact).
67    NotificationPermission,
68    /// `window.matchMedia` must be a function (PX env-bitmask bit 0).
69    MatchMediaPresent,
70    /// `document.elementFromPoint` must be a function (PX env-bitmask bit 1).
71    ElementFromPointPresent,
72    /// `window.requestAnimationFrame` must be a function (PX env-bitmask bit 2).
73    RequestAnimationFramePresent,
74    /// `window.getComputedStyle` must be a function (PX env-bitmask bit 3).
75    GetComputedStylePresent,
76    /// `CSS.supports` must exist and be callable (PX env-bitmask bit 4).
77    CssSupportsPresent,
78    /// `navigator.sendBeacon` must be a function (PX env-bitmask bit 5).
79    SendBeaconPresent,
80    /// `document.execCommand` must be a function (PX env-bitmask bit 6).
81    ExecCommandPresent,
82    /// `process.versions.node` must be absent — not a Node.js environment (PX env-bitmask bit 7).
83    NodeJsAbsent,
84    /// `Navigator.prototype.webdriver` should look like a native accessor descriptor.
85    WebDriverDescriptorShape,
86    /// `navigator.userAgentData` should exist and expose coherent client hints.
87    UserAgentDataPresent,
88    /// `navigator.connection` should expose plausible network information.
89    ConnectionPresent,
90    /// Hidden font-probe elements should yield non-zero layout measurements.
91    HiddenFontProbeRect,
92    /// `screen` metrics and `devicePixelRatio` should be plausible and coherent.
93    ScreenMetricsCoherent,
94    /// The Web Audio surface should exist and expose a non-zero sample rate.
95    AudioContextPresent,
96}
97
98/// Stable identifier for a browser surface we do not yet spoof or validate fully.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum LimitationId {
102    /// WebGPU / `navigator.gpu` is exposed but not yet spoofed or validated.
103    WebGpuSurface,
104    /// `performance.memory` is exposed but not yet spoofed or validated.
105    PerformanceMemorySurface,
106    /// `navigator.storage` may be unavailable on opaque origins (e.g. `about:blank`).
107    OpaqueOriginStorage,
108}
109
110// ── CheckResult ───────────────────────────────────────────────────────────────
111
112/// The outcome of running a single detection check in the browser.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct CheckResult {
115    /// Which check produced this result.
116    pub id: CheckId,
117    /// Human-readable description of what was tested.
118    pub description: String,
119    /// `true` if the browser appears legitimate for this check.
120    pub passed: bool,
121    /// Diagnostic detail returned by the JavaScript evaluation.
122    pub details: String,
123}
124
125/// A known browser surface that is visible but not yet covered by stealth diagnostics.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct KnownLimitation {
128    /// Which limitation was observed.
129    pub id: LimitationId,
130    /// Human-readable description of the uncovered surface.
131    pub description: String,
132    /// Runtime detail from the page context.
133    pub details: String,
134}
135
136/// Optional observed transport fingerprints to compare against expected values.
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct TransportObservations {
139    /// Observed JA3 hash (lower/upper hex accepted).
140    pub ja3_hash: Option<String>,
141    /// Observed JA4 fingerprint string.
142    pub ja4: Option<String>,
143    /// Observed HTTP/3 perk text (`SETTINGS|PSEUDO_HEADERS`).
144    pub http3_perk_text: Option<String>,
145    /// Observed HTTP/3 perk hash.
146    pub http3_perk_hash: Option<String>,
147}
148
149/// Transport-level diagnostics emitted alongside JavaScript stealth checks.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct TransportDiagnostic {
152    /// User-Agent sampled from the live page.
153    pub user_agent: String,
154    /// Built-in profile name inferred from User-Agent, if any.
155    pub expected_profile: Option<String>,
156    /// Expected JA3 raw string from the inferred profile.
157    pub expected_ja3_raw: Option<String>,
158    /// Expected JA3 hash from the inferred profile.
159    pub expected_ja3_hash: Option<String>,
160    /// Expected JA4 fingerprint from the inferred profile.
161    pub expected_ja4: Option<String>,
162    /// Expected HTTP/3 perk text derived from User-Agent.
163    pub expected_http3_perk_text: Option<String>,
164    /// Expected HTTP/3 perk hash derived from User-Agent.
165    pub expected_http3_perk_hash: Option<String>,
166    /// Caller-supplied observed transport values.
167    pub observed: TransportObservations,
168    /// `true` when all supplied observations match expected fingerprints.
169    /// `None` when no observations were supplied.
170    pub transport_match: Option<bool>,
171    /// Human-readable mismatch reasons.
172    pub mismatches: Vec<String>,
173}
174
175impl TransportDiagnostic {
176    /// Build transport diagnostics from `user_agent` and optional observations.
177    #[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        // Resolve profile once; derive all fingerprints from it to avoid repeated UA parsing.
185        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 callers supplied observed transport fields that cannot be compared
221        // due to missing expectations, surface that explicitly instead of
222        // reporting a false positive match.
223        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// ── DiagnosticReport ──────────────────────────────────────────────────────────
268
269/// Aggregate result from running all detection checks.
270///
271/// # Example
272///
273/// ```
274/// use stygian_browser::diagnostic::{all_checks, DiagnosticReport};
275///
276/// let results = all_checks()
277///     .iter()
278///     .map(|c| c.parse_output(r#"{"passed":true,"details":"ok"}"#))
279///     .collect::<Vec<_>>();
280/// let report = DiagnosticReport::new(results);
281/// assert!(report.is_clean());
282/// assert!((report.coverage_pct() - 100.0).abs() < 0.001);
283/// ```
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct DiagnosticReport {
286    /// Individual check results in catalogue order.
287    pub checks: Vec<CheckResult>,
288    /// Number of checks where `passed == true`.
289    pub passed_count: usize,
290    /// Number of checks where `passed == false`.
291    pub failed_count: usize,
292    /// Browser surfaces observed at runtime that are not yet covered fully.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub known_limitations: Vec<KnownLimitation>,
295    /// Optional transport-layer diagnostics (JA3/JA4/HTTP3 perk).
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub transport: Option<TransportDiagnostic>,
298    /// Optional transport-realism score (T82). Backward-compatible
299    /// additive field: omitted from JSON when `None`, present (as
300    /// an object) when transport-realism scoring ran. See
301    /// [`crate::transport_realism::TransportRealismReport`] for
302    /// the schema.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub transport_realism: Option<TransportRealismReport>,
305    /// Optional integrity canary report (T92). Backward-compatible
306    /// additive field: omitted from JSON when `None`, present (as
307    /// an object) when integrity canary scoring ran. Carries the
308    /// aggregate risk score, Suspected/Confirmed classification,
309    /// per-probe findings, and aggregated mitigation hints. See
310    /// [`crate::integrity_canary::IntegrityCanaryReport`] for the
311    /// schema.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub integrity_canary: Option<IntegrityCanaryReport>,
314}
315
316impl DiagnosticReport {
317    /// Build a report from an ordered list of check results.
318    #[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    /// Attach known browser-surface limitations to this report.
334    #[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    /// Attach transport diagnostics to this report.
341    #[must_use]
342    pub fn with_transport(mut self, transport: TransportDiagnostic) -> Self {
343        self.transport = Some(transport);
344        self
345    }
346
347    /// Attach transport-realism diagnostics (T82) to this report.
348    ///
349    /// Backward-compatible: the field is omitted from JSON when the
350    /// supplied report would otherwise be `None` and the rest of the
351    /// `DiagnosticReport` schema is unchanged.
352    #[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    /// Attach integrity canary diagnostics (T92) to this report.
359    ///
360    /// Backward-compatible: the field is omitted from JSON when the
361    /// supplied report would otherwise be `None` and the rest of the
362    /// `DiagnosticReport` schema is unchanged.
363    #[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    /// Returns `true` when every check passed.
370    #[must_use]
371    pub const fn is_clean(&self) -> bool {
372        self.failed_count == 0
373    }
374
375    /// Percentage of checks that passed (0.0–100.0).
376    #[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    /// Iterate over all checks that returned `passed: false`.
386    pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
387        self.checks.iter().filter(|r| !r.passed)
388    }
389}
390
391// ── DetectionCheck ────────────────────────────────────────────────────────────
392
393/// A single stealth detection check: identifier, description, and JavaScript
394/// to evaluate via CDP `Runtime.evaluate`.
395pub struct DetectionCheck {
396    /// Stable identifier.
397    pub id: CheckId,
398    /// Human-readable description of what this check tests.
399    pub description: &'static str,
400    /// Self-contained JavaScript expression that **must** evaluate to a JSON
401    /// string with shape `'{"passed":true|false,"details":"..."}'`.
402    ///
403    /// The expression is sent verbatim to CDP `Runtime.evaluate`.  Use IIFEs
404    /// (`(function(){ ... })()`) for multi-statement scripts.
405    pub script: &'static str,
406}
407
408/// A runtime probe for a visible browser surface we do not yet fully cover.
409pub struct LimitationProbe {
410    /// Stable identifier.
411    pub id: LimitationId,
412    /// Human-readable description.
413    pub description: &'static str,
414    /// JavaScript expression returning a JSON string with shape
415    /// `'{"limited":true|false,"details":"..."}'`.
416    pub script: &'static str,
417}
418
419impl DetectionCheck {
420    /// Parse the JSON string returned by the CDP evaluation of [`script`](Self::script).
421    ///
422    /// If the JSON is invalid (e.g. the script threw an exception), returns a
423    /// failing [`CheckResult`] with the raw output in `details` (conservative
424    /// fallback — avoids silently hiding problems).
425    #[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    /// Parse the JSON string returned by the CDP evaluation of [`script`](Self::script).
461    #[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
477// ── Built-in JavaScript scripts ───────────────────────────────────────────────
478
479const 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// ── Static check catalogue ────────────────────────────────────────────────────
711
712/// Return all built-in stealth detection checks.
713///
714/// Iterate the slice, send each `check.script` to the browser via CDP, then
715/// call [`DetectionCheck::parse_output`] with the returned JSON string.
716#[must_use]
717pub fn all_checks() -> &'static [DetectionCheck] {
718    CHECKS
719}
720
721/// Return all known browser-surface limitation probes.
722#[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// ── tests ─────────────────────────────────────────────────────────────────────
869
870#[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]; // WebDriverFlag
920        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()); // vacuously true
994        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        // Ensure test UA resolves at least one expected fingerprint.
1055        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        // Unknown UA should not resolve any expectations.
1108        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        // With observations but no expectations, mismatches should be flagged.
1124        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    // ── T82 transport-realism diagnostic schema (backward-compatible) ────────
1135
1136    #[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        // The new field is additive — it must NOT appear when no
1145        // transport-realism score has been attached. This is the
1146        // backward-compatible contract for downstream automation
1147        // that parses diagnostic payloads.
1148        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        // Simulates a payload produced by an older stygian-browser
1186        // version (before T82 landed) — must still deserialize into
1187        // a DiagnosticReport with transport_realism == None.
1188        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        // Sanity-check the JSON wire format so downstream automation
1202        // can rely on the documented schema.
1203        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        // Round-trip via a generic Value to confirm backward compat:
1217        // every top-level field deserializes back to the same shape.
1218        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    // ── T92 integrity canary diagnostic schema (backward-compatible) ──────────
1231
1232    #[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        // The new field is additive — it must NOT appear when no
1241        // integrity canary report has been attached. This is the
1242        // backward-compatible contract for downstream automation
1243        // that parses diagnostic payloads.
1244        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        // Simulates a payload produced by an older stygian-browser
1285        // version (before T92 landed) — must still deserialize into
1286        // a DiagnosticReport with integrity_canary == None.
1287        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// =============================================================================
1301// Diagnostic hints — non-fatal warnings emitted by configuration validation.
1302// =============================================================================
1303
1304/// HTTP/3 preference supported by the browser stack.
1305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1306pub enum ProtocolVersion {
1307    /// HTTP/2 over TLS — Chrome's default when no H3 is negotiated.
1308    H2,
1309    /// HTTP/3 over QUIC.
1310    H3,
1311}
1312
1313impl ProtocolVersion {
1314    /// Short wire label.
1315    #[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/// Why a protocol downgrade occurred — T105 only has one cause today
1331/// (operator-configured HTTP proxy), but the variant is open for future
1332/// causes (e.g. tunnel wrappers that strip UDP).
1333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1334pub enum DowngradeCause {
1335    /// Operator configured an HTTP proxy URL — Chrome is TCP-only over
1336    /// SOCKS5 in practice and HTTP proxies tunnel TCP only, so even
1337    /// with `prefer_h3 = true` the negotiated protocol falls back to
1338    /// HTTP/2 (per [Web Scraping Guide §Innovation](https://web-scraping-guide.com/#innovation)).
1339    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/// Severity for a [`DiagnosticHint`]. Hints are non-fatal; `Warning`
1351/// does not block CI gates that look for hard errors.
1352#[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/// Non-fatal diagnostic hint emitted by configuration validation. Hints
1368/// surface structural incompatibilities that the operator may want to
1369/// know about but that don't block the browser from launching.
1370#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1371pub struct DiagnosticHint {
1372    /// Severity — `Warning` is the default for structural incompatibilities.
1373    pub severity: HintSeverity,
1374    /// Short identifier (e.g. `"protocol_downgrade"`). Stable across
1375    /// versions; useful for tooling that wants to ignore specific hints.
1376    pub kind: String,
1377    /// Human-readable message.
1378    pub message: String,
1379}
1380
1381impl DiagnosticHint {
1382    /// New info-level hint.
1383    #[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    /// New warning-level hint.
1393    #[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    /// `true` if the hint is a warning.
1403    #[must_use]
1404    pub const fn is_warning(&self) -> bool {
1405        matches!(self.severity, HintSeverity::Warning)
1406    }
1407
1408    /// Render as `[{severity}] {kind}: {message}`.
1409    #[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/// Build the protocol-downgrade hint payload for a proxy + `prefer_h3`
1422/// combination. Public so the `BrowserConfig::diagnostic_hints()` accessor
1423/// can call it without re-implementing the message string.
1424#[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}