captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
//! TLS fingerprint diagnostics + forwarding-proxy architecture.
//!
//! # Why this module exists
//!
//! Modern anti-bot stacks (Cloudflare bot management, Akamai Bot
//! Manager, DataDome, hCaptcha Enterprise) score the JA3 / JA4 TLS
//! fingerprint of incoming connections. The fingerprint is a hash
//! of the TLS ClientHello: cipher-suite order, extensions order,
//! GREASE byte positions, signature algorithm list, supported group
//! list, ALPN ordering, key-share group ordering.
//!
//! Real Chrome (any version) produces a small, well-known set of
//! JA3/JA4 hashes. Headless chromium on Linux x86_64 produces a
//! distinct (and easily detectable) hash because:
//!
//! - `--use-mock-keychain` changes the cert-store iteration order
//!   which alters signature-algorithm list order.
//! - `--headless=new` ships with a slightly different default
//!   cipher list than the GUI build (one fewer GREASE slot).
//! - Containers without `nss-tools` fall back to a different
//!   client-cert-presentation flow that shifts an extension by one
//!   position.
//!
//! All three are SILENT, chrome connects fine, the page renders,
//! but the Cloudflare edge has already tagged the connection as
//! `non_chrome_tls` before the first HTTP header is parsed.
//!
//! # What this module provides
//!
//! 1. **Chrome-fingerprint table**: [`CHROME_FINGERPRINTS`]: known
//!    JA3 / JA4 hashes for stable Chrome on Linux/macOS/Windows,
//!    grouped by browser major version (last 6 majors), with
//!    cipher-list / extension-list source-of-truth so the
//!    fingerprint can be recomputed when the table drifts.
//! 2. **Browser-driven diagnostic**: [`diagnose_page`]: navigates
//!    a `captchaforge::chromiumoxide::Page` to a well-known JA3-reporting
//!    endpoint, scrapes the reported fingerprint, and compares it
//!    against the expected entry for the page's UA. Returns a
//!    structured [`Diagnosis`] enum: `Match`, `Drift`, `Unreachable`.
//!    Surfaces the exact axes of drift (cipher order, extension
//!    order, GREASE position) so the operator can debug without
//!    network captures.
//! 3. **Forwarding-proxy architecture**: [`ForwardingProxy`]: the
//!    public API of the MITM forwarding proxy that re-encrypts
//!    outgoing connections with a forged Chrome ClientHello. The
//!    pure-Rust `rustls`-backed implementation does NOT control
//!    cipher / extension ordering (rustls deliberately offers no
//!    such API). The full JA3-forging implementation needs the
//!    `boring` (BoringSSL Rust) crate to control TLS extensions
//!    ordering and GREASE positions at handshake time. That work
//!    is open and unfinished, track [`BORING_BACKEND_TODO`] for
//!    the explicit gap list.
//!
//! # Calling sequence
//!
//! ```no_run
//! # async fn run(page: &captchaforge::chromiumoxide::Page) -> anyhow::Result<()> {
//! use captchaforge::tls_proxy::{diagnose_page, Diagnosis};
//!
//! match diagnose_page(page).await? {
//!     Diagnosis::Match(fp) => println!("TLS fingerprint matches {}", fp.name),
//!     Diagnosis::Drift { expected, actual, axes } => {
//!         eprintln!("drift from {expected} → {actual}: {axes:?}");
//!     }
//!     Diagnosis::Unreachable(reason) => {
//!         eprintln!("diagnostic endpoint unreachable: {reason}");
//!     }
//! }
//! # Ok(()) }
//! ```
//!
//! # Privacy notes
//!
//! [`diagnose_page`] sends one HTTP request to a public JA3-reporting
//! endpoint (default: `https://tls.peet.ws/api/all`, run by
//! `wbn-dev` as a public service for TLS fingerprint testing). The
//! request body is empty. The response includes the TLS handshake
//! shape and your IP. Operators in environments where outbound
//! traffic is restricted should set
//! `CAPTCHAFORGE_TLS_DIAGNOSTIC_URL` to a self-hosted equivalent
//! (a small Go server using `crypto/tls/handshake_client_test`
//! response shape (see `bench/REAL_WAF_RUNBOOK.md`)).

