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