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