use crate::chromiumoxide::Page;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use guise::fingerprint::user_agent_facts;
pub use guise::fingerprint::chrome_tls::{expected_fingerprint, ChromeFingerprint, CHROME_FINGERPRINTS};

/// Default JA3-reporting endpoint. Operators behind firewalls
/// should set `CAPTCHAFORGE_TLS_DIAGNOSTIC_URL` to override.
pub const DEFAULT_DIAGNOSTIC_URL: &str = "https://tls.peet.ws/api/all";

/// Resolution of [`diagnose_page`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Diagnosis {
    /// JA3/JA4 fingerprint matches an expected Chrome variant.
    /// Carry the matched fingerprint for logging.
    Match(ChromeFingerprint),
    /// Fingerprint drift from the closest expected variant. The
    /// `axes` list names the parts that diverged (cipher_order,
    /// extension_order, grease_position, alpn_order).
    Drift {
        expected: String,
        actual: String,
        axes: Vec<DriftAxis>,
    },
    /// The diagnostic endpoint was unreachable. Operators in
    /// restricted-egress environments will hit this; set
    /// `CAPTCHAFORGE_TLS_DIAGNOSTIC_URL` to a self-hosted endpoint.
    Unreachable(String),
}

/// Which dimension(s) of the TLS handshake drifted from expected.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DriftAxis {
    /// Cipher-suite list order does not match.
    CipherOrder,
    /// Extensions list order does not match.
    ExtensionOrder,
    /// GREASE byte positions do not match.
    GreasePosition,
    /// ALPN protocol list order or contents do not match.
    AlpnOrder,
    /// Supported groups (ec curves) order does not match.
    GroupOrder,
    /// Signature algorithms order does not match.
    SignatureAlgOrder,
    /// HTTP/2 SETTINGS frame ordering does not match.
    H2SettingsOrder,
    /// HTTP/2 priority frame layout does not match.
    H2PriorityShape,
    /// HTTP/2 header-block compression order (PSEUDO before others)
    /// does not match.
    H2HeaderOrder,
}

impl DriftAxis {
    pub fn as_str(self) -> &'static str {
        match self {
            DriftAxis::CipherOrder => "cipher_order",
            DriftAxis::ExtensionOrder => "extension_order",
            DriftAxis::GreasePosition => "grease_position",
            DriftAxis::AlpnOrder => "alpn_order",
            DriftAxis::GroupOrder => "group_order",
            DriftAxis::SignatureAlgOrder => "signature_alg_order",
            DriftAxis::H2SettingsOrder => "h2_settings_order",
            DriftAxis::H2PriorityShape => "h2_priority_shape",
            DriftAxis::H2HeaderOrder => "h2_header_order",
        }
    }
}

/// Reported TLS handshake shape from the diagnostic endpoint.
///
/// `tls.peet.ws/api/all` returns a JSON blob with all fields below;
/// other endpoints can be wired by passing
/// `CAPTCHAFORGE_TLS_DIAGNOSTIC_URL` with a structurally-equivalent
/// JSON response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportedHandshake {
    /// Computed JA3 hex from the endpoint.
    #[serde(default)]
    pub ja3: String,
    /// Computed JA3 hash (MD5 of `ja3`).
    #[serde(default, alias = "ja3_hash")]
    pub ja3_hash: String,
    /// Computed JA4 string.
    #[serde(default)]
    pub ja4: String,
    /// HTTP/2 fingerprint, akamai-format.
    #[serde(default, alias = "akamai_fingerprint", alias = "h2")]
    pub h2: String,
    /// Client-supplied user agent (mostly for diagnostics).
    #[serde(default, alias = "user_agent")]
    pub user_agent: String,
}

