1use 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#[derive(Debug, Clone)]
14pub struct EntityConfig {
15 pub max_entities: usize,
17 pub max_entities_per_site: usize,
26 pub risk_half_life_minutes: f64,
39 pub repeat_offender_max_factor: f64,
46 pub block_threshold: f64,
48 pub max_rules_per_entity: usize,
50 pub enabled: bool,
52 pub max_risk: f64,
54 pub max_anomalies_per_entity: usize,
56}
57
58impl Default for EntityConfig {
59 fn default() -> Self {
60 Self {
61 max_entities: 100_000, max_entities_per_site: 10_000, risk_half_life_minutes: 5.0, repeat_offender_max_factor: 3.0, 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#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct EntityState {
77 pub entity_id: String,
79 #[serde(default)]
81 pub site_id: Option<String>,
82 pub risk: f64,
84 pub first_seen_at: u64,
86 pub last_seen_at: u64,
88 pub last_decay_at: u64,
90 pub request_count: u64,
92 pub blocked: bool,
94 pub blocked_reason: Option<String>,
96 pub blocked_since: Option<u64>,
98 pub matches: HashMap<u32, RuleMatchHistory>,
100 pub ja4_fingerprint: Option<String>,
102 pub combined_fingerprint: Option<String>,
104 pub previous_ja4: Option<String>,
106 pub ja4_change_count: u32,
108 pub last_ja4_change_ms: Option<u64>,
110}
111
112impl EntityState {
113 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, 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 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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct RuleMatchHistory {
179 pub rule_id: u32,
181 pub first_matched_at: u64,
183 pub last_matched_at: u64,
185 pub count: u32,
187}
188
189impl RuleMatchHistory {
190 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#[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#[derive(Debug, Clone)]
220pub struct BlockDecision {
221 pub blocked: bool,
223 pub risk: f64,
225 pub reason: Option<String>,
227 pub blocked_since: Option<u64>,
229}
230
231#[derive(Debug, Clone)]
233pub struct RiskApplication {
234 pub new_risk: f64,
236 pub base_risk: f64,
238 pub multiplier: f64,
240 pub final_risk: f64,
242 pub match_count: u32,
244}
245
246#[derive(Debug, Clone)]
248pub struct Ja4ReputationResult {
249 pub rapid_changes: bool,
251 pub change_count: u32,
253}
254
255pub struct EntityManager {
263 entities: DashMap<String, EntityState>,
265 site_counts: DashMap<String, AtomicU64>,
267 config: EntityConfig,
269 total_created: AtomicU64,
271 total_evicted: AtomicU64,
273 touch_counter: AtomicU32,
275}
276
277impl Default for EntityManager {
278 fn default() -> Self {
279 Self::new(EntityConfig::default())
280 }
281}
282
283impl EntityManager {
284 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 pub fn config(&self) -> &EntityConfig {
298 &self.config
299 }
300
301 pub fn is_enabled(&self) -> bool {
303 self.config.enabled
304 }
305
306 pub fn len(&self) -> usize {
308 self.entities.len()
309 }
310
311 pub fn is_empty(&self) -> bool {
313 self.entities.is_empty()
314 }
315
316 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 pub fn touch_entity(&self, ip: &str) -> EntitySnapshot {
331 let now = now_ms();
332
333 self.maybe_evict();
335
336 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 self.apply_decay(entity, now);
346
347 entity.last_seen_at = now;
349 entity.request_count += 1;
350
351 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 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 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 pub fn touch_entity_for_site(&self, ip: &str, site_id: &str) -> Option<EntitySnapshot> {
406 let now = now_ms();
407
408 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 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 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 self.evict_oldest_for_site(site_id, 10);
433 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 self.maybe_evict();
449
450 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 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 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 fn decrement_site_count(&self, site_id: &str) {
489 if let Some(counter) = self.site_counts.get(site_id) {
490 let current = counter.load(Ordering::Relaxed);
492 if current > 0 {
493 counter.fetch_sub(1, Ordering::Relaxed);
494 }
495 }
496 }
497
498 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 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 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 self.apply_decay(entity, now);
543
544 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 entity.risk = (entity.risk + final_risk.max(0.0)).min(max_risk);
556
557 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 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 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 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 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 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 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 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 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 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 pub fn list_entity_ids(&self) -> Vec<String> {
750 self.entities.iter().map(|e| e.key().clone()).collect()
751 }
752
753 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 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; 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 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 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 } else {
825 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 fn apply_decay(&self, entity: &mut EntityState, now: u64) {
849 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 if elapsed_ms < 1000 {
858 return;
859 }
860
861 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 let effective_half_life_minutes = self.config.risk_half_life_minutes * repeat_factor;
868
869 let elapsed_minutes = elapsed_ms as f64 / 60_000.0;
871
872 let decay_exponent =
875 -std::f64::consts::LN_2 * elapsed_minutes / effective_half_life_minutes;
876 let decay_factor = decay_exponent.exp();
877
878 entity.risk = (entity.risk * decay_factor).max(0.0);
880
881 if entity.risk < 0.01 {
883 entity.risk = 0.0;
884 }
885
886 entity.last_decay_at = now;
887 }
888
889 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 factor.min(self.config.repeat_offender_max_factor)
911 }
912
913 fn maybe_evict(&self) {
917 let count = self.touch_counter.fetch_add(1, Ordering::Relaxed);
919 if !count.is_multiple_of(100) {
920 return;
921 }
922
923 if self.entities.len() < self.config.max_entities {
925 return;
926 }
927
928 let evict_count = (self.config.max_entities / 100).max(1);
930 self.evict_oldest(evict_count);
931 }
932
933 fn evict_oldest(&self, count: usize) {
939 let sample_size = (count * 10).min(1000).min(self.entities.len());
941
942 if sample_size == 0 {
943 return;
944 }
945
946 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 candidates.sort_unstable_by_key(|(_, _, ts)| *ts);
958
959 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 if let Some(ref site) = site_id {
965 self.decrement_site_count(site);
966 }
967 }
968 }
969 }
970
971 fn evict_oldest_for_site(&self, site_id: &str, count: usize) {
976 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 candidates.sort_unstable_by_key(|(_, ts)| *ts);
995
996 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 fn trim_rule_history(matches: &mut HashMap<u32, RuleMatchHistory>, max_rules: usize) {
1017 if matches.len() <= max_rules {
1018 return;
1019 }
1020
1021 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 #[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 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 #[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 pub fn snapshot(&self) -> Vec<EntityState> {
1064 self.entities.iter().map(|e| e.value().clone()).collect()
1065 }
1066
1067 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 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 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 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 pub fn clear(&self) {
1116 self.entities.clear();
1117 self.site_counts.clear();
1118 }
1119}
1120
1121#[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#[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#[derive(Debug, Clone)]
1142pub struct SiteMetrics {
1143 pub site_id: String,
1144 pub entity_count: u64,
1145 pub max_entities: u64,
1146}
1147
1148#[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 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 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 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 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 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 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 let config = EntityConfig {
1292 max_entities: 1000,
1293 ..Default::default()
1294 };
1295 let manager = EntityManager::new(config);
1296
1297 for i in 0..1500 {
1302 manager.touch_entity(&format!("{}.{}.{}.{}", i, i, i, i));
1303 }
1304
1305 let after_loading = manager.len();
1306
1307 assert!(
1310 after_loading <= 1500,
1311 "Should not have more than created: {}",
1312 after_loading
1313 );
1314
1315 let metrics = manager.metrics();
1317 assert!(
1318 metrics.total_evicted > 0,
1319 "Should have evicted some entities: {}",
1320 metrics.total_evicted
1321 );
1322
1323 for _ in 0..500 {
1326 manager.touch_entity("force.eviction");
1327 }
1328
1329 let after_force = manager.len();
1330
1331 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 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 assert!(manager.len() > 0);
1371 assert!(manager.len() <= 100); }
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 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 #[test]
1436 fn test_ja4_first_fingerprint() {
1437 let manager = EntityManager::new(EntityConfig::default());
1438
1439 manager.touch_entity("1.2.3.4");
1441
1442 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 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 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); manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_3", 20000); let result = manager.check_ja4_reputation("1.2.3.4", "ja4_fingerprint_4", 30000); 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 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 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); }
1499
1500 #[test]
1501 fn test_ja4_nonexistent_entity() {
1502 let manager = EntityManager::new(EntityConfig::default());
1503
1504 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 let r1 = manager
1516 .check_ja4_reputation("1.2.3.4", "fp1", 1000)
1517 .unwrap();
1518 assert_eq!(r1.change_count, 0);
1519
1520 let r2 = manager
1522 .check_ja4_reputation("1.2.3.4", "fp2", 2000)
1523 .unwrap();
1524 assert_eq!(r2.change_count, 1);
1525
1526 let r3 = manager
1528 .check_ja4_reputation("1.2.3.4", "fp3", 3000)
1529 .unwrap();
1530 assert_eq!(r3.change_count, 2);
1531
1532 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 #[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 manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1549
1550 manager.check_ja4_reputation("1.2.3.4", "fp2", 10_000);
1552
1553 let result = manager.check_ja4_reputation("1.2.3.4", "fp3", 70_000);
1555 assert!(result.is_some());
1556 let result = result.unwrap();
1557 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 manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1570
1571 manager.check_ja4_reputation("1.2.3.4", "fp2", 10_000);
1573
1574 let result = manager.check_ja4_reputation("1.2.3.4", "fp3", 69_999);
1576 assert!(result.is_some());
1577 let result = result.unwrap();
1578 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 manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1589
1590 manager.check_ja4_reputation("1.2.3.4", "fp2", 10_000);
1592
1593 let result = manager.check_ja4_reputation("1.2.3.4", "fp3", 70_001);
1595 assert!(result.is_some());
1596 let result = result.unwrap();
1597 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 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 let r2 = manager
1616 .check_ja4_reputation("1.2.3.4", "fp4", 100_000)
1617 .unwrap();
1618 assert_eq!(r2.change_count, 1); 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 let r1 = manager.check_ja4_reputation("1.2.3.4", "", 1000);
1641 assert!(r1.is_some());
1642
1643 let r2 = manager
1645 .check_ja4_reputation("1.2.3.4", "fp1", 2000)
1646 .unwrap();
1647 assert_eq!(r2.change_count, 1);
1648
1649 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 manager.check_ja4_reputation("1.2.3.4", " ", 1000);
1661
1662 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 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 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 let r1 = manager.check_ja4_reputation("1.2.3.4", "日本語", 1000);
1692 assert!(r1.is_some());
1693
1694 let r2 = manager
1696 .check_ja4_reputation("1.2.3.4", "中文", 2000)
1697 .unwrap();
1698 assert_eq!(r2.change_count, 1);
1699
1700 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 manager.check_ja4_reputation("1.2.3.4", "ABC", 1000);
1714
1715 let r = manager
1717 .check_ja4_reputation("1.2.3.4", "abc", 2000)
1718 .unwrap();
1719 assert_eq!(r.change_count, 1);
1720
1721 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 manager.check_ja4_reputation("1.2.3.4", "fp1", u64::MAX - 1000);
1735
1736 let r = manager.check_ja4_reputation("1.2.3.4", "fp2", u64::MAX);
1738 assert!(r.is_some());
1739 let r = r.unwrap();
1740 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 let r1 = manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1751 assert!(r1.is_some());
1752
1753 let r2 = manager.check_ja4_reputation("1.2.3.4", "fp2", 0).unwrap();
1755 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 manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1766
1767 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 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 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 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 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 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 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 manager.check_ja4_reputation("1.2.3.4", "constant_fp", 0);
1847
1848 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 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 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 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 manager.touch_entity_with_fingerprint(
1919 "1.2.3.4",
1920 Some("initial_ja4"),
1921 Some("combined_hash"),
1922 );
1923
1924 let r = manager.check_ja4_reputation("1.2.3.4", "different_ja4", 1000);
1926 assert!(r.is_some());
1927 let r = r.unwrap();
1928 assert_eq!(r.change_count, 0); }
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 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 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 manager.check_ja4_reputation("1.2.3.4", "fp1", 0);
1958 manager.check_ja4_reputation("1.2.3.4", "fp2", 1000);
1959
1960 manager.release_entity("1.2.3.4");
1963
1964 manager.touch_entity("1.2.3.4");
1967
1968 let r = manager.check_ja4_reputation("1.2.3.4", "new_fp", 5000);
1971 assert!(r.is_some());
1972 let r = r.unwrap();
1973 assert_eq!(r.change_count, 2);
1977 assert!(!r.rapid_changes); }
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 let special_fps = [
1987 "t13d1516h2_8daaf6152771_02713d6af862", "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 #[test]
2006 fn test_exponential_decay_basic() {
2007 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 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 let five_minutes_ms = 5 * 60 * 1000;
2027 let risk_after = manager.test_decay("1.2.3.4", five_minutes_ms).unwrap();
2028
2029 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 let ten_minutes_ms = 10 * 60 * 1000;
2052 let risk_after = manager.test_decay("1.2.3.4", ten_minutes_ms).unwrap();
2053
2054 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 manager.touch_entity("first.offender");
2074 manager.touch_entity("repeat.offender");
2075
2076 manager.apply_rule_risk("first.offender", 100, 80.0, true);
2078
2079 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 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 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 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 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 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 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); 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, ..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); let sixty_minutes_ms = 60 * 60 * 1000; 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 let config = EntityConfig {
2170 risk_half_life_minutes: 5.0,
2171 ..Default::default()
2172 };
2173 let manager = EntityManager::new(config);
2174
2175 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 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 let drop_from_80 = 80.0 - high_after;
2188 let drop_from_40 = 40.0 - low_after;
2189
2190 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}