use ikigai_web::{SourceLimits, SourceRate, DEFAULT_MAX_SOURCES};
use std::time::Duration;
pub const RULE_KEY: &str = "http.rate_limit";
pub const SOURCES_KEY: &str = "http.rate_limit_sources";
pub const DEFAULTS: &[(&str, u32, u64)] = &[
("urn:contact:submit", 10, 3600),
("urn:booking:submit", 10, 3600),
("urn:passkey:challenge", 30, 120),
];
pub fn limits_from(entries: &[(String, String)]) -> Result<Option<SourceLimits>, String> {
let mut rules: Vec<(String, Option<(u32, u64)>)> = DEFAULTS
.iter()
.map(|(prefix, max, secs)| (prefix.to_string(), Some((*max, *secs))))
.collect();
let mut named: Vec<String> = Vec::new();
let mut sources = DEFAULT_MAX_SOURCES;
let mut sources_seen = false;
for (key, value) in entries {
match key.as_str() {
RULE_KEY => {
let (prefix, rule) = parse_rule(value)?;
if named.contains(&prefix) {
return Err(format!(
"`{RULE_KEY}` names `{prefix}` twice; give each prefix one line"
));
}
named.push(prefix.clone());
match rules.iter_mut().find(|(p, _)| *p == prefix) {
Some(slot) => slot.1 = rule,
None => rules.push((prefix, rule)),
}
}
SOURCES_KEY => {
if sources_seen {
return Err(format!("`{SOURCES_KEY}` is set twice; set it once"));
}
sources_seen = true;
sources = whole_number(SOURCES_KEY, value)? as usize;
}
other => {
return Err(format!(
"`{other}` is not an HTTP rate-limit setting; the keys are `{RULE_KEY}` \
and `{SOURCES_KEY}`"
))
}
}
}
let rules: Vec<SourceRate> = rules
.into_iter()
.filter_map(|(prefix, rule)| {
rule.map(|(max, secs)| SourceRate::new(prefix, max, Duration::from_secs(secs)))
})
.collect();
if rules.is_empty() {
return Ok(None);
}
Ok(Some(SourceLimits::new(rules).max_sources(sources)))
}
fn parse_rule(value: &str) -> Result<(String, Option<(u32, u64)>), String> {
let shape = || {
format!(
"`{RULE_KEY} = \"{value}\"` is not `<IRI prefix> <requests>/<seconds>` (or \
`<IRI prefix> off`), e.g. `urn:contact:submit 10/3600`"
)
};
let mut words = value.split_whitespace();
let (Some(prefix), Some(rate), None) = (words.next(), words.next(), words.next()) else {
return Err(shape());
};
if !prefix.contains(':') {
return Err(shape());
}
if rate == "off" {
return Ok((prefix.to_string(), None));
}
let (max, secs) = rate.split_once('/').ok_or_else(shape)?;
let max = whole_number(RULE_KEY, max)?;
let max =
u32::try_from(max).map_err(|_| format!("`{RULE_KEY}`: {max} requests is too many"))?;
let secs = whole_number(RULE_KEY, secs)?;
Ok((prefix.to_string(), Some((max, secs))))
}
fn whole_number(key: &str, value: &str) -> Result<u64, String> {
let digits: String = value.chars().filter(|c| *c != '_').collect();
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err(format!("`{key}`: `{value}` is not a whole number"));
}
match digits.parse::<u64>() {
Ok(0) => Err(format!(
"`{key}`: `{value}`: the least it can be is 1 (use `off` to drop a rule)"
)),
Ok(n) => Ok(n),
Err(_) => Err(format!("`{key}`: `{value}` is too large")),
}
}
pub fn describe(limits: Option<&SourceLimits>) -> String {
match limits {
None => "no rate limits".to_string(),
Some(limits) => {
let mut rules: Vec<String> = limits
.rules()
.iter()
.map(|r| format!("{} {}/{}s", r.prefix, r.max, r.window.as_secs()))
.collect();
rules.sort();
format!("rate limits per client: {}", rules.join(", "))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entries(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn rules(limits: &SourceLimits) -> Vec<(String, u32, u64)> {
let mut out: Vec<_> = limits
.rules()
.iter()
.map(|r| (r.prefix.clone(), r.max, r.window.as_secs()))
.collect();
out.sort();
out
}
#[test]
fn with_nothing_configured_the_intake_and_the_challenge_are_limited() {
let limits = limits_from(&[]).unwrap().expect("on by default");
assert_eq!(
rules(&limits),
vec![
("urn:booking:submit".to_string(), 10, 3600),
("urn:contact:submit".to_string(), 10, 3600),
("urn:passkey:challenge".to_string(), 30, 120),
]
);
assert_eq!(
describe(Some(&limits)),
"rate limits per client: urn:booking:submit 10/3600s, urn:contact:submit 10/3600s, \
urn:passkey:challenge 30/120s"
);
}
#[test]
fn a_line_replaces_its_prefixs_default_adds_a_rule_or_turns_one_off() {
let limits = limits_from(&entries(&[
("http.rate_limit", "urn:contact:submit 5/600"),
("http.rate_limit", "urn:passkey:challenge off"),
("http.rate_limit", "urn:calendar-request: 20/60"),
("http.rate_limit_sources", "8_192"),
]))
.unwrap()
.unwrap();
assert_eq!(
rules(&limits),
vec![
("urn:booking:submit".to_string(), 10, 3600),
("urn:calendar-request:".to_string(), 20, 60),
("urn:contact:submit".to_string(), 5, 600),
]
);
}
#[test]
fn every_rule_off_is_no_limits() {
let off = limits_from(&entries(&[
("http.rate_limit", "urn:contact:submit off"),
("http.rate_limit", "urn:booking:submit off"),
("http.rate_limit", "urn:passkey:challenge off"),
]))
.unwrap();
assert!(off.is_none());
assert_eq!(describe(None), "no rate limits");
}
#[test]
fn a_line_that_does_not_parse_is_refused_naming_it() {
for (key, value, says) in [
(
"http.rate_limit",
"urn:contact:submit",
"is not `<IRI prefix>",
),
(
"http.rate_limit",
"urn:contact:submit 10",
"is not `<IRI prefix>",
),
(
"http.rate_limit",
"urn:contact:submit 10/3600 extra",
"is not `<IRI prefix>",
),
("http.rate_limit", "contact 10/3600", "is not `<IRI prefix>"),
(
"http.rate_limit",
"urn:contact:submit 0/3600",
"the least it can be is 1",
),
(
"http.rate_limit",
"urn:contact:submit 10/0",
"the least it can be is 1",
),
(
"http.rate_limit",
"urn:contact:submit ten/3600",
"is not a whole number",
),
("http.rate_limit_sources", "0", "the least it can be is 1"),
(
"http.rate_limits",
"urn:x 1/1",
"is not an HTTP rate-limit setting",
),
] {
let err = limits_from(&entries(&[(key, value)]))
.err()
.unwrap_or_else(|| panic!("`{key} = {value}` must be refused"));
assert!(err.contains(says), "`{key} = {value}`: {err}");
}
let twice = limits_from(&entries(&[
("http.rate_limit", "urn:contact:submit 1/1"),
("http.rate_limit", "urn:contact:submit 2/2"),
]))
.err()
.unwrap();
assert!(twice.contains("twice"), "{twice}");
}
}