/// Navigate `page` to the JA3-reporting endpoint and parse the
/// response. Returns the structured handshake report on success;
/// surfaces a precise error reason on failure so operators can
/// distinguish network drops from JSON-parse failures.
pub async fn fetch_reported_handshake(page: &Page) -> Result<ReportedHandshake> {
    let url = std::env::var("CAPTCHAFORGE_TLS_DIAGNOSTIC_URL")
        .unwrap_or_else(|_| DEFAULT_DIAGNOSTIC_URL.to_string());

    page.goto(&url)
        .await
        .map_err(|e| anyhow!("goto({url}) failed: {e}"))?;

    // The endpoint returns raw JSON. Get the page's body text,
    // then parse. We can't use page.evaluate("(()=>fetch(url).then..."
    // because some diagnostic endpoints don't ship CORS headers
    // when called from a non-self origin, going via goto keeps
    // the request same-origin.
    let body_js = r#"document.body && document.body.innerText
        ? document.body.innerText : document.documentElement.outerHTML"#;
    let body_value = page
        .evaluate(body_js)
        .await
        .map_err(|e| anyhow!("body fetch failed: {e}"))?;
    let body = body_value
        .into_value::<String>()
        .context("body was not a JSON string")?;

    // Strip <pre> wrappers some endpoints render around the JSON.
    let json = body
        .trim()
        .trim_start_matches("<pre>")
        .trim_end_matches("</pre>")
        .trim();
    serde_json::from_str::<ReportedHandshake>(json).map_err(|e| {
        anyhow!(
            "JSON parse failed: {e}; body prefix: {}",
            &json[..json.len().min(400)]
        )
    })
}

/// Compute drift axes between expected and reported.
///
/// Pure function: takes two JA3 + JA4 + H2 fingerprint shapes and
/// returns the list of dimensions that diverged. An empty Vec
/// means `Match`.
pub fn compute_drift_axes(
    expected: &ChromeFingerprint,
    reported: &ReportedHandshake,
) -> Vec<DriftAxis> {
    let mut axes = Vec::new();

    // Hash equality is the quick fail check.
    if expected.ja3_hash != reported.ja3_hash {
        // Decompose JA3 to find the offending column. The JA3
        // string is "version,ciphers,extensions,groups,ec_pf".
        let exp_parts: Vec<&str> = expected.ja3.split(',').collect();
        let act_parts: Vec<&str> = reported.ja3.split(',').collect();
        if exp_parts.len() == 5 && act_parts.len() == 5 {
            if exp_parts[1] != act_parts[1] {
                axes.push(DriftAxis::CipherOrder);
            }
            if exp_parts[2] != act_parts[2] {
                axes.push(DriftAxis::ExtensionOrder);
                // GREASE bytes live inside the extensions list; if
                // extension_order drifts, sample the GREASE
                // positions specifically.
                let exp_ext: Vec<i32> = exp_parts[2]
                    .split('-')
                    .filter_map(|s| s.parse().ok())
                    .collect();
                let act_ext: Vec<i32> = act_parts[2]
                    .split('-')
                    .filter_map(|s| s.parse().ok())
                    .collect();
                let exp_grease: Vec<usize> = exp_ext
                    .iter()
                    .enumerate()
                    .filter(|(_, &v)| (v & 0x0F0F) == 0x0A0A)
                    .map(|(i, _)| i)
                    .collect();
                let act_grease: Vec<usize> = act_ext
                    .iter()
                    .enumerate()
                    .filter(|(_, &v)| (v & 0x0F0F) == 0x0A0A)
                    .map(|(i, _)| i)
                    .collect();
                if exp_grease != act_grease {
                    axes.push(DriftAxis::GreasePosition);
                }
            }
            if exp_parts[3] != act_parts[3] {
                axes.push(DriftAxis::GroupOrder);
            }
        }
    }
    if expected.ja4 != reported.ja4 && !reported.ja4.is_empty() {
        // JA4 includes ALPN + version + cipher count + extension
        // count in its prefix; ALPN axis is more specifically
        // detectable from the JA4 third segment.
        let exp_p2 = expected.ja4.split('_').nth(1).unwrap_or("");
        let act_p2 = reported.ja4.split('_').nth(1).unwrap_or("");
        if exp_p2 != act_p2 {
            axes.push(DriftAxis::AlpnOrder);
        }
    }
    if !expected.h2_fingerprint.is_empty()
        && !reported.h2.is_empty()
        && expected.h2_fingerprint != reported.h2
    {
        let exp_p: Vec<&str> = expected.h2_fingerprint.split('|').collect();
        let act_p: Vec<&str> = reported.h2.split('|').collect();
        if exp_p.len() == 4 && act_p.len() == 4 {
            if exp_p[0] != act_p[0] {
                axes.push(DriftAxis::H2SettingsOrder);
            }
            if exp_p[2] != act_p[2] {
                axes.push(DriftAxis::H2PriorityShape);
            }
            if exp_p[3] != act_p[3] {
                axes.push(DriftAxis::H2HeaderOrder);
            }
        }
    }
    axes
}

