1use async_trait::async_trait;
4
5pub 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
13const 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
23const AWSWAF_MARKERS: &[&str] = &["gokuprops", "awswafcookiedomainlist"];
25
26const AWSWAF_ACTIVE_LOADER: &[&str] =
28 &["token.awswaf.com", "awswafintegration", "checkforcerefresh"];
29
30const PHRASE: &[(&str, &str)] = &[
32 ("just a moment", "ManagedChallenge-CHL"),
33 ("checking your browser", "ManagedChallenge-CHL"),
34 ("captcha-delivery.com", "Interstitial-CHL"),
35 ("press & hold", "HoldChallenge-PaH"),
36 ("pardon our interruption", "SensorChallenge-CHL"),
37];
38
39const 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
52const 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
62const 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#[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#[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#[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
135fn 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#[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
168pub 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#[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 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 #[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 #[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 & 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let _ = ec.tag;
855 let _ = ec.verdict;
856 let _ = ec.len;
857 }
858 }
859 }
860}