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    let hex = short_hash_hex(input.as_bytes());
68    // The hex digits are ASCII.
69    String::from_utf8(hex.to_vec()).expect("hex digits are ASCII")
70}
71
72/// [`short_hash`] of `input` as its 32 lower-case hex digits, built without
73/// allocating.
74pub(crate) fn short_hash_hex(input: &[u8]) -> [u8; 32] {
75    use sha2::{Digest, Sha256};
76    const DIGITS: &[u8; 16] = b"0123456789abcdef";
77    let digest = Sha256::digest(input);
78    let mut hex = [0u8; 32];
79    let (pairs, _) = hex.as_chunks_mut::<2>();
80    for (pair, byte) in pairs.iter_mut().zip(&digest[..16]) {
81        *pair = [
82            DIGITS[usize::from(byte >> 4)],
83            DIGITS[usize::from(byte & 0x0f)],
84        ];
85    }
86    hex
87}
88
89/// Build a glob matcher where `*` stays within a path segment and `**` spans
90/// segments, matching the `google.api.http` / maintenance path convention.
91pub(crate) fn path_glob(pattern: &str) -> Result<GlobMatcher, String> {
92    globset::GlobBuilder::new(pattern)
93        .literal_separator(true)
94        .build()
95        .map(|g| g.compile_matcher())
96        .map_err(|e| format!("invalid glob pattern {pattern:?}: {e}"))
97}
98
99/// Compile a single limit profile from its config.
100pub fn compile_profile(cfg: &LimitProfileConfig) -> Result<CompiledProfile, String> {
101    let rate = super::rate::Rate::parse(&cfg.rate, BARE_COUNT_WINDOW)?;
102    // Reject non-positive limits explicitly rather than silently clamping them
103    // to 1, which would hide a misconfigured (effectively unlimited or dead) tier.
104    if rate.limit == 0 {
105        return Err(format!(
106            "profile rate must be greater than 0, got {:?}",
107            cfg.rate
108        ));
109    }
110    if cfg.burst == Some(0) {
111        return Err("profile burst must be greater than 0".to_string());
112    }
113    // Default burst = one full window of the sustained rate.
114    let burst = cfg.burst.unwrap_or(rate.limit);
115    let gcra = Gcra::from_profile(Profile {
116        rate: rate.limit,
117        window: rate.window,
118        burst,
119    });
120    Ok(CompiledProfile {
121        gcra,
122        limit: rate.limit,
123        window: rate.window,
124    })
125}
126
127/// Compile every named profile.
128pub fn compile_profiles(
129    configs: &HashMap<String, LimitProfileConfig>,
130) -> Result<HashMap<String, CompiledProfile>, String> {
131    configs
132        .iter()
133        .map(|(name, cfg)| Ok((name.clone(), compile_profile(cfg)?)))
134        .collect()
135}
136
137/// Compile rules, validating that any pinned `profile` names an existing tier.
138/// Each rule's phase is derived from its key: a `jwt_claim` key needs validated
139/// claims and runs after auth; every other key runs before auth.
140pub fn compile_rules(
141    configs: &[RateRuleConfig],
142    profiles: &HashMap<String, CompiledProfile>,
143) -> Result<Vec<CompiledRule>, String> {
144    configs
145        .iter()
146        .map(|c| {
147            // Patterns are matched against the request path, which always starts
148            // with `/`. Reject a relative pattern (a missing-slash typo) rather
149            // than compiling a matcher that silently never fires, leaving the
150            // route unmetered.
151            if !c.pattern.starts_with('/') {
152                return Err(format!(
153                    "rule pattern {:?} must start with '/' (matched against the request path)",
154                    c.pattern
155                ));
156            }
157            if let Some(name) = &c.profile {
158                if !profiles.contains_key(name) {
159                    return Err(format!(
160                        "rule {:?} references unknown profile {name:?}",
161                        c.pattern
162                    ));
163                }
164            }
165            let key = match &c.key {
166                KeySourceConfig::Ip => KeySource::Ip,
167                KeySourceConfig::Header { name } => {
168                    // Reject an invalid header name at build time: a typo (e.g.
169                    // whitespace) would never match a real header, silently
170                    // downgrading a per-API-key limit into a per-IP one. Store the
171                    // normalized (lowercased) form so a casing-only config
172                    // difference between instances (`X-API-Key` vs `x-api-key`)
173                    // yields the same fingerprint and shares one counter namespace.
174                    let hn = http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
175                        format!("rule {:?} has invalid header name {name:?}", c.pattern)
176                    })?;
177                    KeySource::Header(hn.as_str().to_string())
178                }
179                KeySourceConfig::JwtClaim { claim } => KeySource::JwtClaim(claim.clone()),
180            };
181            let phase = match &key {
182                KeySource::JwtClaim(_) => Phase::PostAuth,
183                KeySource::Ip | KeySource::Header(_) => Phase::PreAuth,
184            };
185            let key_repr = match &key {
186                KeySource::Ip => "ip".to_string(),
187                KeySource::Header(name) => format!("hdr:{name}"),
188                KeySource::JwtClaim(claim) => format!("jwt:{claim}"),
189            };
190            let fingerprint = short_hash(&format!(
191                "{}\u{1f}{}\u{1f}{}",
192                c.pattern,
193                key_repr,
194                c.profile.as_deref().unwrap_or("")
195            ));
196            Ok(CompiledRule {
197                matcher: path_glob(&c.pattern)?,
198                key,
199                profile: c.profile.clone(),
200                phase,
201                fingerprint,
202            })
203        })
204        .collect()
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn profiles() -> HashMap<String, CompiledProfile> {
212        let mut cfg = HashMap::new();
213        cfg.insert(
214            "auth".to_string(),
215            LimitProfileConfig {
216                rate: "20/min".to_string(),
217                burst: Some(5),
218            },
219        );
220        compile_profiles(&cfg).unwrap()
221    }
222
223    #[test]
224    fn profile_defaults_burst_to_rate_count() {
225        let p = compile_profile(&LimitProfileConfig {
226            rate: "100/min".to_string(),
227            burst: None,
228        })
229        .unwrap();
230        assert_eq!(p.limit, 100);
231    }
232
233    #[test]
234    fn header_key_fingerprint_is_case_insensitive() {
235        // Casing-only differences in the configured header name must map to the
236        // same fingerprint so instances share one counter namespace.
237        let rules = compile_rules(
238            &[
239                RateRuleConfig {
240                    pattern: "/a".to_string(),
241                    key: KeySourceConfig::Header {
242                        name: "X-API-Key".to_string(),
243                    },
244                    profile: Some("auth".to_string()),
245                },
246                RateRuleConfig {
247                    pattern: "/a".to_string(),
248                    key: KeySourceConfig::Header {
249                        name: "x-api-key".to_string(),
250                    },
251                    profile: Some("auth".to_string()),
252                },
253            ],
254            &profiles(),
255        )
256        .unwrap();
257        assert_eq!(rules[0].fingerprint, rules[1].fingerprint);
258    }
259
260    #[test]
261    fn invalid_header_name_is_rejected() {
262        let err = compile_rules(
263            &[RateRuleConfig {
264                pattern: "/a".to_string(),
265                key: KeySourceConfig::Header {
266                    name: "bad name".to_string(),
267                },
268                profile: Some("auth".to_string()),
269            }],
270            &profiles(),
271        );
272        assert!(err.is_err());
273    }
274
275    #[test]
276    fn zero_rate_or_burst_is_rejected() {
277        assert!(compile_profile(&LimitProfileConfig {
278            rate: "0/min".to_string(),
279            burst: None,
280        })
281        .is_err());
282        assert!(compile_profile(&LimitProfileConfig {
283            rate: "100/min".to_string(),
284            burst: Some(0),
285        })
286        .is_err());
287    }
288
289    #[test]
290    fn glob_respects_and_spans_segments() {
291        let rules = compile_rules(
292            &[
293                RateRuleConfig {
294                    pattern: "/api/v1/heavy-*".to_string(),
295                    key: KeySourceConfig::Ip,
296                    profile: Some("auth".to_string()),
297                },
298                RateRuleConfig {
299                    pattern: "/v1/auth/**".to_string(),
300                    key: KeySourceConfig::Ip,
301                    profile: None,
302                },
303            ],
304            &profiles(),
305        )
306        .unwrap();
307        assert!(rules[0].matcher.is_match("/api/v1/heavy-export"));
308        assert!(!rules[0].matcher.is_match("/api/v1/heavy-export/sub"));
309        assert!(rules[1].matcher.is_match("/v1/auth/opaque/start"));
310    }
311
312    #[test]
313    fn phase_is_derived_from_key() {
314        let rules = compile_rules(
315            &[
316                RateRuleConfig {
317                    pattern: "/a".to_string(),
318                    key: KeySourceConfig::Ip,
319                    profile: None,
320                },
321                RateRuleConfig {
322                    pattern: "/b".to_string(),
323                    key: KeySourceConfig::JwtClaim {
324                        claim: "sub".to_string(),
325                    },
326                    profile: None,
327                },
328            ],
329            &profiles(),
330        )
331        .unwrap();
332        assert_eq!(rules[0].phase, Phase::PreAuth);
333        assert_eq!(rules[1].phase, Phase::PostAuth);
334    }
335
336    #[test]
337    fn unknown_profile_reference_fails() {
338        let err = compile_rules(
339            &[RateRuleConfig {
340                pattern: "/x".to_string(),
341                key: KeySourceConfig::Ip,
342                profile: Some("nope".to_string()),
343            }],
344            &profiles(),
345        );
346        assert!(err.is_err());
347    }
348
349    #[test]
350    fn relative_pattern_is_rejected() {
351        // A pattern without a leading slash (a missing-slash typo like `api/**`)
352        // can never match `request.uri().path()`, which always starts with `/`,
353        // so the route would silently run unmetered. Reject it at build time.
354        let err = compile_rules(
355            &[RateRuleConfig {
356                pattern: "api/**".to_string(),
357                key: KeySourceConfig::Ip,
358                profile: Some("auth".to_string()),
359            }],
360            &profiles(),
361        );
362        assert!(err.is_err());
363    }
364}