1use crate::rule::model::{Action, BodySource, TerminalReason};
2use crate::rule::engine::executor::ExecutionContext;
3use crate::rule::engine::actions::utils::{substitute_variables, resolve_body_source};
4use crate::rule::engine::actions::transform::apply_transform;
5use crate::rule::engine::actions::ActionOutcome;
6use relay_core_api::flow::{Cookie, Flow, Layer, HttpResponse, ResponseTiming};
7use url::Url;
8
9fn status_text_for(code: u16) -> String {
10 http::StatusCode::from_u16(code)
11 .ok()
12 .and_then(|s| s.canonical_reason().map(|r| r.to_string()))
13 .unwrap_or_else(|| "Mocked".to_string())
14}
15
16fn parse_set_cookie_header(value: &str) -> Option<Cookie> {
17 let mut segments = value.split(';');
18 let first = segments.next()?.trim();
19 let (name, cookie_value) = first.split_once('=')?;
20 if name.trim().is_empty() {
21 return None;
22 }
23
24 let mut cookie = Cookie {
25 name: name.trim().to_string(),
26 value: cookie_value.trim().to_string(),
27 path: None,
28 domain: None,
29 expires: None,
30 http_only: None,
31 secure: None,
32 };
33
34 for seg in segments {
35 let attr = seg.trim();
36 if attr.eq_ignore_ascii_case("httponly") {
37 cookie.http_only = Some(true);
38 continue;
39 }
40 if attr.eq_ignore_ascii_case("secure") {
41 cookie.secure = Some(true);
42 continue;
43 }
44 if let Some((k, v)) = attr.split_once('=') {
45 let key = k.trim();
46 let val = v.trim().to_string();
47 if key.eq_ignore_ascii_case("path") {
48 cookie.path = Some(val);
49 } else if key.eq_ignore_ascii_case("domain") {
50 cookie.domain = Some(val);
51 } else if key.eq_ignore_ascii_case("expires") {
52 cookie.expires = Some(val);
53 }
54 }
55 }
56
57 Some(cookie)
58}
59
60fn parse_response_cookies(headers: &[(String, String)]) -> Vec<Cookie> {
61 headers
62 .iter()
63 .filter(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
64 .filter_map(|(_, v)| parse_set_cookie_header(v))
65 .collect()
66}
67
68pub async fn execute(
69 action: &Action,
70 flow: &mut Flow,
71 ctx: &mut ExecutionContext,
72) -> ActionOutcome {
73 match action {
74 Action::SetRequestMethod { method } => {
76 let val = substitute_variables(method, flow, ctx, None);
77 if let Layer::Http(http) = &mut flow.layer {
78 http.request.method = val;
79 }
80 ActionOutcome::Continue
81 }
82 Action::SetRequestUrl { url } => {
83 let val = substitute_variables(url, flow, ctx, None);
84 if let Ok(new_url) = Url::parse(&val)
85 && let Layer::Http(http) = &mut flow.layer {
86 http.request.url = new_url;
87 }
88 ActionOutcome::Continue
89 }
90 Action::SetRequestBody { body } => {
91 if let Some(body_data) = resolve_body_source(body, ctx.policy.as_deref()).await
92 && let Layer::Http(http) = &mut flow.layer {
93 http.request.body = Some(body_data);
94 }
95 ActionOutcome::Continue
96 }
97
98 Action::SetResponseStatus { status } => {
100 if let Layer::Http(http) = &mut flow.layer
101 && let Some(res) = &mut http.response {
102 res.status = *status;
103 }
104 ActionOutcome::Continue
105 }
106 Action::SetResponseBody { body } => {
107 if let Some(body_data) = resolve_body_source(body, ctx.policy.as_deref()).await
108 && let Layer::Http(http) = &mut flow.layer
109 && let Some(res) = &mut http.response {
110 res.body = Some(body_data);
111 }
112 ActionOutcome::Continue
113 }
114 Action::Redirect { location, status } => {
115 let val = substitute_variables(location, flow, ctx, None);
116 let headers = vec![("Location".to_string(), val)];
117
118 let res = HttpResponse {
119 status: *status,
120 status_text: "Redirect".to_string(),
121 version: "HTTP/1.1".to_string(),
122 headers,
123 cookies: vec![],
124 body: None,
125 timing: ResponseTiming {
126 time_to_first_byte: Some(0),
127 time_to_last_byte: Some(0),
128 },
129 };
130
131 if let Layer::Http(http) = &mut flow.layer {
132 http.response = Some(res);
133 } else if let Layer::WebSocket(ws) = &mut flow.layer {
134 ws.handshake_response = res;
135 }
136 ActionOutcome::Terminated(TerminalReason::Redirect)
137 }
138
139 Action::AddRequestHeader { name, value } => {
141 let val = substitute_variables(value, flow, ctx, None);
142 if let Layer::Http(http) = &mut flow.layer {
143 http.request.headers.push((name.clone(), val));
144 }
145 ActionOutcome::Continue
146 }
147 Action::UpdateRequestHeader {
148 name,
149 value,
150 add_if_missing,
151 } => {
152 let mut old_val = None;
154 if let Layer::Http(http) = &flow.layer {
155 for (k, v) in &http.request.headers {
156 if k.eq_ignore_ascii_case(name) {
157 old_val = Some(v.clone());
158 break;
159 }
160 }
161 }
162
163 let new_val = substitute_variables(value, flow, ctx, old_val.as_deref());
165
166 if let Layer::Http(http) = &mut flow.layer {
168 let mut found = false;
169 for (k, v) in http.request.headers.iter_mut() {
170 if k.eq_ignore_ascii_case(name) {
171 *v = new_val.clone();
172 found = true;
173 }
174 }
175 if !found && *add_if_missing {
176 http.request.headers.push((name.clone(), new_val));
177 }
178 }
179 ActionOutcome::Continue
180 }
181 Action::DeleteRequestHeader { name } => {
182 if let Layer::Http(http) = &mut flow.layer {
183 http.request.headers
184 .retain(|(k, _)| !k.eq_ignore_ascii_case(name));
185 }
186 ActionOutcome::Continue
187 }
188
189 Action::AddResponseHeader { name, value } => {
191 let val = substitute_variables(value, flow, ctx, None);
192 if let Layer::Http(http) = &mut flow.layer
193 && let Some(res) = &mut http.response {
194 res.headers.push((name.clone(), val));
195 }
196 ActionOutcome::Continue
197 }
198 Action::UpdateResponseHeader { name, value, add_if_missing } => {
199 let mut old_val = None;
201 if let Layer::Http(http) = &flow.layer
202 && let Some(res) = &http.response {
203 for (k, v) in &res.headers {
204 if k.eq_ignore_ascii_case(name) {
205 old_val = Some(v.clone());
206 break;
207 }
208 }
209 }
210
211 let new_val = substitute_variables(value, flow, ctx, old_val.as_deref());
213
214 if let Layer::Http(http) = &mut flow.layer
216 && let Some(res) = &mut http.response {
217 let mut found = false;
218 for (k, v) in res.headers.iter_mut() {
219 if k.eq_ignore_ascii_case(name) {
220 *v = new_val.clone();
221 found = true;
222 }
223 }
224 if !found && *add_if_missing {
225 res.headers.push((name.clone(), new_val));
226 }
227 }
228 ActionOutcome::Continue
229 }
230 Action::DeleteResponseHeader { name } => {
231 if let Layer::Http(http) = &mut flow.layer
232 && let Some(res) = &mut http.response {
233 res.headers
234 .retain(|(k, _)| !k.eq_ignore_ascii_case(name));
235 }
236 ActionOutcome::Continue
237 }
238
239 Action::MockResponse { status, headers, body } => {
241 let body_data = if let Some(source) = body {
242 resolve_body_source(source, ctx.policy.as_deref()).await
243 } else {
244 None
245 };
246
247 if let Layer::Http(http) = &mut flow.layer {
248 let mut res_headers = Vec::new();
249 for (k, v) in headers {
250 res_headers.push((k.clone(), v.clone()));
251 }
252 let cookies = parse_response_cookies(&res_headers);
253
254 http.response = Some(HttpResponse {
255 status: *status,
256 status_text: status_text_for(*status),
257 version: "HTTP/1.1".to_string(),
258 headers: res_headers,
259 cookies,
260 body: body_data,
261 timing: ResponseTiming {
262 time_to_first_byte: Some(0),
263 time_to_last_byte: Some(0),
264 },
265 });
266 } else if let Layer::WebSocket(ws) = &mut flow.layer {
267 let mut res_headers = Vec::new();
268 for (k, v) in headers {
269 res_headers.push((k.clone(), v.clone()));
270 }
271 let cookies = parse_response_cookies(&res_headers);
272
273 ws.handshake_response = HttpResponse {
274 status: *status,
275 status_text: status_text_for(*status),
276 version: "HTTP/1.1".to_string(),
277 headers: res_headers,
278 cookies,
279 body: body_data,
280 timing: ResponseTiming {
281 time_to_first_byte: Some(0),
282 time_to_last_byte: Some(0),
283 },
284 };
285 }
286 ActionOutcome::Terminated(TerminalReason::Mock)
287 }
288 Action::MapLocal { path, content_type } => {
289 let body_data = resolve_body_source(&BodySource::File(path.clone()), ctx.policy.as_deref()).await;
290
291 if let Some(body) = body_data {
292 if let Layer::Http(http) = &mut flow.layer {
293 let headers = content_type.as_ref()
294 .map(|ct| vec![("Content-Type".to_string(), ct.clone())])
295 .unwrap_or_default();
296
297 http.response = Some(HttpResponse {
298 status: 200,
299 status_text: "OK".to_string(),
300 version: "HTTP/1.1".to_string(),
301 headers,
302 cookies: vec![],
303 body: Some(body),
304 timing: ResponseTiming {
305 time_to_first_byte: Some(0),
306 time_to_last_byte: Some(0),
307 },
308 });
309 }
310 ActionOutcome::Terminated(TerminalReason::Mock)
311 } else {
312 ActionOutcome::Failed(format!("Failed to load local file: {}", path))
313 }
314 }
315
316 Action::TransformRequestBody { transform } => {
318 if let Layer::Http(http) = &mut flow.layer
319 && let Some(body) = &mut http.request.body {
320 apply_transform(body, transform);
321 }
322 ActionOutcome::Continue
323 }
324 Action::TransformResponseBody { transform } => {
325 if let Layer::Http(http) = &mut flow.layer
326 && let Some(res) = &mut http.response
327 && let Some(body) = &mut res.body {
328 apply_transform(body, transform);
329 }
330 ActionOutcome::Continue
331 }
332
333 _ => ActionOutcome::Failed(format!("Action {:?} not supported in http handler", action)),
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::{execute, parse_set_cookie_header};
340 use crate::rule::engine::actions::ActionOutcome;
341 use crate::rule::engine::executor::ExecutionContext;
342 use crate::rule::engine::state::InMemoryRuleStateStore;
343 use crate::rule::model::{Action, RuleTraceSummary};
344 use chrono::Utc;
345 use relay_core_api::flow::{Flow, HttpLayer, HttpRequest, Layer, NetworkInfo, TransportProtocol};
346 use std::collections::HashMap;
347 use std::sync::Arc;
348 use url::Url;
349 use uuid::Uuid;
350
351 fn sample_flow() -> Flow {
352 Flow {
353 id: Uuid::new_v4(),
354 start_time: Utc::now(),
355 end_time: None,
356 network: NetworkInfo {
357 client_ip: "127.0.0.1".to_string(),
358 client_port: 12345,
359 server_ip: "1.1.1.1".to_string(),
360 server_port: 443,
361 protocol: TransportProtocol::TCP,
362 tls: true,
363 tls_version: Some("TLS1.3".to_string()),
364 sni: Some("example.com".to_string()),
365 },
366 layer: Layer::Http(HttpLayer {
367 request: HttpRequest {
368 method: "GET".to_string(),
369 url: Url::parse("https://example.com/").expect("url"),
370 version: "HTTP/1.1".to_string(),
371 headers: vec![],
372 cookies: vec![],
373 query: vec![],
374 body: None,
375 },
376 response: None,
377 error: None,
378 }),
379 tags: vec![],
380 meta: HashMap::new(),
381 }
382 }
383
384 fn sample_ctx() -> ExecutionContext {
385 ExecutionContext {
386 trace: vec![],
387 variables: HashMap::new(),
388 policy: None,
389 summary: RuleTraceSummary::NoMatch,
390 state_store: Arc::new(InMemoryRuleStateStore::new()),
391 }
392 }
393
394 #[test]
395 fn test_parse_set_cookie_header_with_attributes() {
396 let c = parse_set_cookie_header("sid=abc; Path=/; Domain=example.com; HttpOnly; Secure; Expires=Wed, 21 Oct 2026 07:28:00 GMT")
397 .expect("cookie");
398 assert_eq!(c.name, "sid");
399 assert_eq!(c.value, "abc");
400 assert_eq!(c.path.as_deref(), Some("/"));
401 assert_eq!(c.domain.as_deref(), Some("example.com"));
402 assert_eq!(c.http_only, Some(true));
403 assert_eq!(c.secure, Some(true));
404 assert!(c.expires.is_some());
405 }
406
407 #[tokio::test]
408 async fn test_mock_response_extracts_set_cookie() {
409 let mut headers = HashMap::new();
410 headers.insert(
411 "Set-Cookie".to_string(),
412 "token=xyz; Path=/; HttpOnly".to_string(),
413 );
414 let action = Action::MockResponse {
415 status: 200,
416 headers,
417 body: None,
418 };
419
420 let mut flow = sample_flow();
421 let mut ctx = sample_ctx();
422 let out = execute(&action, &mut flow, &mut ctx).await;
423 assert!(matches!(out, ActionOutcome::Terminated(_)));
424
425 let Layer::Http(http) = flow.layer else {
426 panic!("expected http layer");
427 };
428 let res = http.response.expect("mocked response");
429 assert_eq!(res.cookies.len(), 1);
430 assert_eq!(res.cookies[0].name, "token");
431 assert_eq!(res.cookies[0].value, "xyz");
432 assert_eq!(res.cookies[0].path.as_deref(), Some("/"));
433 assert_eq!(res.cookies[0].http_only, Some(true));
434 }
435}