1use std::sync::Arc;
4use std::time::Duration;
5
6use tokio::time::Instant;
7use zendriver_transport::SessionHandle;
8
9use crate::captcha::CaptchaSolver;
10use crate::detection::{DataDomeSurface, DetectionSnapshot};
11use crate::error::DataDomeError;
12
13pub(crate) const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(250);
14pub(crate) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
15
16#[derive(Debug, Clone)]
19pub enum ClearanceOutcome {
20 Cleared { datadome: String },
22 ChallengeGone,
24 AlreadyClear,
26 Blocked,
29 TimedOut {
31 last_surface: Option<DataDomeSurface>,
32 },
33}
34
35pub struct DataDomeBypass<'tab> {
39 pub(crate) session: &'tab SessionHandle,
40 pub(crate) poll_interval: Duration,
41 pub(crate) timeout: Duration,
42 pub(crate) on_captcha: Option<Arc<CaptchaSolver>>,
43 pub(crate) interception_enabled: bool,
44}
45
46impl std::fmt::Debug for DataDomeBypass<'_> {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("DataDomeBypass")
49 .field("poll_interval", &self.poll_interval)
50 .field("timeout", &self.timeout)
51 .field("on_captcha", &self.on_captcha.as_ref().map(|_| "..."))
52 .field("interception_enabled", &self.interception_enabled)
53 .finish()
54 }
55}
56
57impl<'tab> DataDomeBypass<'tab> {
58 pub fn new(session: &'tab SessionHandle) -> Self {
60 Self {
61 session,
62 poll_interval: DEFAULT_POLL_INTERVAL,
63 timeout: DEFAULT_TIMEOUT,
64 on_captcha: None,
65 interception_enabled: false,
66 }
67 }
68
69 #[must_use]
71 pub fn timeout(mut self, dur: Duration) -> Self {
72 self.timeout = dur;
73 self
74 }
75
76 #[must_use]
78 pub fn poll_interval(mut self, dur: Duration) -> Self {
79 self.poll_interval = dur;
80 self
81 }
82
83 #[must_use]
87 pub fn with_interception(mut self) -> Self {
88 self.interception_enabled = true;
89 self
90 }
91
92 #[must_use]
102 pub fn on_captcha<F, Fut>(mut self, f: F) -> Self
103 where
104 F: Fn(crate::captcha::DataDomeChallenge) -> Fut + Send + Sync + 'static,
105 Fut: std::future::Future<
106 Output = Result<
107 crate::captcha::DataDomeSolution,
108 Box<dyn std::error::Error + Send + Sync>,
109 >,
110 > + Send
111 + 'static,
112 {
113 self.on_captcha = Some(crate::captcha::arc_solver(f));
114 self
115 }
116
117 pub async fn wait_for_clearance(self) -> Result<ClearanceOutcome, DataDomeError> {
132 let deadline = Instant::now() + self.timeout;
133
134 let snapshot = match tokio::time::timeout_at(
135 deadline,
136 crate::detection::detect_snapshot(self.session),
137 )
138 .await
139 {
140 Ok(res) => res?,
141 Err(_) => return Ok(ClearanceOutcome::TimedOut { last_surface: None }),
142 };
143
144 match snapshot.surface {
145 DataDomeSurface::None if snapshot.body_clean => {
146 return Ok(ClearanceOutcome::AlreadyClear);
147 }
148 DataDomeSurface::Block => return Ok(ClearanceOutcome::Blocked),
149 _ => {}
150 }
151
152 if snapshot.surface == DataDomeSurface::Captcha {
154 let Some(solver) = self.on_captcha.clone() else {
155 return Err(DataDomeError::CaptchaRequired);
156 };
157 let challenge = crate::captcha::build_challenge(self.session, &snapshot).await?;
158 let site_url = challenge.site_url.clone();
159 let solution = solver(challenge)
160 .await
161 .map_err(DataDomeError::CaptchaSolver)?;
162 crate::captcha::apply_solution(self.session, &solution, &site_url).await?;
163 return self.poll_loop(deadline, None, None, None).await;
165 }
166
167 let (interception_rx, interception_guard) = if self.interception_enabled {
171 let (rx, guard) = crate::interception::spawn_signal(self.session);
172 (Some(rx), Some(guard))
173 } else {
174 (None, None)
175 };
176 self.poll_loop(
177 deadline,
178 Some(snapshot),
179 interception_rx,
180 interception_guard,
181 )
182 .await
183 }
184
185 pub(crate) async fn poll_loop(
186 self,
187 deadline: Instant,
188 mut next_snapshot: Option<DetectionSnapshot>,
189 mut interception_rx: Option<tokio::sync::oneshot::Receiver<()>>,
190 _interception_guard: Option<crate::interception::InterceptionGuard>,
191 ) -> Result<ClearanceOutcome, DataDomeError> {
192 use tokio::time::{Interval, MissedTickBehavior};
193
194 let mut ticker: Interval = tokio::time::interval(self.poll_interval);
195 ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
196
197 #[allow(unused_assignments)]
200 let mut last_surface: Option<DataDomeSurface> = None;
201
202 loop {
203 let snap = match next_snapshot.take() {
204 Some(s) => s,
205 None => match tokio::time::timeout_at(
206 deadline,
207 crate::detection::detect_snapshot(self.session),
208 )
209 .await
210 {
211 Ok(res) => res?,
212 Err(_) => return Ok(ClearanceOutcome::TimedOut { last_surface }),
213 },
214 };
215
216 let cookie = snap.datadome.as_ref().filter(|v| !v.is_empty());
217 match (cookie, snap.body_clean) {
218 (Some(c), true) => {
219 return Ok(ClearanceOutcome::Cleared {
220 datadome: c.clone(),
221 });
222 }
223 (None, true) if snap.surface == DataDomeSurface::None => {
224 return Ok(ClearanceOutcome::ChallengeGone);
225 }
226 _ => last_surface = Some(snap.surface),
227 }
228
229 if Instant::now() >= deadline {
230 return Ok(ClearanceOutcome::TimedOut { last_surface });
231 }
232
233 tokio::select! {
234 _ = ticker.tick() => {}
235 () = tokio::time::sleep_until(deadline) => {
236 return Ok(ClearanceOutcome::TimedOut { last_surface });
237 }
238 Ok(()) = async {
239 match interception_rx.as_mut() {
240 Some(rx) => rx.await,
241 None => std::future::pending().await,
245 }
246 } => {
247 interception_rx = None;
250 }
251 }
252 }
253 }
254}
255
256#[cfg(test)]
257#[allow(clippy::panic, clippy::unwrap_used)]
258mod tests {
259 use super::*;
260 use zendriver_transport::testing::MockConnection;
261
262 #[tokio::test]
263 async fn builder_defaults_and_overrides() {
264 let (_, conn) = MockConnection::pair();
265 let sess = SessionHandle::new(conn.clone(), "S1");
266 let b = DataDomeBypass::new(&sess);
267 assert_eq!(b.poll_interval, DEFAULT_POLL_INTERVAL);
268 assert_eq!(b.timeout, DEFAULT_TIMEOUT);
269 assert!(b.on_captcha.is_none());
270 assert!(!b.interception_enabled);
271
272 let b = b
273 .timeout(std::time::Duration::from_secs(45))
274 .poll_interval(std::time::Duration::from_millis(100))
275 .with_interception();
276 assert_eq!(b.timeout, std::time::Duration::from_secs(45));
277 assert_eq!(b.poll_interval, std::time::Duration::from_millis(100));
278 assert!(b.interception_enabled);
279 conn.shutdown();
280 }
281
282 use crate::detection::DataDomeSurface;
283 use serde_json::json;
284
285 fn snap_reply(v: serde_json::Value) -> serde_json::Value {
286 json!({ "result": { "type": "object", "value": v } })
287 }
288
289 #[tokio::test]
290 async fn already_clear_on_clean_page() {
291 let (mut mock, conn) = MockConnection::pair();
292 let sess = SessionHandle::new(conn.clone(), "S1");
293 let fut = tokio::spawn({
294 let s = sess.clone();
295 async move { DataDomeBypass::new(&s).wait_for_clearance().await }
296 });
297 let id = mock.expect_cmd("Runtime.evaluate").await;
298 mock.reply(
299 id,
300 snap_reply(
301 json!({"surface":"none","datadome":"DD","dd":null,"captcha_url":null,"body_clean":true}),
302 ),
303 )
304 .await;
305 assert!(matches!(
306 fut.await.unwrap().unwrap(),
307 ClearanceOutcome::AlreadyClear
308 ));
309 conn.shutdown();
310 }
311
312 #[tokio::test]
313 async fn block_is_immediate_terminal() {
314 let (mut mock, conn) = MockConnection::pair();
315 let sess = SessionHandle::new(conn.clone(), "S1");
316 let fut = tokio::spawn({
317 let s = sess.clone();
318 async move { DataDomeBypass::new(&s).wait_for_clearance().await }
319 });
320 let id = mock.expect_cmd("Runtime.evaluate").await;
321 mock.reply(
322 id,
323 snap_reply(
324 json!({"surface":"block","datadome":null,"dd":{"cid":"C","hsh":"H","t":"bv","host":"h"},"captcha_url":null,"body_clean":false}),
325 ),
326 )
327 .await;
328 assert!(matches!(
329 fut.await.unwrap().unwrap(),
330 ClearanceOutcome::Blocked
331 ));
332 conn.shutdown();
333 }
334
335 #[tokio::test]
336 async fn device_check_clears_when_cookie_lands_and_body_clean() {
337 let (mut mock, conn) = MockConnection::pair();
338 let sess = SessionHandle::new(conn.clone(), "S1");
339 let fut = tokio::spawn({
340 let s = sess.clone();
341 async move {
342 DataDomeBypass::new(&s)
343 .poll_interval(Duration::from_millis(1))
344 .wait_for_clearance()
345 .await
346 }
347 });
348 let id1 = mock.expect_cmd("Runtime.evaluate").await;
350 mock.reply(
351 id1,
352 snap_reply(
353 json!({"surface":"device_check","datadome":null,"dd":{"cid":"C","hsh":"H","t":"fe","host":"h"},"captcha_url":null,"body_clean":false}),
354 ),
355 )
356 .await;
357 let id2 = mock.expect_cmd("Runtime.evaluate").await;
359 mock.reply(
360 id2,
361 snap_reply(
362 json!({"surface":"none","datadome":"COOKIE_OK","dd":null,"captcha_url":null,"body_clean":true}),
363 ),
364 )
365 .await;
366 match fut.await.unwrap().unwrap() {
367 ClearanceOutcome::Cleared { datadome } => assert_eq!(datadome, "COOKIE_OK"),
368 other => panic!("expected Cleared, got {other:?}"),
369 }
370 conn.shutdown();
371 }
372
373 #[tokio::test]
374 async fn times_out_when_device_check_never_clears() {
375 let (mut mock, conn) = MockConnection::pair();
376 let sess = SessionHandle::new(conn.clone(), "S1");
377 let fut = tokio::spawn({
378 let s = sess.clone();
379 async move {
380 DataDomeBypass::new(&s)
381 .poll_interval(Duration::from_millis(1))
382 .timeout(Duration::from_millis(40))
383 .wait_for_clearance()
384 .await
385 }
386 });
387 for _ in 0..50 {
388 let Ok(id) = tokio::time::timeout(
389 Duration::from_millis(80),
390 mock.expect_cmd("Runtime.evaluate"),
391 )
392 .await
393 else {
394 break;
395 };
396 mock.reply(
397 id,
398 snap_reply(
399 json!({"surface":"device_check","datadome":null,"dd":{"cid":"C","hsh":"H","t":"fe","host":"h"},"captcha_url":null,"body_clean":false}),
400 ),
401 )
402 .await;
403 }
404 match fut.await.unwrap().unwrap() {
405 ClearanceOutcome::TimedOut { last_surface } => {
406 assert_eq!(last_surface, Some(DataDomeSurface::DeviceCheck));
407 }
408 other => panic!("expected TimedOut, got {other:?}"),
409 }
410 conn.shutdown();
411 }
412
413 #[tokio::test]
414 async fn captcha_with_solver_applies_cookie_then_clears() {
415 let (mut mock, conn) = MockConnection::pair();
416 let sess = SessionHandle::new(conn.clone(), "S1");
417 let fut = tokio::spawn({
418 let s = sess.clone();
419 async move {
420 DataDomeBypass::new(&s)
421 .poll_interval(Duration::from_millis(1))
422 .on_captcha(|ch| async move {
423 assert!(ch.captcha_url.contains("captcha-delivery.com"));
424 Ok(crate::captcha::DataDomeSolution {
425 datadome_cookie: "FROM_SOLVER".into(),
426 })
427 })
428 .wait_for_clearance()
429 .await
430 }
431 });
432
433 let id1 = mock.expect_cmd("Runtime.evaluate").await;
435 mock.reply(
436 id1,
437 snap_reply(
438 json!({"surface":"captcha","datadome":"OLD","dd":{"cid":"C","hsh":"H","t":"fe","host":"h"},"captcha_url":"https://geo.captcha-delivery.com/captcha/?cid=C","body_clean":false}),
439 ),
440 )
441 .await;
442 let id_ctx = mock.expect_cmd("Runtime.evaluate").await;
444 mock.reply(
445 id_ctx,
446 json!({"result":{"type":"object","value":{"url":"https://shop.example.com/p","ua":"Mozilla/5.0"}}}),
447 )
448 .await;
449 let id_cookie = mock.expect_cmd("Network.setCookie").await;
451 mock.reply(id_cookie, json!({"success":true})).await;
452 let id_reload = mock.expect_cmd("Page.reload").await;
453 mock.reply(id_reload, json!({})).await;
454 let id_poll = mock.expect_cmd("Runtime.evaluate").await;
456 mock.reply(
457 id_poll,
458 snap_reply(
459 json!({"surface":"none","datadome":"FROM_SOLVER","dd":null,"captcha_url":null,"body_clean":true}),
460 ),
461 )
462 .await;
463
464 match fut.await.unwrap().unwrap() {
465 ClearanceOutcome::Cleared { datadome } => assert_eq!(datadome, "FROM_SOLVER"),
466 other => panic!("expected Cleared, got {other:?}"),
467 }
468 conn.shutdown();
469 }
470
471 #[tokio::test]
472 async fn pre_loop_probe_respects_timeout() {
473 let (mut mock, conn) = MockConnection::pair();
474 let sess = SessionHandle::new(conn.clone(), "S1");
475 let fut = tokio::spawn({
476 let s = sess.clone();
477 async move {
478 DataDomeBypass::new(&s)
479 .timeout(Duration::from_millis(30))
480 .wait_for_clearance()
481 .await
482 }
483 });
484 let _id = mock.expect_cmd("Runtime.evaluate").await;
486 match fut.await.unwrap().unwrap() {
487 ClearanceOutcome::TimedOut { last_surface } => assert_eq!(last_surface, None),
488 other => panic!("expected TimedOut, got {other:?}"),
489 }
490 conn.shutdown();
491 }
492
493 #[tokio::test]
494 async fn captcha_without_solver_errors() {
495 let (mut mock, conn) = MockConnection::pair();
496 let sess = SessionHandle::new(conn.clone(), "S1");
497 let fut = tokio::spawn({
498 let s = sess.clone();
499 async move { DataDomeBypass::new(&s).wait_for_clearance().await }
500 });
501 let id = mock.expect_cmd("Runtime.evaluate").await;
502 mock.reply(
503 id,
504 snap_reply(
505 json!({"surface":"captcha","datadome":null,"dd":null,"captcha_url":"https://geo.captcha-delivery.com/captcha/?cid=C","body_clean":false}),
506 ),
507 )
508 .await;
509 assert!(matches!(
510 fut.await.unwrap().unwrap_err(),
511 DataDomeError::CaptchaRequired
512 ));
513 conn.shutdown();
514 }
515
516 #[tokio::test]
517 async fn poll_loop_probe_hang_respects_deadline() {
518 let (mut mock, conn) = MockConnection::pair();
519 let sess = SessionHandle::new(conn.clone(), "S1");
520 let fut = tokio::spawn({
521 let s = sess.clone();
522 async move {
523 DataDomeBypass::new(&s)
524 .poll_interval(Duration::from_millis(1))
525 .poll_loop(Instant::now() + Duration::from_millis(30), None, None, None)
526 .await
527 }
528 });
529 let _id = mock.expect_cmd("Runtime.evaluate").await;
531 match fut.await.unwrap().unwrap() {
532 ClearanceOutcome::TimedOut { .. } => {}
533 other => panic!("expected TimedOut, got {other:?}"),
534 }
535 conn.shutdown();
536 }
537
538 #[tokio::test]
539 async fn interception_signal_triggers_reprobe() {
540 let (mut mock, conn) = MockConnection::pair();
541 let sess = SessionHandle::new(conn.clone(), "S1");
542 let (tx, rx) = tokio::sync::oneshot::channel();
543 tx.send(()).unwrap(); let fut = tokio::spawn({
545 let s = sess.clone();
546 async move {
547 DataDomeBypass::new(&s)
548 .poll_interval(Duration::from_secs(10))
549 .poll_loop(
550 Instant::now() + Duration::from_secs(5),
551 None,
552 Some(rx),
553 None,
554 )
555 .await
556 }
557 });
558 let id = mock.expect_cmd("Runtime.evaluate").await;
560 mock.reply(
561 id,
562 snap_reply(
563 json!({"surface":"none","datadome":"DD","dd":null,"captcha_url":null,"body_clean":true}),
564 ),
565 )
566 .await;
567 match fut.await.unwrap().unwrap() {
568 ClearanceOutcome::Cleared { datadome } => assert_eq!(datadome, "DD"),
569 other => panic!("expected Cleared, got {other:?}"),
570 }
571 conn.shutdown();
572 }
573}