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 let hex = short_hash_hex(input.as_bytes());
68 String::from_utf8(hex.to_vec()).expect("hex digits are ASCII")
70}
71
72pub(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
89pub(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
99pub fn compile_profile(cfg: &LimitProfileConfig) -> Result<CompiledProfile, String> {
101 let rate = super::rate::Rate::parse(&cfg.rate, BARE_COUNT_WINDOW)?;
102 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 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
127pub 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
137pub fn compile_rules(
141 configs: &[RateRuleConfig],
142 profiles: &HashMap<String, CompiledProfile>,
143) -> Result<Vec<CompiledRule>, String> {
144 configs
145 .iter()
146 .map(|c| {
147 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 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 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 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}