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