Skip to main content

relay_core_lib/rule/engine/actions/
http.rs

1use crate::rule::engine::actions::ActionOutcome;
2use crate::rule::engine::actions::transform::apply_transform;
3use crate::rule::engine::actions::utils::{resolve_body_source, substitute_variables};
4use crate::rule::engine::executor::ExecutionContext;
5use crate::rule::model::{Action, BodySource, TerminalReason};
6use relay_core_api::flow::{Cookie, Flow, HttpResponse, Layer, 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        // --- Request Modification Actions ---
75        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            {
87                http.request.url = new_url;
88            }
89            ActionOutcome::Continue
90        }
91        Action::SetRequestBody { body } => {
92            if let Some(body_data) = resolve_body_source(body, ctx.policy.as_deref()).await
93                && let Layer::Http(http) = &mut flow.layer
94            {
95                http.request.body = Some(body_data);
96            }
97            ActionOutcome::Continue
98        }
99
100        // --- Response Modification Actions ---
101        Action::SetResponseStatus { status } => {
102            if let Layer::Http(http) = &mut flow.layer
103                && let Some(res) = &mut http.response
104            {
105                res.status = *status;
106            }
107            ActionOutcome::Continue
108        }
109        Action::SetResponseBody { body } => {
110            if let Some(body_data) = resolve_body_source(body, ctx.policy.as_deref()).await
111                && let Layer::Http(http) = &mut flow.layer
112                && let Some(res) = &mut http.response
113            {
114                res.body = Some(body_data);
115            }
116            ActionOutcome::Continue
117        }
118        Action::Redirect { location, status } => {
119            let val = substitute_variables(location, flow, ctx, None);
120            let headers = vec![("Location".to_string(), val)];
121
122            let res = HttpResponse {
123                status: *status,
124                status_text: "Redirect".to_string(),
125                version: "HTTP/1.1".to_string(),
126                headers,
127                cookies: vec![],
128                body: None,
129                timing: ResponseTiming {
130                    time_to_first_byte: Some(0),
131                    time_to_last_byte: Some(0),
132                    connect_time_ms: None,
133                    ssl_time_ms: None,
134                },
135            };
136
137            if let Layer::Http(http) = &mut flow.layer {
138                http.response = Some(res);
139            } else if let Layer::WebSocket(ws) = &mut flow.layer {
140                ws.handshake_response = res;
141            }
142            ActionOutcome::Terminated(TerminalReason::Redirect)
143        }
144
145        // --- Request Header Actions ---
146        Action::AddRequestHeader { name, value } => {
147            let val = substitute_variables(value, flow, ctx, None);
148            if let Layer::Http(http) = &mut flow.layer {
149                http.request.headers.push((name.clone(), val));
150            }
151            ActionOutcome::Continue
152        }
153        Action::UpdateRequestHeader {
154            name,
155            value,
156            add_if_missing,
157        } => {
158            // First pass: find existing value to use as {{previous}}
159            let mut old_val = None;
160            if let Layer::Http(http) = &flow.layer {
161                for (k, v) in &http.request.headers {
162                    if k.eq_ignore_ascii_case(name) {
163                        old_val = Some(v.clone());
164                        break;
165                    }
166                }
167            }
168
169            // Compute new value
170            let new_val = substitute_variables(value, flow, ctx, old_val.as_deref());
171
172            // Second pass: apply change
173            if let Layer::Http(http) = &mut flow.layer {
174                let mut found = false;
175                for (k, v) in http.request.headers.iter_mut() {
176                    if k.eq_ignore_ascii_case(name) {
177                        *v = new_val.clone();
178                        found = true;
179                    }
180                }
181                if !found && *add_if_missing {
182                    http.request.headers.push((name.clone(), new_val));
183                }
184            }
185            ActionOutcome::Continue
186        }
187        Action::DeleteRequestHeader { name } => {
188            if let Layer::Http(http) = &mut flow.layer {
189                http.request
190                    .headers
191                    .retain(|(k, _)| !k.eq_ignore_ascii_case(name));
192            }
193            ActionOutcome::Continue
194        }
195
196        // --- Response Header Actions ---
197        Action::AddResponseHeader { name, value } => {
198            let val = substitute_variables(value, flow, ctx, None);
199            if let Layer::Http(http) = &mut flow.layer
200                && let Some(res) = &mut http.response
201            {
202                res.headers.push((name.clone(), val));
203            }
204            ActionOutcome::Continue
205        }
206        Action::UpdateResponseHeader {
207            name,
208            value,
209            add_if_missing,
210        } => {
211            // First pass: find existing value
212            let mut old_val = None;
213            if let Layer::Http(http) = &flow.layer
214                && let Some(res) = &http.response
215            {
216                for (k, v) in &res.headers {
217                    if k.eq_ignore_ascii_case(name) {
218                        old_val = Some(v.clone());
219                        break;
220                    }
221                }
222            }
223
224            // Compute new value
225            let new_val = substitute_variables(value, flow, ctx, old_val.as_deref());
226
227            // Second pass: apply change
228            if let Layer::Http(http) = &mut flow.layer
229                && let Some(res) = &mut http.response
230            {
231                let mut found = false;
232                for (k, v) in res.headers.iter_mut() {
233                    if k.eq_ignore_ascii_case(name) {
234                        *v = new_val.clone();
235                        found = true;
236                    }
237                }
238                if !found && *add_if_missing {
239                    res.headers.push((name.clone(), new_val));
240                }
241            }
242            ActionOutcome::Continue
243        }
244        Action::DeleteResponseHeader { name } => {
245            if let Layer::Http(http) = &mut flow.layer
246                && let Some(res) = &mut http.response
247            {
248                res.headers.retain(|(k, _)| !k.eq_ignore_ascii_case(name));
249            }
250            ActionOutcome::Continue
251        }
252
253        // --- Terminal Actions ---
254        Action::MockResponse {
255            status,
256            headers,
257            body,
258        } => {
259            let body_data = if let Some(source) = body {
260                resolve_body_source(source, ctx.policy.as_deref()).await
261            } else {
262                None
263            };
264
265            if let Layer::Http(http) = &mut flow.layer {
266                let mut res_headers = Vec::new();
267                for (k, v) in headers {
268                    res_headers.push((k.clone(), v.clone()));
269                }
270                let cookies = parse_response_cookies(&res_headers);
271
272                http.response = Some(HttpResponse {
273                    status: *status,
274                    status_text: status_text_for(*status),
275                    version: "HTTP/1.1".to_string(),
276                    headers: res_headers,
277                    cookies,
278                    body: body_data,
279                    timing: ResponseTiming {
280                        time_to_first_byte: Some(0),
281                        time_to_last_byte: Some(0),
282                        connect_time_ms: None,
283                        ssl_time_ms: None,
284                    },
285                });
286            } else if let Layer::WebSocket(ws) = &mut flow.layer {
287                let mut res_headers = Vec::new();
288                for (k, v) in headers {
289                    res_headers.push((k.clone(), v.clone()));
290                }
291                let cookies = parse_response_cookies(&res_headers);
292
293                ws.handshake_response = HttpResponse {
294                    status: *status,
295                    status_text: status_text_for(*status),
296                    version: "HTTP/1.1".to_string(),
297                    headers: res_headers,
298                    cookies,
299                    body: body_data,
300                    timing: ResponseTiming {
301                        time_to_first_byte: Some(0),
302                        time_to_last_byte: Some(0),
303                        connect_time_ms: None,
304                        ssl_time_ms: None,
305                    },
306                };
307            }
308            ActionOutcome::Terminated(TerminalReason::Mock)
309        }
310        Action::MapLocal { path, content_type } => {
311            let body_data =
312                resolve_body_source(&BodySource::File(path.clone()), ctx.policy.as_deref()).await;
313
314            if let Some(body) = body_data {
315                if let Layer::Http(http) = &mut flow.layer {
316                    let headers = content_type
317                        .as_ref()
318                        .map(|ct| vec![("Content-Type".to_string(), ct.clone())])
319                        .unwrap_or_default();
320
321                    http.response = Some(HttpResponse {
322                        status: 200,
323                        status_text: "OK".to_string(),
324                        version: "HTTP/1.1".to_string(),
325                        headers,
326                        cookies: vec![],
327                        body: Some(body),
328                        timing: ResponseTiming {
329                            time_to_first_byte: Some(0),
330                            time_to_last_byte: Some(0),
331                            connect_time_ms: None,
332                            ssl_time_ms: None,
333                        },
334                    });
335                }
336                ActionOutcome::Terminated(TerminalReason::Mock)
337            } else {
338                ActionOutcome::Failed(format!("Failed to load local file: {}", path))
339            }
340        }
341
342        // --- Transformation Actions ---
343        Action::TransformRequestBody { transform } => {
344            if let Layer::Http(http) = &mut flow.layer
345                && let Some(body) = &mut http.request.body
346            {
347                apply_transform(body, transform);
348            }
349            ActionOutcome::Continue
350        }
351        Action::TransformResponseBody { transform } => {
352            if let Layer::Http(http) = &mut flow.layer
353                && let Some(res) = &mut http.response
354                && let Some(body) = &mut res.body
355            {
356                apply_transform(body, transform);
357            }
358            ActionOutcome::Continue
359        }
360
361        _ => ActionOutcome::Failed(format!("Action {:?} not supported in http handler", action)),
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::{execute, parse_set_cookie_header};
368    use crate::rule::engine::actions::ActionOutcome;
369    use crate::rule::engine::executor::ExecutionContext;
370    use crate::rule::engine::state::InMemoryRuleStateStore;
371    use crate::rule::model::{Action, RuleTraceSummary};
372    use chrono::Utc;
373    use relay_core_api::flow::{
374        Flow, HttpLayer, HttpRequest, Layer, NetworkInfo, TransportProtocol,
375    };
376    use std::collections::HashMap;
377    use std::sync::Arc;
378    use url::Url;
379    use uuid::Uuid;
380
381    fn sample_flow() -> Flow {
382        Flow {
383            id: Uuid::new_v4(),
384            start_time: Utc::now(),
385            end_time: None,
386            network: NetworkInfo {
387                client_ip: "127.0.0.1".to_string(),
388                client_port: 12345,
389                server_ip: "1.1.1.1".to_string(),
390                server_port: 443,
391                protocol: TransportProtocol::TCP,
392                tls: true,
393                tls_version: Some("TLS1.3".to_string()),
394                sni: Some("example.com".to_string()),
395            },
396            layer: Layer::Http(HttpLayer {
397                request: HttpRequest {
398                    method: "GET".to_string(),
399                    url: Url::parse("https://example.com/").expect("url"),
400                    version: "HTTP/1.1".to_string(),
401                    headers: vec![],
402                    cookies: vec![],
403                    query: vec![],
404                    body: None,
405                },
406                response: None,
407                error: None,
408            }),
409            tags: vec![],
410            meta: HashMap::new(),
411        }
412    }
413
414    fn sample_ctx() -> ExecutionContext {
415        ExecutionContext {
416            trace: vec![],
417            variables: HashMap::new(),
418            policy: None,
419            summary: RuleTraceSummary::NoMatch,
420            state_store: Arc::new(InMemoryRuleStateStore::new()),
421        }
422    }
423
424    #[test]
425    fn test_parse_set_cookie_header_with_attributes() {
426        let c = parse_set_cookie_header("sid=abc; Path=/; Domain=example.com; HttpOnly; Secure; Expires=Wed, 21 Oct 2026 07:28:00 GMT")
427            .expect("cookie");
428        assert_eq!(c.name, "sid");
429        assert_eq!(c.value, "abc");
430        assert_eq!(c.path.as_deref(), Some("/"));
431        assert_eq!(c.domain.as_deref(), Some("example.com"));
432        assert_eq!(c.http_only, Some(true));
433        assert_eq!(c.secure, Some(true));
434        assert!(c.expires.is_some());
435    }
436
437    #[tokio::test]
438    async fn test_mock_response_extracts_set_cookie() {
439        let mut headers = HashMap::new();
440        headers.insert(
441            "Set-Cookie".to_string(),
442            "token=xyz; Path=/; HttpOnly".to_string(),
443        );
444        let action = Action::MockResponse {
445            status: 200,
446            headers,
447            body: None,
448        };
449
450        let mut flow = sample_flow();
451        let mut ctx = sample_ctx();
452        let out = execute(&action, &mut flow, &mut ctx).await;
453        assert!(matches!(out, ActionOutcome::Terminated(_)));
454
455        let Layer::Http(http) = flow.layer else {
456            panic!("expected http layer");
457        };
458        let res = http.response.expect("mocked response");
459        assert_eq!(res.cookies.len(), 1);
460        assert_eq!(res.cookies[0].name, "token");
461        assert_eq!(res.cookies[0].value, "xyz");
462        assert_eq!(res.cookies[0].path.as_deref(), Some("/"));
463        assert_eq!(res.cookies[0].http_only, Some(true));
464    }
465}