guard_core_engine/geo.rs
1//! The country gate: `whitelist_countries` and `blocked_countries`.
2//!
3//! This is the Rust family's port of the reference engine's global country
4//! check - the country branch of `check_ip_access`
5//! (`guard_core/_utils/access_control.py`) with the verdict logic of
6//! `_resolve_country_verdict` and `_evaluate_country_access`, plus the
7//! config coercion of `_validate_country_set_value`. One call,
8//! [`check_countries`], decides a request IP's country access:
9//!
10//! ```text
11//! skip (a global whitelist match set it): pass
12//! no rules configured: pass
13//! loopback address: pass (the loopback exemption)
14//! geolocation unresolved (or empty):
15//! blocked iff whitelist_countries is non-empty
16//! whitelist_countries non-empty:
17//! country in the whitelist: pass
18//! anything else: blocked
19//! blocked_countries non-empty:
20//! country in the blocklist: blocked
21//! anything else: pass
22//! ```
23//!
24//! ## Details that mirror the reference exactly
25//!
26//! - The country lists are sets of ISO 3166-1 alpha-2 codes, uppercased at
27//! config time (`_validate_country_set_value` coerces with `upper()`) and
28//! compared exactly - a handler returning `"us"` does not match `"US"`.
29//! - The loopback exemption runs before geolocation: `127.0.0.1` and `::1`
30//! pass even under a restrictive whitelist.
31//! - An unresolved country is blocked only under a restrictive
32//! whitelist (the reference `return bool(config.whitelist_countries)`),
33//! with the generic list reason instead of a country reason.
34//! - An explicit whitelist match outranks `blocked_countries`: the
35//! whitelist branch decides first, exactly as the reference
36//! `_evaluate_country_access` returns from it.
37//! - The reference initializes its geo handler on first use
38//! (`is_initialized` / `initialize()`); the Rust handler trait is sync
39//! and assumed ready.
40//!
41//! The geolocation itself comes from the [`GeoIpHandler`] seam: the
42//! reference resolves countries through an `IPInfo` `MMDB` database (or a
43//! custom handler); the MMDB reading is adapter work, the gate takes the
44//! resolved country code.
45//!
46//! # Example
47//!
48//! ```
49//! use std::net::IpAddr;
50//! use std::str::FromStr;
51//!
52//! use guard_core_engine::geo::{
53//! check_countries, parse_country_lists, CountryGate, GeoIpHandler,
54//! };
55//!
56//! struct FixedUS;
57//! impl GeoIpHandler for FixedUS {
58//! fn get_country(&self, _ip: IpAddr) -> Option<String> {
59//! Some("US".to_owned())
60//! }
61//! }
62//!
63//! // A restrictive whitelist: only US traffic passes.
64//! let gate: CountryGate = parse_country_lists(["us"], [] as [&str; 0]);
65//! let handler = FixedUS;
66//!
67//! let passed = check_countries(
68//! IpAddr::from_str("192.0.2.1").unwrap(),
69//! &gate,
70//! &handler,
71//! false,
72//! );
73//! assert!(passed.is_none(), "US is whitelisted");
74//!
75//! // Under the same rules a non-US address is blocked with the reference
76//! // reason, and a whitelist match carries the skip flag past this check.
77//! struct FixedDE;
78//! impl GeoIpHandler for FixedDE {
79//! fn get_country(&self, _ip: IpAddr) -> Option<String> {
80//! Some("DE".to_owned())
81//! }
82//! }
83//! let blocked = check_countries(
84//! IpAddr::from_str("192.0.2.2").unwrap(),
85//! &gate,
86//! &FixedDE,
87//! false,
88//! )
89//! .expect("blocked");
90//! assert_eq!(blocked.reason, "IP from blocked country: DE");
91//!
92//! let skipped = check_countries(
93//! IpAddr::from_str("192.0.2.2").unwrap(),
94//! &gate,
95//! &FixedDE,
96//! true,
97//! );
98//! assert!(skipped.is_none(), "a whitelist match skips the country check");
99//! ```
100
101use std::fmt;
102use std::net::IpAddr;
103
104/// The country gate: the parsed `whitelist_countries` and
105/// `blocked_countries` sets.
106///
107/// Build it with [`parse_country_lists`] (which uppercases and dedups the
108/// codes, the reference `_validate_country_set_value` coercion). The
109/// default has no rules: [`check_countries`] passes everything.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct CountryGate {
112 /// `whitelist_countries`: restrictive when non-empty (an unresolved or
113 /// unlisted country is blocked).
114 pub whitelist_countries: Vec<String>,
115 /// `blocked_countries`: always blocked, consulted when the whitelist is
116 /// empty.
117 pub blocked_countries: Vec<String>,
118}
119
120impl CountryGate {
121 /// Whether any rule is configured (the reference `_has_country_rules`).
122 #[must_use]
123 pub const fn has_rules(&self) -> bool {
124 !self.whitelist_countries.is_empty() || !self.blocked_countries.is_empty()
125 }
126}
127
128/// Coerce the two country-code lists into the gate: every code is
129/// uppercased and duplicates dropped (`_validate_country_set_value`'s
130/// `frozenset(str(item).upper())`).
131#[must_use]
132pub fn parse_country_lists<I, J>(whitelist: I, blocked: J) -> CountryGate
133where
134 I: IntoIterator,
135 I::Item: AsRef<str>,
136 J: IntoIterator,
137 J::Item: AsRef<str>,
138{
139 CountryGate {
140 whitelist_countries: normalize_codes(whitelist),
141 blocked_countries: normalize_codes(blocked),
142 }
143}
144
145/// Uppercase and dedup one code list, preserving first-seen order.
146fn normalize_codes<I>(codes: I) -> Vec<String>
147where
148 I: IntoIterator,
149 I::Item: AsRef<str>,
150{
151 let mut normalized: Vec<String> = Vec::new();
152 for code in codes {
153 let code = code.as_ref().to_ascii_uppercase();
154 if !code.is_empty() && !normalized.iter().any(|known| known == &code) {
155 normalized.push(code);
156 }
157 }
158 normalized
159}
160
161/// The geolocation seam: the ISO 3166-1 alpha-2 country code of an
162/// address, `None` when the address is not geolocated.
163///
164/// The reference protocol is `GeoIPHandler.get_country(ip)`; this port is
165/// sync and assumed ready (the reference initializes its handler on first
166/// use). An empty string reads as unresolved, like Python's falsy check.
167pub trait GeoIpHandler: Send + Sync {
168 /// The country code of `ip`, or `None` when not geolocated.
169 fn get_country(&self, ip: IpAddr) -> Option<String>;
170}
171
172/// The block answer of a failed country check: the reference
173/// `IpAccessResult` reason and the resolved country when one exists.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct CountryBlock {
176 /// The resolved country code, absent for an unresolved address (the
177 /// generic reason carries the IP instead).
178 pub country: Option<String>,
179 /// The reference `IpAccessResult.reason`
180 /// (`"IP from blocked country: {code}"`, or the generic list reason
181 /// for an unresolved address).
182 pub reason: String,
183}
184
185impl fmt::Display for CountryBlock {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 write!(f, "{}", self.reason)
188 }
189}
190
191/// The generic block reason (`_GENERIC_LIST_BLOCK_REASON` with the IP
192/// filled in) used when an address under a restrictive whitelist is not
193/// geolocated.
194#[must_use]
195pub fn generic_list_block_reason(ip: IpAddr) -> String {
196 generic_list_block_reason_for(&ip.to_string())
197}
198
199/// The generic block reason for an arbitrary identity string: the
200/// reference formats the same template with `UNKNOWN_CLIENT_IDENTITY`
201/// (`"unknown"`) when a request carries no client IP.
202#[must_use]
203pub fn generic_list_block_reason_for(identity: &str) -> String {
204 format!("IP {identity} not in global allowlist/blocklist")
205}
206
207/// The country branch of the reference `check_ip_access`.
208///
209/// `skip` is the whitelist-match skip flag (`skip_countries` in the
210/// reference: a global whitelist match outranks the country rules).
211/// `None` passes; `Some(block)` is the reference denial (the
212/// `_check_global_ip_restrictions` shape answers `403 "Forbidden"` with
213/// [`CountryBlock::reason`] as the log reason).
214#[must_use]
215pub fn check_countries(
216 ip: IpAddr,
217 gate: &CountryGate,
218 handler: &dyn GeoIpHandler,
219 skip: bool,
220) -> Option<CountryBlock> {
221 if skip || !gate.has_rules() {
222 return None;
223 }
224 // The loopback exemption runs before geolocation.
225 if ip.is_loopback() {
226 return None;
227 }
228 // An empty string reads as unresolved, like Python's falsy country.
229 // The handler's code is compared exactly (no case folding), mirroring
230 // Python's `country in config.blocked_countries` membership.
231 let country = handler.get_country(ip).filter(|code| !code.is_empty());
232 let Some(country) = country else {
233 // `return bool(config.whitelist_countries), None` in the reference:
234 // an unresolved address blocks only under a restrictive whitelist.
235 if gate.whitelist_countries.is_empty() {
236 return None;
237 }
238 return Some(CountryBlock {
239 country: None,
240 reason: generic_list_block_reason(ip),
241 });
242 };
243 let blocked_reason = format!("IP from blocked country: {country}");
244 if !gate.whitelist_countries.is_empty() {
245 return if gate.whitelist_countries.iter().any(|code| code == &country) {
246 None
247 } else {
248 Some(CountryBlock {
249 country: Some(country),
250 reason: blocked_reason,
251 })
252 };
253 }
254 if gate.blocked_countries.iter().any(|code| code == &country) {
255 return Some(CountryBlock {
256 country: Some(country),
257 reason: blocked_reason,
258 });
259 }
260 None
261}
262
263/// The route-level country verdict: the reference `check_country_access`
264/// (`guard_core/core/checks/helpers.py`), the country arm of
265/// `check_route_ip_access`.
266///
267/// ```text
268/// no handler: None (no verdict)
269/// blocked_countries non-empty, country listed: Some(false)
270/// whitelist_countries non-empty:
271/// country resolved and listed: Some(true)
272/// anything else (unresolved, unlisted): Some(false)
273/// otherwise: None
274/// ```
275///
276/// Unlike [`check_countries`] this has no loopback exemption and no
277/// "whitelist outranks blocklist" shortcut: `blocked_countries` is asked
278/// first, so a country in both lists is `Some(false)`, and the verdict is
279/// tri-state because a route whitelist match (`Some(true)`) also skips the
280/// global country stage. An empty country string reads as unresolved.
281#[must_use]
282pub fn check_route_country(
283 ip: IpAddr,
284 gate: &CountryGate,
285 handler: Option<&dyn GeoIpHandler>,
286) -> Option<bool> {
287 let handler = handler?;
288 let resolve = || handler.get_country(ip).filter(|code| !code.is_empty());
289 let mut country = None;
290 if !gate.blocked_countries.is_empty() {
291 country = resolve();
292 if country
293 .as_ref()
294 .is_some_and(|code| gate.blocked_countries.contains(code))
295 {
296 return Some(false);
297 }
298 }
299 if !gate.whitelist_countries.is_empty() {
300 let country = country.or_else(resolve);
301 return Some(country.is_some_and(|code| gate.whitelist_countries.contains(&code)));
302 }
303 None
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use std::str::FromStr;
310
311 /// A handler answering a fixed country for every address.
312 struct Fixed(&'static str);
313
314 impl GeoIpHandler for Fixed {
315 fn get_country(&self, _ip: IpAddr) -> Option<String> {
316 (!self.0.is_empty()).then(|| self.0.to_owned())
317 }
318 }
319
320 /// A handler resolving nothing.
321 struct Nowhere;
322
323 impl GeoIpHandler for Nowhere {
324 fn get_country(&self, _ip: IpAddr) -> Option<String> {
325 None
326 }
327 }
328
329 fn ip(text: &str) -> IpAddr {
330 IpAddr::from_str(text).expect("test address")
331 }
332
333 #[test]
334 fn parse_uppercases_and_dedups_the_codes() {
335 let gate = parse_country_lists(["us", "US", "de"], ["FR", "fr", ""]);
336 assert_eq!(
337 gate.whitelist_countries,
338 vec!["US".to_owned(), "DE".to_owned()]
339 );
340 assert_eq!(gate.blocked_countries, vec!["FR".to_owned()]);
341 assert!(gate.has_rules());
342 // Empty lists are the no-rules default.
343 let gate = parse_country_lists([] as [&str; 0], [] as [&str; 0]);
344 assert!(!gate.has_rules());
345 assert_eq!(gate, CountryGate::default());
346 }
347
348 #[test]
349 fn no_rules_pass_everything() {
350 let gate = CountryGate::default();
351 assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("RU"), false).is_none());
352 assert!(check_countries(ip("127.0.0.1"), &gate, &Fixed("RU"), false).is_none());
353 }
354
355 #[test]
356 fn skip_flag_passes_even_under_a_restrictive_whitelist() {
357 let gate = parse_country_lists(["US"], [] as [&str; 0]);
358 assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("RU"), true).is_none());
359 // The skip flag mirrors `skip_countries`, set by a global whitelist
360 // match; exemption (the second flag) does not reach this check.
361 }
362
363 #[test]
364 fn loopback_is_exempt_before_geolocation() {
365 let gate = parse_country_lists(["US"], ["RU"]);
366 // Even a handler that resolves nothing cannot block loopback.
367 assert!(check_countries(ip("127.0.0.1"), &gate, &Nowhere, false).is_none());
368 assert!(check_countries(ip("::1"), &gate, &Fixed("RU"), false).is_none());
369 assert!(check_countries(ip("127.0.0.1"), &gate, &Fixed("RU"), false).is_none());
370 }
371
372 #[test]
373 fn blocklist_blocks_only_listed_countries() {
374 let gate = parse_country_lists([] as [&str; 0], ["RU", "CN"]);
375 let block = check_countries(ip("192.0.2.1"), &gate, &Fixed("RU"), false).expect("blocked");
376 assert_eq!(block.country.as_deref(), Some("RU"));
377 assert_eq!(block.reason, "IP from blocked country: RU");
378 assert_eq!(block.to_string(), "IP from blocked country: RU");
379 assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("DE"), false).is_none());
380 // An unresolved address under a blocklist-only gate passes.
381 assert!(check_countries(ip("192.0.2.1"), &gate, &Nowhere, false).is_none());
382 assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed(""), false).is_none());
383 }
384
385 #[test]
386 fn whitelist_is_restrictive_and_blocks_the_unresolved() {
387 let gate = parse_country_lists(["US"], ["RU"]);
388 assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("US"), false).is_none());
389 // A non-member is blocked even though RU is also in blocked_countries:
390 // the whitelist branch decides first.
391 let block = check_countries(ip("192.0.2.1"), &gate, &Fixed("DE"), false).expect("blocked");
392 assert_eq!(block.reason, "IP from blocked country: DE");
393 // Unresolved blocks under the whitelist, with the generic reason.
394 let block = check_countries(ip("192.0.2.1"), &gate, &Nowhere, false).expect("blocked");
395 assert_eq!(block.country, None);
396 assert_eq!(
397 block.reason,
398 "IP 192.0.2.1 not in global allowlist/blocklist"
399 );
400 }
401
402 #[test]
403 fn membership_is_exact_no_case_folding() {
404 // The config side is uppercased at parse time; the handler side is
405 // compared exactly, like Python's set membership.
406 let gate = parse_country_lists([] as [&str; 0], ["RU"]);
407 let block = check_countries(ip("192.0.2.1"), &gate, &Fixed("ru"), false);
408 assert!(
409 block.is_none(),
410 "a lowercase handler code must not match the uppercased config"
411 );
412 let gate = parse_country_lists(["us"], [] as [&str; 0]);
413 // Under a restrictive whitelist the same mismatch fails closed.
414 assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("us"), false).is_some());
415 }
416
417 #[test]
418 fn generic_reason_carries_the_ip() {
419 let gate = parse_country_lists(["US"], [] as [&str; 0]);
420 let block = check_countries(ip("198.51.100.7"), &gate, &Nowhere, false).expect("blocked");
421 assert_eq!(
422 block.reason,
423 "IP 198.51.100.7 not in global allowlist/blocklist"
424 );
425 }
426}