use std::fmt;
use std::net::IpAddr;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CountryGate {
pub whitelist_countries: Vec<String>,
pub blocked_countries: Vec<String>,
}
impl CountryGate {
#[must_use]
pub const fn has_rules(&self) -> bool {
!self.whitelist_countries.is_empty() || !self.blocked_countries.is_empty()
}
}
#[must_use]
pub fn parse_country_lists<I, J>(whitelist: I, blocked: J) -> CountryGate
where
I: IntoIterator,
I::Item: AsRef<str>,
J: IntoIterator,
J::Item: AsRef<str>,
{
CountryGate {
whitelist_countries: normalize_codes(whitelist),
blocked_countries: normalize_codes(blocked),
}
}
fn normalize_codes<I>(codes: I) -> Vec<String>
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let mut normalized: Vec<String> = Vec::new();
for code in codes {
let code = code.as_ref().to_ascii_uppercase();
if !code.is_empty() && !normalized.iter().any(|known| known == &code) {
normalized.push(code);
}
}
normalized
}
pub trait GeoIpHandler: Send + Sync {
fn get_country(&self, ip: IpAddr) -> Option<String>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CountryBlock {
pub country: Option<String>,
pub reason: String,
}
impl fmt::Display for CountryBlock {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.reason)
}
}
#[must_use]
pub fn generic_list_block_reason(ip: IpAddr) -> String {
generic_list_block_reason_for(&ip.to_string())
}
#[must_use]
pub fn generic_list_block_reason_for(identity: &str) -> String {
format!("IP {identity} not in global allowlist/blocklist")
}
#[must_use]
pub fn check_countries(
ip: IpAddr,
gate: &CountryGate,
handler: &dyn GeoIpHandler,
skip: bool,
) -> Option<CountryBlock> {
if skip || !gate.has_rules() {
return None;
}
if ip.is_loopback() {
return None;
}
let country = handler.get_country(ip).filter(|code| !code.is_empty());
let Some(country) = country else {
if gate.whitelist_countries.is_empty() {
return None;
}
return Some(CountryBlock {
country: None,
reason: generic_list_block_reason(ip),
});
};
let blocked_reason = format!("IP from blocked country: {country}");
if !gate.whitelist_countries.is_empty() {
return if gate.whitelist_countries.iter().any(|code| code == &country) {
None
} else {
Some(CountryBlock {
country: Some(country),
reason: blocked_reason,
})
};
}
if gate.blocked_countries.iter().any(|code| code == &country) {
return Some(CountryBlock {
country: Some(country),
reason: blocked_reason,
});
}
None
}
#[must_use]
pub fn check_route_country(
ip: IpAddr,
gate: &CountryGate,
handler: Option<&dyn GeoIpHandler>,
) -> Option<bool> {
let handler = handler?;
let resolve = || handler.get_country(ip).filter(|code| !code.is_empty());
let mut country = None;
if !gate.blocked_countries.is_empty() {
country = resolve();
if country
.as_ref()
.is_some_and(|code| gate.blocked_countries.contains(code))
{
return Some(false);
}
}
if !gate.whitelist_countries.is_empty() {
let country = country.or_else(resolve);
return Some(country.is_some_and(|code| gate.whitelist_countries.contains(&code)));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
struct Fixed(&'static str);
impl GeoIpHandler for Fixed {
fn get_country(&self, _ip: IpAddr) -> Option<String> {
(!self.0.is_empty()).then(|| self.0.to_owned())
}
}
struct Nowhere;
impl GeoIpHandler for Nowhere {
fn get_country(&self, _ip: IpAddr) -> Option<String> {
None
}
}
fn ip(text: &str) -> IpAddr {
IpAddr::from_str(text).expect("test address")
}
#[test]
fn parse_uppercases_and_dedups_the_codes() {
let gate = parse_country_lists(["us", "US", "de"], ["FR", "fr", ""]);
assert_eq!(
gate.whitelist_countries,
vec!["US".to_owned(), "DE".to_owned()]
);
assert_eq!(gate.blocked_countries, vec!["FR".to_owned()]);
assert!(gate.has_rules());
let gate = parse_country_lists([] as [&str; 0], [] as [&str; 0]);
assert!(!gate.has_rules());
assert_eq!(gate, CountryGate::default());
}
#[test]
fn no_rules_pass_everything() {
let gate = CountryGate::default();
assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("RU"), false).is_none());
assert!(check_countries(ip("127.0.0.1"), &gate, &Fixed("RU"), false).is_none());
}
#[test]
fn skip_flag_passes_even_under_a_restrictive_whitelist() {
let gate = parse_country_lists(["US"], [] as [&str; 0]);
assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("RU"), true).is_none());
}
#[test]
fn loopback_is_exempt_before_geolocation() {
let gate = parse_country_lists(["US"], ["RU"]);
assert!(check_countries(ip("127.0.0.1"), &gate, &Nowhere, false).is_none());
assert!(check_countries(ip("::1"), &gate, &Fixed("RU"), false).is_none());
assert!(check_countries(ip("127.0.0.1"), &gate, &Fixed("RU"), false).is_none());
}
#[test]
fn blocklist_blocks_only_listed_countries() {
let gate = parse_country_lists([] as [&str; 0], ["RU", "CN"]);
let block = check_countries(ip("192.0.2.1"), &gate, &Fixed("RU"), false).expect("blocked");
assert_eq!(block.country.as_deref(), Some("RU"));
assert_eq!(block.reason, "IP from blocked country: RU");
assert_eq!(block.to_string(), "IP from blocked country: RU");
assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("DE"), false).is_none());
assert!(check_countries(ip("192.0.2.1"), &gate, &Nowhere, false).is_none());
assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed(""), false).is_none());
}
#[test]
fn whitelist_is_restrictive_and_blocks_the_unresolved() {
let gate = parse_country_lists(["US"], ["RU"]);
assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("US"), false).is_none());
let block = check_countries(ip("192.0.2.1"), &gate, &Fixed("DE"), false).expect("blocked");
assert_eq!(block.reason, "IP from blocked country: DE");
let block = check_countries(ip("192.0.2.1"), &gate, &Nowhere, false).expect("blocked");
assert_eq!(block.country, None);
assert_eq!(
block.reason,
"IP 192.0.2.1 not in global allowlist/blocklist"
);
}
#[test]
fn membership_is_exact_no_case_folding() {
let gate = parse_country_lists([] as [&str; 0], ["RU"]);
let block = check_countries(ip("192.0.2.1"), &gate, &Fixed("ru"), false);
assert!(
block.is_none(),
"a lowercase handler code must not match the uppercased config"
);
let gate = parse_country_lists(["us"], [] as [&str; 0]);
assert!(check_countries(ip("192.0.2.1"), &gate, &Fixed("us"), false).is_some());
}
#[test]
fn generic_reason_carries_the_ip() {
let gate = parse_country_lists(["US"], [] as [&str; 0]);
let block = check_countries(ip("198.51.100.7"), &gate, &Nowhere, false).expect("blocked");
assert_eq!(
block.reason,
"IP 198.51.100.7 not in global allowlist/blocklist"
);
}
}