Skip to main content

hpx_browser/
challenge.rs

1//! Challenge detection and solving.
2
3use async_trait::async_trait;
4
5// ── Size gates ──────────────────────────────────────────────────────────
6
7pub const INTERSTITIAL_MAX_BYTES: usize = 30 * 1024;
8pub const BLOCKED_WORD_MAX_BYTES: usize = 5 * 1024;
9pub const THIN_BODY_MAX_BYTES: usize = 1000;
10pub const THIN_SHELL_MAX_BYTES: usize = 15 * 1024;
11pub const SENSOR_SPLIT_BYTES: usize = 50 * 1024;
12
13// ── Marker tables ───────────────────────────────────────────────────────
14
15/// Any-size structural tokens — first match wins.
16const UNAMBIGUOUS: &[(&str, &str)] = &[
17    ("cf-browser-verification", "ManagedChallenge-CHL"),
18    ("_cf_chl_opt", "ManagedChallenge-CHL"),
19    ("/_sec/cp_challenge", "SecCpt-CHL"),
20    ("ddcaptchaencoded", "Interstitial-CHL"),
21];
22
23/// AWS-WAF envelope markers — challenge only when co-signed by active loader.
24const AWSWAF_MARKERS: &[&str] = &["gokuprops", "awswafcookiedomainlist"];
25
26/// Active PoW loader substrings.
27const AWSWAF_ACTIVE_LOADER: &[&str] =
28    &["token.awswaf.com", "awswafintegration", "checkforcerefresh"];
29
30/// Phrase markers — size-gated to < INTERSTITIAL_MAX_BYTES.
31const PHRASE: &[(&str, &str)] = &[
32    ("just a moment", "ManagedChallenge-CHL"),
33    ("checking your browser", "ManagedChallenge-CHL"),
34    ("captcha-delivery.com", "Interstitial-CHL"),
35    ("press &amp; hold", "HoldChallenge-PaH"),
36    ("pardon our interruption", "SensorChallenge-CHL"),
37];
38
39/// Small-body markers — size-gated, some require co-signals.
40const SMALL_BODY: &[(&str, &str)] = &[
41    ("akam/13", "SensorChallenge-CHL"),
42    ("_abck", "SensorChallenge-CHL"),
43    ("_kpsdk", "ScriptChallenge-CHL"),
44    ("ips.js", "ScriptChallenge-CHL"),
45    ("_pxhd", "BehaviorChallenge-CHL"),
46    ("px-captcha", "BehaviorChallenge-CHL"),
47    ("captcha", "captcha-CHL"),
48    ("403 forbidden", "BLOCKED"),
49    ("access denied", "BLOCKED"),
50];
51
52/// Co-signals required for `akam/13` to count as a challenge.
53const SENSOR_CHALLENGE_COSIGNAL: &[&str] = &[
54    "sensor_data",
55    "bm-verify",
56    "sec-if-cpt-container",
57    "sec-cpt-if",
58    "/_sec/cp_challenge",
59    "pardon our interruption",
60];
61
62/// Co-signals that prove a genuinely interactive captcha (not invisible v3).
63const INTERACTIVE_CAPTCHA_COSIGNAL: &[&str] = &[
64    "api2/bframe",
65    "api2/anchor",
66    "hcaptcha.com",
67    "cf-turnstile",
68    "challenges.cloudflare.com/turnstile",
69    "i'm not a robot",
70    "i\u{2019}m not a robot",
71    "verify you are human",
72    "are you a robot",
73    "select all images",
74    "recaptcha challenge",
75];
76
77// ── Verdict ─────────────────────────────────────────────────────────────
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum ChallengeVerdict {
81    Pass,
82    RenderIncomplete,
83    EdgeBlock,
84    SensorFail,
85    ThinShell,
86    ChallengeIncomplete,
87}
88
89impl ChallengeVerdict {
90    pub const fn is_challenge(self) -> bool {
91        matches!(
92            self,
93            Self::EdgeBlock | Self::SensorFail | Self::ChallengeIncomplete
94        )
95    }
96}
97
98// ── Classification result ───────────────────────────────────────────────
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub struct EngineClass {
102    pub tag: &'static str,
103    pub verdict: ChallengeVerdict,
104    pub len: usize,
105}
106
107// ── ChallengeKind / SolveOutcome / trait ────────────────────────────────
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ChallengeKind {
111    pub vendor: &'static str,
112    pub sub_kind: &'static str,
113}
114
115impl ChallengeKind {
116    pub const fn new(vendor: &'static str, sub_kind: &'static str) -> Self {
117        Self { vendor, sub_kind }
118    }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SolveOutcome {
123    NotApplicable,
124    InProgress,
125    Solved,
126    Unsolvable,
127}
128
129#[async_trait]
130pub trait ChallengeSolver: Send + Sync {
131    fn can_handle(&self, kind: &ChallengeKind) -> bool;
132    async fn solve(&self, kind: &ChallengeKind, page: &mut crate::page::Page) -> SolveOutcome;
133}
134
135// ── Internal helpers ────────────────────────────────────────────────────
136
137fn small_body_row_qualifies(needle: &str, lower: &str) -> bool {
138    match needle {
139        "akam/13" => SENSOR_CHALLENGE_COSIGNAL.iter().any(|c| lower.contains(c)),
140        "captcha" => INTERACTIVE_CAPTCHA_COSIGNAL
141            .iter()
142            .any(|c| lower.contains(c)),
143        _ => true,
144    }
145}
146
147fn verdict_for(tag: &str, len: usize) -> ChallengeVerdict {
148    match tag {
149        "L3-RENDERED" if len < THIN_SHELL_MAX_BYTES => ChallengeVerdict::ThinShell,
150        "L3-RENDERED" => ChallengeVerdict::Pass,
151        "THIN-BODY" => ChallengeVerdict::RenderIncomplete,
152        "ManagedChallenge-CHL" if len >= SENSOR_SPLIT_BYTES => {
153            ChallengeVerdict::ChallengeIncomplete
154        }
155        _ if len < SENSOR_SPLIT_BYTES => ChallengeVerdict::EdgeBlock,
156        _ => ChallengeVerdict::SensorFail,
157    }
158}
159
160// ── Public API ──────────────────────────────────────────────────────────
161
162#[cfg_attr(feature = "hotpath", hotpath::measure)]
163pub fn engine_classify(body: &str) -> EngineClass {
164    let lower = body.to_lowercase();
165    engine_classify_impl(&lower)
166}
167
168/// Classify a challenge engine from an already-lowercased body.
169///
170/// This skips the full-body `to_lowercase` allocation, which is useful when the
171/// caller has already lowercased the body (e.g. `Page::reload_html` shares one
172/// lowered copy between title extraction and challenge classification). All
173/// internal matching is case-insensitive and operates on the lowered input.
174pub fn engine_classify_lower(lowered_body: &str) -> EngineClass {
175    engine_classify_impl(lowered_body)
176}
177
178fn engine_classify_impl(lowered_body: &str) -> EngineClass {
179    let len = lowered_body.len();
180
181    let tag: &'static str = 'tag: {
182        for (n, t) in UNAMBIGUOUS {
183            if lowered_body.contains(n) {
184                break 'tag t;
185            }
186        }
187        if AWSWAF_MARKERS.iter().any(|n| lowered_body.contains(n))
188            && AWSWAF_ACTIVE_LOADER
189                .iter()
190                .any(|n| lowered_body.contains(n))
191        {
192            break 'tag "AWS-WAF-CHL";
193        }
194        if len < INTERSTITIAL_MAX_BYTES {
195            for (n, t) in PHRASE {
196                if lowered_body.contains(n) {
197                    break 'tag t;
198                }
199            }
200            for (n, t) in SMALL_BODY {
201                if lowered_body.contains(n) && small_body_row_qualifies(n, lowered_body) {
202                    break 'tag t;
203                }
204            }
205        }
206        if len < BLOCKED_WORD_MAX_BYTES && lowered_body.contains("blocked") {
207            break 'tag "BLOCKED";
208        }
209        if len < THIN_BODY_MAX_BYTES {
210            break 'tag "THIN-BODY";
211        }
212        "L3-RENDERED"
213    };
214
215    EngineClass {
216        tag,
217        verdict: verdict_for(tag, len),
218        len,
219    }
220}
221
222pub fn is_managed_challenge_doc(body: &str) -> bool {
223    body.contains("_cf_chl_opt")
224        || body.contains("/cdn-cgi/challenge-platform/")
225        || body.contains("cf-browser-verification")
226}
227
228// ── Tests ───────────────────────────────────────────────────────────────
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn page_is_challenge(body: &str) -> bool {
235        engine_classify(body).verdict.is_challenge()
236    }
237    fn holistic_tag(body: &str) -> &'static str {
238        engine_classify(body).tag
239    }
240
241    /// Helper: build a large rendered body embedding `seed`.
242    fn big(seed: &str) -> String {
243        let mut h = String::from("<html><body>");
244        h.push_str(seed);
245        for _ in 0..30000 {
246            h.push_str("<div>actual rendered content paragraph</div>");
247        }
248        h.push_str("</body></html>");
249        h
250    }
251
252    // ── 1. all_call_sites_agree ─────────────────────────────────────────
253
254    #[test]
255    fn all_call_sites_agree() {
256        struct Case {
257            name: &'static str,
258            body: String,
259            tag: &'static str,
260            challenge: bool,
261        }
262        let cases = vec![
263            Case {
264                name: "empty",
265                body: "<html></html>".into(),
266                tag: "THIN-BODY",
267                challenge: false,
268            },
269            Case {
270                name: "cf small",
271                body: "<html><body>Just a moment...</body></html>".into(),
272                tag: "ManagedChallenge-CHL",
273                challenge: true,
274            },
275            Case {
276                name: "dd small",
277                body: r#"<script src="https://geo.captcha-delivery.com/c"></script>"#.into(),
278                tag: "Interstitial-CHL",
279                challenge: true,
280            },
281            Case {
282                name: "akam small",
283                body: r#"<script src="/akam/13/abc"></script><form id="bm-verify"></form>"#.into(),
284                tag: "SensorChallenge-CHL",
285                challenge: true,
286            },
287            Case {
288                name: "pxhd large benign",
289                body: big(r#"<script>window._pxhd="sdk"</script>"#),
290                tag: "L3-RENDERED",
291                challenge: false,
292            },
293            Case {
294                name: "just-a-moment large benign",
295                body: big("<p>give us just a moment to load</p>"),
296                tag: "L3-RENDERED",
297                challenge: false,
298            },
299            Case {
300                name: "grecaptcha config large",
301                body: big(r#"<script>window.C={"googleRecaptcha":1}</script>"#),
302                tag: "L3-RENDERED",
303                challenge: false,
304            },
305        ];
306        for c in cases {
307            let ec = engine_classify(&c.body);
308            assert_eq!(ec.tag, c.tag, "tag mismatch [{}]", c.name);
309            assert_eq!(
310                holistic_tag(&c.body),
311                c.tag,
312                "holistic disagrees [{}]",
313                c.name
314            );
315            assert_eq!(
316                page_is_challenge(&c.body),
317                c.challenge,
318                "page/holistic challenge-verdict disagree [{}] tag={}",
319                c.name,
320                ec.tag
321            );
322        }
323    }
324
325    // ── 2. FP-B2: literal strong markers size-gated ─────────────────────
326
327    #[test]
328    fn fp_b2_literal_strong_markers_size_gated() {
329        let wf = big(
330            r#"<script>window.__CONSENT={"_px3":"NECESSARY","px-captcha":"NECESSARY"};</script>"#,
331        );
332        assert_eq!(engine_classify(&wf).tag, "L3-RENDERED");
333        assert_eq!(engine_classify(&wf).verdict, ChallengeVerdict::Pass);
334
335        let dd = big(r#"<img src="https://x.captcha-delivery.com/pixel.gif">"#);
336        assert_eq!(engine_classify(&dd).tag, "L3-RENDERED");
337        assert_eq!(engine_classify(&dd).verdict, ChallengeVerdict::Pass);
338
339        let px_chl = r#"<html><body><div id="px-captcha"></div><p>verifying</p></body></html>"#;
340        assert_eq!(engine_classify(px_chl).tag, "BehaviorChallenge-CHL");
341        assert!(engine_classify(px_chl).verdict.is_challenge());
342
343        let pah = r#"<html><body><p>Press &amp; Hold to confirm</p></body></html>"#;
344        assert_eq!(engine_classify(pah).tag, "HoldChallenge-PaH");
345        assert!(engine_classify(pah).verdict.is_challenge());
346    }
347
348    // ── 3. FP-B5: AWS-WAF loader co-signed ──────────────────────────────
349
350    #[test]
351    fn fp_b5_awswaf_markers_loader_cosigned() {
352        let mut solved = String::from(
353            r#"<html><head><script>window.awsWafCookieDomainList=["redfin.com"];</script></head><body>
354               <script>window.REDFIN_APP_NAME="customer-pages-personalization";</script>"#,
355        );
356        for _ in 0..6000 {
357            solved.push_str("<div class='HomeCard'>real listing content here</div>");
358        }
359        solved.push_str("</body></html>");
360        assert_eq!(engine_classify(&solved).tag, "L3-RENDERED");
361        assert_eq!(engine_classify(&solved).verdict, ChallengeVerdict::Pass);
362
363        let small_leftover = r#"<html><body><script>window.awsWafCookieDomainList=["redfin.com"];</script>
364            <p>real content</p></body></html>"#;
365        assert_ne!(engine_classify(small_leftover).tag, "AWS-WAF-CHL");
366        assert!(!engine_classify(small_leftover).verdict.is_challenge());
367
368        let stub = r#"<html><head><script src="https://x.token.awswaf.com/challenge.js"></script>
369            <script>window.gokuProps={key:'a',context:'b',iv:'c'};</script></head>
370            <body><div id="challenge-container"></div></body></html>"#;
371        assert_eq!(engine_classify(stub).tag, "AWS-WAF-CHL");
372        assert!(engine_classify(stub).verdict.is_challenge());
373
374        let stub2 = r#"<html><body><script>var awsWafCookieDomainList=["a.com"];
375            AwsWafIntegration.checkForceRefresh().then(()=>{});</script></body></html>"#;
376        assert_eq!(engine_classify(stub2).tag, "AWS-WAF-CHL");
377        assert!(engine_classify(stub2).verdict.is_challenge());
378    }
379
380    // ── 4. FP-B4: ManagedChallenge split from SensorFail ────────────────
381
382    #[test]
383    fn fp_b4_managed_incomplete_split_from_sensorfail() {
384        let mut shell = String::from(
385            r#"<html><head><title>Just a moment...</title></head><body>
386               <script>window._cf_chl_opt={cvId:'3',cType:'managed'};</script>"#,
387        );
388        for _ in 0..2000 {
389            shell.push_str("<div>cf challenge orchestrator shell padding</div>");
390        }
391        shell.push_str("</body></html>");
392        assert!(shell.len() >= SENSOR_SPLIT_BYTES);
393        let ec = engine_classify(&shell);
394        assert_eq!(ec.tag, "ManagedChallenge-CHL");
395        assert_eq!(ec.verdict, ChallengeVerdict::ChallengeIncomplete);
396        assert_ne!(ec.verdict, ChallengeVerdict::SensorFail);
397        assert_ne!(ec.verdict, ChallengeVerdict::Pass);
398        assert!(ec.verdict.is_challenge());
399
400        let stub =
401            "<html><body><script>window._cf_chl_opt={}</script>Just a moment...</body></html>";
402        assert_eq!(engine_classify(stub).verdict, ChallengeVerdict::EdgeBlock);
403
404        let mut passed = String::from(
405            r#"<html><body><script src="/cdn-cgi/challenge-platform/h/b/jsd"></script>"#,
406        );
407        for _ in 0..3000 {
408            passed.push_str("<article>real rendered course catalog content</article>");
409        }
410        passed.push_str("</body></html>");
411        assert!(passed.len() >= SENSOR_SPLIT_BYTES);
412        assert_eq!(engine_classify(&passed).tag, "L3-RENDERED");
413        assert_eq!(engine_classify(&passed).verdict, ChallengeVerdict::Pass);
414    }
415
416    // ── 5. FP-C2: managed challenge doc predicate ───────────────────────
417
418    #[test]
419    fn fp_c2_managed_challenge_doc_predicate() {
420        assert!(is_managed_challenge_doc(
421            r#"<script>window._cf_chl_opt={cvId:'3'};</script>"#
422        ));
423        assert!(is_managed_challenge_doc(
424            r#"<script src="/cdn-cgi/challenge-platform/h/b/jsd/r/x"></script>"#
425        ));
426        assert!(is_managed_challenge_doc(
427            r#"<html class="cf-browser-verification"><body>...</body></html>"#
428        ));
429        assert!(!is_managed_challenge_doc(
430            "<html><body>fully rendered course catalog, no CF challenge</body></html>"
431        ));
432    }
433
434    // ── 6. FP-B3: thin shell band ───────────────────────────────────────
435
436    #[test]
437    fn fp_b3_thin_shell_band() {
438        let mut shell = String::from("<html><body>");
439        for _ in 0..60 {
440            shell.push_str("<div>spa hydration placeholder</div>");
441        }
442        shell.push_str("</body></html>");
443        assert!(shell.len() > THIN_BODY_MAX_BYTES && shell.len() < THIN_SHELL_MAX_BYTES);
444        let ec = engine_classify(&shell);
445        assert_eq!(ec.tag, "L3-RENDERED");
446        assert_eq!(ec.verdict, ChallengeVerdict::ThinShell);
447        assert!(!ec.verdict.is_challenge());
448
449        let mut full = String::from("<html><body>");
450        for _ in 0..1000 {
451            full.push_str("<article>real rendered content paragraph here</article>");
452        }
453        full.push_str("</body></html>");
454        assert!(full.len() >= THIN_SHELL_MAX_BYTES);
455        let fc = engine_classify(&full);
456        assert_eq!(fc.tag, "L3-RENDERED");
457        assert_eq!(fc.verdict, ChallengeVerdict::Pass);
458
459        assert_eq!(
460            engine_classify("<html></html>").verdict,
461            ChallengeVerdict::RenderIncomplete
462        );
463    }
464
465    // ── 7. FP-D2: unsolved managed challenge never passes ───────────────
466
467    #[test]
468    fn fp_d2_managed_unsolved_never_passes() {
469        let stub =
470            "<html><body>Just a moment...<script>window._cf_chl_opt={}</script></body></html>";
471        let s = engine_classify(stub);
472        assert!(s.verdict.is_challenge());
473        assert_ne!(s.verdict, ChallengeVerdict::Pass);
474
475        let mut shell = String::from(r#"<script>window._cf_chl_opt={cvId:'3'}</script>"#);
476        for _ in 0..2500 {
477            shell.push_str("<div>cf shell padding padding padding</div>");
478        }
479        assert!(shell.len() >= SENSOR_SPLIT_BYTES);
480        let l = engine_classify(&shell);
481        assert!(l.verdict.is_challenge());
482        assert_ne!(l.verdict, ChallengeVerdict::Pass);
483        assert_eq!(l.verdict, ChallengeVerdict::ChallengeIncomplete);
484    }
485
486    // ── 8. FP-Tier1: invisible recaptcha + akam/13 co-signal ────────────
487
488    #[test]
489    fn fp_t1_invisible_recaptcha_and_akam13_cosignal() {
490        let mut spotify = String::from(
491            r#"<html><head><style>.grecaptcha-badge { display: none !important }</style></head><body><textarea id="g-recaptcha-response-100000" name="g-recaptcha-response"></textarea><script src="https://www.gstatic.com/recaptcha/releases/abc/recaptcha__en.js"></script>"#,
492        );
493        for _ in 0..120 {
494            spotify
495                .push_str("<div class=\"sp-shell\">spotify web player hydration placeholder</div>");
496        }
497        spotify.push_str("</body></html>");
498        assert!(spotify.len() > THIN_BODY_MAX_BYTES && spotify.len() < THIN_SHELL_MAX_BYTES);
499        let s = engine_classify(&spotify);
500        assert_eq!(s.tag, "L3-RENDERED");
501        assert!(!s.verdict.is_challenge());
502        assert_eq!(s.verdict, ChallengeVerdict::ThinShell);
503
504        let real_cap = r#"<html><body><iframe src="https://www.google.com/recaptcha/api2/bframe?k=x"></iframe><p>Select all images with a bus — verify you are human</p></body></html>"#;
505        assert_eq!(engine_classify(real_cap).tag, "captcha-CHL");
506        assert!(engine_classify(real_cap).verdict.is_challenge());
507
508        let mut bestbuy = String::from(
509            r#"<html><head><script type="text/javascript" src="https://www.bestbuy.com/akam/13/62321f80" defer=""></script></head><body><h1>Choose a country</h1>"#,
510        );
511        for _ in 0..100 {
512            bestbuy.push_str(
513                "<a class=\"country\" href=\"/intl\">United States / Canada region selector</a>",
514            );
515        }
516        bestbuy.push_str("</body></html>");
517        assert!(bestbuy.len() > THIN_BODY_MAX_BYTES && bestbuy.len() < THIN_SHELL_MAX_BYTES);
518        let b = engine_classify(&bestbuy);
519        assert_eq!(b.tag, "L3-RENDERED");
520        assert!(!b.verdict.is_challenge());
521
522        let akam_chl = r#"<html><head><script src="/akam/13/abc"></script></head><body><form id="bm-verify"></form></body></html>"#;
523        assert_eq!(engine_classify(akam_chl).tag, "SensorChallenge-CHL");
524        assert!(engine_classify(akam_chl).verdict.is_challenge());
525    }
526
527    // ── 9. verdict_mapping_is_consistent ─────────────────────────────────
528
529    #[test]
530    fn verdict_mapping_is_consistent() {
531        assert_eq!(
532            engine_classify("<html></html>").verdict,
533            ChallengeVerdict::RenderIncomplete
534        );
535        assert_eq!(
536            engine_classify("<html><body>Just a moment...</body></html>").verdict,
537            ChallengeVerdict::EdgeBlock
538        );
539        let mut big_dd = String::from(r#"<script>var ddcaptchaEncoded="z";</script>"#);
540        for _ in 0..3000 {
541            big_dd.push_str("<p>padding padding padding padding</p>");
542        }
543        assert!(big_dd.len() >= SENSOR_SPLIT_BYTES);
544        assert_eq!(
545            engine_classify(&big_dd).verdict,
546            ChallengeVerdict::SensorFail
547        );
548    }
549
550    // ── 10. AWS-WAF never passes unsolved ───────────────────────────────
551
552    #[test]
553    fn inverse_chl_awswaf_never_passes_unsolved() {
554        let stub = r#"<html><head><script type="text/javascript">
555            window.awsWafCookieDomainList = [];
556            window.gokuProps = {"key":"AQ==","iv":"A6==","context":"gl=="};
557            </script><script src="https://x.token.awswaf.com/x/challenge.js"></script></head>
558            <body><script>AwsWafIntegration.checkForceRefresh().then(()=>{});</script></body></html>"#;
559        let s = engine_classify(stub);
560        assert_eq!(s.tag, "AWS-WAF-CHL");
561        assert!(s.verdict.is_challenge());
562        assert_ne!(s.verdict, ChallengeVerdict::Pass);
563
564        let mut grown = String::from(
565            r#"<script>window.gokuProps={"key":"AQ=="};window.awsWafCookieDomainList=[];
566            AwsWafIntegration.checkForceRefresh();</script>
567            <script src="https://x.token.awswaf.com/x/challenge.js"></script>"#,
568        );
569        for _ in 0..3000 {
570            grown.push_str("<div>partially rendered challenge shell padding here</div>");
571        }
572        assert!(grown.len() >= SENSOR_SPLIT_BYTES);
573        let g = engine_classify(&grown);
574        assert_eq!(g.tag, "AWS-WAF-CHL");
575        assert!(g.verdict.is_challenge());
576        assert_ne!(g.verdict, ChallengeVerdict::Pass);
577
578        let mut solved = String::from("<html><body>");
579        for _ in 0..2000 {
580            solved.push_str("<div class=\"product-card\">real amazon product listing</div>");
581        }
582        solved.push_str("</body></html>");
583        assert!(solved.len() >= THIN_SHELL_MAX_BYTES);
584        let v = engine_classify(&solved);
585        assert_eq!(v.tag, "L3-RENDERED");
586        assert_eq!(v.verdict, ChallengeVerdict::Pass);
587    }
588
589    // ── 11. tail-pin: known thin shells stay ThinShell ──────────────────
590
591    #[test]
592    fn tail_pin_known_thin_shells_stay_thinshell() {
593        let mut duo = String::from(
594            r#"<html><head><style>.grecaptcha-badge{display:none}</style></head><body>"#,
595        );
596        while duo.len() < 13_000 {
597            duo.push_str("<div class=\"_2it2\">duolingo unsupported-browser shell</div>");
598        }
599        duo.push_str("</body></html>");
600        assert!(
601            duo.len() > THIN_BODY_MAX_BYTES && duo.len() < THIN_SHELL_MAX_BYTES,
602            "duolingo shell must sit under the 15 KB ThinShell floor (len={})",
603            duo.len()
604        );
605        let d = engine_classify(&duo);
606        assert_eq!(d.tag, "L3-RENDERED");
607        assert_eq!(d.verdict, ChallengeVerdict::ThinShell);
608        assert!(!d.verdict.is_challenge());
609    }
610
611    // ── 12. DataDome captcha detection ──────────────────────────────────
612
613    #[test]
614    fn detect_datadome_captcha() {
615        let body = r#"<script src="https://geo.captcha-delivery.com/captcha.js"></script><div id="ddcaptchaencoded">encoded_payload</div>"#;
616        let ec = engine_classify(body);
617        assert_eq!(ec.tag, "Interstitial-CHL");
618        assert!(ec.verdict.is_challenge());
619    }
620
621    // ── 13. SecCpt challenge ────────────────────────────────────────────
622
623    #[test]
624    fn detect_sec_cpt() {
625        let body = r#"<html><body><div>loading...</div><script src="/_sec/cp_challenge/verify"></script></body></html>"#;
626        let ec = engine_classify(body);
627        assert_eq!(ec.tag, "SecCpt-CHL");
628        assert!(ec.verdict.is_challenge());
629    }
630
631    // ── 14. Kasada script challenge ─────────────────────────────────────
632
633    #[test]
634    fn detect_kasada_script() {
635        let mut body = String::from(
636            r#"<html><body><script>window._kpsdk={p:"abc"};</script><script src="/ips.js"></script>"#,
637        );
638        while body.len() < 2000 {
639            body.push_str("<div>padding for threshold</div>");
640        }
641        body.push_str("</body></html>");
642        let ec = engine_classify(&body);
643        assert_eq!(ec.tag, "ScriptChallenge-CHL");
644        assert!(ec.verdict.is_challenge());
645    }
646
647    // ── 15. PerimeterX challenge ────────────────────────────────────────
648
649    #[test]
650    fn detect_perimeterx_challenge() {
651        let body = r#"<html><body><div id="px-captcha"></div><script>window._pxhd="abc";</script></body></html>"#;
652        let ec = engine_classify(body);
653        assert_eq!(ec.tag, "BehaviorChallenge-CHL");
654        assert!(ec.verdict.is_challenge());
655    }
656
657    // ── 16. Akamai sensor with co-signal ────────────────────────────────
658
659    #[test]
660    fn detect_akamai_sensor_with_cosignal() {
661        let body = r#"<html><body><script src="/akam/13/pixel"></script><div id="sensor_data">payload</div></body></html>"#;
662        let ec = engine_classify(body);
663        assert_eq!(ec.tag, "SensorChallenge-CHL");
664        assert!(ec.verdict.is_challenge());
665    }
666
667    // ── 17. Akamai without co-signal is NOT a challenge ─────────────────
668
669    #[test]
670    fn akamai_without_cosignal_not_challenge() {
671        let body =
672            r#"<html><body><script src="/akam/13/pixel"></script><p>Welcome</p></body></html>"#;
673        let ec = engine_classify(body);
674        assert_ne!(ec.tag, "SensorChallenge-CHL");
675    }
676
677    // ── 18. Blocked word detection ──────────────────────────────────────
678
679    #[test]
680    fn detect_blocked_small_body() {
681        let body = "<html><body><h1>403 Forbidden</h1><p>Access Denied</p></body></html>";
682        let ec = engine_classify(body);
683        assert_eq!(ec.tag, "BLOCKED");
684        assert!(ec.verdict.is_challenge());
685    }
686
687    // ── 19. Blocked word size-gated ─────────────────────────────────────
688
689    #[test]
690    fn blocked_word_size_gated() {
691        let body = big("Access Denied");
692        let ec = engine_classify(&body);
693        assert_eq!(ec.tag, "L3-RENDERED");
694        assert_eq!(ec.verdict, ChallengeVerdict::Pass);
695    }
696
697    // ── 20. cf-browser-verification ─────────────────────────────────────
698
699    #[test]
700    fn detect_cf_browser_verification() {
701        let body =
702            r#"<html class="cf-browser-verification"><body>Checking your browser...</body></html>"#;
703        let ec = engine_classify(body);
704        assert_eq!(ec.tag, "ManagedChallenge-CHL");
705        assert!(ec.verdict.is_challenge());
706    }
707
708    // ── 21. checking your browser phrase ────────────────────────────────
709
710    #[test]
711    fn detect_checking_your_browser() {
712        let body =
713            "<html><body><p>Checking your browser before accessing the site...</p></body></html>";
714        let ec = engine_classify(body);
715        assert_eq!(ec.tag, "ManagedChallenge-CHL");
716        assert!(ec.verdict.is_challenge());
717    }
718
719    // ── 22. hcaptcha detection ──────────────────────────────────────────
720
721    #[test]
722    fn detect_hcaptcha() {
723        let body = r#"<html><body><iframe src="https://hcaptcha.com/captcha/v1/challenge"></iframe></body></html>"#;
724        let ec = engine_classify(body);
725        assert_eq!(ec.tag, "captcha-CHL");
726        assert!(ec.verdict.is_challenge());
727    }
728
729    // ── 23. cf-turnstile detection ──────────────────────────────────────
730
731    #[test]
732    fn detect_cf_turnstile() {
733        let mut body = String::from(
734            r#"<html><body><div class="cf-turnstile" data-sitekey="x"></div><p>captcha verification</p>"#,
735        );
736        while body.len() < 2000 {
737            body.push_str("<p>Verify you are human to continue browsing this site</p>");
738        }
739        body.push_str("</body></html>");
740        let ec = engine_classify(&body);
741        assert_eq!(ec.tag, "captcha-CHL");
742        assert!(ec.verdict.is_challenge());
743    }
744
745    // ── 24. pardon our interruption ─────────────────────────────────────
746
747    #[test]
748    fn detect_pardon_interruption() {
749        let body = "<html><body><p>Pardon our interruption, verifying access</p></body></html>";
750        let ec = engine_classify(body);
751        assert_eq!(ec.tag, "SensorChallenge-CHL");
752        assert!(ec.verdict.is_challenge());
753    }
754
755    // ── 25. normal HTML is L3-RENDERED ──────────────────────────────────
756
757    #[test]
758    fn normal_html_passes() {
759        let mut body = String::from("<html><body>");
760        for _ in 0..400 {
761            body.push_str("<p>Normal rendered content paragraph with enough text to fill.</p>");
762        }
763        body.push_str("</body></html>");
764        assert!(body.len() >= THIN_SHELL_MAX_BYTES, "body must be >= 15KB");
765        let ec = engine_classify(&body);
766        assert_eq!(ec.tag, "L3-RENDERED");
767        assert_eq!(ec.verdict, ChallengeVerdict::Pass);
768    }
769
770    // ── 26. BDD: CF managed challenge ───────────────────────────────────
771
772    #[test]
773    fn bdd_cf_managed_challenge() {
774        let body = r#"<html><body><script>window._cf_chl_opt={cvId:'3'};</script>
775            <script src="/cdn-cgi/challenge-platform/h/b/jsd"></script></body></html>"#;
776        let ec = engine_classify(body);
777        assert_eq!(ec.tag, "ManagedChallenge-CHL");
778        assert_eq!(ec.verdict, ChallengeVerdict::EdgeBlock);
779    }
780
781    // ── 27. BDD: AWS-WAF challenge ──────────────────────────────────────
782
783    #[test]
784    fn bdd_aws_waf_challenge() {
785        let body = r#"<html><body>
786            <script>window.gokuProps={key:'a'};window.awsWafCookieDomainList=["x.com"];</script>
787            <script src="https://x.token.awswaf.com/challenge.js"></script>
788            <script>AwsWafIntegration.checkForceRefresh();</script>
789        </body></html>"#;
790        let ec = engine_classify(body);
791        assert_eq!(ec.tag, "AWS-WAF-CHL");
792        assert_eq!(ec.verdict, ChallengeVerdict::EdgeBlock);
793    }
794
795    // ── 28. BDD: clean response ─────────────────────────────────────────
796
797    #[test]
798    fn bdd_clean_response() {
799        let mut body = String::from("<html><body>");
800        for _ in 0..400 {
801            body.push_str("<p>Normal content with enough text to exceed the 15KB threshold.</p>");
802        }
803        body.push_str("</body></html>");
804        assert!(body.len() >= THIN_SHELL_MAX_BYTES);
805        let ec = engine_classify(&body);
806        assert_eq!(ec.tag, "L3-RENDERED");
807        assert_eq!(ec.verdict, ChallengeVerdict::Pass);
808    }
809
810    // ── 29. BDD: thin body ──────────────────────────────────────────────
811
812    #[test]
813    fn bdd_thin_body() {
814        let body = "<html><body>tiny</body></html>";
815        let ec = engine_classify(body);
816        assert_eq!(ec.tag, "THIN-BODY");
817        assert_eq!(ec.verdict, ChallengeVerdict::RenderIncomplete);
818    }
819
820    // ── 30. ChallengeVerdict::is_challenge coverage ─────────────────────
821
822    #[test]
823    fn is_challenge_coverage() {
824        assert!(!ChallengeVerdict::Pass.is_challenge());
825        assert!(!ChallengeVerdict::RenderIncomplete.is_challenge());
826        assert!(!ChallengeVerdict::ThinShell.is_challenge());
827        assert!(ChallengeVerdict::EdgeBlock.is_challenge());
828        assert!(ChallengeVerdict::SensorFail.is_challenge());
829        assert!(ChallengeVerdict::ChallengeIncomplete.is_challenge());
830    }
831
832    // ── 31. EngineClamp len field ───────────────────────────────────────
833
834    #[test]
835    fn engine_class_len_matches_body() {
836        let body = "hello";
837        let ec = engine_classify(body);
838        assert_eq!(ec.len, 5);
839    }
840
841    // ── proptest: engine_classify never panics on arbitrary input ────────
842
843    #[cfg(feature = "proptest")]
844    mod proptests {
845        use proptest::prelude::*;
846
847        use super::*;
848
849        proptest! {
850            #[test]
851            fn engine_classify_never_panics(body in ".*") {
852                let ec = engine_classify(&body);
853                // Must always return a valid tag
854                let _ = ec.tag;
855                let _ = ec.verdict;
856                let _ = ec.len;
857            }
858        }
859    }
860}