Skip to main content

synapse_pingora/entity/
store.rs

1//! Thread-safe entity store using DashMap for concurrent access.
2//!
3//! Provides lock-free entity tracking for high-RPS WAF scenarios.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use dashmap::DashMap;
10use serde::{Deserialize, Serialize};
11
12/// Configuration for entity tracking.
13#[derive(Debug, Clone)]
14pub struct EntityConfig {
15    /// Maximum number of entities to track (LRU eviction when exceeded).
16    pub max_entities: usize,
17    /// Maximum entities per site/tenant (prevents single tenant from filling global pool).
18    ///
19    /// SECURITY: This limit ensures fair-share allocation across tenants. A single
20    /// tenant generating many unique actors (intentional attack or misconfigured
21    /// client) cannot fill the global pool and degrade security for other tenants.
22    ///
23    /// Default: 10% of max_entities (10,000 for default 100,000 max)
24    /// Set to 0 to disable per-site limits.
25    pub max_entities_per_site: usize,
26    /// Risk half-life in minutes (time for risk to decay to 50% of current value).
27    ///
28    /// SECURITY: Using exponential decay prevents attackers from predicting when
29    /// their risk score will drop below threshold. With linear decay (deprecated),
30    /// attackers could time attacks to occur right after score drops below threshold.
31    ///
32    /// Formula: new_risk = old_risk * 0.5^(elapsed_minutes / half_life_minutes)
33    ///
34    /// Default: 5 minutes (score decays to 50% every 5 minutes)
35    /// - After 5 min: 50% of original
36    /// - After 10 min: 25% of original
37    /// - After 20 min: 6.25% of original
38    pub risk_half_life_minutes: f64,
39    /// Minimum half-life for repeat offenders (multiplied from base).
40    ///
41    /// Entities with many rule matches decay slower as punishment.
42    /// Applied as: effective_half_life = base_half_life * repeat_offender_factor
43    ///
44    /// Default factor range: 1.0 (first offense) to 3.0 (heavy offender)
45    pub repeat_offender_max_factor: f64,
46    /// Risk threshold for automatic blocking.
47    pub block_threshold: f64,
48    /// Maximum number of rule matches to track per entity.
49    pub max_rules_per_entity: usize,
50    /// Whether entity tracking is enabled.
51    pub enabled: bool,
52    /// Maximum risk score (default: 100.0, extended: 1000.0).
53    pub max_risk: f64,
54    /// Maximum number of anomaly entries to track per entity.
55    pub max_anomalies_per_entity: usize,
56}
57
58impl Default for EntityConfig {
59    fn default() -> Self {
60        Self {
61            max_entities: 100_000,           // 100K for production
62            max_entities_per_site: 10_000,   // 10% of max - fair share per tenant
63            risk_half_life_minutes: 5.0,     // 50% decay every 5 minutes
64            repeat_offender_max_factor: 3.0, // Up to 3x longer half-life for repeat offenders
65            block_threshold: 70.0,
66            max_rules_per_entity: 50,
67            enabled: true,
68            max_risk: 100.0,
69            max_anomalies_per_entity: 100,
70        }
71    }
72}
73
74/// Per-IP entity state.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct EntityState {
77    /// IP address (primary key).
78    pub entity_id: String,
79    /// Site/tenant ID this entity is associated with (for multi-tenant isolation).
80    #[serde(default)]
81    pub site_id: Option<String>,
82    /// Accumulated risk score (0.0-max_risk).
83    pub risk: f64,
84    /// First seen timestamp (ms).
85    pub first_seen_at: u64,
86    /// Last seen timestamp (ms).
87    pub last_seen_at: u64,
88    /// Last decay timestamp (ms).
89    pub last_decay_at: u64,
90    /// Total request count.
91    pub request_count: u64,
92    /// Whether this entity is blocked.
93    pub blocked: bool,
94    /// Reason for blocking.
95    pub blocked_reason: Option<String>,
96    /// Timestamp when blocked (ms).
97    pub blocked_since: Option<u64>,
98    /// Rule match history (rule_id -> history).
99    pub matches: HashMap<u32, RuleMatchHistory>,
100    /// JA4 fingerprint (if available).
101    pub ja4_fingerprint: Option<String>,
102    /// Combined fingerprint hash (for correlation).
103    pub combined_fingerprint: Option<String>,
104    /// Previous JA4 fingerprint (for change detection).
105    pub previous_ja4: Option<String>,
106    /// Count of JA4 changes within the tracking window.
107    pub ja4_change_count: u32,
108    /// Timestamp of last JA4 change (milliseconds).
109    pub last_ja4_change_ms: Option<u64>,
110}
111
112impl EntityState {
113    /// Create a new entity state for the given IP.
114    pub fn new(entity_id: String, now: u64) -> Self {
115        Self {
116            entity_id,
117            site_id: None,
118            risk: 0.0,
119            first_seen_at: now,
120            last_seen_at: now,
121            last_decay_at: now,
122            request_count: 0, // touch will increment to 1
123            blocked: false,
124            blocked_reason: None,
125            blocked_since: None,
126            matches: HashMap::new(),
127            ja4_fingerprint: None,
128            combined_fingerprint: None,
129            previous_ja4: None,
130            ja4_change_count: 0,
131            last_ja4_change_ms: None,
132        }
133    }
134
135    /// Create a new entity state with a site ID.
136    pub fn with_site(entity_id: String, site_id: String, now: u64) -> Self {
137        Self {
138            entity_id,
139            site_id: Some(site_id),
140            risk: 0.0,
141            first_seen_at: now,
142            last_seen_at: now,
143            last_decay_at: now,
144            request_count: 0,
145            blocked: false,
146            blocked_reason: None,
147            blocked_since: None,
148            matches: HashMap::new(),
149            ja4_fingerprint: None,
150            combined_fingerprint: None,
151            previous_ja4: None,
152            ja4_change_count: 0,
153            last_ja4_change_ms: None,
154        }
155    }
156
157    /// Get the repeat offender multiplier for a rule.
158    ///
159    /// Returns 1.0 if rule hasn't been matched before.
160    /// Multiplier tiers: 1→1.0, 2→1.25, 6→1.5, 11→2.0
161    #[inline]
162    pub fn get_match_multiplier(&self, rule_id: u32) -> f64 {
163        self.matches
164            .get(&rule_id)
165            .map(|h| repeat_multiplier(h.count))
166            .unwrap_or(1.0)
167    }
168
169    /// Get match count for a rule (0 if not matched).
170    #[inline]
171    pub fn get_match_count(&self, rule_id: u32) -> u32 {
172        self.matches.get(&rule_id).map(|h| h.count).unwrap_or(0)
173    }
174}
175
176/// Rule match history for a single rule.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct RuleMatchHistory {
179    /// Rule ID.
180    pub rule_id: u32,
181    /// First match timestamp (ms).
182    pub first_matched_at: u64,
183    /// Last match timestamp (ms).
184    pub last_matched_at: u64,
185    /// Match count.
186    pub count: u32,
187}
188
189impl RuleMatchHistory {
190    /// Create a new rule match history.
191    pub fn new(rule_id: u32, now: u64) -> Self {
192        Self {
193            rule_id,
194            first_matched_at: now,
195            last_matched_at: now,
196            count: 1,
197        }
198    }
199}
200
201/// Calculate repeat offender multiplier based on match count.
202///
203/// Tiered multiplier system:
204/// - 1 match: 1.0x
205/// - 2-5 matches: 1.25x
206/// - 6-10 matches: 1.5x
207/// - 11+ matches: 2.0x
208#[inline]
209pub fn repeat_multiplier(count: u32) -> f64 {
210    match count {
211        0..=1 => 1.0,
212        2..=5 => 1.25,
213        6..=10 => 1.5,
214        _ => 2.0,
215    }
216}
217
218/// Block decision result.
219#[derive(Debug, Clone)]
220pub struct BlockDecision {
221    /// Whether the entity is blocked.
222    pub blocked: bool,
223    /// Current risk score.
224    pub risk: f64,
225    /// Reason for blocking (if blocked).
226    pub reason: Option<String>,
227    /// Timestamp when blocked (if blocked).
228    pub blocked_since: Option<u64>,
229}
230
231/// Risk application result.
232#[derive(Debug, Clone)]
233pub struct RiskApplication {
234    /// New risk score after application.
235    pub new_risk: f64,
236    /// Base risk that was applied.
237    pub base_risk: f64,
238    /// Multiplier used (1.0 if disabled).
239    pub multiplier: f64,
240    /// Final risk added (base * multiplier).
241    pub final_risk: f64,
242    /// Current match count for the rule.
243    pub match_count: u32,
244}
245
246/// Result of JA4 reputation check.
247#[derive(Debug, Clone)]
248pub struct Ja4ReputationResult {
249    /// Whether rapid fingerprint changes were detected.
250    pub rapid_changes: bool,
251    /// Number of changes in the tracking window.
252    pub change_count: u32,
253}
254
255/// Thread-safe entity manager using DashMap.
256///
257/// Provides lock-free concurrent access to entity state for high-RPS WAF scenarios.
258/// Uses timestamp-based LRU eviction instead of ordered list for better concurrency.
259///
260/// SECURITY: Implements per-site entity quotas to prevent a single tenant from
261/// filling the global pool and degrading security for all tenants.
262pub struct EntityManager {
263    /// Entities by IP address (lock-free concurrent map).
264    entities: DashMap<String, EntityState>,
265    /// Per-site entity counts for fair-share allocation.
266    site_counts: DashMap<String, AtomicU64>,
267    /// Configuration (immutable after creation).
268    config: EntityConfig,
269    /// Total entities ever created (for metrics).
270    total_created: AtomicU64,
271    /// Total entities evicted (for metrics).
272    total_evicted: AtomicU64,
273    /// Touch counter for lazy operations.
274    touch_counter: AtomicU32,
275}
276
277impl Default for EntityManager {
278    fn default() -> Self {
279        Self::new(EntityConfig::default())
280    }
281}
282
283impl EntityManager {
284    /// Create a new entity manager with the given configuration.
285    pub fn new(config: EntityConfig) -> Self {
286        Self {
287            entities: DashMap::with_capacity(config.max_entities),
288            site_counts: DashMap::new(),
289            config,
290            total_created: AtomicU64::new(0),
291            total_evicted: AtomicU64::new(0),
292            touch_counter: AtomicU32::new(0),
293        }
294    }
295
296    /// Get the configuration.
297    pub fn config(&self) -> &EntityConfig {
298        &self.config
299    }
300
301    /// Check if entity tracking is enabled.
302    pub fn is_enabled(&self) -> bool {
303        self.config.enabled
304    }
305
306    /// Get the number of tracked entities.
307    pub fn len(&self) -> usize {
308        self.entities.len()
309    }
310
311    /// Check if the store is empty.
312    pub fn is_empty(&self) -> bool {
313        self.entities.is_empty()
314    }
315
316    /// Get metrics about the entity store.
317    pub fn metrics(&self) -> EntityMetrics {
318        EntityMetrics {
319            current_entities: self.entities.len(),
320            max_entities: self.config.max_entities,
321            total_created: self.total_created.load(Ordering::Relaxed),
322            total_evicted: self.total_evicted.load(Ordering::Relaxed),
323        }
324    }
325
326    /// Touch an entity (update last_seen, apply decay, increment request_count).
327    ///
328    /// Creates the entity if it doesn't exist.
329    /// Returns a snapshot of the entity state.
330    pub fn touch_entity(&self, ip: &str) -> EntitySnapshot {
331        let now = now_ms();
332
333        // Check capacity and evict if needed (before inserting)
334        self.maybe_evict();
335
336        // Use entry API for atomic get-or-insert
337        let mut entry = self.entities.entry(ip.to_string()).or_insert_with(|| {
338            self.total_created.fetch_add(1, Ordering::Relaxed);
339            EntityState::new(ip.to_string(), now)
340        });
341
342        let entity = entry.value_mut();
343
344        // Apply decay
345        self.apply_decay(entity, now);
346
347        // Update timestamps and count
348        entity.last_seen_at = now;
349        entity.request_count += 1;
350
351        // Return snapshot
352        EntitySnapshot {
353            entity_id: entity.entity_id.clone(),
354            risk: entity.risk,
355            request_count: entity.request_count,
356            blocked: entity.blocked,
357            blocked_reason: entity.blocked_reason.clone(),
358        }
359    }
360
361    /// Touch an entity and associate fingerprint.
362    pub fn touch_entity_with_fingerprint(
363        &self,
364        ip: &str,
365        ja4: Option<&str>,
366        combined: Option<&str>,
367    ) -> EntitySnapshot {
368        let now = now_ms();
369        self.maybe_evict();
370
371        let mut entry = self.entities.entry(ip.to_string()).or_insert_with(|| {
372            self.total_created.fetch_add(1, Ordering::Relaxed);
373            EntityState::new(ip.to_string(), now)
374        });
375
376        let entity = entry.value_mut();
377        self.apply_decay(entity, now);
378
379        entity.last_seen_at = now;
380        entity.request_count += 1;
381
382        // Update fingerprints if provided
383        if let Some(ja4) = ja4 {
384            entity.ja4_fingerprint = Some(ja4.to_string());
385        }
386        if let Some(combined) = combined {
387            entity.combined_fingerprint = Some(combined.to_string());
388        }
389
390        EntitySnapshot {
391            entity_id: entity.entity_id.clone(),
392            risk: entity.risk,
393            request_count: entity.request_count,
394            blocked: entity.blocked,
395            blocked_reason: entity.blocked_reason.clone(),
396        }
397    }
398
399    /// Touch an entity for a specific site/tenant.
400    ///
401    /// SECURITY: Enforces per-site entity limits to prevent a single tenant from
402    /// exhausting the global entity pool and degrading security for all tenants.
403    ///
404    /// Returns None if the site has exceeded its quota and the entity doesn't exist.
405    pub fn touch_entity_for_site(&self, ip: &str, site_id: &str) -> Option<EntitySnapshot> {
406        let now = now_ms();
407
408        // Check if entity already exists (allows updates even if at quota)
409        if let Some(mut entry) = self.entities.get_mut(ip) {
410            let entity = entry.value_mut();
411            self.apply_decay(entity, now);
412            entity.last_seen_at = now;
413            entity.request_count += 1;
414            // Update site_id if not set
415            if entity.site_id.is_none() {
416                entity.site_id = Some(site_id.to_string());
417            }
418            return Some(EntitySnapshot {
419                entity_id: entity.entity_id.clone(),
420                risk: entity.risk,
421                request_count: entity.request_count,
422                blocked: entity.blocked,
423                blocked_reason: entity.blocked_reason.clone(),
424            });
425        }
426
427        // Entity doesn't exist - check per-site quota before creating
428        if self.config.max_entities_per_site > 0 {
429            let site_count = self.get_site_count(site_id);
430            if site_count >= self.config.max_entities_per_site as u64 {
431                // Site at quota - try to evict some old entries for this site
432                self.evict_oldest_for_site(site_id, 10);
433                // Check again after eviction
434                let new_count = self.get_site_count(site_id);
435                if new_count >= self.config.max_entities_per_site as u64 {
436                    tracing::warn!(
437                        site_id = %site_id,
438                        count = site_count,
439                        max = self.config.max_entities_per_site,
440                        "Site entity quota exceeded, rejecting new entity"
441                    );
442                    return None;
443                }
444            }
445        }
446
447        // Check global capacity
448        self.maybe_evict();
449
450        // Create new entity with site_id
451        let mut entry = self.entities.entry(ip.to_string()).or_insert_with(|| {
452            self.total_created.fetch_add(1, Ordering::Relaxed);
453            self.increment_site_count(site_id);
454            EntityState::with_site(ip.to_string(), site_id.to_string(), now)
455        });
456
457        let entity = entry.value_mut();
458        self.apply_decay(entity, now);
459        entity.last_seen_at = now;
460        entity.request_count += 1;
461
462        Some(EntitySnapshot {
463            entity_id: entity.entity_id.clone(),
464            risk: entity.risk,
465            request_count: entity.request_count,
466            blocked: entity.blocked,
467            blocked_reason: entity.blocked_reason.clone(),
468        })
469    }
470
471    /// Get the current entity count for a site.
472    pub fn get_site_count(&self, site_id: &str) -> u64 {
473        self.site_counts
474            .get(site_id)
475            .map(|c| c.load(Ordering::Relaxed))
476            .unwrap_or(0)
477    }
478
479    /// Increment the entity count for a site.
480    fn increment_site_count(&self, site_id: &str) {
481        self.site_counts
482            .entry(site_id.to_string())
483            .or_insert_with(|| AtomicU64::new(0))
484            .fetch_add(1, Ordering::Relaxed);
485    }
486
487    /// Decrement the entity count for a site.
488    fn decrement_site_count(&self, site_id: &str) {
489        if let Some(counter) = self.site_counts.get(site_id) {
490            // Use saturating sub to avoid underflow
491            let current = counter.load(Ordering::Relaxed);
492            if current > 0 {
493                counter.fetch_sub(1, Ordering::Relaxed);
494            }
495        }
496    }
497
498    /// Get site metrics for monitoring.
499    pub fn site_metrics(&self) -> Vec<SiteMetrics> {
500        self.site_counts
501            .iter()
502            .map(|entry| SiteMetrics {
503                site_id: entry.key().clone(),
504                entity_count: entry.value().load(Ordering::Relaxed),
505                max_entities: self.config.max_entities_per_site as u64,
506            })
507            .collect()
508    }
509
510    /// Get an entity snapshot (read-only).
511    pub fn get_entity(&self, ip: &str) -> Option<EntitySnapshot> {
512        self.entities.get(ip).map(|entry| {
513            let entity = entry.value();
514            EntitySnapshot {
515                entity_id: entity.entity_id.clone(),
516                risk: entity.risk,
517                request_count: entity.request_count,
518                blocked: entity.blocked,
519                blocked_reason: entity.blocked_reason.clone(),
520            }
521        })
522    }
523
524    /// Apply risk from a matched rule.
525    ///
526    /// Returns the risk application result, or None if entity doesn't exist.
527    pub fn apply_rule_risk(
528        &self,
529        ip: &str,
530        rule_id: u32,
531        base_risk: f64,
532        enable_multiplier: bool,
533    ) -> Option<RiskApplication> {
534        let now = now_ms();
535        let max_risk = self.config.max_risk;
536        let max_rules = self.config.max_rules_per_entity;
537
538        self.entities.get_mut(ip).map(|mut entry| {
539            let entity = entry.value_mut();
540
541            // Apply decay first
542            self.apply_decay(entity, now);
543
544            // Calculate multiplier based on current count (before incrementing)
545            let current_count = entity.get_match_count(rule_id);
546            let multiplier = if enable_multiplier {
547                repeat_multiplier(current_count + 1)
548            } else {
549                1.0
550            };
551
552            let final_risk = base_risk * multiplier;
553
554            // Add risk (clamped to max_risk)
555            entity.risk = (entity.risk + final_risk.max(0.0)).min(max_risk);
556
557            // Update rule match history
558            if let Some(history) = entity.matches.get_mut(&rule_id) {
559                history.last_matched_at = now;
560                history.count += 1;
561            } else {
562                entity
563                    .matches
564                    .insert(rule_id, RuleMatchHistory::new(rule_id, now));
565            }
566
567            // Trim rule history if needed
568            if entity.matches.len() > max_rules {
569                Self::trim_rule_history(&mut entity.matches, max_rules);
570            }
571
572            RiskApplication {
573                new_risk: entity.risk,
574                base_risk,
575                multiplier,
576                final_risk,
577                match_count: current_count + 1,
578            }
579        })
580    }
581
582    /// Apply external risk (e.g., from anomaly detection).
583    ///
584    /// Creates the entity if it doesn't exist.
585    ///
586    /// # Arguments
587    /// * `ip` - Client IP address
588    /// * `risk` - Risk points to add (will be clamped to max_risk)
589    /// * `reason` - Reason for risk application (logged at debug level)
590    pub fn apply_external_risk(&self, ip: &str, risk: f64, reason: &str) -> f64 {
591        let now = now_ms();
592        let max_risk = self.config.max_risk;
593        self.maybe_evict();
594
595        let mut entry = self.entities.entry(ip.to_string()).or_insert_with(|| {
596            self.total_created.fetch_add(1, Ordering::Relaxed);
597            EntityState::new(ip.to_string(), now)
598        });
599
600        let entity = entry.value_mut();
601        self.apply_decay(entity, now);
602
603        entity.last_seen_at = now;
604        entity.request_count += 1;
605        let old_risk = entity.risk;
606        entity.risk = (entity.risk + risk.max(0.0)).min(max_risk);
607
608        // Log risk application for debugging and audit
609        if risk > 0.0 && !reason.is_empty() {
610            tracing::debug!(
611                ip = %ip,
612                old_risk = old_risk,
613                added_risk = risk,
614                new_risk = entity.risk,
615                reason = %reason,
616                "Applied external risk"
617            );
618        }
619
620        entity.risk
621    }
622
623    /// Apply anomaly-based risk to an entity.
624    ///
625    /// Used for behavioral anomalies like honeypot hits, rapid fingerprint changes, etc.
626    /// Creates the entity if it doesn't exist.
627    ///
628    /// # Arguments
629    /// * `ip` - Client IP address
630    /// * `anomaly_type` - Type of anomaly detected (e.g., "honeypot_hit", "ja4_rapid_change")
631    /// * `risk` - Risk points to add
632    /// * `details` - Optional details about the anomaly
633    pub fn apply_anomaly_risk(
634        &self,
635        ip: &str,
636        anomaly_type: &str,
637        risk: f64,
638        details: Option<&str>,
639    ) -> f64 {
640        let reason = match details {
641            Some(d) => format!("{}: {}", anomaly_type, d),
642            None => anomaly_type.to_string(),
643        };
644        self.apply_external_risk(ip, risk, &reason)
645    }
646
647    /// Check if an entity should be blocked based on risk threshold.
648    ///
649    /// Returns the block decision.
650    pub fn check_block(&self, ip: &str) -> BlockDecision {
651        let now = now_ms();
652        let threshold = self.config.block_threshold;
653
654        match self.entities.get_mut(ip) {
655            Some(mut entry) => {
656                let entity = entry.value_mut();
657                self.apply_decay(entity, now);
658
659                if entity.risk >= threshold {
660                    if !entity.blocked {
661                        entity.blocked = true;
662                        entity.blocked_since = Some(now);
663                    }
664                    entity.blocked_reason = Some(format!(
665                        "Risk {:.1} >= threshold {:.1}",
666                        entity.risk, threshold
667                    ));
668                    BlockDecision {
669                        blocked: true,
670                        risk: entity.risk,
671                        reason: entity.blocked_reason.clone(),
672                        blocked_since: entity.blocked_since,
673                    }
674                } else {
675                    // Below threshold - clear block status
676                    entity.blocked = false;
677                    entity.blocked_reason = None;
678                    entity.blocked_since = None;
679                    BlockDecision {
680                        blocked: false,
681                        risk: entity.risk,
682                        reason: None,
683                        blocked_since: None,
684                    }
685                }
686            }
687            None => BlockDecision {
688                blocked: false,
689                risk: 0.0,
690                reason: None,
691                blocked_since: None,
692            },
693        }
694    }
695
696    /// Manually block an entity.
697    pub fn manual_block(&self, ip: &str, reason: &str) -> bool {
698        let now = now_ms();
699        match self.entities.get_mut(ip) {
700            Some(mut entry) => {
701                let entity = entry.value_mut();
702                entity.blocked = true;
703                entity.blocked_reason = Some(reason.to_string());
704                if entity.blocked_since.is_none() {
705                    entity.blocked_since = Some(now);
706                }
707                true
708            }
709            None => false,
710        }
711    }
712
713    /// Release an entity (reset risk and unblock).
714    pub fn release_entity(&self, ip: &str) -> bool {
715        match self.entities.get_mut(ip) {
716            Some(mut entry) => {
717                let entity = entry.value_mut();
718                entity.risk = 0.0;
719                entity.blocked = false;
720                entity.blocked_reason = None;
721                entity.blocked_since = None;
722                entity.matches.clear();
723                true
724            }
725            None => false,
726        }
727    }
728
729    /// Release all entities (reset risk and unblock all).
730    ///
731    /// Returns the number of entities released.
732    pub fn release_all(&self) -> usize {
733        let mut count = 0;
734        for mut entry in self.entities.iter_mut() {
735            let entity = entry.value_mut();
736            if entity.blocked || entity.risk > 0.0 {
737                entity.risk = 0.0;
738                entity.blocked = false;
739                entity.blocked_reason = None;
740                entity.blocked_since = None;
741                entity.matches.clear();
742                count += 1;
743            }
744        }
745        count
746    }
747
748    /// List all entity IDs.
749    pub fn list_entity_ids(&self) -> Vec<String> {
750        self.entities.iter().map(|e| e.key().clone()).collect()
751    }
752
753    /// Returns top N entities sorted by risk score (highest first)
754    pub fn list_top_risk(&self, limit: usize) -> Vec<EntitySnapshot> {
755        let mut entities: Vec<_> = self
756            .entities
757            .iter()
758            .map(|entry| {
759                let state = entry.value();
760                EntitySnapshot {
761                    entity_id: state.entity_id.clone(),
762                    risk: state.risk,
763                    request_count: state.request_count,
764                    blocked: state.blocked,
765                    blocked_reason: state.blocked_reason.clone(),
766                }
767            })
768            .collect();
769
770        entities.sort_by(|a, b| {
771            b.risk
772                .partial_cmp(&a.risk)
773                .unwrap_or(std::cmp::Ordering::Equal)
774        });
775        entities.truncate(limit);
776        entities
777    }
778
779    /// Check JA4 reputation for an IP address.
780    /// Detects rapid fingerprint changes that indicate bot behavior.
781    ///
782    /// # Arguments
783    /// * `ip` - Client IP address
784    /// * `current_ja4` - Current JA4 fingerprint
785    /// * `now_ms` - Current timestamp in milliseconds
786    ///
787    /// # Returns
788    /// Reputation result if entity exists, None otherwise
789    pub fn check_ja4_reputation(
790        &self,
791        ip: &str,
792        current_ja4: &str,
793        now_ms: u64,
794    ) -> Option<Ja4ReputationResult> {
795        let mut entry = self.entities.get_mut(ip)?;
796
797        const RAPID_CHANGE_WINDOW_MS: u64 = 60_000; // 1 minute
798        const RAPID_CHANGE_THRESHOLD: u32 = 3;
799
800        let mut rapid_changes = false;
801
802        if let Some(ref prev_ja4) = entry.previous_ja4 {
803            if prev_ja4 != current_ja4 {
804                // Fingerprint changed!
805                let within_window = entry
806                    .last_ja4_change_ms
807                    .map(|t| now_ms.saturating_sub(t) < RAPID_CHANGE_WINDOW_MS)
808                    .unwrap_or(false);
809
810                if within_window {
811                    entry.ja4_change_count += 1;
812                    if entry.ja4_change_count >= RAPID_CHANGE_THRESHOLD {
813                        rapid_changes = true;
814                    }
815                } else {
816                    // Outside window - reset counter
817                    entry.ja4_change_count = 1;
818                }
819
820                entry.previous_ja4 = Some(current_ja4.to_string());
821                entry.last_ja4_change_ms = Some(now_ms);
822            }
823            // If fingerprint is same, don't update anything
824        } else {
825            // First fingerprint seen for this IP
826            entry.previous_ja4 = Some(current_ja4.to_string());
827            entry.last_ja4_change_ms = Some(now_ms);
828            entry.ja4_change_count = 0;
829        }
830
831        Some(Ja4ReputationResult {
832            rapid_changes,
833            change_count: entry.ja4_change_count,
834        })
835    }
836
837    // Internal helpers
838
839    /// Apply exponential decay to an entity based on elapsed time.
840    ///
841    /// SECURITY: Uses exponential decay (half-life model) instead of linear decay
842    /// to prevent attackers from predicting when their risk score will drop below
843    /// threshold. With linear decay, attackers could precisely calculate wait times.
844    ///
845    /// Formula: new_risk = old_risk * 0.5^(elapsed_minutes / effective_half_life)
846    ///
847    /// Repeat offenders decay slower (longer half-life) as punishment.
848    fn apply_decay(&self, entity: &mut EntityState, now: u64) {
849        // Early exit if no risk to decay
850        if entity.risk <= 0.0 {
851            entity.last_decay_at = now;
852            return;
853        }
854
855        let elapsed_ms = now.saturating_sub(entity.last_decay_at);
856        // Skip decay for short intervals (< 1 second) - optimization
857        if elapsed_ms < 1000 {
858            return;
859        }
860
861        // Calculate repeat offender factor based on total rule match history
862        // More matches = slower decay (longer half-life) as punishment
863        let total_matches: u32 = entity.matches.values().map(|h| h.count).sum();
864        let repeat_factor = self.calculate_repeat_offender_factor(total_matches);
865
866        // Effective half-life increases with repeat offenses
867        let effective_half_life_minutes = self.config.risk_half_life_minutes * repeat_factor;
868
869        // Convert elapsed time to minutes
870        let elapsed_minutes = elapsed_ms as f64 / 60_000.0;
871
872        // Exponential decay: risk = risk * 0.5^(elapsed / half_life)
873        // Using natural log: risk = risk * e^(-ln(2) * elapsed / half_life)
874        let decay_exponent =
875            -std::f64::consts::LN_2 * elapsed_minutes / effective_half_life_minutes;
876        let decay_factor = decay_exponent.exp();
877
878        // Apply decay
879        entity.risk = (entity.risk * decay_factor).max(0.0);
880
881        // Clamp very small values to zero (floating point cleanup)
882        if entity.risk < 0.01 {
883            entity.risk = 0.0;
884        }
885
886        entity.last_decay_at = now;
887    }
888
889    /// Calculate the repeat offender factor for decay slowdown.
890    ///
891    /// Returns a multiplier (1.0 to max_factor) based on total rule match count.
892    /// Higher match counts result in slower decay (longer half-life).
893    ///
894    /// Tiers:
895    /// - 0-2 matches: 1.0x (normal decay)
896    /// - 3-5 matches: 1.25x slower
897    /// - 6-10 matches: 1.5x slower
898    /// - 11-20 matches: 2.0x slower
899    /// - 21+ matches: max_factor (default 3.0x slower)
900    fn calculate_repeat_offender_factor(&self, total_matches: u32) -> f64 {
901        let factor = match total_matches {
902            0..=2 => 1.0,
903            3..=5 => 1.25,
904            6..=10 => 1.5,
905            11..=20 => 2.0,
906            _ => self.config.repeat_offender_max_factor,
907        };
908
909        // Clamp to configured maximum
910        factor.min(self.config.repeat_offender_max_factor)
911    }
912
913    /// Maybe evict oldest entities if at capacity.
914    ///
915    /// Uses lazy eviction: only check every 100th touch to avoid overhead.
916    fn maybe_evict(&self) {
917        // Lazy check - only evaluate every 100th operation
918        let count = self.touch_counter.fetch_add(1, Ordering::Relaxed);
919        if !count.is_multiple_of(100) {
920            return;
921        }
922
923        // Check if we need to evict
924        if self.entities.len() < self.config.max_entities {
925            return;
926        }
927
928        // Evict oldest 1% of entities (batch eviction for efficiency)
929        let evict_count = (self.config.max_entities / 100).max(1);
930        self.evict_oldest(evict_count);
931    }
932
933    /// Evict the N oldest entities by last_seen_at timestamp.
934    ///
935    /// Uses sampling to avoid O(n) collection of all entities.
936    /// Samples up to 10x the eviction count, then evicts the oldest from the sample.
937    /// This provides probabilistically good eviction while maintaining O(sample_size) complexity.
938    fn evict_oldest(&self, count: usize) {
939        // Sample size: 10x eviction count, capped at 1000 to avoid excessive memory
940        let sample_size = (count * 10).min(1000).min(self.entities.len());
941
942        if sample_size == 0 {
943            return;
944        }
945
946        // Sample entities - DashMap iter() provides reasonable distribution
947        let mut candidates: Vec<(String, Option<String>, u64)> = Vec::with_capacity(sample_size);
948        for entry in self.entities.iter().take(sample_size) {
949            candidates.push((
950                entry.key().clone(),
951                entry.value().site_id.clone(),
952                entry.value().last_seen_at,
953            ));
954        }
955
956        // Sort sampled candidates by last_seen_at (oldest first)
957        candidates.sort_unstable_by_key(|(_, _, ts)| *ts);
958
959        // Evict oldest N from sample
960        for (ip, site_id, _) in candidates.into_iter().take(count) {
961            if self.entities.remove(&ip).is_some() {
962                self.total_evicted.fetch_add(1, Ordering::Relaxed);
963                // Decrement site count if entity had a site_id
964                if let Some(ref site) = site_id {
965                    self.decrement_site_count(site);
966                }
967            }
968        }
969    }
970
971    /// Evict oldest entities for a specific site.
972    ///
973    /// SECURITY: Used when a site exceeds its quota to make room for new entities.
974    /// Only evicts entities belonging to the specified site.
975    fn evict_oldest_for_site(&self, site_id: &str, count: usize) {
976        // Sample entities belonging to this site
977        let sample_size = (count * 10).min(500);
978        let mut candidates: Vec<(String, u64)> = Vec::with_capacity(sample_size);
979
980        for entry in self.entities.iter() {
981            if entry.value().site_id.as_deref() == Some(site_id) {
982                candidates.push((entry.key().clone(), entry.value().last_seen_at));
983                if candidates.len() >= sample_size {
984                    break;
985                }
986            }
987        }
988
989        if candidates.is_empty() {
990            return;
991        }
992
993        // Sort by last_seen_at (oldest first)
994        candidates.sort_unstable_by_key(|(_, ts)| *ts);
995
996        // Evict oldest N
997        let mut evicted = 0;
998        for (ip, _) in candidates.into_iter().take(count) {
999            if self.entities.remove(&ip).is_some() {
1000                self.total_evicted.fetch_add(1, Ordering::Relaxed);
1001                self.decrement_site_count(site_id);
1002                evicted += 1;
1003            }
1004        }
1005
1006        if evicted > 0 {
1007            tracing::debug!(
1008                site_id = %site_id,
1009                evicted = evicted,
1010                "Evicted oldest entities for site to make room"
1011            );
1012        }
1013    }
1014
1015    /// Trim rule history to max size, keeping most recent.
1016    fn trim_rule_history(matches: &mut HashMap<u32, RuleMatchHistory>, max_rules: usize) {
1017        if matches.len() <= max_rules {
1018            return;
1019        }
1020
1021        // Find oldest entries to remove
1022        let mut entries: Vec<_> = matches.iter().collect();
1023        entries.sort_by_key(|(_, h)| h.last_matched_at);
1024
1025        let to_remove = matches.len() - max_rules;
1026        let remove_ids: Vec<u32> = entries.iter().take(to_remove).map(|(id, _)| **id).collect();
1027
1028        for id in remove_ids {
1029            matches.remove(&id);
1030        }
1031    }
1032
1033    // ========== Testing Methods ==========
1034
1035    /// Simulate time-based decay for testing purposes.
1036    ///
1037    /// This method allows tests to verify decay behavior without waiting for real time to pass.
1038    /// It sets the entity's last_decay_at to a past time, then applies decay based on elapsed time.
1039    #[cfg(test)]
1040    pub fn test_decay(&self, ip: &str, elapsed_ms: u64) -> Option<f64> {
1041        let now = now_ms();
1042        if let Some(mut entry) = self.entities.get_mut(ip) {
1043            // Set last_decay_at to simulate elapsed time
1044            entry.last_decay_at = now.saturating_sub(elapsed_ms);
1045            self.apply_decay(&mut entry, now);
1046            Some(entry.risk)
1047        } else {
1048            None
1049        }
1050    }
1051
1052    /// Get the full entity state for testing (exposes internal fields).
1053    #[cfg(test)]
1054    pub fn test_get_entity_state(&self, ip: &str) -> Option<EntityState> {
1055        self.entities.get(ip).map(|e| e.value().clone())
1056    }
1057
1058    // ========== Persistence Methods ==========
1059
1060    /// Create a snapshot of all entity states for persistence.
1061    ///
1062    /// Returns a Vec of cloned EntityState suitable for serialization.
1063    pub fn snapshot(&self) -> Vec<EntityState> {
1064        self.entities.iter().map(|e| e.value().clone()).collect()
1065    }
1066
1067    /// Restore entity states from a persisted snapshot.
1068    ///
1069    /// Clears existing entities and inserts the restored ones.
1070    /// Updates total_created counter to reflect restored count.
1071    /// Rebuilds site_counts from restored entity site_id fields.
1072    pub fn restore(&self, entities: Vec<EntityState>) {
1073        self.entities.clear();
1074        self.site_counts.clear();
1075
1076        let count = entities.len() as u64;
1077        for entity in entities {
1078            // Rebuild site counts
1079            if let Some(ref site_id) = entity.site_id {
1080                self.increment_site_count(site_id);
1081            }
1082            self.entities.insert(entity.entity_id.clone(), entity);
1083        }
1084        self.total_created.store(count, Ordering::Relaxed);
1085        self.total_evicted.store(0, Ordering::Relaxed);
1086    }
1087
1088    /// Merge restored entities with existing ones (additive restore).
1089    ///
1090    /// Only inserts entities that don't already exist.
1091    /// Useful for partial recovery scenarios.
1092    /// Updates site_counts for newly merged entities.
1093    pub fn merge_restore(&self, entities: Vec<EntityState>) -> usize {
1094        let mut merged = 0;
1095        for entity in entities {
1096            let site_id = entity.site_id.clone();
1097            if self
1098                .entities
1099                .insert(entity.entity_id.clone(), entity)
1100                .is_none()
1101            {
1102                merged += 1;
1103                // Update site count for new entity
1104                if let Some(ref site) = site_id {
1105                    self.increment_site_count(site);
1106                }
1107            }
1108        }
1109        self.total_created
1110            .fetch_add(merged as u64, Ordering::Relaxed);
1111        merged
1112    }
1113
1114    /// Clear the entity store and all site counts.
1115    pub fn clear(&self) {
1116        self.entities.clear();
1117        self.site_counts.clear();
1118    }
1119}
1120
1121/// Snapshot of entity state (for returning across lock boundaries).
1122#[derive(Debug, Clone, Serialize)]
1123pub struct EntitySnapshot {
1124    pub entity_id: String,
1125    pub risk: f64,
1126    pub request_count: u64,
1127    pub blocked: bool,
1128    pub blocked_reason: Option<String>,
1129}
1130
1131/// Entity store metrics.
1132#[derive(Debug, Clone)]
1133pub struct EntityMetrics {
1134    pub current_entities: usize,
1135    pub max_entities: usize,
1136    pub total_created: u64,
1137    pub total_evicted: u64,
1138}
1139
1140/// Per-site entity metrics for monitoring multi-tenant fairness.
1141#[derive(Debug, Clone)]
1142pub struct SiteMetrics {
1143    pub site_id: String,
1144    pub entity_count: u64,
1145    pub max_entities: u64,
1146}
1147
1148/// Get current time in milliseconds since Unix epoch.
1149#[inline]
1150fn now_ms() -> u64 {
1151    SystemTime::now()
1152        .duration_since(UNIX_EPOCH)
1153        .map(|d| d.as_millis() as u64)
1154        .unwrap_or(0)
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160    use std::sync::Arc;
1161    use std::thread;
1162
1163    #[test]
1164    fn test_entity_creation() {
1165        let manager = EntityManager::default();
1166        let snapshot = manager.touch_entity("192.168.1.1");
1167
1168        assert_eq!(snapshot.entity_id, "192.168.1.1");
1169        assert_eq!(snapshot.risk, 0.0);
1170        assert_eq!(snapshot.request_count, 1);
1171        assert!(!snapshot.blocked);
1172    }
1173
1174    #[test]
1175    fn test_entity_touch_increments_count() {
1176        let manager = EntityManager::default();
1177        manager.touch_entity("192.168.1.1");
1178        manager.touch_entity("192.168.1.1");
1179        let snapshot = manager.touch_entity("192.168.1.1");
1180
1181        assert_eq!(snapshot.request_count, 3);
1182    }
1183
1184    #[test]
1185    fn test_apply_rule_risk() {
1186        let manager = EntityManager::default();
1187        manager.touch_entity("192.168.1.1");
1188
1189        let result = manager.apply_rule_risk("192.168.1.1", 100, 10.0, false);
1190        assert!(result.is_some());
1191
1192        let result = result.unwrap();
1193        assert!(result.new_risk >= 10.0);
1194        assert_eq!(result.base_risk, 10.0);
1195        assert_eq!(result.multiplier, 1.0);
1196        assert_eq!(result.match_count, 1);
1197    }
1198
1199    #[test]
1200    fn test_apply_rule_risk_with_multiplier() {
1201        let manager = EntityManager::default();
1202        manager.touch_entity("192.168.1.1");
1203
1204        // First match: 1.0x
1205        let r1 = manager
1206            .apply_rule_risk("192.168.1.1", 100, 10.0, true)
1207            .unwrap();
1208        assert_eq!(r1.multiplier, 1.0);
1209        assert_eq!(r1.match_count, 1);
1210
1211        // Second match: 1.25x
1212        let r2 = manager
1213            .apply_rule_risk("192.168.1.1", 100, 10.0, true)
1214            .unwrap();
1215        assert_eq!(r2.multiplier, 1.25);
1216        assert_eq!(r2.match_count, 2);
1217
1218        // After 6 matches: 1.5x
1219        for _ in 0..4 {
1220            manager.apply_rule_risk("192.168.1.1", 100, 10.0, true);
1221        }
1222        let r6 = manager
1223            .apply_rule_risk("192.168.1.1", 100, 10.0, true)
1224            .unwrap();
1225        assert_eq!(r6.multiplier, 1.5);
1226
1227        // After 11 matches: 2.0x
1228        for _ in 0..4 {
1229            manager.apply_rule_risk("192.168.1.1", 100, 10.0, true);
1230        }
1231        let r11 = manager
1232            .apply_rule_risk("192.168.1.1", 100, 10.0, true)
1233            .unwrap();
1234        assert_eq!(r11.multiplier, 2.0);
1235    }
1236
1237    #[test]
1238    fn test_risk_capping() {
1239        let manager = EntityManager::default();
1240        manager.touch_entity("192.168.1.1");
1241
1242        // Apply more than 100 risk
1243        for _ in 0..15 {
1244            manager.apply_rule_risk("192.168.1.1", 100, 10.0, false);
1245        }
1246
1247        let snapshot = manager.get_entity("192.168.1.1").unwrap();
1248        assert!(snapshot.risk <= 100.0);
1249    }
1250
1251    #[test]
1252    fn test_risk_blocking() {
1253        let config = EntityConfig {
1254            block_threshold: 50.0,
1255            ..Default::default()
1256        };
1257        let manager = EntityManager::new(config);
1258        manager.touch_entity("192.168.1.1");
1259
1260        // Apply 60 risk
1261        manager.apply_rule_risk("192.168.1.1", 100, 60.0, false);
1262
1263        let decision = manager.check_block("192.168.1.1");
1264        assert!(decision.blocked);
1265        assert!(decision.reason.is_some());
1266        assert!(decision.reason.unwrap().contains("60.0"));
1267    }
1268
1269    #[test]
1270    fn test_release_entity() {
1271        let manager = EntityManager::default();
1272        manager.touch_entity("192.168.1.1");
1273        manager.apply_rule_risk("192.168.1.1", 100, 50.0, false);
1274        manager.manual_block("192.168.1.1", "test");
1275
1276        let snapshot = manager.get_entity("192.168.1.1").unwrap();
1277        assert!(snapshot.blocked);
1278        assert!(snapshot.risk > 0.0);
1279
1280        manager.release_entity("192.168.1.1");
1281
1282        let snapshot = manager.get_entity("192.168.1.1").unwrap();
1283        assert!(!snapshot.blocked);
1284        assert_eq!(snapshot.risk, 0.0);
1285    }
1286
1287    #[test]
1288    fn test_lru_eviction() {
1289        // Use max_entities=1000 to test eviction behavior
1290        // Eviction happens every 100 touches, evicting 1% (10 entities) each time
1291        let config = EntityConfig {
1292            max_entities: 1000,
1293            ..Default::default()
1294        };
1295        let manager = EntityManager::new(config);
1296
1297        // Add 1500 unique entities
1298        // Eviction starts after touch 1000 when we exceed capacity
1299        // With 500 over-capacity touches, we get ~5 eviction cycles (at 1001, 1101, 1201, 1301, 1401)
1300        // Each cycle evicts 10 entities, so ~50 total evicted
1301        for i in 0..1500 {
1302            manager.touch_entity(&format!("{}.{}.{}.{}", i, i, i, i));
1303        }
1304
1305        let after_loading = manager.len();
1306
1307        // Expected: 1500 created - ~50 evicted = ~1450 remaining
1308        // Lazy eviction doesn't aggressively enforce the limit - it slowly brings it down
1309        assert!(
1310            after_loading <= 1500,
1311            "Should not have more than created: {}",
1312            after_loading
1313        );
1314
1315        // Verify some eviction occurred (should be around 50)
1316        let metrics = manager.metrics();
1317        assert!(
1318            metrics.total_evicted > 0,
1319            "Should have evicted some entities: {}",
1320            metrics.total_evicted
1321        );
1322
1323        // Now force more eviction cycles to get closer to max_entities
1324        // Each 100 touches triggers an eviction check
1325        for _ in 0..500 {
1326            manager.touch_entity("force.eviction");
1327        }
1328
1329        let after_force = manager.len();
1330
1331        // After 500 more touches (5 more eviction cycles), should be closer to limit
1332        assert!(
1333            after_force < after_loading,
1334            "Additional touches should trigger more eviction: before={}, after={}",
1335            after_loading,
1336            after_force
1337        );
1338
1339        println!(
1340            "LRU eviction test: created={}, evicted={}, after_load={}, after_force={}",
1341            metrics.total_created,
1342            manager.metrics().total_evicted,
1343            after_loading,
1344            after_force
1345        );
1346    }
1347
1348    #[test]
1349    fn test_concurrent_access() {
1350        let manager = Arc::new(EntityManager::default());
1351        let mut handles = vec![];
1352
1353        // Spawn 10 threads, each touching entities 100 times
1354        for thread_id in 0..10 {
1355            let manager = Arc::clone(&manager);
1356            handles.push(thread::spawn(move || {
1357                for i in 0..100 {
1358                    let ip = format!("192.168.{}.{}", thread_id, i % 10);
1359                    manager.touch_entity(&ip);
1360                    manager.apply_rule_risk(&ip, 100, 1.0, true);
1361                }
1362            }));
1363        }
1364
1365        for handle in handles {
1366            handle.join().unwrap();
1367        }
1368
1369        // Verify no panics and reasonable state
1370        assert!(manager.len() > 0);
1371        assert!(manager.len() <= 100); // 10 threads * 10 unique IPs each
1372    }
1373
1374    #[test]
1375    fn test_fingerprint_association() {
1376        let manager = EntityManager::default();
1377
1378        manager.touch_entity_with_fingerprint(
1379            "192.168.1.1",
1380            Some("t13d1516h2_abc123_def456"),
1381            Some("combined_hash_xyz"),
1382        );
1383
1384        // Fingerprints are stored but not in snapshot (kept internal)
1385        let snapshot = manager.get_entity("192.168.1.1").unwrap();
1386        assert_eq!(snapshot.entity_id, "192.168.1.1");
1387        assert_eq!(snapshot.request_count, 1);
1388    }
1389
1390    #[test]
1391    fn test_release_all() {
1392        let manager = EntityManager::default();
1393
1394        manager.touch_entity("1.1.1.1");
1395        manager.touch_entity("2.2.2.2");
1396        manager.apply_rule_risk("1.1.1.1", 100, 50.0, false);
1397        manager.apply_rule_risk("2.2.2.2", 100, 30.0, false);
1398
1399        let count = manager.release_all();
1400        assert_eq!(count, 2);
1401
1402        assert_eq!(manager.get_entity("1.1.1.1").unwrap().risk, 0.0);
1403        assert_eq!(manager.get_entity("2.2.2.2").unwrap().risk, 0.0);
1404    }
1405
1406    #[test]
1407    fn test_metrics() {
1408        let manager = EntityManager::default();
1409
1410        for i in 0..5 {
1411            manager.touch_entity(&format!("192.168.1.{}", i));
1412        }
1413
1414        let metrics = manager.metrics();
1415        assert_eq!(metrics.current_entities, 5);
1416        assert_eq!(metrics.max_entities, 100_000);
1417        assert_eq!(metrics.total_created, 5);
1418        assert_eq!(metrics.total_evicted, 0);
1419    }
1420
1421    #[test]
1422    fn test_repeat_multiplier() {
1423        assert_eq!(repeat_multiplier(0), 1.0);
1424        assert_eq!(repeat_multiplier(1), 1.0);
1425        assert_eq!(repeat_multiplier(2), 1.25);
1426        assert_eq!(repeat_multiplier(5), 1.25);
1427        assert_eq!(repeat_multiplier(6), 1.5);
1428        assert_eq!(repeat_multiplier(10), 1.5);
1429        assert_eq!(repeat_multiplier(11), 2.0);
1430        assert_eq!(repeat_multiplier(100), 2.0);
1431    }
1432
1433    // ==================== JA4 Reputation Tests ====================
1434
1435    #[test]
1436    fn test_ja4_first_fingerprint() {
1437        let manager = EntityManager::new(EntityConfig::default());
1438
1439        // Touch entity first to create it
1440        manager.touch_entity("1.2.3.4");
1441
1442        // First fingerprint - should not trigger rapid changes
1443        let result = manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_1", 1000);
1444        assert!(result.is_some());
1445        let result = result.unwrap();
1446        assert!(!result.rapid_changes);
1447        assert_eq!(result.change_count, 0);
1448    }
1449
1450    #[test]
1451    fn test_ja4_same_fingerprint_no_change() {
1452        let manager = EntityManager::new(EntityConfig::default());
1453        manager.touch_entity("1.2.3.4");
1454
1455        // Same fingerprint twice - no change
1456        manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_1", 1000);
1457        let result = manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_1", 2000);
1458
1459        assert!(result.is_some());
1460        let result = result.unwrap();
1461        assert!(!result.rapid_changes);
1462        assert_eq!(result.change_count, 0);
1463    }
1464
1465    #[test]
1466    fn test_ja4_rapid_changes_triggers() {
1467        let manager = EntityManager::new(EntityConfig::default());
1468        manager.touch_entity("1.2.3.4");
1469
1470        // 3 different fingerprints within 60 seconds should trigger
1471        manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_1", 1000);
1472        manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_2", 10000); // 10s later
1473        manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_3", 20000); // 20s later
1474        let result = manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_4", 30000); // 30s later
1475
1476        assert!(result.is_some());
1477        let result = result.unwrap();
1478        assert!(result.rapid_changes);
1479        assert!(result.change_count >= 3);
1480    }
1481
1482    #[test]
1483    fn test_ja4_changes_outside_window_reset() {
1484        let manager = EntityManager::new(EntityConfig::default());
1485        manager.touch_entity("1.2.3.4");
1486
1487        // First change
1488        manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_1", 1000);
1489        manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_2", 10000);
1490
1491        // Change outside window (> 60 seconds later)
1492        let result = manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_3", 100000);
1493
1494        assert!(result.is_some());
1495        let result = result.unwrap();
1496        assert!(!result.rapid_changes);
1497        assert_eq!(result.change_count, 1); // Counter was reset
1498    }
1499
1500    #[test]
1501    fn test_ja4_nonexistent_entity() {
1502        let manager = EntityManager::new(EntityConfig::default());
1503
1504        // Entity doesn't exist - should return None
1505        let result = manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_1", 1000);
1506        assert!(result.is_none());
1507    }
1508
1509    #[test]
1510    fn test_ja4_change_count_increments() {
1511        let manager = EntityManager::new(EntityConfig::default());
1512        manager.touch_entity("1.2.3.4");
1513
1514        // First fingerprint
1515        let r1 = manager
1516            .check_ja4_reputation("1.2.3.4", "fp1", 1000)
1517            .unwrap();
1518        assert_eq!(r1.change_count, 0);
1519
1520        // Second fingerprint (change)
1521        let r2 = manager
1522            .check_ja4_reputation("1.2.3.4", "fp2", 2000)
1523            .unwrap();
1524        assert_eq!(r2.change_count, 1);
1525
1526        // Third fingerprint (change)
1527        let r3 = manager
1528            .check_ja4_reputation("1.2.3.4", "fp3", 3000)
1529            .unwrap();
1530        assert_eq!(r3.change_count, 2);
1531
1532        // Fourth fingerprint (change) - should trigger rapid_changes
1533        let r4 = manager
1534            .check_ja4_reputation("1.2.3.4", "fp4", 4000)
1535            .unwrap();
1536        assert_eq!(r4.change_count, 3);
1537        assert!(r4.rapid_changes);
1538    }
1539
1540    // ==================== JA4 Reputation Edge Case Tests ====================
1541
1542    #[test]
1543    fn test_ja4_window_boundary_exactly_at_60s() {
1544        let manager = EntityManager::new(EntityConfig::default());
1545        manager.touch_entity("1.2.3.4");
1546
1547        // First fingerprint at t=0
1548        manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1549
1550        // Change at t=10s
1551        manager.check_ja4_reputation("1.2.3.4", "fp2", 10_000);
1552
1553        // Change exactly at 60s boundary (should still be within window)
1554        let result = manager.check_ja4_reputation("1.2.3.4", "fp3", 70_000);
1555        assert!(result.is_some());
1556        let result = result.unwrap();
1557        // 70000 - 10000 = 60000ms exactly - this is NOT < 60000, so outside window
1558        // Counter should reset
1559        assert_eq!(result.change_count, 1);
1560        assert!(!result.rapid_changes);
1561    }
1562
1563    #[test]
1564    fn test_ja4_window_boundary_just_inside() {
1565        let manager = EntityManager::new(EntityConfig::default());
1566        manager.touch_entity("1.2.3.4");
1567
1568        // First fingerprint at t=0
1569        manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1570
1571        // Change at t=10s
1572        manager.check_ja4_reputation("1.2.3.4", "fp2", 10_000);
1573
1574        // Change at t=69.999s (just inside 60s window from last change)
1575        let result = manager.check_ja4_reputation("1.2.3.4", "fp3", 69_999);
1576        assert!(result.is_some());
1577        let result = result.unwrap();
1578        // 69999 - 10000 = 59999ms < 60000ms - still within window
1579        assert_eq!(result.change_count, 2);
1580    }
1581
1582    #[test]
1583    fn test_ja4_window_boundary_just_outside() {
1584        let manager = EntityManager::new(EntityConfig::default());
1585        manager.touch_entity("1.2.3.4");
1586
1587        // First fingerprint at t=0
1588        manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1589
1590        // Change at t=10s
1591        manager.check_ja4_reputation("1.2.3.4", "fp2", 10_000);
1592
1593        // Change at t=70.001s (just outside 60s window from last change)
1594        let result = manager.check_ja4_reputation("1.2.3.4", "fp3", 70_001);
1595        assert!(result.is_some());
1596        let result = result.unwrap();
1597        // 70001 - 10000 = 60001ms > 60000ms - outside window, counter resets
1598        assert_eq!(result.change_count, 1);
1599    }
1600
1601    #[test]
1602    fn test_ja4_counter_reset_timing() {
1603        let manager = EntityManager::new(EntityConfig::default());
1604        manager.touch_entity("1.2.3.4");
1605
1606        // Build up to 2 changes within window
1607        manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1608        manager.check_ja4_reputation("1.2.3.4", "fp2", 10_000);
1609        let r1 = manager
1610            .check_ja4_reputation("1.2.3.4", "fp3", 20_000)
1611            .unwrap();
1612        assert_eq!(r1.change_count, 2);
1613
1614        // Long delay - counter should reset
1615        let r2 = manager
1616            .check_ja4_reputation("1.2.3.4", "fp4", 100_000)
1617            .unwrap();
1618        assert_eq!(r2.change_count, 1); // Reset to 1
1619
1620        // Continue from reset - need 2 more changes to trigger
1621        let r3 = manager
1622            .check_ja4_reputation("1.2.3.4", "fp5", 110_000)
1623            .unwrap();
1624        assert_eq!(r3.change_count, 2);
1625        assert!(!r3.rapid_changes);
1626
1627        let r4 = manager
1628            .check_ja4_reputation("1.2.3.4", "fp6", 120_000)
1629            .unwrap();
1630        assert_eq!(r4.change_count, 3);
1631        assert!(r4.rapid_changes);
1632    }
1633
1634    #[test]
1635    fn test_ja4_empty_fingerprint() {
1636        let manager = EntityManager::new(EntityConfig::default());
1637        manager.touch_entity("1.2.3.4");
1638
1639        // Empty fingerprint should be treated as valid (just an empty string)
1640        let r1 = manager.check_ja4_reputation("1.2.3.4", "", 1000);
1641        assert!(r1.is_some());
1642
1643        // Change from empty to non-empty
1644        let r2 = manager
1645            .check_ja4_reputation("1.2.3.4", "fp1", 2000)
1646            .unwrap();
1647        assert_eq!(r2.change_count, 1);
1648
1649        // Change back to empty
1650        let r3 = manager.check_ja4_reputation("1.2.3.4", "", 3000).unwrap();
1651        assert_eq!(r3.change_count, 2);
1652    }
1653
1654    #[test]
1655    fn test_ja4_whitespace_fingerprint() {
1656        let manager = EntityManager::new(EntityConfig::default());
1657        manager.touch_entity("1.2.3.4");
1658
1659        // Whitespace is a valid (though unusual) fingerprint
1660        manager.check_ja4_reputation("1.2.3.4", "   ", 1000);
1661
1662        // Different whitespace is a change
1663        let r = manager.check_ja4_reputation("1.2.3.4", "\t", 2000).unwrap();
1664        assert_eq!(r.change_count, 1);
1665    }
1666
1667    #[test]
1668    fn test_ja4_very_long_fingerprint() {
1669        let manager = EntityManager::new(EntityConfig::default());
1670        manager.touch_entity("1.2.3.4");
1671
1672        // Very long fingerprint
1673        let long_fp = "a".repeat(10000);
1674        let r1 = manager.check_ja4_reputation("1.2.3.4", &long_fp, 1000);
1675        assert!(r1.is_some());
1676
1677        // Different long fingerprint
1678        let long_fp2 = "b".repeat(10000);
1679        let r2 = manager
1680            .check_ja4_reputation("1.2.3.4", &long_fp2, 2000)
1681            .unwrap();
1682        assert_eq!(r2.change_count, 1);
1683    }
1684
1685    #[test]
1686    fn test_ja4_unicode_fingerprint() {
1687        let manager = EntityManager::new(EntityConfig::default());
1688        manager.touch_entity("1.2.3.4");
1689
1690        // Unicode fingerprint (shouldn't happen in practice but should handle)
1691        let r1 = manager.check_ja4_reputation("1.2.3.4", "日本語", 1000);
1692        assert!(r1.is_some());
1693
1694        // Different unicode
1695        let r2 = manager
1696            .check_ja4_reputation("1.2.3.4", "中文", 2000)
1697            .unwrap();
1698        assert_eq!(r2.change_count, 1);
1699
1700        // Emoji
1701        let r3 = manager
1702            .check_ja4_reputation("1.2.3.4", "🔒🔑", 3000)
1703            .unwrap();
1704        assert_eq!(r3.change_count, 2);
1705    }
1706
1707    #[test]
1708    fn test_ja4_case_sensitivity() {
1709        let manager = EntityManager::new(EntityConfig::default());
1710        manager.touch_entity("1.2.3.4");
1711
1712        // Fingerprints are case-sensitive
1713        manager.check_ja4_reputation("1.2.3.4", "ABC", 1000);
1714
1715        // Different case = different fingerprint
1716        let r = manager
1717            .check_ja4_reputation("1.2.3.4", "abc", 2000)
1718            .unwrap();
1719        assert_eq!(r.change_count, 1);
1720
1721        // Back to original case
1722        let r2 = manager
1723            .check_ja4_reputation("1.2.3.4", "ABC", 3000)
1724            .unwrap();
1725        assert_eq!(r2.change_count, 2);
1726    }
1727
1728    #[test]
1729    fn test_ja4_timestamp_overflow_protection() {
1730        let manager = EntityManager::new(EntityConfig::default());
1731        manager.touch_entity("1.2.3.4");
1732
1733        // Set up fingerprint at very high timestamp
1734        manager.check_ja4_reputation("1.2.3.4", "fp1", u64::MAX - 1000);
1735
1736        // Change with timestamp that would overflow if subtracted incorrectly
1737        let r = manager.check_ja4_reputation("1.2.3.4", "fp2", u64::MAX);
1738        assert!(r.is_some());
1739        let r = r.unwrap();
1740        // saturating_sub should prevent overflow
1741        assert_eq!(r.change_count, 1);
1742    }
1743
1744    #[test]
1745    fn test_ja4_timestamp_zero() {
1746        let manager = EntityManager::new(EntityConfig::default());
1747        manager.touch_entity("1.2.3.4");
1748
1749        // Timestamp 0 should work
1750        let r1 = manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1751        assert!(r1.is_some());
1752
1753        // Change with timestamp 0 (same time)
1754        let r2 = manager.check_ja4_reputation("1.2.3.4", "fp2", 0).unwrap();
1755        // 0 - 0 = 0 < 60000, so within window
1756        assert_eq!(r2.change_count, 1);
1757    }
1758
1759    #[test]
1760    fn test_ja4_rapid_threshold_exactly_3() {
1761        let manager = EntityManager::new(EntityConfig::default());
1762        manager.touch_entity("1.2.3.4");
1763
1764        // Setup: First fingerprint
1765        manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1766
1767        // 1 change
1768        let r1 = manager
1769            .check_ja4_reputation("1.2.3.4", "fp2", 1000)
1770            .unwrap();
1771        assert_eq!(r1.change_count, 1);
1772        assert!(!r1.rapid_changes);
1773
1774        // 2 changes
1775        let r2 = manager
1776            .check_ja4_reputation("1.2.3.4", "fp3", 2000)
1777            .unwrap();
1778        assert_eq!(r2.change_count, 2);
1779        assert!(!r2.rapid_changes);
1780
1781        // 3 changes - should trigger
1782        let r3 = manager
1783            .check_ja4_reputation("1.2.3.4", "fp4", 3000)
1784            .unwrap();
1785        assert_eq!(r3.change_count, 3);
1786        assert!(r3.rapid_changes);
1787    }
1788
1789    #[test]
1790    fn test_ja4_rapid_stays_triggered() {
1791        let manager = EntityManager::new(EntityConfig::default());
1792        manager.touch_entity("1.2.3.4");
1793
1794        // Build up to trigger threshold
1795        manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1796        manager.check_ja4_reputation("1.2.3.4", "fp2", 1000);
1797        manager.check_ja4_reputation("1.2.3.4", "fp3", 2000);
1798        let r = manager
1799            .check_ja4_reputation("1.2.3.4", "fp4", 3000)
1800            .unwrap();
1801        assert!(r.rapid_changes);
1802
1803        // Additional changes should keep triggering
1804        let r2 = manager
1805            .check_ja4_reputation("1.2.3.4", "fp5", 4000)
1806            .unwrap();
1807        assert!(r2.rapid_changes);
1808        assert_eq!(r2.change_count, 4);
1809
1810        let r3 = manager
1811            .check_ja4_reputation("1.2.3.4", "fp6", 5000)
1812            .unwrap();
1813        assert!(r3.rapid_changes);
1814        assert_eq!(r3.change_count, 5);
1815    }
1816
1817    #[test]
1818    fn test_ja4_multiple_entities_isolated() {
1819        let manager = EntityManager::new(EntityConfig::default());
1820        manager.touch_entity("1.1.1.1");
1821        manager.touch_entity("2.2.2.2");
1822
1823        // Build up changes for entity 1
1824        manager.check_ja4_reputation("1.1.1.1", "fp1", 0);
1825        manager.check_ja4_reputation("1.1.1.1", "fp2", 1000);
1826        manager.check_ja4_reputation("1.1.1.1", "fp3", 2000);
1827        let r1 = manager
1828            .check_ja4_reputation("1.1.1.1", "fp4", 3000)
1829            .unwrap();
1830        assert!(r1.rapid_changes);
1831
1832        // Entity 2 should be unaffected
1833        let r2 = manager.check_ja4_reputation("2.2.2.2", "other_fp", 3000);
1834        assert!(r2.is_some());
1835        let r2 = r2.unwrap();
1836        assert!(!r2.rapid_changes);
1837        assert_eq!(r2.change_count, 0);
1838    }
1839
1840    #[test]
1841    fn test_ja4_same_fingerprint_repeated_no_change() {
1842        let manager = EntityManager::new(EntityConfig::default());
1843        manager.touch_entity("1.2.3.4");
1844
1845        // Set initial fingerprint
1846        manager.check_ja4_reputation("1.2.3.4", "constant_fp", 0);
1847
1848        // Repeatedly check same fingerprint - should not increment
1849        for i in 1..10 {
1850            let r = manager
1851                .check_ja4_reputation("1.2.3.4", "constant_fp", i * 1000)
1852                .unwrap();
1853            assert_eq!(r.change_count, 0);
1854            assert!(!r.rapid_changes);
1855        }
1856    }
1857
1858    #[test]
1859    fn test_ja4_alternating_fingerprints() {
1860        let manager = EntityManager::new(EntityConfig::default());
1861        manager.touch_entity("1.2.3.4");
1862
1863        // Alternating between two fingerprints should still count as changes
1864        manager.check_ja4_reputation("1.2.3.4", "fp_a", 0);
1865        let r1 = manager
1866            .check_ja4_reputation("1.2.3.4", "fp_b", 1000)
1867            .unwrap();
1868        assert_eq!(r1.change_count, 1);
1869
1870        let r2 = manager
1871            .check_ja4_reputation("1.2.3.4", "fp_a", 2000)
1872            .unwrap();
1873        assert_eq!(r2.change_count, 2);
1874
1875        let r3 = manager
1876            .check_ja4_reputation("1.2.3.4", "fp_b", 3000)
1877            .unwrap();
1878        assert_eq!(r3.change_count, 3);
1879        assert!(r3.rapid_changes);
1880    }
1881
1882    #[test]
1883    fn test_ja4_concurrent_checks() {
1884        use std::sync::Arc;
1885        use std::thread;
1886
1887        let manager = Arc::new(EntityManager::new(EntityConfig::default()));
1888        manager.touch_entity("1.2.3.4");
1889
1890        let mut handles = vec![];
1891
1892        // Spawn multiple threads checking same entity
1893        for thread_id in 0..5 {
1894            let manager = Arc::clone(&manager);
1895            handles.push(thread::spawn(move || {
1896                for i in 0..10 {
1897                    let fp = format!("fp_t{}_i{}", thread_id, i);
1898                    let ts = (thread_id * 10000 + i * 100) as u64;
1899                    let _ = manager.check_ja4_reputation("1.2.3.4", &fp, ts);
1900                }
1901            }));
1902        }
1903
1904        for handle in handles {
1905            handle.join().unwrap();
1906        }
1907
1908        // Should complete without panics
1909        // Entity should still exist
1910        assert!(manager.get_entity("1.2.3.4").is_some());
1911    }
1912
1913    #[test]
1914    fn test_ja4_with_fingerprint_association() {
1915        let manager = EntityManager::new(EntityConfig::default());
1916
1917        // Create entity with fingerprint via touch
1918        manager.touch_entity_with_fingerprint(
1919            "1.2.3.4",
1920            Some("initial_ja4"),
1921            Some("combined_hash"),
1922        );
1923
1924        // Check reputation should work
1925        let r = manager.check_ja4_reputation("1.2.3.4", "different_ja4", 1000);
1926        assert!(r.is_some());
1927        let r = r.unwrap();
1928        // First check sets previous_ja4, so no change counted
1929        // Wait, entity was touched with ja4, but check_ja4_reputation looks at previous_ja4 field
1930        // which is different from ja4_fingerprint field
1931        assert_eq!(r.change_count, 0); // First reputation check
1932    }
1933
1934    #[test]
1935    fn test_entity_state_ja4_fields() {
1936        let manager = EntityManager::new(EntityConfig::default());
1937        manager.touch_entity("1.2.3.4");
1938
1939        // Check sequence of operations
1940        manager.check_ja4_reputation("1.2.3.4", "fp1", 1000);
1941        manager.check_ja4_reputation("1.2.3.4", "fp2", 2000);
1942        manager.check_ja4_reputation("1.2.3.4", "fp3", 3000);
1943
1944        // Verify internal state through entry API
1945        let entry = manager.entities.get("1.2.3.4").unwrap();
1946        assert_eq!(entry.previous_ja4.as_deref(), Some("fp3"));
1947        assert_eq!(entry.ja4_change_count, 2);
1948        assert!(entry.last_ja4_change_ms.is_some());
1949    }
1950
1951    #[test]
1952    fn test_ja4_after_entity_release() {
1953        let manager = EntityManager::new(EntityConfig::default());
1954        manager.touch_entity("1.2.3.4");
1955
1956        // Build up some changes
1957        manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1958        manager.check_ja4_reputation("1.2.3.4", "fp2", 1000);
1959
1960        // Release entity - note: release_entity only resets risk, blocked, and matches
1961        // but does NOT reset JA4 tracking fields (previous_ja4, ja4_change_count, etc.)
1962        manager.release_entity("1.2.3.4");
1963
1964        // Entity still exists with cleared risk but JA4 state preserved
1965        // Touch to update timestamps
1966        manager.touch_entity("1.2.3.4");
1967
1968        // Check with a different fingerprint - since previous_ja4 is preserved ("fp2"),
1969        // this will count as another change
1970        let r = manager.check_ja4_reputation("1.2.3.4", "new_fp", 5000);
1971        assert!(r.is_some());
1972        let r = r.unwrap();
1973        // JA4 change count continues from where it was (1 change from fp1->fp2)
1974        // Now adding fp2->new_fp = 2 changes total
1975        // But timestamp 5000 vs 1000 = 4000ms which is within 60s window
1976        assert_eq!(r.change_count, 2);
1977        assert!(!r.rapid_changes); // Need 3+ for rapid_changes
1978    }
1979
1980    #[test]
1981    fn test_ja4_special_characters_in_fingerprint() {
1982        let manager = EntityManager::new(EntityConfig::default());
1983        manager.touch_entity("1.2.3.4");
1984
1985        // Fingerprints with special characters
1986        let special_fps = [
1987            "t13d1516h2_8daaf6152771_02713d6af862", // Real JA4 format
1988            "fp-with-dashes",
1989            "fp_with_underscores",
1990            "fp.with.dots",
1991            "fp/with/slashes",
1992            "fp\\with\\backslashes",
1993            "fp:with:colons",
1994            "fp;with;semicolons",
1995        ];
1996
1997        for (i, fp) in special_fps.iter().enumerate() {
1998            let r = manager.check_ja4_reputation("1.2.3.4", fp, (i * 1000) as u64);
1999            assert!(r.is_some(), "Failed for fingerprint: {}", fp);
2000        }
2001    }
2002
2003    // ==================== Exponential Decay Tests ====================
2004
2005    #[test]
2006    fn test_exponential_decay_basic() {
2007        // With 5 minute half-life, risk should decay to ~50% after 5 minutes
2008        let config = EntityConfig {
2009            risk_half_life_minutes: 5.0,
2010            repeat_offender_max_factor: 3.0,
2011            ..Default::default()
2012        };
2013        let manager = EntityManager::new(config);
2014
2015        // Create entity with risk
2016        manager.touch_entity("1.2.3.4");
2017        manager.apply_rule_risk("1.2.3.4", 100, 80.0, false);
2018
2019        let initial = manager.get_entity("1.2.3.4").unwrap();
2020        assert!(
2021            (initial.risk - 80.0).abs() < 0.1,
2022            "Initial risk should be ~80"
2023        );
2024
2025        // Simulate 5 minutes passing (half-life)
2026        let five_minutes_ms = 5 * 60 * 1000;
2027        let risk_after = manager.test_decay("1.2.3.4", five_minutes_ms).unwrap();
2028
2029        // Risk should be approximately 50% (40.0)
2030        let ratio = risk_after / 80.0;
2031        assert!(
2032            (ratio - 0.5).abs() < 0.05,
2033            "After 1 half-life, risk should be ~50%: got ratio {}",
2034            ratio
2035        );
2036    }
2037
2038    #[test]
2039    fn test_exponential_decay_two_half_lives() {
2040        let config = EntityConfig {
2041            risk_half_life_minutes: 5.0,
2042            repeat_offender_max_factor: 3.0,
2043            ..Default::default()
2044        };
2045        let manager = EntityManager::new(config);
2046
2047        manager.touch_entity("1.2.3.4");
2048        manager.apply_rule_risk("1.2.3.4", 100, 100.0, false);
2049
2050        // Simulate 10 minutes passing (2 half-lives)
2051        let ten_minutes_ms = 10 * 60 * 1000;
2052        let risk_after = manager.test_decay("1.2.3.4", ten_minutes_ms).unwrap();
2053
2054        // Risk should be approximately 25% (2 half-lives = 0.5^2 = 0.25)
2055        let ratio = risk_after / 100.0;
2056        assert!(
2057            (ratio - 0.25).abs() < 0.05,
2058            "After 2 half-lives, risk should be ~25%: got ratio {}",
2059            ratio
2060        );
2061    }
2062
2063    #[test]
2064    fn test_repeat_offender_decay_slowdown() {
2065        let config = EntityConfig {
2066            risk_half_life_minutes: 5.0,
2067            repeat_offender_max_factor: 3.0,
2068            ..Default::default()
2069        };
2070        let manager = EntityManager::new(config);
2071
2072        // Create two entities: one first-time offender, one repeat offender
2073        manager.touch_entity("first.offender");
2074        manager.touch_entity("repeat.offender");
2075
2076        // First offender: just one rule match
2077        manager.apply_rule_risk("first.offender", 100, 80.0, true);
2078
2079        // Repeat offender: many rule matches (21+ matches = 3x factor)
2080        manager.apply_rule_risk("repeat.offender", 100, 80.0, true);
2081        for i in 2..=25 {
2082            manager.apply_rule_risk("repeat.offender", i, 0.0, true);
2083        }
2084
2085        // Verify initial risk is similar
2086        let first_initial = manager.get_entity("first.offender").unwrap().risk;
2087        let repeat_initial = manager
2088            .test_get_entity_state("repeat.offender")
2089            .unwrap()
2090            .risk;
2091        assert!(
2092            (first_initial - repeat_initial).abs() < 1.0,
2093            "Initial risk should be similar"
2094        );
2095
2096        // Simulate 5 minutes of decay
2097        let five_minutes_ms = 5 * 60 * 1000;
2098        let first_risk_after = manager
2099            .test_decay("first.offender", five_minutes_ms)
2100            .unwrap();
2101        let repeat_risk_after = manager
2102            .test_decay("repeat.offender", five_minutes_ms)
2103            .unwrap();
2104
2105        // Repeat offender should have higher remaining risk (slower decay)
2106        assert!(
2107            repeat_risk_after > first_risk_after,
2108            "Repeat offender should decay slower: first={}, repeat={}",
2109            first_risk_after,
2110            repeat_risk_after
2111        );
2112
2113        // First offender: 5 min = 1 half-life → ~50% remaining
2114        let first_ratio = first_risk_after / first_initial;
2115        assert!(
2116            (first_ratio - 0.5).abs() < 0.1,
2117            "First offender should be ~50%: got {}",
2118            first_ratio
2119        );
2120
2121        // Repeat offender with 3x factor: 5 min = only ~1/3 half-life → ~79% remaining
2122        let repeat_ratio = repeat_risk_after / repeat_initial;
2123        assert!(
2124            repeat_ratio > 0.7,
2125            "Repeat offender should retain >70%: got {}",
2126            repeat_ratio
2127        );
2128    }
2129
2130    #[test]
2131    fn test_calculate_repeat_offender_factor() {
2132        let manager = EntityManager::default();
2133
2134        // Test all tiers
2135        assert_eq!(manager.calculate_repeat_offender_factor(0), 1.0);
2136        assert_eq!(manager.calculate_repeat_offender_factor(2), 1.0);
2137        assert_eq!(manager.calculate_repeat_offender_factor(3), 1.25);
2138        assert_eq!(manager.calculate_repeat_offender_factor(5), 1.25);
2139        assert_eq!(manager.calculate_repeat_offender_factor(6), 1.5);
2140        assert_eq!(manager.calculate_repeat_offender_factor(10), 1.5);
2141        assert_eq!(manager.calculate_repeat_offender_factor(11), 2.0);
2142        assert_eq!(manager.calculate_repeat_offender_factor(20), 2.0);
2143        assert_eq!(manager.calculate_repeat_offender_factor(21), 3.0); // max_factor
2144        assert_eq!(manager.calculate_repeat_offender_factor(100), 3.0);
2145    }
2146
2147    #[test]
2148    fn test_decay_clamps_small_values_to_zero() {
2149        let config = EntityConfig {
2150            risk_half_life_minutes: 1.0, // Fast decay for testing
2151            ..Default::default()
2152        };
2153        let manager = EntityManager::new(config);
2154
2155        manager.touch_entity("1.2.3.4");
2156        manager.apply_rule_risk("1.2.3.4", 100, 0.005, false); // Very small risk
2157
2158        // After significant decay, should clamp to exactly zero
2159        let sixty_minutes_ms = 60 * 60 * 1000; // 60 half-lives
2160        let risk_after = manager.test_decay("1.2.3.4", sixty_minutes_ms).unwrap();
2161
2162        assert_eq!(risk_after, 0.0, "Very small risk should clamp to 0.0");
2163    }
2164
2165    #[test]
2166    fn test_nonlinear_decay_prevents_timing_attacks() {
2167        // This test verifies that decay is non-linear (proportional to current risk),
2168        // making timing attacks harder than with linear decay
2169        let config = EntityConfig {
2170            risk_half_life_minutes: 5.0,
2171            ..Default::default()
2172        };
2173        let manager = EntityManager::new(config);
2174
2175        // Test two entities with different starting risks
2176        manager.touch_entity("high.risk");
2177        manager.touch_entity("low.risk");
2178        manager.apply_rule_risk("high.risk", 100, 80.0, false);
2179        manager.apply_rule_risk("low.risk", 100, 40.0, false);
2180
2181        // Decay both for 1 minute
2182        let one_minute_ms = 60 * 1000;
2183        let high_after = manager.test_decay("high.risk", one_minute_ms).unwrap();
2184        let low_after = manager.test_decay("low.risk", one_minute_ms).unwrap();
2185
2186        // Calculate absolute drops
2187        let drop_from_80 = 80.0 - high_after;
2188        let drop_from_40 = 40.0 - low_after;
2189
2190        // With exponential decay, the *amount* dropped is proportional to current level
2191        // 80 → drops more than 40 → in the same time period
2192        // Drop from 80 should be about 2x the drop from 40 (since risk is 2x higher)
2193        let drop_ratio = drop_from_80 / drop_from_40;
2194        assert!(
2195            (drop_ratio - 2.0).abs() < 0.1,
2196            "Exponential decay should be proportional to current risk: ratio={}",
2197            drop_ratio
2198        );
2199    }
2200}