Skip to main content

mockforge_chaos/
request_matcher.rs

1//! Per-request matcher for conditional chaos injection.
2//!
3//! Lets fault injection (and other chaos features) be gated on properties of the
4//! incoming request — source IP/CIDR, header presence or value, request body size,
5//! and `Transfer-Encoding: chunked`. An empty matcher matches every request.
6//!
7//! AND semantics: every populated field must match. Within a list (e.g. `source_ips`,
8//! `headers`), the field matches if **any** entry matches.
9
10use ipnet::IpNet;
11use serde::{Deserialize, Serialize};
12use std::net::IpAddr;
13use std::str::FromStr;
14
15/// Header presence / exact-value filter.
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
17pub struct HeaderMatch {
18    /// Header name (case-insensitive).
19    pub name: String,
20    /// Optional exact value. `None` = match on presence only.
21    #[serde(default)]
22    pub value: Option<String>,
23}
24
25/// Request properties that gate whether a chaos action fires.
26#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
27pub struct RequestMatcher {
28    /// Match if the client IP falls in any of these CIDR ranges (e.g.
29    /// `"192.168.0.0/16"`, `"10.0.0.5/32"`, `"::1/128"`). A bare IP without a
30    /// prefix is treated as `/32` (v4) or `/128` (v6). Empty list = any IP.
31    #[serde(default)]
32    pub source_ips: Vec<String>,
33
34    /// Required headers. All entries must be satisfied (AND across the list).
35    #[serde(default)]
36    pub headers: Vec<HeaderMatch>,
37
38    /// Minimum request body size in bytes (inclusive).
39    #[serde(default)]
40    pub min_body_size_bytes: Option<usize>,
41
42    /// Maximum request body size in bytes (inclusive).
43    #[serde(default)]
44    pub max_body_size_bytes: Option<usize>,
45
46    /// `Some(true)` matches only requests with `Transfer-Encoding: chunked`,
47    /// `Some(false)` matches only requests **without** chunked encoding,
48    /// `None` is don't-care.
49    #[serde(default)]
50    pub chunked_only: Option<bool>,
51}
52
53impl RequestMatcher {
54    /// True when no field is configured — matches every request.
55    pub fn is_empty(&self) -> bool {
56        self.source_ips.is_empty()
57            && self.headers.is_empty()
58            && self.min_body_size_bytes.is_none()
59            && self.max_body_size_bytes.is_none()
60            && self.chunked_only.is_none()
61    }
62
63    /// Evaluate the matcher against extracted request properties.
64    ///
65    /// `client_ip` should be the resolved client IP string (already de-proxied if
66    /// applicable). `headers` is an iterator over `(name, value)` pairs (header
67    /// names should be lowercase). `body_size` is the request body size in bytes
68    /// or `None` if not yet known. `is_chunked` reflects `Transfer-Encoding: chunked`.
69    pub fn matches<'a, I>(
70        &self,
71        client_ip: Option<&str>,
72        headers: I,
73        body_size: Option<usize>,
74        is_chunked: bool,
75    ) -> bool
76    where
77        I: IntoIterator<Item = (&'a str, &'a str)> + Clone,
78    {
79        if self.is_empty() {
80            return true;
81        }
82
83        if !self.source_ips.is_empty() {
84            let ok = client_ip
85                .and_then(|s| IpAddr::from_str(s).ok())
86                .map(|ip| self.source_ips.iter().any(|cidr| ip_in_cidr(ip, cidr)))
87                .unwrap_or(false);
88            if !ok {
89                return false;
90            }
91        }
92
93        for hm in &self.headers {
94            let needle = hm.name.to_ascii_lowercase();
95            let mut found = false;
96            for (k, v) in headers.clone() {
97                if k.eq_ignore_ascii_case(&needle) {
98                    match &hm.value {
99                        None => {
100                            found = true;
101                            break;
102                        }
103                        Some(expected) if v == expected => {
104                            found = true;
105                            break;
106                        }
107                        _ => continue,
108                    }
109                }
110            }
111            if !found {
112                return false;
113            }
114        }
115
116        if let Some(min) = self.min_body_size_bytes {
117            if body_size.unwrap_or(0) < min {
118                return false;
119            }
120        }
121        if let Some(max) = self.max_body_size_bytes {
122            if body_size.unwrap_or(0) > max {
123                return false;
124            }
125        }
126
127        if let Some(want) = self.chunked_only {
128            if want != is_chunked {
129                return false;
130            }
131        }
132
133        true
134    }
135}
136
137/// True if `ip` belongs to the given CIDR (or equals the bare IP).
138fn ip_in_cidr(ip: IpAddr, cidr: &str) -> bool {
139    if let Ok(net) = IpNet::from_str(cidr) {
140        return net.contains(&ip);
141    }
142    if let Ok(single) = IpAddr::from_str(cidr) {
143        return single == ip;
144    }
145    false
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn h(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
153        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
154    }
155
156    fn iter(v: &[(String, String)]) -> impl IntoIterator<Item = (&str, &str)> + Clone {
157        v.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect::<Vec<_>>()
158    }
159
160    #[test]
161    fn empty_matcher_matches_everything() {
162        let m = RequestMatcher::default();
163        assert!(m.is_empty());
164        let hs = h(&[]);
165        assert!(m.matches(None, iter(&hs), None, false));
166        assert!(m.matches(Some("8.8.8.8"), iter(&hs), Some(1024), true));
167    }
168
169    #[test]
170    fn cidr_v4_match() {
171        let m = RequestMatcher {
172            source_ips: vec!["10.0.0.0/8".into()],
173            ..Default::default()
174        };
175        let hs = h(&[]);
176        assert!(m.matches(Some("10.5.6.7"), iter(&hs), None, false));
177        assert!(!m.matches(Some("11.0.0.1"), iter(&hs), None, false));
178        assert!(!m.matches(None, iter(&hs), None, false));
179    }
180
181    #[test]
182    fn bare_ip_treated_as_host() {
183        let m = RequestMatcher {
184            source_ips: vec!["127.0.0.1".into()],
185            ..Default::default()
186        };
187        let hs = h(&[]);
188        assert!(m.matches(Some("127.0.0.1"), iter(&hs), None, false));
189        assert!(!m.matches(Some("127.0.0.2"), iter(&hs), None, false));
190    }
191
192    #[test]
193    fn cidr_v6_match() {
194        let m = RequestMatcher {
195            source_ips: vec!["2001:db8::/32".into()],
196            ..Default::default()
197        };
198        let hs = h(&[]);
199        assert!(m.matches(Some("2001:db8::1"), iter(&hs), None, false));
200        assert!(!m.matches(Some("2001:db9::1"), iter(&hs), None, false));
201    }
202
203    #[test]
204    fn header_presence_only() {
205        let m = RequestMatcher {
206            headers: vec![HeaderMatch {
207                name: "x-test".into(),
208                value: None,
209            }],
210            ..Default::default()
211        };
212        let with = h(&[("x-test", "anything")]);
213        let without = h(&[("x-other", "v")]);
214        assert!(m.matches(None, iter(&with), None, false));
215        assert!(!m.matches(None, iter(&without), None, false));
216    }
217
218    #[test]
219    fn header_exact_value_case_insensitive_name() {
220        let m = RequestMatcher {
221            headers: vec![HeaderMatch {
222                name: "X-Test".into(),
223                value: Some("abc".into()),
224            }],
225            ..Default::default()
226        };
227        let good = h(&[("x-test", "abc")]);
228        let bad = h(&[("x-test", "xyz")]);
229        assert!(m.matches(None, iter(&good), None, false));
230        assert!(!m.matches(None, iter(&bad), None, false));
231    }
232
233    #[test]
234    fn body_size_threshold() {
235        let m = RequestMatcher {
236            min_body_size_bytes: Some(1024),
237            ..Default::default()
238        };
239        let hs = h(&[]);
240        assert!(m.matches(None, iter(&hs), Some(2048), false));
241        assert!(!m.matches(None, iter(&hs), Some(512), false));
242        assert!(!m.matches(None, iter(&hs), None, false));
243
244        let m2 = RequestMatcher {
245            max_body_size_bytes: Some(1024),
246            ..Default::default()
247        };
248        assert!(m2.matches(None, iter(&hs), Some(512), false));
249        assert!(!m2.matches(None, iter(&hs), Some(2048), false));
250    }
251
252    #[test]
253    fn chunked_only() {
254        let m_chunked = RequestMatcher {
255            chunked_only: Some(true),
256            ..Default::default()
257        };
258        let m_unchunked = RequestMatcher {
259            chunked_only: Some(false),
260            ..Default::default()
261        };
262        let hs = h(&[]);
263        assert!(m_chunked.matches(None, iter(&hs), None, true));
264        assert!(!m_chunked.matches(None, iter(&hs), None, false));
265        assert!(!m_unchunked.matches(None, iter(&hs), None, true));
266        assert!(m_unchunked.matches(None, iter(&hs), None, false));
267    }
268
269    #[test]
270    fn and_semantics_across_fields() {
271        let m = RequestMatcher {
272            source_ips: vec!["10.0.0.0/8".into()],
273            headers: vec![HeaderMatch {
274                name: "x-test".into(),
275                value: Some("yes".into()),
276            }],
277            min_body_size_bytes: Some(100),
278            chunked_only: Some(true),
279            ..Default::default()
280        };
281        let hs = h(&[("x-test", "yes")]);
282        assert!(m.matches(Some("10.1.1.1"), iter(&hs), Some(200), true));
283        // Wrong IP
284        assert!(!m.matches(Some("8.8.8.8"), iter(&hs), Some(200), true));
285        // Wrong header value
286        let bad_hs = h(&[("x-test", "no")]);
287        assert!(!m.matches(Some("10.1.1.1"), iter(&bad_hs), Some(200), true));
288        // Body too small
289        assert!(!m.matches(Some("10.1.1.1"), iter(&hs), Some(50), true));
290        // Not chunked
291        assert!(!m.matches(Some("10.1.1.1"), iter(&hs), Some(200), false));
292    }
293
294    #[test]
295    fn invalid_cidr_does_not_panic() {
296        let m = RequestMatcher {
297            source_ips: vec!["not-an-ip".into()],
298            ..Default::default()
299        };
300        let hs = h(&[]);
301        assert!(!m.matches(Some("1.2.3.4"), iter(&hs), None, false));
302    }
303}