Skip to main content

zendriver_interception/
rule.rs

1//! Declarative interception rules.
2//!
3//! Each rule pairs a matcher (a [`UrlPattern`], or a [`HostMatcher`] for
4//! [`BlockHosts`]) with one of six actions ([`Block`], [`Redirect`],
5//! [`Respond`], [`Modify`], [`ModifyResponse`], [`BlockHosts`]). The actor in
6//! T6 walks the rule list in registration order on each `Fetch.requestPaused`
7//! event; the first rule that matches the request URL wins.
8//!
9//! [`Block`]: Rule::Block
10//! [`Redirect`]: Rule::Redirect
11//! [`Respond`]: Rule::Respond
12//! [`Modify`]: Rule::Modify
13//! [`ModifyResponse`]: Rule::ModifyResponse
14//! [`BlockHosts`]: Rule::BlockHosts
15//! [`HostMatcher`]: crate::host_matcher::HostMatcher
16
17use std::fmt;
18use std::sync::Arc;
19
20use crate::host_matcher::HostMatcher;
21use crate::types::{RequestInfo, RequestOverrides, ResponseInfo, ResponseOverrides};
22use crate::url_pattern::UrlPattern;
23
24/// A single interception rule.
25///
26/// Each variant carries its own [`UrlPattern`], so different rules in the same
27/// [`InterceptBuilder`](crate::builder::InterceptBuilder) can match disjoint
28/// URL sets. Rules are evaluated in registration order — earlier rules
29/// shadow later ones for overlapping patterns.
30//
31// Not `Clone`: `Modify` holds an `Arc<dyn Fn ...>` which is cheap to clone,
32// but the other variants own `Vec`s/`String`s that we'd rather not silently
33// duplicate. The actor consumes the rule list by reference (`&Rule`) so a
34// `Clone` impl is unnecessary in practice.
35pub enum Rule {
36    /// Abort matching requests with `Fetch.failRequest`
37    /// (`errorReason: "BlockedByClient"`).
38    Block {
39        /// URL pattern matched against `Fetch.requestPaused.request.url`.
40        pattern: UrlPattern,
41    },
42    /// Redirect matching requests to `to` via `Fetch.continueRequest { url }`.
43    Redirect {
44        /// URL pattern matched against the incoming request URL.
45        from: UrlPattern,
46        /// Absolute target URL substituted into the continued request.
47        to: String,
48    },
49    /// Serve a synthesized response with `Fetch.fulfillRequest`.
50    Respond {
51        /// URL pattern matched against the incoming request URL.
52        pattern: UrlPattern,
53        /// HTTP status code returned to the page (`responseCode`).
54        status: u16,
55        /// Response headers as `(name, value)` pairs.
56        headers: Vec<(String, String)>,
57        /// Raw response body bytes (base64-encoded on the wire by the actor).
58        body: Vec<u8>,
59    },
60    /// Rewrite the outgoing request per-field via a user closure, then
61    /// continue. The closure receives the live [`RequestInfo`] and returns
62    /// the [`RequestOverrides`] to apply.
63    Modify {
64        /// URL pattern matched against the incoming request URL.
65        pattern: UrlPattern,
66        /// Closure invoked per matching request to produce overrides.
67        ///
68        /// Wrapped in [`Arc`] so the actor can cheaply share the closure
69        /// across the rule list without forcing the rule itself to be `Clone`
70        /// or `Send`-by-value.
71        modify: Arc<dyn Fn(&RequestInfo) -> RequestOverrides + Send + Sync>,
72    },
73    /// Rewrite an upstream response's status/headers per a user closure, then
74    /// continue with `Fetch.continueResponse` (keeping Chrome's body). Only
75    /// fires at the `Response` stage — the closure receives the live
76    /// [`ResponseInfo`] and returns the [`ResponseOverrides`] to apply. A rule
77    /// of this kind that matches at the `Request` stage is a no-op (there is
78    /// no response yet).
79    ModifyResponse {
80        /// URL pattern matched against the incoming request URL.
81        pattern: UrlPattern,
82        /// Closure invoked per matching response to produce overrides.
83        ///
84        /// Wrapped in [`Arc`] for the same cheap-share reason as [`Modify`].
85        ///
86        /// [`Modify`]: Rule::Modify
87        modify: Arc<dyn Fn(&ResponseInfo) -> ResponseOverrides + Send + Sync>,
88    },
89    /// Abort requests whose **host** is in a [`HostMatcher`] with
90    /// `Fetch.failRequest { errorReason: "BlockedByClient" }` — the same
91    /// `net::ERR_BLOCKED_BY_CLIENT` a real adblocker / Brave raises.
92    ///
93    /// Unlike [`Block`](Rule::Block), which globs the full URL, this matches
94    /// host-set membership (exact + parent-domain suffix on dot boundaries),
95    /// so a curated list of thousands of hosts is one O(1) set lookup per
96    /// request rather than N glob comparisons. Powers the tracker blocklist.
97    BlockHosts {
98        /// Shared host set; cheap to clone across tabs/rules.
99        matcher: Arc<HostMatcher>,
100    },
101}
102
103impl Rule {
104    /// Test whether this rule's pattern matches `url`.
105    ///
106    /// Delegates to the embedded [`UrlPattern::matches`]; the field selector
107    /// (`pattern`, `from`) varies per variant but the semantics are identical
108    /// — a CDP-style wildcard match against the full request URL.
109    pub fn matches(&self, url: &str) -> bool {
110        match self {
111            Self::Block { pattern }
112            | Self::Respond { pattern, .. }
113            | Self::Modify { pattern, .. }
114            | Self::ModifyResponse { pattern, .. } => pattern.matches(url),
115            Self::Redirect { from, .. } => from.matches(url),
116            Self::BlockHosts { matcher } => {
117                crate::host_matcher::host_of(url).is_some_and(|h| matcher.is_blocked(h))
118            }
119        }
120    }
121}
122
123// Hand-written `Debug` because the `Modify` / `ModifyResponse` variants hold
124// `Arc<dyn Fn ...>`, which is not `Debug`. We print the closure as a
125// placeholder so the rest of the rule (the pattern) stays inspectable.
126impl fmt::Debug for Rule {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            Self::Block { pattern } => f.debug_struct("Block").field("pattern", pattern).finish(),
130            Self::Redirect { from, to } => f
131                .debug_struct("Redirect")
132                .field("from", from)
133                .field("to", to)
134                .finish(),
135            Self::Respond {
136                pattern,
137                status,
138                headers,
139                body,
140            } => f
141                .debug_struct("Respond")
142                .field("pattern", pattern)
143                .field("status", status)
144                .field("headers", headers)
145                .field("body_len", &body.len())
146                .finish(),
147            Self::Modify { pattern, .. } => f
148                .debug_struct("Modify")
149                .field("pattern", pattern)
150                .field("modify", &"<closure>")
151                .finish(),
152            Self::ModifyResponse { pattern, .. } => f
153                .debug_struct("ModifyResponse")
154                .field("pattern", pattern)
155                .field("modify", &"<closure>")
156                .finish(),
157            Self::BlockHosts { matcher } => f
158                .debug_struct("BlockHosts")
159                .field("hosts", &matcher.len())
160                .finish(),
161        }
162    }
163}
164
165#[cfg(test)]
166#[allow(clippy::panic, clippy::unwrap_used)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn block_matches_via_pattern() {
172        let rule = Rule::Block {
173            pattern: UrlPattern::new("*/ads/*").unwrap(),
174        };
175        assert!(rule.matches("https://example.com/ads/banner.png"));
176        assert!(!rule.matches("https://example.com/content/main.css"));
177    }
178
179    #[test]
180    fn redirect_matches_via_from_field() {
181        let rule = Rule::Redirect {
182            from: UrlPattern::new("*/old/*").unwrap(),
183            to: "https://example.com/new/replacement".into(),
184        };
185        assert!(rule.matches("https://example.com/old/page.html"));
186        assert!(!rule.matches("https://example.com/new/page.html"));
187    }
188
189    #[test]
190    fn block_hosts_matches_on_host_and_subdomain() {
191        let rule = Rule::BlockHosts {
192            matcher: Arc::new(HostMatcher::new(["evil.com".to_string()])),
193        };
194        assert!(rule.matches("https://evil.com/track.js"));
195        assert!(rule.matches("https://a.b.evil.com/x?y=1"));
196        assert!(!rule.matches("https://good.com/app.js"));
197        assert!(!rule.matches("https://notevil.com/app.js"));
198
199        // Debug renders the variant name + the host count (the Arc<HostMatcher>
200        // is summarized, not dumped).
201        let dbg = format!("{rule:?}");
202        assert!(dbg.contains("BlockHosts"), "got: {dbg}");
203        assert!(dbg.contains("hosts"), "got: {dbg}");
204    }
205
206    #[test]
207    fn rule_modify_response_matches_and_debug() {
208        let rule = Rule::ModifyResponse {
209            pattern: UrlPattern::new("*/api/*").unwrap(),
210            modify: Arc::new(|_resp: &ResponseInfo| ResponseOverrides {
211                status: Some(418),
212                ..ResponseOverrides::default()
213            }),
214        };
215        assert!(rule.matches("https://example.com/api/users"));
216        assert!(!rule.matches("https://example.com/static/app.js"));
217
218        // Debug renders the pattern + a closure placeholder (the Arc<dyn Fn>
219        // isn't Debug).
220        let dbg = format!("{rule:?}");
221        assert!(dbg.contains("ModifyResponse"), "got: {dbg}");
222        assert!(dbg.contains("*/api/*"), "got: {dbg}");
223        assert!(dbg.contains("<closure>"), "got: {dbg}");
224    }
225}