use std::collections::{HashMap, HashSet, VecDeque};
use super::haversine::{haversine_distance, is_valid_coordinates};
use super::types::{GeoLocation, LoginEvent, Severity, TravelAlert, TravelConfig, TravelStats};
#[derive(Debug, Clone)]
struct StoredLogin {
timestamp_ms: u64,
latitude: f64,
longitude: f64,
country_code: String,
ip: String,
city: Option<String>,
accuracy_radius_km: u32,
device_fingerprint: Option<String>,
}
impl From<&LoginEvent> for StoredLogin {
fn from(event: &LoginEvent) -> Self {
Self {
timestamp_ms: event.timestamp_ms,
latitude: event.location.latitude,
longitude: event.location.longitude,
country_code: event.location.country_code.clone(),
ip: event.location.ip.clone(),
city: event.location.city.clone(),
accuracy_radius_km: event.location.accuracy_radius_km,
device_fingerprint: event.device_fingerprint.clone(),
}
}
}
pub struct ImpossibleTravelDetector {
user_history: HashMap<String, VecDeque<StoredLogin>>,
whitelist: HashMap<String, HashSet<(String, String)>>,
config: TravelConfig,
total_logins: u64,
alerts_generated: u64,
}
impl ImpossibleTravelDetector {
pub fn new(config: TravelConfig) -> Self {
Self {
user_history: HashMap::new(),
whitelist: HashMap::new(),
config,
total_logins: 0,
alerts_generated: 0,
}
}
pub fn with_defaults() -> Self {
Self::new(TravelConfig::default())
}
pub fn check_login(&mut self, event: &LoginEvent) -> Option<TravelAlert> {
if !is_valid_coordinates(event.location.latitude, event.location.longitude) {
return None;
}
self.total_logins += 1;
let login = StoredLogin::from(event);
let history = self.user_history.entry(event.user_id.clone()).or_default();
let cutoff = event
.timestamp_ms
.saturating_sub(self.config.history_window_ms);
while let Some(front) = history.front() {
if front.timestamp_ms < cutoff {
history.pop_front();
} else {
break;
}
}
let prev_login = history.back().cloned();
history.push_back(login.clone());
while history.len() > self.config.max_history_per_user {
history.pop_front();
}
let alert = if let Some(ref prev) = prev_login {
self.check_travel(prev, &login, &event.user_id, &event.location)
} else {
None
};
if alert.is_some() {
self.alerts_generated += 1;
}
alert
}
fn check_travel(
&self,
from: &StoredLogin,
to: &StoredLogin,
user_id: &str,
to_location: &GeoLocation,
) -> Option<TravelAlert> {
let distance = haversine_distance(from.latitude, from.longitude, to.latitude, to.longitude);
if distance < self.config.min_distance_km {
return None;
}
let time_diff_ms = to.timestamp_ms.saturating_sub(from.timestamp_ms);
let time_diff_hours = time_diff_ms as f64 / (3600.0 * 1000.0);
if time_diff_hours < 0.0001 {
let alert = self.build_alert(
user_id,
from,
to,
to_location,
distance,
time_diff_hours,
f64::INFINITY,
);
return Some(alert);
}
let required_speed = distance / time_diff_hours;
if required_speed > self.config.max_speed_kmh {
if self.is_whitelisted(user_id, &from.country_code, &to.country_code) {
return None;
}
let alert = self.build_alert(
user_id,
from,
to,
to_location,
distance,
time_diff_hours,
required_speed,
);
return Some(alert);
}
None
}
fn build_alert(
&self,
user_id: &str,
from: &StoredLogin,
to: &StoredLogin,
to_location: &GeoLocation,
distance: f64,
time_diff_hours: f64,
required_speed: f64,
) -> TravelAlert {
let severity = self.calculate_severity(required_speed, distance);
let confidence = self.calculate_confidence(from, to, required_speed);
TravelAlert {
user_id: user_id.to_string(),
severity,
from_location: GeoLocation {
ip: from.ip.clone(),
latitude: from.latitude,
longitude: from.longitude,
city: from.city.clone(),
country: from.country_code.clone(), country_code: from.country_code.clone(),
accuracy_radius_km: from.accuracy_radius_km,
},
from_time: from.timestamp_ms,
to_location: to_location.clone(),
to_time: to.timestamp_ms,
distance_km: distance,
time_diff_hours,
required_speed_kmh: if required_speed.is_infinite() {
-1.0 } else {
required_speed
},
confidence,
}
}
fn calculate_severity(&self, speed: f64, distance: f64) -> Severity {
if speed.is_infinite() || speed > 10000.0 {
Severity::Critical
} else if speed > 5000.0 || distance > 10000.0 {
Severity::High
} else if speed > 2000.0 || distance > 5000.0 {
Severity::Medium
} else {
Severity::Low
}
}
fn calculate_confidence(&self, from: &StoredLogin, to: &StoredLogin, speed: f64) -> f64 {
let mut confidence: f64 = 0.5;
if from.country_code != to.country_code {
confidence += 0.2;
}
let avg_accuracy = (from.accuracy_radius_km + to.accuracy_radius_km) as f64 / 2.0;
if avg_accuracy > 100.0 {
confidence -= 0.2;
} else if avg_accuracy < 25.0 {
confidence += 0.1;
}
if speed > 5000.0 {
confidence += 0.15;
}
if let (Some(fp1), Some(fp2)) = (&from.device_fingerprint, &to.device_fingerprint) {
if fp1 == fp2 {
confidence += 0.15;
}
}
confidence.clamp(0.0, 1.0)
}
fn is_whitelisted(&self, user_id: &str, country1: &str, country2: &str) -> bool {
if let Some(routes) = self.whitelist.get(user_id) {
routes.contains(&(country1.to_string(), country2.to_string()))
|| routes.contains(&(country2.to_string(), country1.to_string()))
} else {
false
}
}
pub fn add_whitelist_route(&mut self, user_id: &str, country1: &str, country2: &str) {
let routes = self.whitelist.entry(user_id.to_string()).or_default();
routes.insert((country1.to_string(), country2.to_string()));
routes.insert((country2.to_string(), country1.to_string()));
}
pub fn remove_whitelist_route(&mut self, user_id: &str, country1: &str, country2: &str) {
if let Some(routes) = self.whitelist.get_mut(user_id) {
routes.remove(&(country1.to_string(), country2.to_string()));
routes.remove(&(country2.to_string(), country1.to_string()));
}
}
pub fn get_user_history(&self, user_id: &str) -> Vec<LoginEvent> {
self.user_history
.get(user_id)
.map(|history| {
history
.iter()
.map(|stored| LoginEvent {
user_id: user_id.to_string(),
timestamp_ms: stored.timestamp_ms,
location: GeoLocation {
ip: stored.ip.clone(),
latitude: stored.latitude,
longitude: stored.longitude,
city: stored.city.clone(),
country: stored.country_code.clone(),
country_code: stored.country_code.clone(),
accuracy_radius_km: stored.accuracy_radius_km,
},
success: true, device_fingerprint: stored.device_fingerprint.clone(),
})
.collect()
})
.unwrap_or_default()
}
pub fn clear_user(&mut self, user_id: &str) {
self.user_history.remove(user_id);
self.whitelist.remove(user_id);
}
pub fn clear(&mut self) {
self.user_history.clear();
self.whitelist.clear();
self.total_logins = 0;
self.alerts_generated = 0;
}
pub fn stats(&self) -> TravelStats {
let whitelist_routes: usize = self.whitelist.values().map(|s| s.len()).sum();
TravelStats {
tracked_users: self.user_history.len() as u32,
total_logins: self.total_logins,
alerts_generated: self.alerts_generated,
whitelist_routes: (whitelist_routes / 2) as u32,
}
}
pub fn config(&self) -> &TravelConfig {
&self.config
}
pub fn set_config(&mut self, config: TravelConfig) {
self.config = config;
}
pub fn user_count(&self) -> usize {
self.user_history.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_event(
user_id: &str,
timestamp_ms: u64,
lat: f64,
lon: f64,
country_code: &str,
) -> LoginEvent {
LoginEvent {
user_id: user_id.to_string(),
timestamp_ms,
location: GeoLocation {
ip: "1.2.3.4".to_string(),
latitude: lat,
longitude: lon,
city: Some("Test City".to_string()),
country: country_code.to_string(),
country_code: country_code.to_string(),
accuracy_radius_km: 10,
},
success: true,
device_fingerprint: None,
}
}
fn create_event_with_fingerprint(
user_id: &str,
timestamp_ms: u64,
lat: f64,
lon: f64,
country_code: &str,
fingerprint: &str,
) -> LoginEvent {
let mut event = create_test_event(user_id, timestamp_ms, lat, lon, country_code);
event.device_fingerprint = Some(fingerprint.to_string());
event
}
#[test]
fn test_first_login_no_alert() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event = create_test_event("user1", 0, 40.7128, -74.0060, "US");
let alert = detector.check_login(&event);
assert!(alert.is_none(), "First login should not trigger alert");
assert_eq!(detector.stats().total_logins, 1);
}
#[test]
fn test_normal_travel_no_alert() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user1", 3600 * 1000, 40.7128, -74.0060, "US");
assert!(
detector.check_login(&event2).is_none(),
"Same location should not trigger"
);
}
#[test]
fn test_impossible_travel_detected() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user1", 10 * 60 * 1000, 51.5074, -0.1278, "GB");
let alert = detector.check_login(&event2);
assert!(alert.is_some(), "Impossible travel should trigger alert");
let alert = alert.unwrap();
assert_eq!(alert.user_id, "user1");
assert_eq!(alert.severity, Severity::Critical); assert!(alert.distance_km > 5500.0);
assert!(
alert.required_speed_kmh > 30000.0,
"Speed should be >30000 km/h"
);
assert!(alert.confidence > 0.5);
}
#[test]
fn test_possible_travel_no_alert() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user1", 8 * 3600 * 1000, 51.5074, -0.1278, "GB");
assert!(
detector.check_login(&event2).is_none(),
"Realistic travel should not trigger"
);
}
#[test]
fn test_below_min_distance() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user1", 60 * 1000, 40.7500, -74.0100, "US");
assert!(
detector.check_login(&event2).is_none(),
"Below min distance should not trigger"
);
}
#[test]
fn test_instant_travel() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 1000, 40.7128, -74.0060, "US");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user1", 1001, 51.5074, -0.1278, "GB");
let alert = detector.check_login(&event2);
assert!(alert.is_some(), "Instant travel should trigger");
let alert = alert.unwrap();
assert_eq!(alert.severity, Severity::Critical);
assert_eq!(alert.required_speed_kmh, -1.0); }
#[test]
fn test_whitelist_prevents_alert() {
let mut detector = ImpossibleTravelDetector::with_defaults();
detector.add_whitelist_route("user1", "US", "GB");
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user1", 10 * 60 * 1000, 51.5074, -0.1278, "GB");
assert!(
detector.check_login(&event2).is_none(),
"Whitelisted route should not trigger"
);
}
#[test]
fn test_whitelist_bidirectional() {
let mut detector = ImpossibleTravelDetector::with_defaults();
detector.add_whitelist_route("user1", "US", "GB");
let event1 = create_test_event("user1", 0, 51.5074, -0.1278, "GB");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user1", 10 * 60 * 1000, 40.7128, -74.0060, "US");
assert!(
detector.check_login(&event2).is_none(),
"Reverse direction should also be whitelisted"
);
}
#[test]
fn test_whitelist_user_specific() {
let mut detector = ImpossibleTravelDetector::with_defaults();
detector.add_whitelist_route("user1", "US", "GB");
let event1 = create_test_event("user2", 0, 40.7128, -74.0060, "US");
assert!(detector.check_login(&event1).is_none());
let event2 = create_test_event("user2", 10 * 60 * 1000, 51.5074, -0.1278, "GB");
assert!(
detector.check_login(&event2).is_some(),
"Non-whitelisted user should trigger"
);
}
#[test]
fn test_remove_whitelist() {
let mut detector = ImpossibleTravelDetector::with_defaults();
detector.add_whitelist_route("user1", "US", "GB");
detector.remove_whitelist_route("user1", "US", "GB");
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event1);
let event2 = create_test_event("user1", 10 * 60 * 1000, 51.5074, -0.1278, "GB");
assert!(
detector.check_login(&event2).is_some(),
"Removed whitelist should trigger"
);
}
#[test]
fn test_severity_critical() {
let detector = ImpossibleTravelDetector::with_defaults();
assert_eq!(
detector.calculate_severity(15000.0, 5000.0),
Severity::Critical
);
assert_eq!(
detector.calculate_severity(f64::INFINITY, 1000.0),
Severity::Critical
);
}
#[test]
fn test_severity_high() {
let detector = ImpossibleTravelDetector::with_defaults();
assert_eq!(detector.calculate_severity(6000.0, 3000.0), Severity::High);
assert_eq!(detector.calculate_severity(3000.0, 12000.0), Severity::High);
}
#[test]
fn test_severity_medium() {
let detector = ImpossibleTravelDetector::with_defaults();
assert_eq!(
detector.calculate_severity(3000.0, 2000.0),
Severity::Medium
);
assert_eq!(
detector.calculate_severity(1500.0, 6000.0),
Severity::Medium
);
}
#[test]
fn test_severity_low() {
let detector = ImpossibleTravelDetector::with_defaults();
assert_eq!(detector.calculate_severity(1500.0, 100.0), Severity::Low);
assert_eq!(detector.calculate_severity(1100.0, 200.0), Severity::Low);
}
#[test]
fn test_confidence_different_countries() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event1);
let event2 = create_test_event("user1", 10 * 60 * 1000, 51.5074, -0.1278, "GB");
let alert = detector.check_login(&event2).unwrap();
assert!(
alert.confidence >= 0.7,
"Different countries should have high confidence"
);
}
#[test]
fn test_confidence_same_fingerprint() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_event_with_fingerprint(
"user1",
0,
40.7128,
-74.0060,
"US",
"device-fingerprint-123",
);
detector.check_login(&event1);
let event2 = create_event_with_fingerprint(
"user1",
10 * 60 * 1000,
51.5074,
-0.1278,
"GB",
"device-fingerprint-123",
);
let alert = detector.check_login(&event2).unwrap();
assert!(
alert.confidence >= 0.85,
"Same fingerprint should increase confidence, got {}",
alert.confidence
);
}
#[test]
fn test_history_pruning() {
let config = TravelConfig::new(1000.0, 50.0, 1.0, 10);
let mut detector = ImpossibleTravelDetector::new(config);
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event1);
let event2 = create_test_event("user1", 2 * 3600 * 1000, 40.7128, -74.0060, "US");
detector.check_login(&event2);
let history = detector.get_user_history("user1");
assert_eq!(history.len(), 1, "Old login should be pruned");
}
#[test]
fn test_max_history_per_user() {
let config = TravelConfig::new(1000.0, 50.0, 24.0, 3);
let mut detector = ImpossibleTravelDetector::new(config);
for i in 0..5 {
let event = create_test_event("user1", i * 1000, 40.7128, -74.0060, "US");
detector.check_login(&event);
}
let history = detector.get_user_history("user1");
assert_eq!(history.len(), 3, "Should only keep last 3 entries");
}
#[test]
fn test_clear_user() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event);
detector.add_whitelist_route("user1", "US", "GB");
detector.clear_user("user1");
assert!(
detector.get_user_history("user1").is_empty(),
"History should be cleared"
);
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event1);
let event2 = create_test_event("user1", 10 * 60 * 1000, 51.5074, -0.1278, "GB");
assert!(
detector.check_login(&event2).is_some(),
"Whitelist should be cleared"
);
}
#[test]
fn test_clear_all() {
let mut detector = ImpossibleTravelDetector::with_defaults();
for i in 0..5 {
let event = create_test_event(&format!("user{i}"), 0, 40.7128, -74.0060, "US");
detector.check_login(&event);
}
detector.clear();
assert_eq!(detector.user_count(), 0);
assert_eq!(detector.stats().total_logins, 0);
assert_eq!(detector.stats().alerts_generated, 0);
}
#[test]
fn test_stats() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event1);
let event2 = create_test_event("user2", 0, 51.5074, -0.1278, "GB");
detector.check_login(&event2);
let stats = detector.stats();
assert_eq!(stats.tracked_users, 2);
assert_eq!(stats.total_logins, 2);
assert_eq!(stats.alerts_generated, 0);
}
#[test]
fn test_stats_with_alerts() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event1);
let event2 = create_test_event("user1", 10 * 60 * 1000, 51.5074, -0.1278, "GB");
detector.check_login(&event2);
let stats = detector.stats();
assert_eq!(stats.tracked_users, 1);
assert_eq!(stats.total_logins, 2);
assert_eq!(stats.alerts_generated, 1);
}
#[test]
fn test_invalid_coordinates_ignored() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let mut event = create_test_event("user1", 0, 91.0, 0.0, "XX");
assert!(detector.check_login(&event).is_none());
event.location.latitude = 0.0;
event.location.longitude = 181.0;
event.timestamp_ms = 1000;
assert!(detector.check_login(&event).is_none());
event.location.latitude = f64::NAN;
event.location.longitude = 0.0;
event.timestamp_ms = 2000;
assert!(detector.check_login(&event).is_none());
assert_eq!(detector.user_count(), 0);
}
#[test]
fn test_multiple_users_independent() {
let mut detector = ImpossibleTravelDetector::with_defaults();
let event1 = create_test_event("user1", 0, 40.7128, -74.0060, "US");
detector.check_login(&event1);
let event2 = create_test_event("user2", 0, 51.5074, -0.1278, "GB");
detector.check_login(&event2);
let event3 = create_test_event("user1", 10 * 60 * 1000, 40.7128, -74.0060, "US");
assert!(
detector.check_login(&event3).is_none(),
"Should compare within same user only"
);
}
}