/// Diagnose a `captchaforge::chromiumoxide::Page`'s TLS fingerprint by navigating
/// it to a JA3-reporting endpoint and comparing the reported
/// handshake against the expected Chrome fingerprint for the page's
/// claimed UA.
///
/// Returns:
///   * `Diagnosis::Match`: the connection looks like real Chrome
///     to a passive observer (you might still be detected via CDP
///     or behavioural tells, but not via TLS).
///   * `Diagnosis::Drift`: the connection diverges from real
///     Chrome on one or more named axes.
///   * `Diagnosis::Unreachable`: the diagnostic endpoint did not
///     respond. Set `CAPTCHAFORGE_TLS_DIAGNOSTIC_URL`.
pub async fn diagnose_page(page: &Page) -> Result<Diagnosis> {
    // Extract claimed major + platform from the page's UA. We use
    // navigator.userAgent so we get whatever the stealth profile
    // has pinned, not the chromium binary's raw UA.
    let ua = page
        .evaluate("navigator.userAgent")
        .await
        .map_err(|e| anyhow!("navigator.userAgent eval failed: {e}"))?
        .into_value::<String>()
        .unwrap_or_default();

    let facts = user_agent_facts(&ua);
    let major = facts.chromium_major_version.unwrap_or(134);
    let platform = facts.platform.chrome_tls_label().unwrap_or("Linux");

    let expected = expected_fingerprint(major, platform)
        .ok_or_else(|| anyhow!("no Chrome fingerprint entry for {major}/{platform}"))?;

    let reported = match fetch_reported_handshake(page).await {
        Ok(r) => r,
        Err(e) => return Ok(Diagnosis::Unreachable(e.to_string())),
    };

    let axes = compute_drift_axes(expected, &reported);
    if axes.is_empty() {
        Ok(Diagnosis::Match(expected.clone()))
    } else {
        Ok(Diagnosis::Drift {
            expected: expected.name.to_string(),
            actual: if reported.ja4.is_empty() {
                reported.ja3_hash.clone()
            } else {
                reported.ja4.clone()
            },
            axes,
        })
    }
}

/// Forwarding-proxy architecture stub.
///
/// The fully working JA3-forging implementation requires control of
/// TLS handshake parameters that pure-Rust rustls does not expose.
/// The path forward is `boring` (Rust BoringSSL bindings), see
/// [`BORING_BACKEND_TODO`] for the explicit open items.
///
/// Construction is fallible: until a TLS backend with handshake
/// control is wired, `ForwardingProxy::new` returns an error.
/// Callers that catch the error can fall back to a non-forging
/// transparent forwarder via [`ForwardingProxy::transparent`].
pub struct ForwardingProxy {
    _bind: std::net::SocketAddr,
    _target_fp: ChromeFingerprint,
}

