Skip to main content

structured_proxy/shield/
matcher.rs

1//! Compile Shield config into runtime matchers, limiters, and rules.
2
3use std::collections::HashMap;
4use std::time::Duration;
5
6use globset::GlobMatcher;
7
8use super::gcra::{Gcra, Profile};
9use crate::config::{KeySourceConfig, LimitProfileConfig, RateRuleConfig};
10
11/// Bare rate counts (`"20"` with no unit) are interpreted per this window.
12const BARE_COUNT_WINDOW: Duration = Duration::from_secs(60);
13
14/// A compiled limit tier: the GCRA shaper, the per-window count reported in the
15/// `RateLimit-Limit` header, and the window itself (the fleet-gate epoch length).
16#[derive(Debug, Clone, Copy)]
17pub struct CompiledProfile {
18    pub gcra: Gcra,
19    /// Sustained request count per window (for `RateLimit-Limit`).
20    pub limit: u64,
21    /// Length of the sustained-rate window.
22    pub window: Duration,
23}
24
25/// How a rule derives its limit key.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum KeySource {
28    /// Client IP (trusted-proxy aware).
29    Ip,
30    /// A named request-header value (API-key style); IP fallback when absent.
31    Header(String),
32    /// A validated-JWT claim value; IP fallback for anonymous traffic.
33    JwtClaim(String),
34}
35
36/// Whether a rule can be decided before auth runs, or needs validated claims.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Phase {
39    /// No validated claims needed: run before auth so floods are shed cheaply.
40    PreAuth,
41    /// Needs validated claims (key derived from the JWT): run after auth.
42    PostAuth,
43}
44
45/// A compiled rate-limit rule.
46#[derive(Debug, Clone)]
47pub struct CompiledRule {
48    pub matcher: GlobMatcher,
49    pub key: KeySource,
50    /// Static profile name, if the rule pins one.
51    pub profile: Option<String>,
52    pub phase: Phase,
53    /// Stable identifier for this rule, derived from its shape (pattern + key +
54    /// profile) rather than its position. Namespaces store keys so reordering
55    /// rules or a rolling deploy does not reset budgets.
56    pub fingerprint: String,
57}
58
59/// A short, stable, non-reversible hash (128-bit hex) of `input`. Used both to
60/// fingerprint rules and to de-identify key values before they reach the shared
61/// store, and deterministic across instances so reconciliation keys agree.
62pub fn short_hash(input: &str) -> String {
63    use sha2::{Digest, Sha256};
64    let digest = Sha256::digest(input.as_bytes());
65    digest[..16].iter().map(|b| format!("{b:02x}")).collect()
66}
67
68/// Build a glob matcher where `*` stays within a path segment and `**` spans
69/// segments, matching the `google.api.http` / maintenance path convention.
70fn path_glob(pattern: &str) -> Result<GlobMatcher, String> {
71    globset::GlobBuilder::new(pattern)
72        .literal_separator(true)
73        .build()
74        .map(|g| g.compile_matcher())
75        .map_err(|e| format!("invalid glob pattern {pattern:?}: {e}"))
76}
77
78/// Compile a single limit profile from its config.
79pub fn compile_profile(cfg: &LimitProfileConfig) -> Result<CompiledProfile, String> {
80    let rate = super::rate::Rate::parse(&cfg.rate, BARE_COUNT_WINDOW)?;
81    // Reject non-positive limits explicitly rather than silently clamping them
82    // to 1, which would hide a misconfigured (effectively unlimited or dead) tier.
83    if rate.limit == 0 {
84        return Err(format!(
85            "profile rate must be greater than 0, got {:?}",
86            cfg.rate
87        ));
88    }
89    if cfg.burst == Some(0) {
90        return Err("profile burst must be greater than 0".to_string());
91    }
92    // Default burst = one full window of the sustained rate.
93    let burst = cfg.burst.unwrap_or(rate.limit);
94    let gcra = Gcra::from_profile(Profile {
95        rate: rate.limit,
96        window: rate.window,
97        burst,
98    });
99    Ok(CompiledProfile {
100        gcra,
101        limit: rate.limit,
102        window: rate.window,
103    })
104}
105
106/// Compile every named profile.
107pub fn compile_profiles(
108    configs: &HashMap<String, LimitProfileConfig>,
109) -> Result<HashMap<String, CompiledProfile>, String> {
110    configs
111        .iter()
112        .map(|(name, cfg)| Ok((name.clone(), compile_profile(cfg)?)))
113        .collect()
114}
115
116/// Compile rules, validating that any pinned `profile` names an existing tier.
117/// Each rule's phase is derived from its key: a `jwt_claim` key needs validated
118/// claims and runs after auth; every other key runs before auth.
119pub fn compile_rules(
120    configs: &[RateRuleConfig],
121    profiles: &HashMap<String, CompiledProfile>,
122) -> Result<Vec<CompiledRule>, String> {
123    configs
124        .iter()
125        .map(|c| {
126            // Patterns are matched against the request path, which always starts
127            // with `/`. Reject a relative pattern (a missing-slash typo) rather
128            // than compiling a matcher that silently never fires, leaving the
129            // route unmetered.
130            if !c.pattern.starts_with('/') {
131                return Err(format!(
132                    "rule pattern {:?} must start with '/' (matched against the request path)",
133                    c.pattern
134                ));
135            }
136            if let Some(name) = &c.profile {
137                if !profiles.contains_key(name) {
138                    return Err(format!(
139                        "rule {:?} references unknown profile {name:?}",
140                        c.pattern
141                    ));
142                }
143            }
144            let key = match &c.key {
145                KeySourceConfig::Ip => KeySource::Ip,
146                KeySourceConfig::Header { name } => {
147                    // Reject an invalid header name at build time: a typo (e.g.
148                    // whitespace) would never match a real header, silently
149                    // downgrading a per-API-key limit into a per-IP one. Store the
150                    // normalized (lowercased) form so a casing-only config
151                    // difference between instances (`X-API-Key` vs `x-api-key`)
152                    // yields the same fingerprint and shares one counter namespace.
153                    let hn = http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
154                        format!("rule {:?} has invalid header name {name:?}", c.pattern)
155                    })?;
156                    KeySource::Header(hn.as_str().to_string())
157                }
158                KeySourceConfig::JwtClaim { claim } => KeySource::JwtClaim(claim.clone()),
159            };
160            let phase = match &key {
161                KeySource::JwtClaim(_) => Phase::PostAuth,
162                KeySource::Ip | KeySource::Header(_) => Phase::PreAuth,
163            };
164            let key_repr = match &key {
165                KeySource::Ip => "ip".to_string(),
166                KeySource::Header(name) => format!("hdr:{name}"),
167                KeySource::JwtClaim(claim) => format!("jwt:{claim}"),
168            };
169            let fingerprint = short_hash(&format!(
170                "{}\u{1f}{}\u{1f}{}",
171                c.pattern,
172                key_repr,
173                c.profile.as_deref().unwrap_or("")
174            ));
175            Ok(CompiledRule {
176                matcher: path_glob(&c.pattern)?,
177                key,
178                profile: c.profile.clone(),
179                phase,
180                fingerprint,
181            })
182        })
183        .collect()
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn profiles() -> HashMap<String, CompiledProfile> {
191        let mut cfg = HashMap::new();
192        cfg.insert(
193            "auth".to_string(),
194            LimitProfileConfig {
195                rate: "20/min".to_string(),
196                burst: Some(5),
197            },
198        );
199        compile_profiles(&cfg).unwrap()
200    }
201
202    #[test]
203    fn profile_defaults_burst_to_rate_count() {
204        let p = compile_profile(&LimitProfileConfig {
205            rate: "100/min".to_string(),
206            burst: None,
207        })
208        .unwrap();
209        assert_eq!(p.limit, 100);
210    }
211
212    #[test]
213    fn header_key_fingerprint_is_case_insensitive() {
214        // Casing-only differences in the configured header name must map to the
215        // same fingerprint so instances share one counter namespace.
216        let rules = compile_rules(
217            &[
218                RateRuleConfig {
219                    pattern: "/a".to_string(),
220                    key: KeySourceConfig::Header {
221                        name: "X-API-Key".to_string(),
222                    },
223                    profile: Some("auth".to_string()),
224                },
225                RateRuleConfig {
226                    pattern: "/a".to_string(),
227                    key: KeySourceConfig::Header {
228                        name: "x-api-key".to_string(),
229                    },
230                    profile: Some("auth".to_string()),
231                },
232            ],
233            &profiles(),
234        )
235        .unwrap();
236        assert_eq!(rules[0].fingerprint, rules[1].fingerprint);
237    }
238
239    #[test]
240    fn invalid_header_name_is_rejected() {
241        let err = compile_rules(
242            &[RateRuleConfig {
243                pattern: "/a".to_string(),
244                key: KeySourceConfig::Header {
245                    name: "bad name".to_string(),
246                },
247                profile: Some("auth".to_string()),
248            }],
249            &profiles(),
250        );
251        assert!(err.is_err());
252    }
253
254    #[test]
255    fn zero_rate_or_burst_is_rejected() {
256        assert!(compile_profile(&LimitProfileConfig {
257            rate: "0/min".to_string(),
258            burst: None,
259        })
260        .is_err());
261        assert!(compile_profile(&LimitProfileConfig {
262            rate: "100/min".to_string(),
263            burst: Some(0),
264        })
265        .is_err());
266    }
267
268    #[test]
269    fn glob_respects_and_spans_segments() {
270        let rules = compile_rules(
271            &[
272                RateRuleConfig {
273                    pattern: "/api/v1/heavy-*".to_string(),
274                    key: KeySourceConfig::Ip,
275                    profile: Some("auth".to_string()),
276                },
277                RateRuleConfig {
278                    pattern: "/v1/auth/**".to_string(),
279                    key: KeySourceConfig::Ip,
280                    profile: None,
281                },
282            ],
283            &profiles(),
284        )
285        .unwrap();
286        assert!(rules[0].matcher.is_match("/api/v1/heavy-export"));
287        assert!(!rules[0].matcher.is_match("/api/v1/heavy-export/sub"));
288        assert!(rules[1].matcher.is_match("/v1/auth/opaque/start"));
289    }
290
291    #[test]
292    fn phase_is_derived_from_key() {
293        let rules = compile_rules(
294            &[
295                RateRuleConfig {
296                    pattern: "/a".to_string(),
297                    key: KeySourceConfig::Ip,
298                    profile: None,
299                },
300                RateRuleConfig {
301                    pattern: "/b".to_string(),
302                    key: KeySourceConfig::JwtClaim {
303                        claim: "sub".to_string(),
304                    },
305                    profile: None,
306                },
307            ],
308            &profiles(),
309        )
310        .unwrap();
311        assert_eq!(rules[0].phase, Phase::PreAuth);
312        assert_eq!(rules[1].phase, Phase::PostAuth);
313    }
314
315    #[test]
316    fn unknown_profile_reference_fails() {
317        let err = compile_rules(
318            &[RateRuleConfig {
319                pattern: "/x".to_string(),
320                key: KeySourceConfig::Ip,
321                profile: Some("nope".to_string()),
322            }],
323            &profiles(),
324        );
325        assert!(err.is_err());
326    }
327
328    #[test]
329    fn relative_pattern_is_rejected() {
330        // A pattern without a leading slash (a missing-slash typo like `api/**`)
331        // can never match `request.uri().path()`, which always starts with `/`,
332        // so the route would silently run unmetered. Reject it at build time.
333        let err = compile_rules(
334            &[RateRuleConfig {
335                pattern: "api/**".to_string(),
336                key: KeySourceConfig::Ip,
337                profile: Some("auth".to_string()),
338            }],
339            &profiles(),
340        );
341        assert!(err.is_err());
342    }
343}