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)]
17pub struct CompiledProfile {
18 pub gcra: Gcra,
19 pub limit: u64,
21 pub window: Duration,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum KeySource {
28 Ip,
30 Header(String),
32 JwtClaim(String),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Phase {
39 PreAuth,
41 PostAuth,
43}
44
45#[derive(Debug, Clone)]
47pub struct CompiledRule {
48 pub matcher: GlobMatcher,
49 pub key: KeySource,
50 pub profile: Option<String>,
52 pub phase: Phase,
53 pub fingerprint: String,
57}
58
59pub 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
68fn 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
78pub fn compile_profile(cfg: &LimitProfileConfig) -> Result<CompiledProfile, String> {
80 let rate = super::rate::Rate::parse(&cfg.rate, BARE_COUNT_WINDOW)?;
81 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 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
106pub 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
116pub fn compile_rules(
120 configs: &[RateRuleConfig],
121 profiles: &HashMap<String, CompiledProfile>,
122) -> Result<Vec<CompiledRule>, String> {
123 configs
124 .iter()
125 .map(|c| {
126 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 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 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 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}