1use axum::http::HeaderMap;
7#[cfg(not(target_arch = "wasm32"))]
8use axum::http::HeaderName;
9
10use crate::email;
11use crate::ports::{Decision, RateLimiter};
12use std::sync::Arc;
13
14#[must_use]
21pub fn client_ip(headers: &HeaderMap) -> Option<String> {
22 if let Some(ip) = headers
23 .get("cf-connecting-ip")
24 .and_then(|v| v.to_str().ok())
25 {
26 return Some(ip.trim().to_owned());
27 }
28 #[cfg(not(target_arch = "wasm32"))]
29 if let Some(first) = headers
30 .get(HeaderName::from_static("x-forwarded-for"))
31 .and_then(|v| v.to_str().ok())
32 .and_then(|list| list.split(',').next())
33 .map(str::trim)
34 .filter(|first| !first.is_empty())
35 {
36 return Some(first.to_owned());
37 }
38 None
39}
40
41#[must_use]
45pub fn rate_limit_keys(remote_ip: Option<&str>, email: Option<&str>) -> Vec<String> {
46 let mut keys = vec![format!("ip:{}", remote_ip.unwrap_or("unknown"))];
47 if let Some(email) = email {
48 keys.push(format!("email:{}", email::normalize(email)));
49 }
50 keys
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum RateLimitFailure {
58 FailOpen,
63 FailClosed,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum RateLimit {
71 Allowed,
72 Denied {
76 decision: Decision,
77 },
78}
79
80pub async fn check_rate_limit(
85 limiter: Option<&Arc<dyn RateLimiter>>,
86 keys: &[String],
87 on_failure: RateLimitFailure,
88) -> RateLimit {
89 let Some(limiter) = limiter else {
90 return RateLimit::Allowed;
91 };
92 for key in keys {
93 match limiter.limit(key).await {
94 Ok(decision) if decision.ok => {}
95 Ok(decision) => {
96 return RateLimit::Denied { decision };
97 }
98 Err(err) => {
99 let redacted_key = crate::logging::redacted_value("key", key);
109 if on_failure == RateLimitFailure::FailClosed {
110 let detail =
111 format!("rate limiter unavailable; denying: {err} (key: {redacted_key})");
112 tracing::warn!(error = %err, key = %key, "rate limiter unavailable; denying");
113 crate::logging::forward_control_event(
114 crate::logging::ControlLevel::Warn,
115 &detail,
116 );
117 return RateLimit::Denied {
118 decision: Decision {
119 ok: false,
120 retry_after: None,
121 quota: None,
122 },
123 };
124 }
125 let detail =
126 format!("rate limiter unavailable; allowing: {err} (key: {redacted_key})");
127 tracing::warn!(error = %err, key = %key, "rate limiter unavailable; allowing");
128 crate::logging::forward_control_event(crate::logging::ControlLevel::Warn, &detail);
129 }
130 }
131 }
132 RateLimit::Allowed
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use axum::http::header;
139 use std::time::Duration;
140
141 #[test]
142 fn keys_are_ip_then_normalized_email() {
143 let keys = rate_limit_keys(Some("203.0.113.7"), Some(" Nick@Example.COM "));
144 assert_eq!(keys, ["ip:203.0.113.7", "email:nick@example.com"]);
145 }
146
147 #[test]
148 fn missing_ip_falls_back_to_unknown() {
149 assert_eq!(rate_limit_keys(None, None), ["ip:unknown"]);
150 }
151
152 #[test]
153 fn prefers_cf_connecting_ip() {
154 let mut headers = HeaderMap::new();
155 headers.insert(
156 "cf-connecting-ip",
157 header::HeaderValue::from_static("198.51.100.9"),
158 );
159 headers.insert(
160 header::HeaderName::from_static("x-forwarded-for"),
161 header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
162 );
163 assert_eq!(client_ip(&headers).as_deref(), Some("198.51.100.9"));
164 }
165
166 #[cfg(not(target_arch = "wasm32"))]
167 #[test]
168 fn native_honors_forwarded_for_first_hop() {
169 let mut headers = HeaderMap::new();
170 headers.insert(
171 header::HeaderName::from_static("x-forwarded-for"),
172 header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
173 );
174 assert_eq!(client_ip(&headers).as_deref(), Some("1.2.3.4"));
175 }
176
177 struct StubLimiter {
178 ok: bool,
179 transport_error: bool,
180 quota: bool,
181 }
182
183 #[async_trait::async_trait]
184 impl RateLimiter for StubLimiter {
185 async fn limit(&self, _key: &str) -> Result<Decision, crate::ports::RateLimitError> {
186 if self.transport_error {
187 return Err(crate::ports::RateLimitError::Transport("down".to_owned()));
188 }
189 Ok(Decision {
190 ok: self.ok,
191 retry_after: (!self.ok).then(|| Duration::from_secs(7)),
192 quota: self.quota.then(|| crate::ports::Quota {
193 limit: 30,
194 remaining: 0,
195 reset: Duration::from_secs(9),
196 }),
197 })
198 }
199 }
200
201 fn limiter(ok: bool, transport_error: bool) -> Arc<dyn RateLimiter> {
202 Arc::new(StubLimiter {
203 ok,
204 transport_error,
205 quota: false,
206 })
207 }
208
209 fn quota_limiter() -> Arc<dyn RateLimiter> {
210 Arc::new(StubLimiter {
211 ok: false,
212 transport_error: false,
213 quota: true,
214 })
215 }
216
217 fn ip_key() -> Vec<String> {
218 vec!["ip:203.0.113.7".to_owned()]
219 }
220
221 #[pollster::test]
222 async fn all_keys_passing_allows() {
223 let keys = vec!["ip:203.0.113.7".to_owned(), "email:a@b.co".to_owned()];
224 let result = check_rate_limit(
225 Some(&limiter(true, false)),
226 &keys,
227 RateLimitFailure::FailClosed,
228 )
229 .await;
230 assert!(matches!(result, RateLimit::Allowed), "{result:?}");
231 }
232
233 #[pollster::test]
234 async fn a_denied_key_carries_its_retry_after() {
235 let result = check_rate_limit(
236 Some(&limiter(false, false)),
237 &ip_key(),
238 RateLimitFailure::FailClosed,
239 )
240 .await;
241 assert!(
242 matches!(&result, RateLimit::Denied { decision }
243 if decision.retry_after == Some(Duration::from_secs(7)) && decision.quota.is_none()),
244 "{result:?}"
245 );
246 }
247
248 #[pollster::test]
249 async fn a_denied_key_carries_its_quota_whole() {
250 let result = check_rate_limit(
251 Some("a_limiter()),
252 &ip_key(),
253 RateLimitFailure::FailClosed,
254 )
255 .await;
256 assert!(
257 matches!(&result, RateLimit::Denied { decision }
258 if decision.quota.as_ref().is_some_and(|quota| quota.limit == 30
259 && quota.reset == Duration::from_secs(9))),
260 "{result:?}"
261 );
262 }
263
264 #[pollster::test]
265 async fn transport_errors_resolve_at_the_call_site() {
266 let open = check_rate_limit(
267 Some(&limiter(true, true)),
268 &ip_key(),
269 RateLimitFailure::FailOpen,
270 )
271 .await;
272 assert!(matches!(open, RateLimit::Allowed), "{open:?}");
273 let closed = check_rate_limit(
274 Some(&limiter(true, true)),
275 &ip_key(),
276 RateLimitFailure::FailClosed,
277 )
278 .await;
279 assert!(
280 matches!(&closed, RateLimit::Denied { decision }
281 if decision.retry_after.is_none() && decision.quota.is_none()),
282 "{closed:?}"
283 );
284 }
285
286 #[pollster::test]
287 async fn no_limiter_is_a_composition_choice_not_a_failure() {
288 let result = check_rate_limit(None, &ip_key(), RateLimitFailure::FailClosed).await;
289 assert!(matches!(result, RateLimit::Allowed), "{result:?}");
290 }
291}