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}