use std::collections::HashMap;
use std::time::Duration;
use globset::GlobMatcher;
use super::gcra::{Gcra, Profile};
use crate::config::{KeySourceConfig, LimitProfileConfig, RateRuleConfig};
const BARE_COUNT_WINDOW: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy)]
pub struct CompiledProfile {
pub gcra: Gcra,
pub limit: u64,
pub window: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySource {
Ip,
Header(String),
JwtClaim(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
PreAuth,
PostAuth,
}
#[derive(Debug, Clone)]
pub struct CompiledRule {
pub matcher: GlobMatcher,
pub key: KeySource,
pub profile: Option<String>,
pub phase: Phase,
pub fingerprint: String,
}
pub fn short_hash(input: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(input.as_bytes());
digest[..16].iter().map(|b| format!("{b:02x}")).collect()
}
fn path_glob(pattern: &str) -> Result<GlobMatcher, String> {
globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.map(|g| g.compile_matcher())
.map_err(|e| format!("invalid glob pattern {pattern:?}: {e}"))
}
pub fn compile_profile(cfg: &LimitProfileConfig) -> Result<CompiledProfile, String> {
let rate = super::rate::Rate::parse(&cfg.rate, BARE_COUNT_WINDOW)?;
if rate.limit == 0 {
return Err(format!(
"profile rate must be greater than 0, got {:?}",
cfg.rate
));
}
if cfg.burst == Some(0) {
return Err("profile burst must be greater than 0".to_string());
}
let burst = cfg.burst.unwrap_or(rate.limit);
let gcra = Gcra::from_profile(Profile {
rate: rate.limit,
window: rate.window,
burst,
});
Ok(CompiledProfile {
gcra,
limit: rate.limit,
window: rate.window,
})
}
pub fn compile_profiles(
configs: &HashMap<String, LimitProfileConfig>,
) -> Result<HashMap<String, CompiledProfile>, String> {
configs
.iter()
.map(|(name, cfg)| Ok((name.clone(), compile_profile(cfg)?)))
.collect()
}
pub fn compile_rules(
configs: &[RateRuleConfig],
profiles: &HashMap<String, CompiledProfile>,
) -> Result<Vec<CompiledRule>, String> {
configs
.iter()
.map(|c| {
if !c.pattern.starts_with('/') {
return Err(format!(
"rule pattern {:?} must start with '/' (matched against the request path)",
c.pattern
));
}
if let Some(name) = &c.profile {
if !profiles.contains_key(name) {
return Err(format!(
"rule {:?} references unknown profile {name:?}",
c.pattern
));
}
}
let key = match &c.key {
KeySourceConfig::Ip => KeySource::Ip,
KeySourceConfig::Header { name } => {
let hn = http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
format!("rule {:?} has invalid header name {name:?}", c.pattern)
})?;
KeySource::Header(hn.as_str().to_string())
}
KeySourceConfig::JwtClaim { claim } => KeySource::JwtClaim(claim.clone()),
};
let phase = match &key {
KeySource::JwtClaim(_) => Phase::PostAuth,
KeySource::Ip | KeySource::Header(_) => Phase::PreAuth,
};
let key_repr = match &key {
KeySource::Ip => "ip".to_string(),
KeySource::Header(name) => format!("hdr:{name}"),
KeySource::JwtClaim(claim) => format!("jwt:{claim}"),
};
let fingerprint = short_hash(&format!(
"{}\u{1f}{}\u{1f}{}",
c.pattern,
key_repr,
c.profile.as_deref().unwrap_or("")
));
Ok(CompiledRule {
matcher: path_glob(&c.pattern)?,
key,
profile: c.profile.clone(),
phase,
fingerprint,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn profiles() -> HashMap<String, CompiledProfile> {
let mut cfg = HashMap::new();
cfg.insert(
"auth".to_string(),
LimitProfileConfig {
rate: "20/min".to_string(),
burst: Some(5),
},
);
compile_profiles(&cfg).unwrap()
}
#[test]
fn profile_defaults_burst_to_rate_count() {
let p = compile_profile(&LimitProfileConfig {
rate: "100/min".to_string(),
burst: None,
})
.unwrap();
assert_eq!(p.limit, 100);
}
#[test]
fn header_key_fingerprint_is_case_insensitive() {
let rules = compile_rules(
&[
RateRuleConfig {
pattern: "/a".to_string(),
key: KeySourceConfig::Header {
name: "X-API-Key".to_string(),
},
profile: Some("auth".to_string()),
},
RateRuleConfig {
pattern: "/a".to_string(),
key: KeySourceConfig::Header {
name: "x-api-key".to_string(),
},
profile: Some("auth".to_string()),
},
],
&profiles(),
)
.unwrap();
assert_eq!(rules[0].fingerprint, rules[1].fingerprint);
}
#[test]
fn invalid_header_name_is_rejected() {
let err = compile_rules(
&[RateRuleConfig {
pattern: "/a".to_string(),
key: KeySourceConfig::Header {
name: "bad name".to_string(),
},
profile: Some("auth".to_string()),
}],
&profiles(),
);
assert!(err.is_err());
}
#[test]
fn zero_rate_or_burst_is_rejected() {
assert!(compile_profile(&LimitProfileConfig {
rate: "0/min".to_string(),
burst: None,
})
.is_err());
assert!(compile_profile(&LimitProfileConfig {
rate: "100/min".to_string(),
burst: Some(0),
})
.is_err());
}
#[test]
fn glob_respects_and_spans_segments() {
let rules = compile_rules(
&[
RateRuleConfig {
pattern: "/api/v1/heavy-*".to_string(),
key: KeySourceConfig::Ip,
profile: Some("auth".to_string()),
},
RateRuleConfig {
pattern: "/v1/auth/**".to_string(),
key: KeySourceConfig::Ip,
profile: None,
},
],
&profiles(),
)
.unwrap();
assert!(rules[0].matcher.is_match("/api/v1/heavy-export"));
assert!(!rules[0].matcher.is_match("/api/v1/heavy-export/sub"));
assert!(rules[1].matcher.is_match("/v1/auth/opaque/start"));
}
#[test]
fn phase_is_derived_from_key() {
let rules = compile_rules(
&[
RateRuleConfig {
pattern: "/a".to_string(),
key: KeySourceConfig::Ip,
profile: None,
},
RateRuleConfig {
pattern: "/b".to_string(),
key: KeySourceConfig::JwtClaim {
claim: "sub".to_string(),
},
profile: None,
},
],
&profiles(),
)
.unwrap();
assert_eq!(rules[0].phase, Phase::PreAuth);
assert_eq!(rules[1].phase, Phase::PostAuth);
}
#[test]
fn unknown_profile_reference_fails() {
let err = compile_rules(
&[RateRuleConfig {
pattern: "/x".to_string(),
key: KeySourceConfig::Ip,
profile: Some("nope".to_string()),
}],
&profiles(),
);
assert!(err.is_err());
}
#[test]
fn relative_pattern_is_rejected() {
let err = compile_rules(
&[RateRuleConfig {
pattern: "api/**".to_string(),
key: KeySourceConfig::Ip,
profile: Some("auth".to_string()),
}],
&profiles(),
);
assert!(err.is_err());
}
}