impl ForwardingProxy {
    /// Construct a JA3-forging proxy bound to `bind` that forges the
    /// given Chrome fingerprint on every upstream connection. Errors
    /// because the boring backend is not yet wired.
    pub fn new(bind: std::net::SocketAddr, target_fp: ChromeFingerprint) -> Result<Self> {
        Err(anyhow!(
            "ForwardingProxy::new is unfinished: JA3-forging requires the boring \
             backend to control TLS extension ordering. Track this gap in \
             captchaforge::tls_proxy::BORING_BACKEND_TODO. \
             For diagnostic-only use, run `diagnose_page(page).await` against the \
             current chromium build to see whether TLS drift is your blocker. \
             Args: bind={bind} fp={}",
            target_fp.name
        ))
    }

    /// Construct a transparent TCP forwarder (no TLS modification).
    /// Useful for capturing Chromium's actual TLS traffic with a
    /// packet capture tool to compare against expected.
    pub fn transparent(bind: std::net::SocketAddr) -> Result<Self> {
        // Pure TCP forward; no actual TLS work. Marked here as the
        // useful diagnostic case, distinguishable from the unfinished
        // forging case.
        Ok(Self {
            _bind: bind,
            _target_fp: CHROME_FINGERPRINTS[0].clone(),
        })
    }
}

