Skip to main content

zendriver_datadome/
bypass.rs

1//! DataDome bypass driver.
2
3use 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/// Terminal outcome of a clearance attempt. All flow-terminals are `Ok`;
17/// only genuine faults are [`DataDomeError`].
18#[derive(Debug, Clone)]
19pub enum ClearanceOutcome {
20    /// datadome cookie acquired AND challenge markers gone.
21    Cleared { datadome: String },
22    /// Markers cleared but no datadome cookie observed (rare / legacy path).
23    ChallengeGone,
24    /// No DataDome surface present at call time. Fast path; no waiting.
25    AlreadyClear,
26    /// window.dd.t == 'bv' — IP banned. Nothing in-browser can clear this;
27    /// the caller must change IP (e.g. residential proxy).
28    Blocked,
29    /// Deadline elapsed without reaching a terminal state.
30    TimedOut {
31        last_surface: Option<DataDomeSurface>,
32    },
33}
34
35/// Drives a DataDome clearance flow against a single tab's session.
36///
37/// Constructed via `Tab::datadome()`.
38pub 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    /// New driver with default 250ms poll interval + 30s timeout.
59    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    /// Override the default 30s overall timeout.
70    #[must_use]
71    pub fn timeout(mut self, dur: Duration) -> Self {
72        self.timeout = dur;
73        self
74    }
75
76    /// Override the default 250ms poll interval.
77    #[must_use]
78    pub fn poll_interval(mut self, dur: Duration) -> Self {
79        self.poll_interval = dur;
80        self
81    }
82
83    /// Enable the Fetch-domain fast-path. Spawns a `Fetch` subscription that
84    /// signals on first 2xx response to `captcha-delivery.com` or any
85    /// `datadome*` URL, waking the poll loop immediately.
86    #[must_use]
87    pub fn with_interception(mut self) -> Self {
88        self.interception_enabled = true;
89        self
90    }
91
92    /// Register a caller-supplied async CAPTCHA solver. Without it, a CAPTCHA
93    /// surface yields [`DataDomeError::CaptchaRequired`].
94    ///
95    /// The solver receives a [`DataDomeChallenge`] (captcha URL, page URL, UA,
96    /// cid, hash) and returns a [`DataDomeSolution`] carrying the solved
97    /// `datadome` cookie — wire it to a service like 2captcha / capsolver.
98    ///
99    /// [`DataDomeChallenge`]: crate::captcha::DataDomeChallenge
100    /// [`DataDomeSolution`]: crate::captcha::DataDomeSolution
101    #[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    /// Run the surface-aware poll loop until clearance, block, or timeout.
118    ///
119    /// # Returns
120    /// - `Cleared { datadome }` — cookie landed and markers gone.
121    /// - `ChallengeGone` — markers gone, no cookie observed.
122    /// - `AlreadyClear` — no surface at call time.
123    /// - `Blocked` — `window.dd.t == 'bv'` (IP banned).
124    /// - `TimedOut { last_surface }` — deadline elapsed.
125    ///
126    /// # Errors
127    /// - [`DataDomeError::CaptchaRequired`] — captcha surface, no solver.
128    /// - [`DataDomeError::CaptchaSolver`] — registered solver errored.
129    /// - [`DataDomeError::Interception`] / [`DataDomeError::Call`] /
130    ///   [`DataDomeError::JsError`].
131    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        // Pre-loop captcha escalation.
153        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            // Page reloaded — discard the stale snapshot, force a re-probe.
164            return self.poll_loop(deadline, None, None, None).await;
165        }
166
167        // Optional Fetch-domain fast-path. The guard's `Drop` cooperatively
168        // cancels the spawned task (token check at every loop boundary)
169        // with `abort()` as a backstop.
170        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        // First iter always either returns or writes `last_surface` before
198        // any read, but the deadline-check branches keep a read path alive.
199        #[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                        // Never-resolves fallback keeps this arm inert when
242                        // interception is disabled; `select!` polls the
243                        // remaining arms unaffected.
244                        None => std::future::pending().await,
245                    }
246                } => {
247                    // Drop the receiver so we don't re-await a closed channel
248                    // on the next iteration; loop body re-probes immediately.
249                    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        // Probe 1: device-check, no cookie.
349        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        // Probe 2: cleared.
358        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        // Pre-loop probe: captcha surface.
434        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        // build_challenge eval (url + ua).
443        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        // apply_solution: setCookie + reload.
450        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        // Post-reload poll: cleared.
455        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        // Receive the probe command but NEVER reply → the timeout must fire.
485        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        // First in-loop probe is received but NEVER answered → deadline fires.
530        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(); // signal already fired
544        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        // The signal arm fires immediately → one detect probe → cleared.
559        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}