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