1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use relay_core_api::flow::{Flow, Layer};
5use relay_core_lib::rule::{Action, BodySource, Filter, Rule, RuleStage, RuleTermination, StringMatcher};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct InterceptRule {
9 pub id: String,
10 pub active: bool,
11 pub url_pattern: String,
12 pub method: Option<String>,
13 pub phase: String, }
15
16#[derive(Debug, Clone)]
17pub struct InterceptRuleConfig {
18 pub rule_id: String,
19 pub active: bool,
20 pub url_pattern: String,
21 pub method: Option<String>,
22 pub phase: String,
23 pub name: String,
24 pub priority: i32,
25 pub termination: RuleTermination,
26}
27
28#[derive(Debug, Clone)]
29pub struct MockResponseRuleConfig {
30 pub rule_id: String,
31 pub url_pattern: String,
32 pub name: String,
33 pub status: u16,
34 pub content_type: String,
35 pub body: String,
36}
37
38impl InterceptRule {
39 pub fn matches(&self, flow: &Flow, phase: &str) -> bool {
40 if !self.active {
41 return false;
42 }
43
44 if self.phase == "both" {
46 if phase == "ws_message" {
47 return false;
48 }
49 } else if self.phase != phase {
50 return false;
51 }
52
53 let url = match &flow.layer {
54 Layer::Http(http) => Some(http.request.url.to_string()),
55 Layer::WebSocket(ws) => Some(ws.handshake_request.url.to_string()),
56 _ => None,
57 };
58
59 let method = match &flow.layer {
60 Layer::Http(http) => Some(http.request.method.to_string()),
61 Layer::WebSocket(ws) => Some(ws.handshake_request.method.to_string()),
62 _ => None,
63 };
64
65 let url_str = url.as_deref().unwrap_or("");
66 let method_str = method.as_deref().unwrap_or("");
67
68 if let Some(m) = &self.method
69 && !m.eq_ignore_ascii_case(method_str) {
70 return false;
71 }
72
73 if let Ok(re) = Regex::new(&self.url_pattern) {
74 if re.is_match(url_str) {
75 return true;
76 }
77 } else if url_str.contains(&self.url_pattern) {
78 return true;
79 }
80
81 false
82 }
83
84 pub fn to_rules(&self) -> Vec<Rule> {
85 build_intercept_rules(InterceptRuleConfig {
86 rule_id: self.id.clone(),
87 active: self.active,
88 url_pattern: self.url_pattern.clone(),
89 method: self.method.clone(),
90 phase: self.phase.clone(),
91 name: format!("Legacy Rule {}", self.id),
92 priority: 0,
93 termination: RuleTermination::Continue,
94 })
95 }
96}
97
98pub fn build_intercept_rules(config: InterceptRuleConfig) -> Vec<Rule> {
99 if !config.active {
100 return vec![];
101 }
102
103 let InterceptRuleConfig {
104 rule_id,
105 url_pattern,
106 method,
107 phase,
108 name,
109 priority,
110 termination,
111 ..
112 } = config;
113
114 let stages = match phase.as_str() {
115 "request" => vec![RuleStage::RequestHeaders],
116 "response" => vec![RuleStage::ResponseHeaders],
117 "ws_message" => vec![RuleStage::WebSocketMessage],
118 "both" => vec![RuleStage::RequestHeaders, RuleStage::ResponseHeaders],
119 _ => return vec![],
120 };
121
122 let url_filter = Filter::Url(build_url_matcher(url_pattern));
123 let filter = if let Some(method) = method {
124 Filter::And(vec![url_filter, Filter::Method(StringMatcher::Exact(method))])
125 } else {
126 url_filter
127 };
128
129 let stages_len = stages.len();
130 stages
131 .into_iter()
132 .enumerate()
133 .map(|(i, stage)| Rule {
134 id: if stages_len > 1 {
135 format!("{}-{}", rule_id, i)
136 } else {
137 rule_id.clone()
138 },
139 name: name.clone(),
140 active: true,
141 stage,
142 priority,
143 termination: termination.clone(),
144 filter: filter.clone(),
145 actions: vec![Action::Inspect],
146 constraints: None,
147 })
148 .collect()
149}
150
151pub fn build_mock_response_rule(config: MockResponseRuleConfig) -> Rule {
152 let mut headers = HashMap::new();
153 headers.insert("Content-Type".to_string(), config.content_type);
154
155 Rule {
156 id: config.rule_id,
157 name: config.name,
158 active: true,
159 stage: RuleStage::RequestHeaders,
160 priority: 200,
161 termination: RuleTermination::Stop,
162 filter: Filter::Url(build_url_matcher(config.url_pattern)),
163 actions: vec![Action::MockResponse {
164 status: config.status,
165 headers,
166 body: if config.body.is_empty() {
167 None
168 } else {
169 Some(BodySource::Text(config.body))
170 },
171 }],
172 constraints: None,
173 }
174}
175
176fn build_url_matcher(url_pattern: String) -> StringMatcher {
177 if Regex::new(&url_pattern).is_ok() {
178 StringMatcher::Regex(url_pattern)
179 } else {
180 StringMatcher::Contains(url_pattern)
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::{
187 InterceptRule, InterceptRuleConfig, MockResponseRuleConfig, build_intercept_rules,
188 build_mock_response_rule,
189 };
190 use chrono::Utc;
191 use relay_core_api::flow::{
192 Flow, HttpLayer, HttpRequest, HttpResponse, Layer, NetworkInfo, ResponseTiming,
193 TransportProtocol, WebSocketLayer,
194 };
195 use relay_core_lib::rule::{Action, BodySource, Filter, RuleStage, RuleTermination, StringMatcher};
196 use std::collections::HashMap;
197 use url::Url;
198 use uuid::Uuid;
199
200 fn sample_http_flow(url: &str) -> Flow {
201 Flow {
202 id: Uuid::new_v4(),
203 start_time: Utc::now(),
204 end_time: None,
205 network: NetworkInfo {
206 client_ip: "127.0.0.1".to_string(),
207 client_port: 12345,
208 server_ip: "1.1.1.1".to_string(),
209 server_port: 80,
210 protocol: TransportProtocol::TCP,
211 tls: false,
212 tls_version: None,
213 sni: None,
214 },
215 layer: Layer::Http(HttpLayer {
216 request: HttpRequest {
217 method: "GET".to_string(),
218 url: Url::parse(url).expect("url"),
219 version: "HTTP/1.1".to_string(),
220 headers: vec![],
221 body: None,
222 cookies: vec![],
223 query: vec![],
224 },
225 response: None,
226 error: None,
227 }),
228 tags: vec![],
229 meta: HashMap::new(),
230 }
231 }
232
233 fn sample_ws_flow(url: &str) -> Flow {
234 Flow {
235 id: Uuid::new_v4(),
236 start_time: Utc::now(),
237 end_time: None,
238 network: NetworkInfo {
239 client_ip: "127.0.0.1".to_string(),
240 client_port: 12345,
241 server_ip: "1.1.1.1".to_string(),
242 server_port: 80,
243 protocol: TransportProtocol::TCP,
244 tls: false,
245 tls_version: None,
246 sni: None,
247 },
248 layer: Layer::WebSocket(WebSocketLayer {
249 handshake_request: HttpRequest {
250 method: "GET".to_string(),
251 url: Url::parse(url).expect("url"),
252 version: "HTTP/1.1".to_string(),
253 headers: vec![],
254 body: None,
255 cookies: vec![],
256 query: vec![],
257 },
258 handshake_response: HttpResponse {
259 status: 101,
260 status_text: "Switching Protocols".to_string(),
261 version: "HTTP/1.1".to_string(),
262 headers: vec![],
263 body: None,
264 timing: ResponseTiming {
265 time_to_first_byte: None,
266 time_to_last_byte: None,
267 connect_time_ms: None,
268 ssl_time_ms: None,
269 },
270 cookies: vec![],
271 },
272 messages: vec![],
273 closed: false,
274 }),
275 tags: vec![],
276 meta: HashMap::new(),
277 }
278 }
279
280 #[test]
281 fn test_to_rules_inactive_returns_empty() {
282 let r = InterceptRule {
283 id: "legacy-inactive".to_string(),
284 active: false,
285 url_pattern: "example.com".to_string(),
286 method: None,
287 phase: "request".to_string(),
288 };
289 assert!(r.to_rules().is_empty());
290 }
291
292 #[test]
293 fn test_to_rules_invalid_phase_returns_empty() {
294 let r = InterceptRule {
295 id: "legacy-invalid".to_string(),
296 active: true,
297 url_pattern: "example.com".to_string(),
298 method: None,
299 phase: "not-a-phase".to_string(),
300 };
301 assert!(r.to_rules().is_empty());
302 }
303
304 #[test]
305 fn test_to_rules_both_phase_generates_two_stages_with_suffix_ids() {
306 let r = InterceptRule {
307 id: "legacy-both".to_string(),
308 active: true,
309 url_pattern: "example.com".to_string(),
310 method: Some("POST".to_string()),
311 phase: "both".to_string(),
312 };
313 let rules = r.to_rules();
314 assert_eq!(rules.len(), 2);
315 assert_eq!(rules[0].id, "legacy-both-0");
316 assert_eq!(rules[1].id, "legacy-both-1");
317 assert_eq!(rules[0].stage, RuleStage::RequestHeaders);
318 assert_eq!(rules[1].stage, RuleStage::ResponseHeaders);
319
320 for rule in rules {
321 match rule.filter {
322 Filter::And(filters) => assert_eq!(filters.len(), 2),
323 other => panic!("expected And filter for method+url, got {:?}", other),
324 }
325 }
326 }
327
328 #[test]
329 fn test_matches_both_phase_excludes_ws_message_phase() {
330 let r = InterceptRule {
331 id: "legacy-both-match".to_string(),
332 active: true,
333 url_pattern: "example.com".to_string(),
334 method: None,
335 phase: "both".to_string(),
336 };
337 let http = sample_http_flow("http://example.com/path");
338 let ws = sample_ws_flow("ws://example.com/socket");
339 assert!(r.matches(&http, "request"));
340 assert!(r.matches(&http, "response"));
341 assert!(!r.matches(&ws, "ws_message"));
342 }
343
344 #[test]
345 fn test_matches_invalid_regex_falls_back_to_contains() {
346 let r = InterceptRule {
347 id: "legacy-invalid-regex".to_string(),
348 active: true,
349 url_pattern: "[".to_string(),
350 method: None,
351 phase: "request".to_string(),
352 };
353 let flow_hit = sample_http_flow("http://example.com/x[1]");
354 let flow_miss = sample_http_flow("http://example.com/x");
355 assert!(r.matches(&flow_hit, "request"));
356 assert!(!r.matches(&flow_miss, "request"));
357 }
358
359 #[test]
360 fn test_build_intercept_rules_preserves_stop_and_priority() {
361 let rules = build_intercept_rules(InterceptRuleConfig {
362 rule_id: "probe-breakpoint".to_string(),
363 active: true,
364 url_pattern: "example.com".to_string(),
365 method: None,
366 phase: "both".to_string(),
367 name: "probe-intercept:example.com".to_string(),
368 priority: 100,
369 termination: RuleTermination::Stop,
370 });
371
372 assert_eq!(rules.len(), 2);
373 assert_eq!(rules[0].id, "probe-breakpoint-0");
374 assert_eq!(rules[1].id, "probe-breakpoint-1");
375 assert_eq!(rules[0].priority, 100);
376 assert!(matches!(rules[0].termination, RuleTermination::Stop));
377 assert_eq!(rules[0].name, "probe-intercept:example.com");
378 }
379
380 #[test]
381 fn test_build_intercept_rules_invalid_regex_falls_back_to_contains() {
382 let rules = build_intercept_rules(InterceptRuleConfig {
383 rule_id: "api-breakpoint".to_string(),
384 active: true,
385 url_pattern: "[".to_string(),
386 method: Some("POST".to_string()),
387 phase: "request".to_string(),
388 name: "api-intercept:[".to_string(),
389 priority: 100,
390 termination: RuleTermination::Stop,
391 });
392
393 assert_eq!(rules.len(), 1);
394 match &rules[0].filter {
395 Filter::And(filters) => {
396 assert!(matches!(filters[0], Filter::Url(StringMatcher::Contains(_))));
397 assert!(matches!(filters[1], Filter::Method(StringMatcher::Exact(_))));
398 }
399 other => panic!("expected And filter for method+url, got {:?}", other),
400 }
401 }
402
403 #[test]
404 fn test_build_mock_response_rule_sets_mock_action_and_headers() {
405 let rule = build_mock_response_rule(MockResponseRuleConfig {
406 rule_id: "mock-rule".to_string(),
407 url_pattern: "example.com".to_string(),
408 name: "mock".to_string(),
409 status: 201,
410 content_type: "application/json".to_string(),
411 body: "{\"ok\":true}".to_string(),
412 });
413
414 assert_eq!(rule.id, "mock-rule");
415 assert_eq!(rule.stage, RuleStage::RequestHeaders);
416 assert!(matches!(rule.termination, RuleTermination::Stop));
417 match &rule.filter {
418 Filter::Url(StringMatcher::Regex(pattern)) => assert_eq!(pattern, "example.com"),
419 other => panic!("expected regex url filter, got {:?}", other),
420 }
421 match &rule.actions[0] {
422 Action::MockResponse { status, headers, body } => {
423 assert_eq!(*status, 201);
424 assert_eq!(headers.get("Content-Type").map(String::as_str), Some("application/json"));
425 match body {
426 Some(BodySource::Text(text)) => assert_eq!(text, "{\"ok\":true}"),
427 other => panic!("expected text body, got {:?}", other),
428 }
429 }
430 other => panic!("expected mock response action, got {:?}", other),
431 }
432 }
433}