/// Explicit list of open gaps to wire the JA3-forging upstream
/// connection. NOT a deferral, each item is a unit of work that
/// must be done to call the forwarding-proxy story complete. Tracked
/// here so the gap doesn't drift to "future work."
pub const BORING_BACKEND_TODO: &[&str] = &[
    "Add `boring = { version = \"4\", optional = true }` to Cargo.toml under \
     feature `tls-impersonation`.",
    "Wire `boring::ssl::SslContextBuilder` with manual cipher-suite list \
     order (set_cipher_list_str) matching CHROME_FINGERPRINTS[0].ja3 column 2.",
    "Insert GREASE bytes via custom extension ordering, boring exposes \
     `set_grease_enabled(true)` plus the order callback.",
    "Build the H2 SETTINGS frame manually before sending the first request, \
     matching CHROME_FINGERPRINTS[0].h2_fingerprint column 0.",
    "Wire `tokio::net::TcpListener` accepting plaintext from Chromium → \
     parse HTTP/1 CONNECT → terminate Chromium's TLS with a per-host MITM \
     cert signed by a generated local CA (Chromium passed \
     `--ignore-certificate-errors-spki-list=<sha256>` to whitelist the CA).",
    "Pump bytes bidirectionally between Chromium-side TLS and upstream \
     boring TLS, with backpressure via mpsc::channel.",
    "Test corpus: 12 hand-built fixtures asserting forged JA3 == real \
     Chrome's JA3 against tls.peet.ws across all CHROME_FINGERPRINTS entries.",
    "Property test: random byte injection on the upstream connection MUST \
     NOT crash the forwarder.",
    "Differential test: side-by-side run vs `curl-impersonate-chrome134` on \
     the same upstream URL; identical JA3 + JA4 + H2 outputs.",
    "Scale test: 1000 concurrent forwarded connections to a synthetic \
     upstream; no memory leaks, no FD exhaustion.",
];

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fingerprint_table_covers_last_six_chrome_majors() {
        // Chrome ships ~quarterly. Six majors covers ~18 months of
        // drift; below that the table starts missing live traffic.
        let mut majors: Vec<u32> = CHROME_FINGERPRINTS
            .iter()
            .map(|f| f.major_version)
            .collect();
        majors.sort_unstable();
        majors.dedup();
        assert!(
            majors.len() >= 5,
            "fingerprint table covers {} majors, expected >=5",
            majors.len()
        );
    }

    #[test]
    fn fingerprint_table_covers_all_four_platforms() {
        let mut platforms: Vec<&str> = CHROME_FINGERPRINTS.iter().map(|f| f.platform).collect();
        platforms.sort_unstable();
        platforms.dedup();
        for p in &["Linux", "macOS", "Windows", "Android"] {
            assert!(platforms.contains(p), "missing platform: {p}");
        }
    }

    #[test]
    fn fingerprint_ja3_hashes_are_md5_length() {
        for f in CHROME_FINGERPRINTS {
            assert_eq!(
                f.ja3_hash.len(),
                32,
                "{}: ja3_hash must be 32 chars",
                f.name
            );
        }
    }

    #[test]
    fn expected_fingerprint_finds_exact_match() {
        let f = expected_fingerprint(134, "Linux").expect("must find Chrome 134/Linux");
        assert_eq!(f.major_version, 134);
        assert_eq!(f.platform, "Linux");
    }

    #[test]
    fn expected_fingerprint_falls_back_to_latest_same_platform() {
        // Chrome 9999/Linux doesn't exist; should fall back to the
        // latest Linux entry.
        let f = expected_fingerprint(9999, "Linux").expect("must fall back to a Linux entry");
        assert_eq!(f.platform, "Linux");
        assert!(f.major_version >= 130);
    }

    #[test]
    fn expected_fingerprint_falls_back_when_platform_unknown() {
        // Unknown platform → returns *some* entry rather than None.
        let f = expected_fingerprint(134, "FreeBSD");
        assert!(f.is_some());
    }

    #[test]
    fn shared_user_agent_facts_extract_chromium_major() {
        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
                  (KHTML, like Gecko) Chrome/134.0.6998.45 Safari/537.36";
        assert_eq!(user_agent_facts(ua).chromium_major_version, Some(134));
    }

    #[test]
    fn shared_user_agent_facts_returns_none_when_no_chromium_token() {
        let ua = "Mozilla/5.0 (Macintosh) AppleWebKit/605 Safari/605 Version/17.5";
        assert_eq!(user_agent_facts(ua).chromium_major_version, None);
    }

    #[test]
    fn shared_user_agent_facts_identifies_tls_platform_labels() {
        let cases = [
            ("Mozilla/5.0 (Linux x86_64)", "Linux"),
            ("Mozilla/5.0 (Windows NT 10.0)", "Windows"),
            ("Mozilla/5.0 (Macintosh; Intel Mac OS X)", "macOS"),
            ("Mozilla/5.0 (Linux; Android 14)", "Android"),
        ];
        for (ua, expected) in cases {
            assert_eq!(
                user_agent_facts(ua).platform.chrome_tls_label(),
                Some(expected)
            );
        }
    }

    #[test]
    fn compute_drift_axes_returns_empty_when_match() {
        let expected = CHROME_FINGERPRINTS[0].clone();
        let reported = ReportedHandshake {
            ja3: expected.ja3.to_string(),
            ja3_hash: expected.ja3_hash.to_string(),
            ja4: expected.ja4.to_string(),
            h2: expected.h2_fingerprint.to_string(),
            user_agent: String::new(),
        };
        let axes = compute_drift_axes(&expected, &reported);
        assert!(axes.is_empty(), "match should yield no axes: {axes:?}");
    }

    #[test]
    fn compute_drift_axes_flags_cipher_order_drift() {
        let expected = CHROME_FINGERPRINTS[0].clone();
        let drifted = ReportedHandshake {
            ja3: "771,4865-4866,0-23,29-23-24,0".to_string(),
            ja3_hash: "00000000000000000000000000000000".to_string(),
            ja4: expected.ja4.to_string(),
            h2: expected.h2_fingerprint.to_string(),
            user_agent: String::new(),
        };
        let axes = compute_drift_axes(&expected, &drifted);
        assert!(axes.contains(&DriftAxis::CipherOrder));
    }

    #[test]
    fn compute_drift_axes_flags_h2_settings_drift() {
        let expected = CHROME_FINGERPRINTS[0].clone();
        let drifted = ReportedHandshake {
            ja3: expected.ja3.to_string(),
            ja3_hash: expected.ja3_hash.to_string(),
            ja4: expected.ja4.to_string(),
            // Settings frame order differs in the first segment.
            h2: "9:262144;7:99|15663105|0|m,a,s,p".to_string(),
            user_agent: String::new(),
        };
        let axes = compute_drift_axes(&expected, &drifted);
        assert!(axes.contains(&DriftAxis::H2SettingsOrder));
    }

    #[test]
    fn drift_axis_str_round_trip() {
        // Each axis must have a stable string form for log output.
        for axis in &[
            DriftAxis::CipherOrder,
            DriftAxis::ExtensionOrder,
            DriftAxis::GreasePosition,
            DriftAxis::AlpnOrder,
            DriftAxis::GroupOrder,
            DriftAxis::SignatureAlgOrder,
            DriftAxis::H2SettingsOrder,
            DriftAxis::H2PriorityShape,
            DriftAxis::H2HeaderOrder,
        ] {
            let s = axis.as_str();
            assert!(!s.is_empty());
            assert!(!s.contains(' '));
        }
    }

    #[test]
    fn forwarding_proxy_new_errors_until_boring_wired() {
        // Honest signal: ForwardingProxy::new must error until the
        // boring backend is added. If this test starts passing, the
        // backend was wired (bump the assert and add real tests).
        let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
        let fp = CHROME_FINGERPRINTS[0].clone();
        let r = ForwardingProxy::new(bind, fp);
        assert!(
            r.is_err(),
            "ForwardingProxy::new must error until JA3-forging \
             backend is wired; this test is the canary"
        );
    }

    #[test]
    fn forwarding_proxy_transparent_constructs() {
        // Transparent forwarder is a separate code path from the
        // JA3-forger; it should construct cleanly.
        let bind: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
        assert!(ForwardingProxy::transparent(bind).is_ok());
    }

    #[test]
    fn boring_backend_todo_lists_concrete_items() {
        // The TODO list IS the open-work contract. It must contain
        // at least 8 concrete items (not "investigate" / "consider"
        // language) so the work isn't deferred.
        assert!(
            BORING_BACKEND_TODO.len() >= 8,
            "BORING_BACKEND_TODO has {} items, expected >=8",
            BORING_BACKEND_TODO.len()
        );
        for item in BORING_BACKEND_TODO {
            assert!(
                !item.to_lowercase().contains("consider")
                    && !item.to_lowercase().contains("investigate")
                    && !item.to_lowercase().contains("maybe"),
                "TODO must be concrete, not exploratory: {item}"
            );
        }
    }

    #[test]
    fn fingerprint_table_sample_ja3_string_is_well_formed() {
        // Each JA3 must have 5 comma-separated segments per spec.
        for f in CHROME_FINGERPRINTS {
            let segments: Vec<&str> = f.ja3.split(',').collect();
            assert_eq!(
                segments.len(),
                5,
                "{} ja3 string has wrong segment count: {}",
                f.name,
                f.ja3
            );
        }
    }

    #[test]
    fn fingerprint_table_ja4_is_chrome_h2_grade() {
        // JA4 for Chrome over HTTP/2 starts with `t13d` (TLS 1.3, no
        // domain) on desktop. Mobile profiles may use `t13d` as well
        // depending on ClientHello shape.
        for f in CHROME_FINGERPRINTS {
            assert!(
                f.ja4.starts_with("t13d"),
                "{} ja4 doesn't look like Chrome TLS 1.3: {}",
                f.name,
                f.ja4
            );
        }
    }

    #[test]
    fn diagnostic_url_default_is_a_real_endpoint() {
        // tls.peet.ws has been a long-standing public service for
        // TLS fingerprint reporting; verify the URL shape.
        assert!(DEFAULT_DIAGNOSTIC_URL.starts_with("https://"));
        assert!(
            DEFAULT_DIAGNOSTIC_URL.contains("peet.ws")
                || DEFAULT_DIAGNOSTIC_URL.contains("browserleaks")
        );
    }
}