Skip to main content

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}