1use 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#[derive(Debug, Clone)]
34pub enum GeoLookupError {
35 InvalidIp(String),
37 DatabaseError(String),
39 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
55pub trait GeoDatabase: Send + Sync {
61 fn lookup(&self, ip: IpAddr) -> Result<Option<String>, GeoLookupError>;
63
64 fn database_type(&self) -> GeoDatabaseType;
66}
67
68pub struct MaxMindDatabase {
74 reader: maxminddb::Reader<Vec<u8>>,
75}
76
77impl MaxMindDatabase {
78 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
126pub struct Ip2LocationDatabase {
132 db: ip2location::DB,
133}
134
135impl Ip2LocationDatabase {
136 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 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
183struct CachedCountry {
189 country_code: Option<String>,
191 cached_at: Instant,
193}
194
195#[derive(Debug, Clone)]
201pub struct GeoFilterResult {
202 pub allowed: bool,
204 pub country_code: Option<String>,
206 pub cache_hit: bool,
208 pub add_header: bool,
210 pub status_code: u16,
212 pub block_message: Option<String>,
214}
215
216pub struct GeoFilterPool {
222 database: RwLock<Arc<dyn GeoDatabase>>,
224 cache: DashMap<IpAddr, CachedCountry>,
226 config: GeoFilter,
228 countries_set: HashSet<String>,
230 cache_ttl: Duration,
232 database_path: PathBuf,
234 database_type: GeoDatabaseType,
236}
237
238impl GeoFilterPool {
239 pub fn new(config: GeoFilter) -> Result<Self, GeoLookupError> {
241 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 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 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 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 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 {
305 let mut db = self.database.write();
306 *db = new_database;
307 }
308
309 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 pub fn database_path(&self) -> &Path {
322 &self.database_path
323 }
324
325 pub fn check(&self, client_ip: &str) -> GeoFilterResult {
327 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 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 }
345
346 let database = self.database.read();
348 match database.lookup(ip) {
349 Ok(country_code) => {
350 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 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 !in_list
378 }
379 GeoFilterAction::Allow => {
380 if self.countries_set.is_empty() {
383 true
384 } else {
385 in_list
386 }
387 }
388 GeoFilterAction::LogOnly => {
389 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 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 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 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 #[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
471pub struct GeoFilterManager {
477 filter_pools: DashMap<String, Arc<GeoFilterPool>>,
479}
480
481impl GeoFilterManager {
482 pub fn new() -> Self {
484 Self {
485 filter_pools: DashMap::new(),
486 }
487 }
488
489 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 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 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 pub fn has_filter(&self, filter_id: &str) -> bool {
516 self.filter_pools.contains_key(filter_id)
517 }
518
519 pub fn filter_ids(&self) -> Vec<String> {
521 self.filter_pools.iter().map(|r| r.key().clone()).collect()
522 }
523
524 pub fn clear_expired_caches(&self) {
526 for pool in self.filter_pools.iter() {
527 pool.clear_expired();
528 }
529 }
530
531 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 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 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
571pub struct GeoDatabaseWatcher {
577 watcher: RwLock<Option<notify::RecommendedWatcher>>,
579 path_to_filters: RwLock<HashMap<PathBuf, Vec<String>>>,
581 manager: Arc<GeoFilterManager>,
583}
584
585impl GeoDatabaseWatcher {
586 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 pub fn start_watching(&self) -> Result<mpsc::Receiver<PathBuf>, GeoLookupError> {
597 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 *self.path_to_filters.write() = path_map.clone();
612
613 let (tx, rx) = mpsc::channel::<PathBuf>(10);
615
616 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 *self.watcher.write() = Some(watcher);
631
632 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 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 pub fn stop(&self) {
685 *self.watcher.write() = None;
686 info!("Stopped watching geo database files");
687 }
688}
689
690#[cfg(test)]
695mod tests {
696 use super::*;
697 use zentinel_config::{GeoDatabaseType, GeoFailureMode, GeoFilter, GeoFilterAction};
698
699 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 #[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 #[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(); 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 #[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![], 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 #[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 #[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 #[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 let (total, valid) = pool.cache_stats();
977 assert_eq!(total, 0);
978 assert_eq!(valid, 0);
979
980 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, 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 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 #[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 #[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}