Skip to main content

zentinel_proxy/
geo_filter.rs

1//! GeoIP filtering for Zentinel proxy
2//!
3//! This module provides geolocation-based request filtering using MaxMind GeoLite2/GeoIP2
4//! and IP2Location databases. Filters can block, allow, or log requests based on country.
5//!
6//! # Features
7//! - Support for MaxMind (.mmdb) and IP2Location (.bin) databases
8//! - Block mode (blocklist) and Allow mode (allowlist)
9//! - Log-only mode for monitoring without blocking
10//! - Per-filter IP→Country caching with configurable TTL
11//! - Configurable fail-open/fail-closed on lookup errors
12//! - X-GeoIP-Country response header injection
13
14use std::collections::{HashMap, HashSet};
15use std::net::IpAddr;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20use dashmap::DashMap;
21use notify::{Event, EventKind, RecursiveMode, Watcher};
22use parking_lot::RwLock;
23use tokio::sync::mpsc;
24use tracing::{debug, error, info, trace, warn};
25
26use zentinel_config::{GeoDatabaseType, GeoFailureMode, GeoFilter, GeoFilterAction};
27
28// =============================================================================
29// Error Types
30// =============================================================================
31
32/// Errors that can occur during geo lookup
33#[derive(Debug, Clone)]
34pub enum GeoLookupError {
35    /// IP address could not be parsed
36    InvalidIp(String),
37    /// Database error during lookup
38    DatabaseError(String),
39    /// Database file could not be loaded
40    LoadError(String),
41}
42
43impl std::fmt::Display for GeoLookupError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            GeoLookupError::InvalidIp(ip) => write!(f, "invalid IP address: {}", ip),
47            GeoLookupError::DatabaseError(msg) => write!(f, "database error: {}", msg),
48            GeoLookupError::LoadError(msg) => write!(f, "failed to load database: {}", msg),
49        }
50    }
51}
52
53impl std::error::Error for GeoLookupError {}
54
55// =============================================================================
56// GeoDatabase Trait
57// =============================================================================
58
59/// Trait for GeoIP database backends
60pub trait GeoDatabase: Send + Sync {
61    /// Look up the country code for an IP address
62    fn lookup(&self, ip: IpAddr) -> Result<Option<String>, GeoLookupError>;
63
64    /// Get the database type
65    fn database_type(&self) -> GeoDatabaseType;
66}
67
68// =============================================================================
69// MaxMind Database Backend
70// =============================================================================
71
72/// MaxMind GeoLite2/GeoIP2 database backend
73pub struct MaxMindDatabase {
74    reader: maxminddb::Reader<Vec<u8>>,
75}
76
77impl MaxMindDatabase {
78    /// Open a MaxMind database file
79    pub fn open(path: impl AsRef<Path>) -> Result<Self, GeoLookupError> {
80        let path = path.as_ref();
81        let reader = maxminddb::Reader::open_readfile(path).map_err(|e| {
82            GeoLookupError::LoadError(format!("failed to open MaxMind database {:?}: {}", path, e))
83        })?;
84
85        debug!(path = ?path, "Opened MaxMind GeoIP database");
86        Ok(Self { reader })
87    }
88}
89
90impl GeoDatabase for MaxMindDatabase {
91    fn lookup(&self, ip: IpAddr) -> Result<Option<String>, GeoLookupError> {
92        match self.reader.lookup(ip) {
93            Ok(result) => {
94                if !result.has_data() {
95                    trace!(ip = %ip, "IP not found in MaxMind database");
96                    return Ok(None);
97                }
98                match result.decode::<maxminddb::geoip2::Country>() {
99                    Ok(Some(record)) => {
100                        let country_code = record.country.iso_code.map(|s| s.to_string());
101                        trace!(ip = %ip, country = ?country_code, "MaxMind lookup");
102                        Ok(country_code)
103                    }
104                    Ok(None) => {
105                        trace!(ip = %ip, "No country data for IP in MaxMind database");
106                        Ok(None)
107                    }
108                    Err(e) => {
109                        warn!(ip = %ip, error = %e, "MaxMind decode error");
110                        Err(GeoLookupError::DatabaseError(e.to_string()))
111                    }
112                }
113            }
114            Err(e) => {
115                warn!(ip = %ip, error = %e, "MaxMind lookup error");
116                Err(GeoLookupError::DatabaseError(e.to_string()))
117            }
118        }
119    }
120
121    fn database_type(&self) -> GeoDatabaseType {
122        GeoDatabaseType::MaxMind
123    }
124}
125
126// =============================================================================
127// IP2Location Database Backend
128// =============================================================================
129
130/// IP2Location database backend
131pub struct Ip2LocationDatabase {
132    db: ip2location::DB,
133}
134
135impl Ip2LocationDatabase {
136    /// Open an IP2Location database file
137    pub fn open(path: impl AsRef<Path>) -> Result<Self, GeoLookupError> {
138        let path = path.as_ref();
139        let db = ip2location::DB::from_file(path).map_err(|e| {
140            GeoLookupError::LoadError(format!(
141                "failed to open IP2Location database {:?}: {}",
142                path, e
143            ))
144        })?;
145
146        debug!(path = ?path, "Opened IP2Location GeoIP database");
147        Ok(Self { db })
148    }
149}
150
151impl GeoDatabase for Ip2LocationDatabase {
152    fn lookup(&self, ip: IpAddr) -> Result<Option<String>, GeoLookupError> {
153        match self.db.ip_lookup(ip) {
154            Ok(record) => {
155                // Record is an enum - extract country from the LocationDb variant
156                let country_code = match record {
157                    ip2location::Record::LocationDb(loc) => {
158                        loc.country.map(|c| c.short_name.to_string())
159                    }
160                    ip2location::Record::ProxyDb(proxy) => {
161                        proxy.country.map(|c| c.short_name.to_string())
162                    }
163                };
164                trace!(ip = %ip, country = ?country_code, "IP2Location lookup");
165                Ok(country_code)
166            }
167            Err(ip2location::error::Error::RecordNotFound) => {
168                trace!(ip = %ip, "IP not found in IP2Location database");
169                Ok(None)
170            }
171            Err(e) => {
172                warn!(ip = %ip, error = %e, "IP2Location lookup error");
173                Err(GeoLookupError::DatabaseError(e.to_string()))
174            }
175        }
176    }
177
178    fn database_type(&self) -> GeoDatabaseType {
179        GeoDatabaseType::Ip2Location
180    }
181}
182
183// =============================================================================
184// Cached Country Entry
185// =============================================================================
186
187/// Cached country lookup result
188struct CachedCountry {
189    /// The country code (or None if not found)
190    country_code: Option<String>,
191    /// When this entry was cached
192    cached_at: Instant,
193}
194
195// =============================================================================
196// GeoFilterResult
197// =============================================================================
198
199/// Result of a geo filter check
200#[derive(Debug, Clone)]
201pub struct GeoFilterResult {
202    /// Whether the request is allowed
203    pub allowed: bool,
204    /// The country code (if found)
205    pub country_code: Option<String>,
206    /// Whether this was a cache hit
207    pub cache_hit: bool,
208    /// Whether to add the country header
209    pub add_header: bool,
210    /// HTTP status code to return if blocked
211    pub status_code: u16,
212    /// Block message to return if blocked
213    pub block_message: Option<String>,
214}
215
216// =============================================================================
217// GeoFilterPool
218// =============================================================================
219
220/// A single geo filter instance with its database and cache
221pub struct GeoFilterPool {
222    /// The underlying GeoIP database (wrapped in RwLock for hot reload)
223    database: RwLock<Arc<dyn GeoDatabase>>,
224    /// IP → Country cache
225    cache: DashMap<IpAddr, CachedCountry>,
226    /// Filter configuration
227    config: GeoFilter,
228    /// Pre-computed set of countries for fast lookup
229    countries_set: HashSet<String>,
230    /// Cache TTL duration
231    cache_ttl: Duration,
232    /// Database file path for reload
233    database_path: PathBuf,
234    /// Database type
235    database_type: GeoDatabaseType,
236}
237
238impl GeoFilterPool {
239    /// Create a new geo filter pool from configuration
240    pub fn new(config: GeoFilter) -> Result<Self, GeoLookupError> {
241        // Determine database type (auto-detect from extension if not specified)
242        let db_type = config.database_type.clone().unwrap_or_else(|| {
243            if config.database_path.ends_with(".mmdb") {
244                GeoDatabaseType::MaxMind
245            } else {
246                GeoDatabaseType::Ip2Location
247            }
248        });
249
250        let database_path = PathBuf::from(&config.database_path);
251
252        // Open the database
253        let database: Arc<dyn GeoDatabase> = match db_type {
254            GeoDatabaseType::MaxMind => Arc::new(MaxMindDatabase::open(&config.database_path)?),
255            GeoDatabaseType::Ip2Location => {
256                Arc::new(Ip2LocationDatabase::open(&config.database_path)?)
257            }
258        };
259
260        // Build countries set for fast lookup
261        let countries_set: HashSet<String> = config.countries.iter().cloned().collect();
262
263        let cache_ttl = Duration::from_secs(config.cache_ttl_secs);
264
265        debug!(
266            database_path = %config.database_path,
267            database_type = ?db_type,
268            action = ?config.action,
269            countries_count = countries_set.len(),
270            cache_ttl_secs = config.cache_ttl_secs,
271            "Created GeoFilterPool"
272        );
273
274        Ok(Self {
275            database: RwLock::new(database),
276            cache: DashMap::new(),
277            config,
278            countries_set,
279            cache_ttl,
280            database_path,
281            database_type: db_type,
282        })
283    }
284
285    /// Reload the database from disk
286    ///
287    /// This atomically swaps the database and clears the cache.
288    pub fn reload_database(&self) -> Result<(), GeoLookupError> {
289        info!(
290            database_path = %self.database_path.display(),
291            database_type = ?self.database_type,
292            "Reloading geo database"
293        );
294
295        // Open the new database
296        let new_database: Arc<dyn GeoDatabase> = match self.database_type {
297            GeoDatabaseType::MaxMind => Arc::new(MaxMindDatabase::open(&self.database_path)?),
298            GeoDatabaseType::Ip2Location => {
299                Arc::new(Ip2LocationDatabase::open(&self.database_path)?)
300            }
301        };
302
303        // Atomically swap the database
304        {
305            let mut db = self.database.write();
306            *db = new_database;
307        }
308
309        // Clear the cache since country mappings may have changed
310        self.cache.clear();
311
312        info!(
313            database_path = %self.database_path.display(),
314            "Geo database reloaded successfully"
315        );
316
317        Ok(())
318    }
319
320    /// Get the database file path
321    pub fn database_path(&self) -> &Path {
322        &self.database_path
323    }
324
325    /// Check if a client IP should be allowed or blocked
326    pub fn check(&self, client_ip: &str) -> GeoFilterResult {
327        // Parse the IP address
328        let ip: IpAddr = match client_ip.parse() {
329            Ok(ip) => ip,
330            Err(_) => {
331                warn!(client_ip = %client_ip, "Failed to parse client IP for geo filter");
332                return self.handle_failure();
333            }
334        };
335
336        // Check cache first
337        let now = Instant::now();
338        if let Some(entry) = self.cache.get(&ip) {
339            if now.duration_since(entry.cached_at) < self.cache_ttl {
340                trace!(ip = %ip, country = ?entry.country_code, "Geo cache hit");
341                return self.evaluate(entry.country_code.clone(), true);
342            }
343            // Entry expired, will be replaced
344        }
345
346        // Lookup in database
347        let database = self.database.read();
348        match database.lookup(ip) {
349            Ok(country_code) => {
350                // Cache the result
351                self.cache.insert(
352                    ip,
353                    CachedCountry {
354                        country_code: country_code.clone(),
355                        cached_at: now,
356                    },
357                );
358                self.evaluate(country_code, false)
359            }
360            Err(e) => {
361                warn!(ip = %ip, error = %e, "Geo lookup failed");
362                self.handle_failure()
363            }
364        }
365    }
366
367    /// Evaluate the filter action based on country code
368    fn evaluate(&self, country_code: Option<String>, cache_hit: bool) -> GeoFilterResult {
369        let in_list = country_code
370            .as_ref()
371            .map(|c| self.countries_set.contains(c))
372            .unwrap_or(false);
373
374        let allowed = match self.config.action {
375            GeoFilterAction::Block => {
376                // Block mode: block if country is in the list
377                !in_list
378            }
379            GeoFilterAction::Allow => {
380                // Allow mode: allow only if country is in the list
381                // If no country found and list is not empty, block
382                if self.countries_set.is_empty() {
383                    true
384                } else {
385                    in_list
386                }
387            }
388            GeoFilterAction::LogOnly => {
389                // Log-only mode: always allow
390                true
391            }
392        };
393
394        trace!(
395            country = ?country_code,
396            in_list = in_list,
397            action = ?self.config.action,
398            allowed = allowed,
399            "Geo filter evaluation"
400        );
401
402        GeoFilterResult {
403            allowed,
404            country_code,
405            cache_hit,
406            add_header: self.config.add_country_header,
407            status_code: self.config.status_code,
408            block_message: self.config.block_message.clone(),
409        }
410    }
411
412    /// Handle lookup failure based on failure mode
413    fn handle_failure(&self) -> GeoFilterResult {
414        let allowed = match self.config.on_failure {
415            GeoFailureMode::Open => true,
416            GeoFailureMode::Closed => false,
417        };
418
419        GeoFilterResult {
420            allowed,
421            country_code: None,
422            cache_hit: false,
423            add_header: false,
424            status_code: self.config.status_code,
425            block_message: self.config.block_message.clone(),
426        }
427    }
428
429    /// Get cache statistics
430    pub fn cache_stats(&self) -> (usize, usize) {
431        let now = Instant::now();
432        let total = self.cache.len();
433        let valid = self
434            .cache
435            .iter()
436            .filter(|e| now.duration_since(e.cached_at) < self.cache_ttl)
437            .count();
438        (total, valid)
439    }
440
441    /// Clear expired cache entries
442    pub fn clear_expired(&self) {
443        let now = Instant::now();
444        self.cache
445            .retain(|_, v| now.duration_since(v.cached_at) < self.cache_ttl);
446    }
447
448    /// Create a GeoFilterPool with a pre-built database (for testing).
449    #[cfg(test)]
450    pub(crate) fn new_with_database(config: GeoFilter, database: Arc<dyn GeoDatabase>) -> Self {
451        let countries_set: HashSet<String> = config.countries.iter().cloned().collect();
452        let cache_ttl = Duration::from_secs(config.cache_ttl_secs);
453        let database_path = PathBuf::from(&config.database_path);
454        let database_type = config
455            .database_type
456            .clone()
457            .unwrap_or(GeoDatabaseType::MaxMind);
458
459        Self {
460            database: RwLock::new(database),
461            cache: DashMap::new(),
462            config,
463            countries_set,
464            cache_ttl,
465            database_path,
466            database_type,
467        }
468    }
469}
470
471// =============================================================================
472// GeoFilterManager
473// =============================================================================
474
475/// Manages all geo filter instances
476pub struct GeoFilterManager {
477    /// Filter ID → GeoFilterPool mapping
478    filter_pools: DashMap<String, Arc<GeoFilterPool>>,
479}
480
481impl GeoFilterManager {
482    /// Create a new empty geo filter manager
483    pub fn new() -> Self {
484        Self {
485            filter_pools: DashMap::new(),
486        }
487    }
488
489    /// Register a geo filter from configuration
490    pub fn register_filter(
491        &self,
492        filter_id: &str,
493        config: GeoFilter,
494    ) -> Result<(), GeoLookupError> {
495        let pool = GeoFilterPool::new(config)?;
496        self.filter_pools
497            .insert(filter_id.to_string(), Arc::new(pool));
498        debug!(filter_id = %filter_id, "Registered geo filter");
499        Ok(())
500    }
501
502    /// Check a client IP against a specific filter
503    pub fn check(&self, filter_id: &str, client_ip: &str) -> Option<GeoFilterResult> {
504        self.filter_pools
505            .get(filter_id)
506            .map(|pool| pool.check(client_ip))
507    }
508
509    /// Get a reference to a filter pool
510    pub fn get_pool(&self, filter_id: &str) -> Option<Arc<GeoFilterPool>> {
511        self.filter_pools.get(filter_id).map(|r| r.clone())
512    }
513
514    /// Check if a filter exists
515    pub fn has_filter(&self, filter_id: &str) -> bool {
516        self.filter_pools.contains_key(filter_id)
517    }
518
519    /// Get all filter IDs
520    pub fn filter_ids(&self) -> Vec<String> {
521        self.filter_pools.iter().map(|r| r.key().clone()).collect()
522    }
523
524    /// Clear expired cache entries in all pools
525    pub fn clear_expired_caches(&self) {
526        for pool in self.filter_pools.iter() {
527            pool.clear_expired();
528        }
529    }
530
531    /// Reload a filter's database from disk
532    pub fn reload_filter(&self, filter_id: &str) -> Result<(), GeoLookupError> {
533        if let Some(pool) = self.filter_pools.get(filter_id) {
534            pool.reload_database()
535        } else {
536            Err(GeoLookupError::LoadError(format!(
537                "Filter '{}' not found",
538                filter_id
539            )))
540        }
541    }
542
543    /// Reload database for all filters using the given path
544    pub fn reload_by_path(&self, path: &Path) -> Vec<(String, Result<(), GeoLookupError>)> {
545        let mut results = Vec::new();
546        for entry in self.filter_pools.iter() {
547            if entry.value().database_path() == path {
548                let filter_id = entry.key().clone();
549                let result = entry.value().reload_database();
550                results.push((filter_id, result));
551            }
552        }
553        results
554    }
555
556    /// Get all unique database paths being used
557    pub fn database_paths(&self) -> Vec<(String, PathBuf)> {
558        self.filter_pools
559            .iter()
560            .map(|e| (e.key().clone(), e.value().database_path().to_path_buf()))
561            .collect()
562    }
563}
564
565impl Default for GeoFilterManager {
566    fn default() -> Self {
567        Self::new()
568    }
569}
570
571// =============================================================================
572// GeoDatabaseWatcher
573// =============================================================================
574
575/// Watches geo database files for changes and triggers reloads
576pub struct GeoDatabaseWatcher {
577    /// The watcher instance
578    watcher: RwLock<Option<notify::RecommendedWatcher>>,
579    /// Mapping from database path to filter IDs using it
580    path_to_filters: RwLock<HashMap<PathBuf, Vec<String>>>,
581    /// Reference to the geo filter manager
582    manager: Arc<GeoFilterManager>,
583}
584
585impl GeoDatabaseWatcher {
586    /// Create a new database watcher
587    pub fn new(manager: Arc<GeoFilterManager>) -> Self {
588        Self {
589            watcher: RwLock::new(None),
590            path_to_filters: RwLock::new(HashMap::new()),
591            manager,
592        }
593    }
594
595    /// Start watching all registered database files
596    pub fn start_watching(&self) -> Result<mpsc::Receiver<PathBuf>, GeoLookupError> {
597        // Build path → filter ID mapping
598        let db_paths = self.manager.database_paths();
599        let mut path_map: HashMap<PathBuf, Vec<String>> = HashMap::new();
600        for (filter_id, path) in db_paths {
601            path_map.entry(path).or_default().push(filter_id);
602        }
603
604        if path_map.is_empty() {
605            debug!("No geo databases to watch");
606            let (_tx, rx) = mpsc::channel(1);
607            return Ok(rx);
608        }
609
610        // Store the mapping
611        *self.path_to_filters.write() = path_map.clone();
612
613        // Create channel for events
614        let (tx, rx) = mpsc::channel::<PathBuf>(10);
615
616        // Create file watcher
617        let paths: Vec<PathBuf> = path_map.keys().cloned().collect();
618        let watcher = notify::recommended_watcher(move |event: Result<Event, notify::Error>| {
619            if let Ok(event) = event {
620                if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
621                    for path in &event.paths {
622                        let _ = tx.blocking_send(path.clone());
623                    }
624                }
625            }
626        })
627        .map_err(|e| GeoLookupError::LoadError(format!("Failed to create file watcher: {}", e)))?;
628
629        // Store watcher
630        *self.watcher.write() = Some(watcher);
631
632        // Add watches for each database path
633        if let Some(ref mut watcher) = *self.watcher.write() {
634            for path in &paths {
635                if let Err(e) = watcher.watch(path, RecursiveMode::NonRecursive) {
636                    warn!(
637                        path = %path.display(),
638                        error = %e,
639                        "Failed to watch geo database file"
640                    );
641                } else {
642                    info!(
643                        path = %path.display(),
644                        "Watching geo database for changes"
645                    );
646                }
647            }
648        }
649
650        Ok(rx)
651    }
652
653    /// Handle a file change event
654    pub fn handle_change(&self, path: &Path) {
655        let path_map = self.path_to_filters.read();
656        if let Some(filter_ids) = path_map.get(path) {
657            info!(
658                path = %path.display(),
659                filters = ?filter_ids,
660                "Geo database file changed, reloading"
661            );
662
663            for filter_id in filter_ids {
664                match self.manager.reload_filter(filter_id) {
665                    Ok(()) => {
666                        info!(
667                            filter_id = %filter_id,
668                            "Geo filter database reloaded successfully"
669                        );
670                    }
671                    Err(e) => {
672                        error!(
673                            filter_id = %filter_id,
674                            error = %e,
675                            "Failed to reload geo filter database"
676                        );
677                    }
678                }
679            }
680        }
681    }
682
683    /// Stop watching
684    pub fn stop(&self) {
685        *self.watcher.write() = None;
686        info!("Stopped watching geo database files");
687    }
688}
689
690// =============================================================================
691// Tests
692// =============================================================================
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use zentinel_config::{GeoDatabaseType, GeoFailureMode, GeoFilter, GeoFilterAction};
698
699    // =========================================================================
700    // Mock GeoDatabase
701    // =========================================================================
702
703    /// Mock database that returns pre-configured country codes for IPs.
704    struct MockGeoDatabase {
705        mapping: HashMap<IpAddr, String>,
706        fail_on: HashSet<IpAddr>,
707    }
708
709    impl MockGeoDatabase {
710        fn new() -> Self {
711            Self {
712                mapping: HashMap::new(),
713                fail_on: HashSet::new(),
714            }
715        }
716
717        fn with_entries(entries: Vec<(&str, &str)>) -> Self {
718            let mut db = Self::new();
719            for (ip, country) in entries {
720                db.mapping.insert(ip.parse().unwrap(), country.to_string());
721            }
722            db
723        }
724
725        fn with_failure(mut self, ip: &str) -> Self {
726            self.fail_on.insert(ip.parse().unwrap());
727            self
728        }
729    }
730
731    impl GeoDatabase for MockGeoDatabase {
732        fn lookup(&self, ip: IpAddr) -> Result<Option<String>, GeoLookupError> {
733            if self.fail_on.contains(&ip) {
734                return Err(GeoLookupError::DatabaseError("mock failure".to_string()));
735            }
736            Ok(self.mapping.get(&ip).cloned())
737        }
738
739        fn database_type(&self) -> GeoDatabaseType {
740            GeoDatabaseType::MaxMind
741        }
742    }
743
744    fn mock_pool(
745        action: GeoFilterAction,
746        countries: Vec<&str>,
747        on_failure: GeoFailureMode,
748        db: MockGeoDatabase,
749    ) -> GeoFilterPool {
750        let config = GeoFilter {
751            database_path: "/mock/db.mmdb".to_string(),
752            database_type: Some(GeoDatabaseType::MaxMind),
753            action,
754            countries: countries.into_iter().map(|s| s.to_string()).collect(),
755            on_failure,
756            status_code: 403,
757            block_message: Some("Blocked by geo filter".to_string()),
758            cache_ttl_secs: 300,
759            add_country_header: true,
760        };
761        GeoFilterPool::new_with_database(config, Arc::new(db))
762    }
763
764    // =========================================================================
765    // Error Display Tests
766    // =========================================================================
767
768    #[test]
769    fn test_geo_lookup_error_display() {
770        let err = GeoLookupError::InvalidIp("not-an-ip".to_string());
771        assert!(err.to_string().contains("invalid IP"));
772
773        let err = GeoLookupError::DatabaseError("db error".to_string());
774        assert!(err.to_string().contains("database error"));
775
776        let err = GeoLookupError::LoadError("load error".to_string());
777        assert!(err.to_string().contains("failed to load"));
778    }
779
780    // =========================================================================
781    // Block Mode Tests (blocklist)
782    // =========================================================================
783
784    #[test]
785    fn block_mode_blocks_listed_country() {
786        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "CN"), ("5.6.7.8", "US")]);
787        let pool = mock_pool(
788            GeoFilterAction::Block,
789            vec!["CN", "RU"],
790            GeoFailureMode::Open,
791            db,
792        );
793
794        let result = pool.check("1.2.3.4");
795        assert!(!result.allowed, "CN should be blocked");
796        assert_eq!(result.country_code, Some("CN".to_string()));
797        assert_eq!(result.status_code, 403);
798    }
799
800    #[test]
801    fn block_mode_allows_unlisted_country() {
802        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "CN"), ("5.6.7.8", "US")]);
803        let pool = mock_pool(
804            GeoFilterAction::Block,
805            vec!["CN", "RU"],
806            GeoFailureMode::Open,
807            db,
808        );
809
810        let result = pool.check("5.6.7.8");
811        assert!(result.allowed, "US should be allowed");
812        assert_eq!(result.country_code, Some("US".to_string()));
813    }
814
815    #[test]
816    fn block_mode_allows_unknown_ip() {
817        let db = MockGeoDatabase::new(); // Empty database
818        let pool = mock_pool(GeoFilterAction::Block, vec!["CN"], GeoFailureMode::Open, db);
819
820        let result = pool.check("10.0.0.1");
821        assert!(result.allowed, "Unknown IP should be allowed in block mode");
822        assert_eq!(result.country_code, None);
823    }
824
825    // =========================================================================
826    // Allow Mode Tests (allowlist)
827    // =========================================================================
828
829    #[test]
830    fn allow_mode_allows_listed_country() {
831        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "US"), ("5.6.7.8", "GB")]);
832        let pool = mock_pool(
833            GeoFilterAction::Allow,
834            vec!["US", "GB", "CA"],
835            GeoFailureMode::Open,
836            db,
837        );
838
839        let result = pool.check("1.2.3.4");
840        assert!(result.allowed, "US should be allowed");
841
842        let result = pool.check("5.6.7.8");
843        assert!(result.allowed, "GB should be allowed");
844    }
845
846    #[test]
847    fn allow_mode_blocks_unlisted_country() {
848        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "CN")]);
849        let pool = mock_pool(
850            GeoFilterAction::Allow,
851            vec!["US", "GB"],
852            GeoFailureMode::Open,
853            db,
854        );
855
856        let result = pool.check("1.2.3.4");
857        assert!(!result.allowed, "CN should be blocked in allow mode");
858    }
859
860    #[test]
861    fn allow_mode_blocks_unknown_ip() {
862        let db = MockGeoDatabase::new();
863        let pool = mock_pool(GeoFilterAction::Allow, vec!["US"], GeoFailureMode::Open, db);
864
865        let result = pool.check("10.0.0.1");
866        assert!(!result.allowed, "Unknown IP blocked when allowlist is set");
867    }
868
869    #[test]
870    fn allow_mode_allows_all_when_empty_list() {
871        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "CN")]);
872        let pool = mock_pool(
873            GeoFilterAction::Allow,
874            vec![], // Empty countries list
875            GeoFailureMode::Open,
876            db,
877        );
878
879        let result = pool.check("1.2.3.4");
880        assert!(result.allowed, "Empty allow list should allow all");
881    }
882
883    // =========================================================================
884    // Log-Only Mode Tests
885    // =========================================================================
886
887    #[test]
888    fn log_only_mode_never_blocks() {
889        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "CN")]);
890        let pool = mock_pool(
891            GeoFilterAction::LogOnly,
892            vec!["CN"],
893            GeoFailureMode::Open,
894            db,
895        );
896
897        let result = pool.check("1.2.3.4");
898        assert!(result.allowed, "Log-only mode should never block");
899        assert_eq!(result.country_code, Some("CN".to_string()));
900    }
901
902    // =========================================================================
903    // Failure Mode Tests
904    // =========================================================================
905
906    #[test]
907    fn fail_open_allows_on_lookup_error() {
908        let db = MockGeoDatabase::new().with_failure("1.2.3.4");
909        let pool = mock_pool(GeoFilterAction::Block, vec!["CN"], GeoFailureMode::Open, db);
910
911        let result = pool.check("1.2.3.4");
912        assert!(result.allowed, "Fail-open should allow on error");
913        assert_eq!(result.country_code, None);
914    }
915
916    #[test]
917    fn fail_closed_blocks_on_lookup_error() {
918        let db = MockGeoDatabase::new().with_failure("1.2.3.4");
919        let pool = mock_pool(
920            GeoFilterAction::Block,
921            vec!["CN"],
922            GeoFailureMode::Closed,
923            db,
924        );
925
926        let result = pool.check("1.2.3.4");
927        assert!(!result.allowed, "Fail-closed should block on error");
928    }
929
930    #[test]
931    fn fail_open_allows_on_invalid_ip() {
932        let db = MockGeoDatabase::new();
933        let pool = mock_pool(GeoFilterAction::Block, vec!["CN"], GeoFailureMode::Open, db);
934
935        let result = pool.check("not-an-ip");
936        assert!(result.allowed, "Fail-open should allow on invalid IP");
937    }
938
939    #[test]
940    fn fail_closed_blocks_on_invalid_ip() {
941        let db = MockGeoDatabase::new();
942        let pool = mock_pool(
943            GeoFilterAction::Block,
944            vec!["CN"],
945            GeoFailureMode::Closed,
946            db,
947        );
948
949        let result = pool.check("not-an-ip");
950        assert!(!result.allowed, "Fail-closed should block on invalid IP");
951    }
952
953    // =========================================================================
954    // Cache Tests
955    // =========================================================================
956
957    #[test]
958    fn cache_hit_on_repeated_lookup() {
959        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "US")]);
960        let pool = mock_pool(GeoFilterAction::Block, vec!["CN"], GeoFailureMode::Open, db);
961
962        let first = pool.check("1.2.3.4");
963        assert!(!first.cache_hit, "First lookup should be a cache miss");
964
965        let second = pool.check("1.2.3.4");
966        assert!(second.cache_hit, "Second lookup should be a cache hit");
967        assert_eq!(second.country_code, Some("US".to_string()));
968    }
969
970    #[test]
971    fn cache_stats_report_correctly() {
972        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "US"), ("5.6.7.8", "GB")]);
973        let pool = mock_pool(GeoFilterAction::Block, vec!["CN"], GeoFailureMode::Open, db);
974
975        // Initial state
976        let (total, valid) = pool.cache_stats();
977        assert_eq!(total, 0);
978        assert_eq!(valid, 0);
979
980        // After lookups
981        pool.check("1.2.3.4");
982        pool.check("5.6.7.8");
983
984        let (total, valid) = pool.cache_stats();
985        assert_eq!(total, 2);
986        assert_eq!(valid, 2);
987    }
988
989    #[test]
990    fn clear_expired_removes_old_entries() {
991        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "US")]);
992        let config = GeoFilter {
993            database_path: "/mock/db.mmdb".to_string(),
994            database_type: Some(GeoDatabaseType::MaxMind),
995            action: GeoFilterAction::Block,
996            countries: vec!["CN".to_string()],
997            on_failure: GeoFailureMode::Open,
998            status_code: 403,
999            block_message: None,
1000            cache_ttl_secs: 0, // Immediate expiry
1001            add_country_header: true,
1002        };
1003        let pool = GeoFilterPool::new_with_database(config, Arc::new(db));
1004
1005        pool.check("1.2.3.4");
1006        let (total, _) = pool.cache_stats();
1007        assert_eq!(total, 1);
1008
1009        // With TTL=0, entries are expired immediately
1010        std::thread::sleep(Duration::from_millis(10));
1011        pool.clear_expired();
1012
1013        let (total, _) = pool.cache_stats();
1014        assert_eq!(total, 0);
1015    }
1016
1017    // =========================================================================
1018    // Country Header Tests
1019    // =========================================================================
1020
1021    #[test]
1022    fn add_header_flag_propagated() {
1023        let db = MockGeoDatabase::with_entries(vec![("1.2.3.4", "US")]);
1024        let pool = mock_pool(GeoFilterAction::Block, vec!["CN"], GeoFailureMode::Open, db);
1025
1026        let result = pool.check("1.2.3.4");
1027        assert!(result.add_header, "add_country_header should be true");
1028    }
1029
1030    // =========================================================================
1031    // Manager Tests
1032    // =========================================================================
1033
1034    #[test]
1035    fn test_geo_filter_manager_new() {
1036        let manager = GeoFilterManager::new();
1037        assert!(manager.filter_ids().is_empty());
1038        assert!(!manager.has_filter("test"));
1039    }
1040
1041    #[test]
1042    fn test_geo_filter_manager_check_nonexistent_filter() {
1043        let manager = GeoFilterManager::new();
1044        let result = manager.check("nonexistent", "1.2.3.4");
1045        assert!(result.is_none());
1046    }
1047
1048    #[test]
1049    fn test_geo_filter_manager_reload_nonexistent() {
1050        let manager = GeoFilterManager::new();
1051        let result = manager.reload_filter("nonexistent");
1052        assert!(matches!(result, Err(GeoLookupError::LoadError(_))));
1053    }
1054}