Skip to main content

zendriver_interception/
builder.rs

1//! [`InterceptBuilder`] — fluent rule + pattern registration.
2//!
3//! Two-phase API:
4//! - **Configure**: chain [`block`], [`redirect`], [`respond`],
5//!   [`modify_request`], [`modify_response`] for declarative rules, plus
6//!   [`pattern`] / [`at_request`] / [`at_response`] / [`resource`] to control
7//!   which CDP `Fetch.RequestPattern` entries are sent on `Fetch.enable`.
8//! - **Activate**: [`start`](InterceptBuilder::start) spawns the actor task
9//!   (T6) with the registered rules + patterns, returning an
10//!   [`InterceptHandle`] for RAII teardown. Alternatively,
11//!   [`subscribe`](InterceptBuilder::subscribe) returns a
12//!   `Stream<Item = PausedRequest>` for the manual escape-hatch path —
13//!   callers drive Chrome's interception loop themselves.
14//!
15//! The `tab` field is a borrow of [`SessionHandle`] (not the full `Tab` from
16//! `zendriver` core) — this crate must not depend on `zendriver` (cycle).
17//! `Tab::intercept()` in `zendriver` constructs the builder via
18//! `InterceptBuilder::new(self.session())`.
19//!
20//! [`block`]: InterceptBuilder::block
21//! [`redirect`]: InterceptBuilder::redirect
22//! [`respond`]: InterceptBuilder::respond
23//! [`modify_request`]: InterceptBuilder::modify_request
24//! [`modify_response`]: InterceptBuilder::modify_response
25//! [`pattern`]: InterceptBuilder::pattern
26//! [`at_request`]: InterceptBuilder::at_request
27//! [`at_response`]: InterceptBuilder::at_response
28//! [`resource`]: InterceptBuilder::resource
29
30use std::sync::Arc;
31
32use futures::stream::{Stream, StreamExt};
33use serde_json::{Value, json};
34use tokio::sync::oneshot;
35use tokio_util::sync::CancellationToken;
36use tracing::warn;
37use zendriver_transport::SessionHandle;
38
39use crate::actor::{
40    InterceptHandle, RequestPausedEvent, build_request_info, build_response_info, run_actor,
41    serialize_pattern,
42};
43use crate::error::InterceptionError;
44use crate::paused::PausedRequest;
45use crate::rule::Rule;
46use crate::types::{
47    RequestInfo, RequestOverrides, RequestStage, ResourceType, ResponseInfo, ResponseOverrides,
48};
49use crate::url_pattern::UrlPattern;
50
51/// A pending `Fetch.RequestPattern` entry to send on `Fetch.enable`.
52///
53/// CDP's [`Fetch.RequestPattern`] takes an optional `urlPattern`,
54/// `resourceType`, and `requestStage`. We mirror it 1:1 here. The builder
55/// accumulates these via [`InterceptBuilder::pattern`] / `at_request` /
56/// `at_response` / `resource`, mutating the last-pushed entry per chain — so
57/// `builder.pattern("*").at_response().resource(Image)` produces a single
58/// `RequestPattern` with all three fields set.
59///
60/// [`Fetch.RequestPattern`]: https://chromedevtools.github.io/devtools-protocol/tot/Fetch/#type-RequestPattern
61#[derive(Debug, Clone, Default)]
62pub struct RequestPattern {
63    /// URL pattern in CDP wildcard syntax. `None` means "match any URL"
64    /// (CDP default).
65    pub url_pattern: Option<String>,
66    /// Resource type filter (e.g. `Image`, `XHR`). `None` means "all types".
67    pub resource_type: Option<ResourceType>,
68    /// Lifecycle stage at which to pause. `None` means CDP's default
69    /// (`Request`).
70    pub request_stage: Option<RequestStage>,
71}
72
73/// Fluent builder for rule-based interception against a single tab session.
74///
75/// Construct via `Tab::intercept()` (gated `feature = "interception"`, wired
76/// in Task 7). Chain configuration methods to register rules and declare CDP
77/// `Fetch.enable` patterns, then call [`start`](Self::start) (Task 7) to
78/// activate the background actor or [`subscribe`](Self::subscribe) (Task 7)
79/// for the stream-driven escape hatch.
80///
81/// `'tab` ties the builder's lifetime to the tab's session — the borrow lasts
82/// only until `start()` / `subscribe()` consumes the builder.
83//
84// `Debug` works because `Rule` has a hand-written `Debug` impl that renders
85// the closure variant's body as `<closure>`. Inner `Vec<Rule>` derives via
86// that.
87#[derive(Debug)]
88pub struct InterceptBuilder<'tab> {
89    tab: &'tab SessionHandle,
90    patterns: Vec<RequestPattern>,
91    rules: Vec<Rule>,
92    /// Optional proxy/server credentials. When set, `Fetch.enable` is sent
93    /// with `handleAuthRequests: true` and the actor responds to each
94    /// `Fetch.authRequired` event with `Fetch.continueWithAuth` carrying
95    /// these credentials. See cdpdriver/zendriver#208.
96    auth: Option<(String, String)>,
97}
98
99impl<'tab> InterceptBuilder<'tab> {
100    /// Construct a fresh builder bound to `tab`'s session.
101    ///
102    /// `pub` so adapter crates (e.g. `zendriver` core's `Tab::intercept()`
103    /// shim) can construct it from a `&SessionHandle` without going through
104    /// a trait. End users go through `Tab::intercept()` rather than calling
105    /// this directly.
106    ///
107    /// ```no_run
108    /// # async fn ex(tab: &zendriver_transport::SessionHandle)
109    /// #   -> Result<(), zendriver_interception::InterceptionError> {
110    /// use zendriver_interception::InterceptBuilder;
111    ///
112    /// let _handle = InterceptBuilder::new(tab)
113    ///     .block("*/tracker.js")?
114    ///     .start();
115    /// # Ok(()) }
116    /// ```
117    #[must_use]
118    pub fn new(tab: &'tab SessionHandle) -> Self {
119        Self {
120            tab,
121            patterns: Vec::new(),
122            rules: Vec::new(),
123            auth: None,
124        }
125    }
126
127    /// Auto-respond to `Fetch.authRequired` challenges with the given
128    /// credentials.
129    ///
130    /// This is the proxy-auth (and HTTP basic-auth) path: `Fetch.enable` is
131    /// sent with `handleAuthRequests: true` and every `Fetch.authRequired`
132    /// event is answered with `Fetch.continueWithAuth { authChallengeResponse:
133    /// { response: "ProvideCredentials", username, password } }`.
134    ///
135    /// Compose with rules: an `InterceptBuilder` configured with `handle_auth`
136    /// and `block` / `redirect` / `respond` rules handles both paths from the
137    /// same actor. Combine with [`BrowserBuilder::proxy_auth`] in the
138    /// `zendriver` crate if you want the wiring installed automatically on
139    /// every tab.
140    ///
141    /// See cdpdriver/zendriver#208.
142    #[must_use]
143    pub fn handle_auth(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
144        self.auth = Some((user.into(), pass.into()));
145        self
146    }
147
148    /// Push a new pattern entry with the given URL pattern string.
149    ///
150    /// Subsequent [`at_request`](Self::at_request) /
151    /// [`at_response`](Self::at_response) / [`resource`](Self::resource) calls
152    /// mutate this newest entry, so a chain like
153    /// `.pattern("*").at_response().resource(ResourceType::XHR)` produces one
154    /// `RequestPattern` with all three fields populated.
155    #[must_use]
156    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
157        self.patterns.push(RequestPattern {
158            url_pattern: Some(pattern.into()),
159            ..RequestPattern::default()
160        });
161        self
162    }
163
164    /// Pause matching requests at the `Request` stage on the most-recently
165    /// pushed pattern.
166    ///
167    /// If no pattern has been pushed yet, this creates an empty one (matches
168    /// every URL by CDP default) and sets the stage on it.
169    #[must_use]
170    pub fn at_request(mut self) -> Self {
171        self.ensure_pattern().request_stage = Some(RequestStage::Request);
172        self
173    }
174
175    /// Pause matching requests at the `Response` stage on the most-recently
176    /// pushed pattern.
177    #[must_use]
178    pub fn at_response(mut self) -> Self {
179        self.ensure_pattern().request_stage = Some(RequestStage::Response);
180        self
181    }
182
183    /// Restrict the most-recently pushed pattern to a single resource type.
184    #[must_use]
185    pub fn resource(mut self, kind: ResourceType) -> Self {
186        self.ensure_pattern().resource_type = Some(kind);
187        self
188    }
189
190    /// Register a [`Rule::Block`] for `pattern`.
191    ///
192    /// Compiles `pattern` eagerly; an invalid pattern fails the builder chain
193    /// with [`InterceptionError::InvalidPattern`] returned as `Err(Self)` via
194    /// the `Result` wrapper.
195    pub fn block(mut self, pattern: impl Into<String>) -> Result<Self, InterceptionError> {
196        self.rules.push(Rule::Block {
197            pattern: UrlPattern::new(pattern)?,
198        });
199        Ok(self)
200    }
201
202    /// Register a [`Rule::Redirect`] that rewrites `from` → `to`.
203    pub fn redirect(
204        mut self,
205        from: impl Into<String>,
206        to: impl Into<String>,
207    ) -> Result<Self, InterceptionError> {
208        self.rules.push(Rule::Redirect {
209            from: UrlPattern::new(from)?,
210            to: to.into(),
211        });
212        Ok(self)
213    }
214
215    /// Register a [`Rule::Respond`] serving a synthesized response.
216    pub fn respond(
217        mut self,
218        pattern: impl Into<String>,
219        status: u16,
220        headers: Vec<(String, String)>,
221        body: Vec<u8>,
222    ) -> Result<Self, InterceptionError> {
223        self.rules.push(Rule::Respond {
224            pattern: UrlPattern::new(pattern)?,
225            status,
226            headers,
227            body,
228        });
229        Ok(self)
230    }
231
232    /// Register a [`Rule::Modify`] driven by a user closure.
233    ///
234    /// The closure runs on the actor task per matching request — it must be
235    /// `Send + Sync` and `'static`. Wrap shared state in `Arc` if needed.
236    pub fn modify_request<F>(
237        mut self,
238        pattern: impl Into<String>,
239        modify: F,
240    ) -> Result<Self, InterceptionError>
241    where
242        F: Fn(&RequestInfo) -> RequestOverrides + Send + Sync + 'static,
243    {
244        self.rules.push(Rule::Modify {
245            pattern: UrlPattern::new(pattern)?,
246            modify: Arc::new(modify),
247        });
248        Ok(self)
249    }
250
251    /// Register a [`Rule::ModifyResponse`] driven by a user closure.
252    ///
253    /// The closure rewrites an upstream response's status/headers (keeping
254    /// Chrome's body) and only fires at the `Response` stage — pair this with
255    /// [`at_response`](Self::at_response) so Chrome actually pauses there.
256    /// Header overrides are *replacement*, not merge (CDP semantics): return
257    /// every header you want forwarded.
258    ///
259    /// Like [`modify_request`](Self::modify_request), the closure runs on the
260    /// actor task per matching response, so it must be `Send + Sync` and
261    /// `'static`. Wrap shared state in `Arc` if needed.
262    pub fn modify_response<F>(
263        mut self,
264        pattern: impl Into<String>,
265        modify: F,
266    ) -> Result<Self, InterceptionError>
267    where
268        F: Fn(&ResponseInfo) -> ResponseOverrides + Send + Sync + 'static,
269    {
270        self.rules.push(Rule::ModifyResponse {
271            pattern: UrlPattern::new(pattern)?,
272            modify: Arc::new(modify),
273        });
274        Ok(self)
275    }
276
277    /// Activate the rule-based interception loop.
278    ///
279    /// Spawns the background actor task with the registered rules and CDP
280    /// `RequestPattern` list, and returns an [`InterceptHandle`] whose
281    /// [`Drop`] (or explicit [`stop`](InterceptHandle::stop)) tears the
282    /// actor down.
283    ///
284    /// If no [`pattern`](Self::pattern) entries were added, a single
285    /// match-all (`"*"`) pattern is sent so Chrome actually pauses requests
286    /// — without it, `Fetch.enable` would attach to nothing and the rule
287    /// list would never fire.
288    #[must_use = "interception stops when the handle is dropped — bind the returned InterceptHandle to keep it alive"]
289    pub fn start(mut self) -> InterceptHandle {
290        if self.patterns.is_empty() {
291            // Default to a single match-all pattern. Without it Chrome's
292            // `Fetch.enable` receives an empty `patterns` array and pauses
293            // nothing — silently making every rule a no-op. The actor still
294            // sends `handleAuthRequests: false` either way.
295            self.patterns.push(RequestPattern {
296                url_pattern: Some("*".into()),
297                ..RequestPattern::default()
298            });
299        }
300        let cancel = CancellationToken::new();
301        let (done_tx, done_rx) = oneshot::channel();
302        let actor_session = self.tab.clone();
303        let actor_cancel = cancel.clone();
304        let actor_rules = self.rules;
305        let actor_patterns = self.patterns;
306        let actor_auth = self.auth;
307        tokio::spawn(async move {
308            run_actor(
309                actor_session,
310                actor_rules,
311                actor_patterns,
312                actor_auth,
313                actor_cancel,
314                done_tx,
315            )
316            .await;
317        });
318        InterceptHandle::new(cancel, done_rx)
319    }
320
321    /// Manual escape-hatch: subscribe to raw [`PausedRequest`] events.
322    ///
323    /// Enables `Fetch` interception with the declared patterns (defaulting
324    /// to a single match-all `"*"` pattern when none were added) and returns
325    /// a [`Stream`] that yields one [`PausedRequest`] per `Fetch.requestPaused`
326    /// CDP event. Callers must dispatch one of `PausedRequest`'s terminal
327    /// methods (`continue_` / `abort` / `respond` / `modify_and_continue`)
328    /// to release each pause — Chrome holds the request open otherwise.
329    ///
330    /// Rules registered via `block` / `redirect` / `respond` / `modify_request`
331    /// are ignored on this path: stream consumers drive every paused request
332    /// themselves. Use [`start`](Self::start) when you want the actor to
333    /// apply rules automatically.
334    ///
335    /// The returned stream owns the underlying CDP subscription. Dropping
336    /// the stream tears the subscription down — Chrome's interception stays
337    /// active until the session is closed, but no further pauses surface to
338    /// the caller.
339    #[must_use = "the returned stream is the only handle on the subscription"]
340    pub fn subscribe(mut self) -> impl Stream<Item = PausedRequest> + Send + use<> {
341        if self.patterns.is_empty() {
342            self.patterns.push(RequestPattern {
343                url_pattern: Some("*".into()),
344                ..RequestPattern::default()
345            });
346        }
347        // Same ordering as the actor: subscribe BEFORE the (fire-and-forget)
348        // enable so we don't drop events Chrome emits between the enable
349        // round-trip and the subscription registration.
350        let raw = self.tab.subscribe::<Value>("Fetch.requestPaused");
351        let session = self.tab.clone();
352        let enable_session = session.clone();
353        let enable_patterns: Vec<Value> = self.patterns.iter().map(serialize_pattern).collect();
354        tokio::spawn(async move {
355            if let Err(e) = enable_session
356                .call(
357                    "Fetch.enable",
358                    json!({
359                        "patterns": enable_patterns,
360                        "handleAuthRequests": false,
361                    }),
362                )
363                .await
364            {
365                warn!(error = %e, "interception: Fetch.enable failed; subscribe() stream will be empty");
366            }
367        });
368        raw.filter_map(move |ev_value| {
369            let session = session.clone();
370            async move {
371                let ev: RequestPausedEvent = match serde_json::from_value(ev_value) {
372                    Ok(ev) => ev,
373                    Err(e) => {
374                        warn!(error = %e, "interception: skipping malformed Fetch.requestPaused event");
375                        return None;
376                    }
377                };
378                let info = build_request_info(&ev);
379                let response = build_response_info(&ev);
380                Some(PausedRequest::new(ev.request_id, info, response, session))
381            }
382        })
383    }
384
385    /// Lazily push an empty pattern if none exists, so the stage/resource
386    /// setters always have a target. Mirrors CDP's "missing fields default to
387    /// match-all" semantics.
388    fn ensure_pattern(&mut self) -> &mut RequestPattern {
389        if self.patterns.is_empty() {
390            self.patterns.push(RequestPattern::default());
391        }
392        self.patterns
393            .last_mut()
394            .expect("ensure_pattern pushed if empty")
395    }
396
397    /// Test-only accessor: number of registered rules. Used by the Task 5
398    /// builder test (and future actor tests) without exposing the rule list
399    /// as public API.
400    #[cfg(test)]
401    pub(crate) fn rules_count(&self) -> usize {
402        self.rules.len()
403    }
404}
405
406#[cfg(test)]
407#[allow(clippy::panic, clippy::unwrap_used)]
408mod tests {
409    use super::*;
410    use std::time::Duration;
411    use zendriver_transport::testing::MockConnection;
412
413    /// Register three rules (block + redirect + respond) on a fresh builder
414    /// and assert the rule list grew to length 3. Verifies the chain wiring
415    /// without touching the actor (Task 6) or CDP dispatch (Task 7).
416    #[tokio::test]
417    async fn three_rules_register_and_count() {
418        let (_mock, conn) = MockConnection::pair();
419        let sess = SessionHandle::new(conn.clone(), "S1");
420
421        let builder = InterceptBuilder::new(&sess)
422            .block("*/ads/*")
423            .unwrap()
424            .redirect("*/old/*", "https://example.com/new/")
425            .unwrap()
426            .respond(
427                "*/api/health",
428                200,
429                vec![("content-type".into(), "application/json".into())],
430                br#"{"ok":true}"#.to_vec(),
431            )
432            .unwrap();
433
434        assert_eq!(builder.rules_count(), 3);
435        conn.shutdown();
436    }
437
438    /// End-to-end on the rule-driven `start()` path: register a Block rule,
439    /// spawn the actor via `start()`, observe `Fetch.enable`, emit a matching
440    /// `Fetch.requestPaused`, and assert `Fetch.failRequest` is dispatched.
441    ///
442    /// This is the actor test from T6 reframed through the `start()` entry
443    /// point — proves the builder properly forwards rules + patterns to
444    /// `run_actor` (and that `start()` actually spawns the task).
445    #[tokio::test]
446    async fn start_spawns_actor_with_rules() {
447        let (mut mock, conn) = MockConnection::pair();
448        let sess = SessionHandle::new(conn.clone(), "S1");
449
450        let handle = InterceptBuilder::new(&sess)
451            .block("*/blocked/*")
452            .unwrap()
453            .pattern("*")
454            .start();
455
456        // The actor's `Fetch.enable` side-task fires fire-and-forget; wait
457        // for it to land so the `Fetch.requestPaused` subscription is in
458        // place before we emit an event.
459        let enable_id =
460            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
461                .await
462                .expect("actor did not send Fetch.enable within 2s");
463        let enable_params = mock.last_sent()["params"].clone();
464        assert_eq!(enable_params["handleAuthRequests"], false);
465        assert_eq!(enable_params["patterns"][0]["urlPattern"], "*");
466        mock.reply(enable_id, json!({})).await;
467
468        // Emit a paused-event whose URL matches the Block rule.
469        mock.emit_event_for_session(
470            "Fetch.requestPaused",
471            json!({
472                "requestId": "REQ-1",
473                "request": {
474                    "url": "https://example.test/blocked/banner.png",
475                    "method": "GET",
476                    "headers": {},
477                },
478                "resourceType": "Image",
479            }),
480            "S1",
481        )
482        .await;
483
484        // Actor should dispatch Fetch.failRequest with BlockedByClient.
485        let fail_id =
486            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.failRequest"))
487                .await
488                .expect("actor did not send Fetch.failRequest within 2s");
489        let fail_params = mock.last_sent()["params"].clone();
490        assert_eq!(fail_params["requestId"], "REQ-1");
491        assert_eq!(fail_params["errorReason"], "BlockedByClient");
492        mock.reply(fail_id, json!({})).await;
493
494        // Teardown via the handle: stop() cancels + awaits the oneshot the
495        // actor signals after Fetch.disable lands.
496        let stop_fut = tokio::spawn(handle.stop());
497        let disable_id =
498            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.disable"))
499                .await
500                .expect("actor did not send Fetch.disable on stop()");
501        mock.reply(disable_id, json!({})).await;
502        stop_fut
503            .await
504            .expect("stop() task panicked")
505            .expect("stop() returned Err");
506        conn.shutdown();
507    }
508
509    /// `start()` injects a match-all `"*"` pattern when the caller did not
510    /// add any via [`pattern`](InterceptBuilder::pattern) — otherwise
511    /// `Fetch.enable` would arrive with an empty patterns array and Chrome
512    /// would silently pause nothing.
513    #[tokio::test]
514    async fn start_defaults_to_match_all_pattern_when_none_registered() {
515        let (mut mock, conn) = MockConnection::pair();
516        let sess = SessionHandle::new(conn.clone(), "S1");
517
518        let handle = InterceptBuilder::new(&sess)
519            .block("*/blocked/*")
520            .unwrap()
521            .start();
522
523        let enable_id =
524            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
525                .await
526                .expect("actor did not send Fetch.enable within 2s");
527        let patterns = mock.last_sent()["params"]["patterns"].clone();
528        let arr = patterns.as_array().expect("patterns must be a JSON array");
529        assert_eq!(arr.len(), 1);
530        assert_eq!(arr[0]["urlPattern"], "*");
531        mock.reply(enable_id, json!({})).await;
532
533        // Drop the handle to tear down; we don't need to observe the disable.
534        drop(handle);
535        conn.shutdown();
536    }
537
538    /// On the `subscribe()` path: each `Fetch.requestPaused` event becomes
539    /// a `PausedRequest` yielded from the stream, with the request payload
540    /// decoded into [`RequestInfo`].
541    #[tokio::test]
542    async fn subscribe_yields_paused_request_per_event() {
543        let (mut mock, conn) = MockConnection::pair();
544        let sess = SessionHandle::new(conn.clone(), "S1");
545
546        let mut stream = Box::pin(InterceptBuilder::new(&sess).subscribe());
547
548        // Wait for the side-task's Fetch.enable to land so the subscription
549        // is in place before we emit.
550        let enable_id =
551            tokio::time::timeout(Duration::from_secs(2), mock.expect_cmd("Fetch.enable"))
552                .await
553                .expect("subscribe() did not send Fetch.enable within 2s");
554        mock.reply(enable_id, json!({})).await;
555
556        mock.emit_event_for_session(
557            "Fetch.requestPaused",
558            json!({
559                "requestId": "REQ-1",
560                "request": {
561                    "url": "https://example.test/widget.json",
562                    "method": "GET",
563                    "headers": {"accept": "application/json"},
564                },
565                "resourceType": "XHR",
566            }),
567            "S1",
568        )
569        .await;
570
571        let paused = tokio::time::timeout(Duration::from_secs(2), stream.next())
572            .await
573            .expect("subscribe() stream did not yield within 2s")
574            .expect("subscribe() stream closed before yielding");
575        assert_eq!(paused.request_id, "REQ-1");
576        assert_eq!(paused.request.url, "https://example.test/widget.json");
577        assert_eq!(paused.request.method, "GET");
578        assert_eq!(
579            paused
580                .request
581                .headers
582                .iter()
583                .find(|(k, _)| k == "accept")
584                .map(|(_, v)| v.as_str()),
585            Some("application/json"),
586        );
587        assert!(
588            paused.response.is_none(),
589            "request-stage event has no response"
590        );
591
592        drop(stream);
593        conn.shutdown();
594    }
595}