1use std::collections::HashMap;
4use std::time::Duration;
5
6use globset::GlobMatcher;
7
8use super::gcra::{Gcra, Profile};
9use crate::config::{KeySourceConfig, LimitProfileConfig, RateRuleConfig};
10
11const BARE_COUNT_WINDOW: Duration = Duration::from_secs(60);
13
14#[derive(Debug, Clone, Copy)]
17#[non_exhaustive]
18pub struct CompiledProfile {
19 pub gcra: Gcra,
20 pub limit: u64,
22 pub window: Duration,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum KeySource {
30 Ip,
32 Header(String),
34 JwtClaim(String),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum Phase {
42 PreAuth,
44 PostAuth,
46}
47
48#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub struct CompiledRule {
52 pub matcher: GlobMatcher,
53 pub key: KeySource,
54 pub profile: Option<String>,
56 pub phase: Phase,
57 pub fingerprint: String,
61}
62
63pub 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
72pub(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
82pub fn compile_profile(cfg: &LimitProfileConfig) -> Result<CompiledProfile, String> {
84 let rate = super::rate::Rate::parse(&cfg.rate, BARE_COUNT_WINDOW)?;
85 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 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
110pub 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
120pub fn compile_rules(
124 configs: &[RateRuleConfig],
125 profiles: &HashMap<String, CompiledProfile>,
126) -> Result<Vec<CompiledRule>, String> {
127 configs
128 .iter()
129 .map(|c| {
130 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 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 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 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}