1use dashmap::DashMap;
37use sha2::{Digest, Sha256};
38use std::collections::VecDeque;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::time::{SystemTime, UNIX_EPOCH};
41
42#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47#[serde(default)]
48pub struct InjectionTrackerConfig {
49 pub max_records: usize,
51 pub record_ttl_secs: u64,
53 pub min_attempts_for_detection: u32,
55 pub timing_variance_threshold_ms: f64,
58 pub rapid_request_threshold_rps: f64,
60 pub max_fingerprint_changes: u32,
63 pub js_success_rate_threshold: f64,
65 pub response_time_window: usize,
67}
68
69impl Default for InjectionTrackerConfig {
70 fn default() -> Self {
71 Self {
72 max_records: 100_000,
73 record_ttl_secs: 3600,
74 min_attempts_for_detection: 5,
75 timing_variance_threshold_ms: 50.0,
76 rapid_request_threshold_rps: 10.0,
77 max_fingerprint_changes: 20,
78 js_success_rate_threshold: 0.1,
79 response_time_window: 20,
80 }
81 }
82}
83
84#[derive(Debug, Clone)]
86pub struct InjectionRecord {
87 pub ip: String,
89 pub ua_hash: String,
91 pub js_attempts: u32,
93 pub js_successes: u32,
95 pub cookie_attempts: u32,
97 pub cookie_successes: u32,
99 pub response_times: VecDeque<u64>,
101 pub fingerprints_seen: std::collections::HashSet<String>,
103 fingerprints_order: VecDeque<String>,
105 pub fingerprint_changes: u32,
107 pub first_seen: u64,
109 pub last_seen: u64,
111 pub request_count: u64,
113}
114
115impl InjectionRecord {
116 fn new(ip: String, ua_hash: String, now: u64) -> Self {
118 Self {
119 ip,
120 ua_hash,
121 js_attempts: 0,
122 js_successes: 0,
123 cookie_attempts: 0,
124 cookie_successes: 0,
125 response_times: VecDeque::with_capacity(20),
126 fingerprints_seen: std::collections::HashSet::with_capacity(16),
127 fingerprints_order: VecDeque::with_capacity(16),
128 fingerprint_changes: 0,
129 first_seen: now,
130 last_seen: now,
131 request_count: 0,
132 }
133 }
134
135 fn response_time_variance(&self) -> f64 {
137 if self.response_times.len() < 2 {
138 return f64::MAX; }
140
141 let times: Vec<f64> = self.response_times.iter().map(|&t| t as f64).collect();
142 let n = times.len() as f64;
143 let mean = times.iter().sum::<f64>() / n;
144 let variance = times.iter().map(|&t| (t - mean).powi(2)).sum::<f64>() / n;
145 variance.sqrt()
146 }
147
148 fn requests_per_second(&self) -> f64 {
150 let duration_ms = self.last_seen.saturating_sub(self.first_seen);
151 if duration_ms == 0 {
152 return self.request_count as f64; }
154 (self.request_count as f64) / (duration_ms as f64 / 1000.0)
155 }
156
157 fn js_success_rate(&self) -> f64 {
159 if self.js_attempts == 0 {
160 return 1.0; }
162 (self.js_successes as f64) / (self.js_attempts as f64)
163 }
164}
165
166#[derive(Debug, Clone, Default, serde::Serialize)]
168pub struct HeadlessIndicators {
169 pub no_js_execution: bool,
171 pub consistent_timing: bool,
173 pub rapid_requests: bool,
175 pub fingerprint_anomaly: bool,
177 pub timing_variance_ms: f64,
179 pub requests_per_second: f64,
181 pub js_success_rate: f64,
183 pub fingerprint_changes: u32,
185}
186
187impl HeadlessIndicators {
188 #[must_use]
190 pub fn is_suspicious(&self) -> bool {
191 self.no_js_execution
192 || self.consistent_timing
193 || self.rapid_requests
194 || self.fingerprint_anomaly
195 }
196
197 #[inline]
199 pub fn indicator_count(&self) -> u32 {
200 self.no_js_execution as u32
201 + self.consistent_timing as u32
202 + self.rapid_requests as u32
203 + self.fingerprint_anomaly as u32
204 }
205
206 #[must_use]
208 pub fn description(&self) -> String {
209 let mut reasons = Vec::new();
210 if self.no_js_execution {
211 reasons.push(format!(
212 "no_js_execution (success_rate: {:.1}%)",
213 self.js_success_rate * 100.0
214 ));
215 }
216 if self.consistent_timing {
217 reasons.push(format!(
218 "consistent_timing (variance: {:.1}ms)",
219 self.timing_variance_ms
220 ));
221 }
222 if self.rapid_requests {
223 reasons.push(format!(
224 "rapid_requests ({:.1} req/sec)",
225 self.requests_per_second
226 ));
227 }
228 if self.fingerprint_anomaly {
229 reasons.push(format!(
230 "fingerprint_anomaly ({} changes)",
231 self.fingerprint_changes
232 ));
233 }
234 if reasons.is_empty() {
235 "none".to_string()
236 } else {
237 reasons.join(", ")
238 }
239 }
240}
241
242#[derive(Debug, Clone, serde::Serialize)]
244pub struct InjectionSummary {
245 pub ip: String,
247 pub ua_hash: String,
249 pub js_success_rate: f64,
251 pub cookie_success_rate: f64,
253 pub js_attempts: u32,
255 pub cookie_attempts: u32,
257 pub response_time_variance_ms: f64,
259 pub requests_per_second: f64,
261 pub headless_indicators: HeadlessIndicators,
263 pub is_likely_headless: bool,
265 pub first_seen: u64,
267 pub last_seen: u64,
269 pub total_requests: u64,
271}
272
273#[derive(Debug, Default)]
275pub struct InjectionTrackerStats {
276 pub js_attempts_total: AtomicU64,
278 pub js_successes_total: AtomicU64,
280 pub cookie_attempts_total: AtomicU64,
282 pub cookie_successes_total: AtomicU64,
284 pub headless_detected: AtomicU64,
286 pub blocks_issued: AtomicU64,
288 pub records_expired: AtomicU64,
290 pub records_evicted: AtomicU64,
292}
293
294impl InjectionTrackerStats {
295 pub fn snapshot(&self) -> InjectionTrackerStatsSnapshot {
297 InjectionTrackerStatsSnapshot {
298 js_attempts_total: self.js_attempts_total.load(Ordering::Relaxed),
299 js_successes_total: self.js_successes_total.load(Ordering::Relaxed),
300 cookie_attempts_total: self.cookie_attempts_total.load(Ordering::Relaxed),
301 cookie_successes_total: self.cookie_successes_total.load(Ordering::Relaxed),
302 headless_detected: self.headless_detected.load(Ordering::Relaxed),
303 blocks_issued: self.blocks_issued.load(Ordering::Relaxed),
304 records_expired: self.records_expired.load(Ordering::Relaxed),
305 records_evicted: self.records_evicted.load(Ordering::Relaxed),
306 }
307 }
308}
309
310#[derive(Debug, Clone, serde::Serialize)]
312pub struct InjectionTrackerStatsSnapshot {
313 pub js_attempts_total: u64,
314 pub js_successes_total: u64,
315 pub cookie_attempts_total: u64,
316 pub cookie_successes_total: u64,
317 pub headless_detected: u64,
318 pub blocks_issued: u64,
319 pub records_expired: u64,
320 pub records_evicted: u64,
321}
322
323#[derive(Debug)]
327pub struct InjectionTracker {
328 records: DashMap<String, InjectionRecord>,
330 config: InjectionTrackerConfig,
332 stats: InjectionTrackerStats,
334}
335
336impl Default for InjectionTracker {
337 fn default() -> Self {
338 Self::new(InjectionTrackerConfig::default())
339 }
340}
341
342impl InjectionTracker {
343 pub fn new(config: InjectionTrackerConfig) -> Self {
345 Self {
346 records: DashMap::with_capacity(config.max_records / 2),
347 config,
348 stats: InjectionTrackerStats::default(),
349 }
350 }
351
352 pub fn config(&self) -> &InjectionTrackerConfig {
354 &self.config
355 }
356
357 fn actor_key(ip: &str, ua: &str) -> String {
359 let ua_hash = hash_string(ua);
360 format!("{}:{}", ip, ua_hash)
361 }
362
363 fn hash_ua(ua: &str) -> String {
365 hash_string(ua)
366 }
367
368 pub fn record_js_attempt(
380 &self,
381 ip: &str,
382 ua: &str,
383 success: bool,
384 response_time_ms: u64,
385 fingerprint: Option<&str>,
386 ) -> HeadlessIndicators {
387 let now = now_ms();
388 let key = Self::actor_key(ip, ua);
389 let ua_hash = Self::hash_ua(ua);
390
391 self.ensure_capacity();
393
394 let mut entry = self
396 .records
397 .entry(key)
398 .or_insert_with(|| InjectionRecord::new(ip.to_string(), ua_hash, now));
399
400 let record = entry.value_mut();
401 record.js_attempts += 1;
402 if success {
403 record.js_successes += 1;
404 self.stats
405 .js_successes_total
406 .fetch_add(1, Ordering::Relaxed);
407 }
408 record.last_seen = now;
409 record.request_count += 1;
410
411 if record.response_times.len() >= self.config.response_time_window {
413 record.response_times.pop_front();
414 }
415 record.response_times.push_back(response_time_ms);
416
417 if let Some(fp) = fingerprint {
419 let fp_hash = hash_string(fp);
420 if !record.fingerprints_seen.contains(&fp_hash) {
421 if !record.fingerprints_seen.is_empty() {
422 record.fingerprint_changes += 1;
423 }
424 record.fingerprints_seen.insert(fp_hash.clone());
425 record.fingerprints_order.push_back(fp_hash);
426 if record.fingerprints_order.len() > 50 {
428 if let Some(oldest) = record.fingerprints_order.pop_front() {
429 record.fingerprints_seen.remove(&oldest);
430 }
431 }
432 }
433 }
434
435 self.stats.js_attempts_total.fetch_add(1, Ordering::Relaxed);
436
437 self.calculate_indicators(record)
439 }
440
441 pub fn record_cookie_attempt(&self, ip: &str, ua: &str, success: bool) {
448 let now = now_ms();
449 let key = Self::actor_key(ip, ua);
450 let ua_hash = Self::hash_ua(ua);
451
452 self.ensure_capacity();
454
455 let mut entry = self
457 .records
458 .entry(key)
459 .or_insert_with(|| InjectionRecord::new(ip.to_string(), ua_hash, now));
460
461 let record = entry.value_mut();
462 record.cookie_attempts += 1;
463 if success {
464 record.cookie_successes += 1;
465 self.stats
466 .cookie_successes_total
467 .fetch_add(1, Ordering::Relaxed);
468 }
469 record.last_seen = now;
470 record.request_count += 1;
471
472 self.stats
473 .cookie_attempts_total
474 .fetch_add(1, Ordering::Relaxed);
475 }
476
477 pub fn get_summary(&self, ip: &str, ua: &str) -> Option<InjectionSummary> {
486 let key = Self::actor_key(ip, ua);
487 let record = self.records.get(&key)?;
488
489 let indicators = self.calculate_indicators(&record);
490 let is_likely_headless = self.is_likely_headless(&record, &indicators);
491
492 Some(InjectionSummary {
493 ip: record.ip.clone(),
494 ua_hash: record.ua_hash.clone(),
495 js_success_rate: record.js_success_rate(),
496 cookie_success_rate: if record.cookie_attempts == 0 {
497 1.0
498 } else {
499 (record.cookie_successes as f64) / (record.cookie_attempts as f64)
500 },
501 js_attempts: record.js_attempts,
502 cookie_attempts: record.cookie_attempts,
503 response_time_variance_ms: record.response_time_variance(),
504 requests_per_second: record.requests_per_second(),
505 headless_indicators: indicators,
506 is_likely_headless,
507 first_seen: record.first_seen,
508 last_seen: record.last_seen,
509 total_requests: record.request_count,
510 })
511 }
512
513 pub fn should_block(&self, ip: &str, ua: &str) -> (bool, Option<String>) {
522 let key = Self::actor_key(ip, ua);
523 let record = match self.records.get(&key) {
524 Some(r) => r,
525 None => return (false, None),
526 };
527
528 let indicators = self.calculate_indicators(&record);
529 let is_headless = self.is_likely_headless(&record, &indicators);
530
531 if is_headless {
532 let reason = format!("Headless browser detected: {}", indicators.description());
533 self.stats.blocks_issued.fetch_add(1, Ordering::Relaxed);
534 (true, Some(reason))
535 } else {
536 (false, None)
537 }
538 }
539
540 pub fn cleanup_expired(&self) -> usize {
545 let now = now_ms();
546 let ttl_ms = self.config.record_ttl_secs * 1000;
547 let mut removed = 0;
548
549 self.records.retain(|_, record| {
550 if now.saturating_sub(record.last_seen) > ttl_ms {
551 removed += 1;
552 false
553 } else {
554 true
555 }
556 });
557
558 self.stats
559 .records_expired
560 .fetch_add(removed as u64, Ordering::Relaxed);
561 removed
562 }
563
564 pub fn stats(&self) -> &InjectionTrackerStats {
566 &self.stats
567 }
568
569 pub fn len(&self) -> usize {
571 self.records.len()
572 }
573
574 pub fn is_empty(&self) -> bool {
576 self.records.is_empty()
577 }
578
579 pub fn clear(&self) {
581 self.records.clear();
582 }
583
584 fn calculate_indicators(&self, record: &InjectionRecord) -> HeadlessIndicators {
588 let js_success_rate = record.js_success_rate();
589 let timing_variance = record.response_time_variance();
590 let rps = record.requests_per_second();
591
592 let no_js_execution = record.js_attempts >= self.config.min_attempts_for_detection
594 && js_success_rate < self.config.js_success_rate_threshold;
595
596 let consistent_timing = record.response_times.len() >= 5
598 && timing_variance < self.config.timing_variance_threshold_ms;
599
600 let rapid_requests =
602 record.request_count >= 10 && rps > self.config.rapid_request_threshold_rps;
603
604 let fingerprint_anomaly = if record.request_count >= 10 {
607 let never_changes =
609 record.fingerprints_seen.len() <= 1 && record.fingerprint_changes == 0;
610 let too_many_changes = record.fingerprint_changes > self.config.max_fingerprint_changes;
612 never_changes || too_many_changes
613 } else {
614 false
615 };
616
617 HeadlessIndicators {
618 no_js_execution,
619 consistent_timing,
620 rapid_requests,
621 fingerprint_anomaly,
622 timing_variance_ms: timing_variance,
623 requests_per_second: rps,
624 js_success_rate,
625 fingerprint_changes: record.fingerprint_changes,
626 }
627 }
628
629 fn is_likely_headless(
631 &self,
632 record: &InjectionRecord,
633 indicators: &HeadlessIndicators,
634 ) -> bool {
635 if record.js_attempts < self.config.min_attempts_for_detection {
637 return false;
638 }
639
640 if indicators.no_js_execution {
642 self.stats.headless_detected.fetch_add(1, Ordering::Relaxed);
643 return true;
644 }
645
646 if indicators.indicator_count() >= 2 {
648 self.stats.headless_detected.fetch_add(1, Ordering::Relaxed);
649 return true;
650 }
651
652 false
653 }
654
655 fn ensure_capacity(&self) {
658 if self.records.len() >= self.config.max_records {
659 let to_remove = self.config.max_records / 10;
662 let sample_size = (to_remove * 5).min(1000).min(self.records.len());
663
664 if sample_size == 0 {
665 return;
666 }
667
668 let mut candidates: Vec<(String, u64)> = Vec::with_capacity(sample_size);
670 for entry in self.records.iter().take(sample_size) {
671 candidates.push((entry.key().clone(), entry.value().last_seen));
672 }
673
674 candidates.sort_unstable_by_key(|(_, last_seen)| *last_seen);
676
677 for (key, _) in candidates.into_iter().take(to_remove) {
679 self.records.remove(&key);
680 self.stats.records_evicted.fetch_add(1, Ordering::Relaxed);
681 }
682 }
683 }
684}
685
686#[inline]
688fn now_ms() -> u64 {
689 SystemTime::now()
690 .duration_since(UNIX_EPOCH)
691 .map(|d| d.as_millis() as u64)
692 .unwrap_or(0)
693}
694
695fn hash_string(s: &str) -> String {
697 let mut hasher = Sha256::new();
698 hasher.update(s.as_bytes());
699 let result = hasher.finalize();
700 hex::encode(&result[..8]) }
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 fn test_config() -> InjectionTrackerConfig {
708 InjectionTrackerConfig {
709 max_records: 1000,
710 record_ttl_secs: 60,
711 min_attempts_for_detection: 5,
712 timing_variance_threshold_ms: 50.0,
713 rapid_request_threshold_rps: 10.0,
714 max_fingerprint_changes: 20,
715 js_success_rate_threshold: 0.1,
716 response_time_window: 20,
717 }
718 }
719
720 #[test]
721 fn test_new_tracker() {
722 let tracker = InjectionTracker::new(test_config());
723 assert!(tracker.is_empty());
724 assert_eq!(tracker.len(), 0);
725 }
726
727 #[test]
728 fn test_record_js_attempt_success() {
729 let tracker = InjectionTracker::new(test_config());
730
731 let indicators = tracker.record_js_attempt("192.168.1.1", "Mozilla/5.0", true, 100, None);
732
733 assert!(!indicators.is_suspicious());
734 assert_eq!(tracker.len(), 1);
735
736 let stats = tracker.stats().snapshot();
737 assert_eq!(stats.js_attempts_total, 1);
738 assert_eq!(stats.js_successes_total, 1);
739 }
740
741 #[test]
742 fn test_record_js_attempt_failure() {
743 let tracker = InjectionTracker::new(test_config());
744
745 let indicators = tracker.record_js_attempt("192.168.1.1", "Mozilla/5.0", false, 100, None);
746
747 assert!(!indicators.is_suspicious()); assert_eq!(tracker.len(), 1);
749
750 let stats = tracker.stats().snapshot();
751 assert_eq!(stats.js_attempts_total, 1);
752 assert_eq!(stats.js_successes_total, 0);
753 }
754
755 #[test]
756 fn test_no_js_execution_detection() {
757 let tracker = InjectionTracker::new(test_config());
758 let ip = "192.168.1.1";
759 let ua = "Mozilla/5.0";
760
761 for i in 0..6 {
763 let indicators = tracker.record_js_attempt(ip, ua, false, 100 + i, None);
764 if i >= 4 {
765 assert!(indicators.no_js_execution);
767 }
768 }
769
770 let summary = tracker.get_summary(ip, ua).unwrap();
771 assert!(summary.is_likely_headless);
772 assert!(summary.headless_indicators.no_js_execution);
773 }
774
775 #[test]
776 fn test_consistent_timing_detection() {
777 let mut config = test_config();
778 config.timing_variance_threshold_ms = 100.0; let tracker = InjectionTracker::new(config);
780 let ip = "192.168.1.1";
781 let ua = "Mozilla/5.0";
782
783 for _ in 0..10 {
785 tracker.record_js_attempt(ip, ua, true, 100, None);
786 }
787
788 let summary = tracker.get_summary(ip, ua).unwrap();
789 assert!(summary.headless_indicators.consistent_timing);
790 assert!(summary.response_time_variance_ms < 100.0);
791 }
792
793 #[test]
794 fn test_variable_timing_not_suspicious() {
795 let mut config = test_config();
796 config.timing_variance_threshold_ms = 30.0; let tracker = InjectionTracker::new(config);
798 let ip = "192.168.1.1";
799 let ua = "Mozilla/5.0";
800
801 let times = [50, 200, 70, 250, 100, 300, 80, 220, 60, 280];
803 for t in times {
804 tracker.record_js_attempt(ip, ua, true, t, None);
805 }
806
807 let summary = tracker.get_summary(ip, ua).unwrap();
808 assert!(
810 summary.response_time_variance_ms > 30.0,
811 "Expected high variance, got {}",
812 summary.response_time_variance_ms
813 );
814 assert!(!summary.headless_indicators.consistent_timing);
815 }
816
817 #[test]
818 fn test_rapid_requests_detection() {
819 let tracker = InjectionTracker::new(test_config());
820 let ip = "192.168.1.1";
821 let ua = "Mozilla/5.0";
822
823 for _ in 0..20 {
826 tracker.record_js_attempt(ip, ua, true, 100, None);
827 }
828
829 let summary = tracker.get_summary(ip, ua).unwrap();
830 assert!(summary.requests_per_second > 10.0);
832 }
833
834 #[test]
835 fn test_fingerprint_tracking() {
836 let tracker = InjectionTracker::new(test_config());
837 let ip = "192.168.1.1";
838 let ua = "Mozilla/5.0";
839
840 tracker.record_js_attempt(ip, ua, true, 100, Some("fp_hash_1"));
842 tracker.record_js_attempt(ip, ua, true, 100, Some("fp_hash_2"));
843 tracker.record_js_attempt(ip, ua, true, 100, Some("fp_hash_3"));
844
845 let summary = tracker.get_summary(ip, ua).unwrap();
846 assert_eq!(summary.headless_indicators.fingerprint_changes, 2);
847 }
848
849 #[test]
850 fn test_fingerprint_anomaly_too_many_changes() {
851 let mut config = test_config();
852 config.max_fingerprint_changes = 5;
853 config.min_attempts_for_detection = 3;
854 let tracker = InjectionTracker::new(config);
855 let ip = "192.168.1.1";
856 let ua = "Mozilla/5.0";
857
858 for i in 0..15 {
860 tracker.record_js_attempt(ip, ua, true, 100, Some(&format!("fp_{}", i)));
861 }
862
863 let summary = tracker.get_summary(ip, ua).unwrap();
864 assert!(summary.headless_indicators.fingerprint_anomaly);
865 }
866
867 #[test]
868 fn test_record_cookie_attempt() {
869 let tracker = InjectionTracker::new(test_config());
870 let ip = "192.168.1.1";
871 let ua = "Mozilla/5.0";
872
873 tracker.record_cookie_attempt(ip, ua, true);
874 tracker.record_cookie_attempt(ip, ua, false);
875
876 let stats = tracker.stats().snapshot();
877 assert_eq!(stats.cookie_attempts_total, 2);
878 assert_eq!(stats.cookie_successes_total, 1);
879
880 let summary = tracker.get_summary(ip, ua).unwrap();
881 assert_eq!(summary.cookie_attempts, 2);
882 assert_eq!(summary.cookie_success_rate, 0.5);
883 }
884
885 #[test]
886 fn test_should_block_no_record() {
887 let tracker = InjectionTracker::new(test_config());
888
889 let (should_block, reason) = tracker.should_block("192.168.1.1", "Mozilla/5.0");
890 assert!(!should_block);
891 assert!(reason.is_none());
892 }
893
894 #[test]
895 fn test_should_block_headless() {
896 let tracker = InjectionTracker::new(test_config());
897 let ip = "192.168.1.1";
898 let ua = "Mozilla/5.0";
899
900 for _ in 0..6 {
902 tracker.record_js_attempt(ip, ua, false, 100, None);
903 }
904
905 let (should_block, reason) = tracker.should_block(ip, ua);
906 assert!(should_block);
907 assert!(reason.is_some());
908 assert!(reason.unwrap().contains("Headless browser detected"));
909 }
910
911 #[test]
912 fn test_cleanup_expired() {
913 let mut config = test_config();
914 config.record_ttl_secs = 0; let tracker = InjectionTracker::new(config);
916
917 tracker.record_js_attempt("192.168.1.1", "UA1", true, 100, None);
918 tracker.record_js_attempt("192.168.1.2", "UA2", true, 100, None);
919 assert_eq!(tracker.len(), 2);
920
921 std::thread::sleep(std::time::Duration::from_millis(10));
923
924 let removed = tracker.cleanup_expired();
925 assert_eq!(removed, 2);
926 assert!(tracker.is_empty());
927
928 let stats = tracker.stats().snapshot();
929 assert_eq!(stats.records_expired, 2);
930 }
931
932 #[test]
933 fn test_capacity_eviction() {
934 let mut config = test_config();
935 config.max_records = 10;
936 let tracker = InjectionTracker::new(config);
937
938 for i in 0..15 {
940 tracker.record_js_attempt(&format!("192.168.1.{}", i), "UA", true, 100, None);
941 }
942
943 assert!(tracker.len() <= 10);
945 }
946
947 #[test]
948 fn test_actor_key_consistency() {
949 let key1 = InjectionTracker::actor_key("192.168.1.1", "Mozilla/5.0");
950 let key2 = InjectionTracker::actor_key("192.168.1.1", "Mozilla/5.0");
951 let key3 = InjectionTracker::actor_key("192.168.1.1", "Chrome/100");
952
953 assert_eq!(key1, key2);
954 assert_ne!(key1, key3);
955 }
956
957 #[test]
958 fn test_ua_hash() {
959 let hash1 = InjectionTracker::hash_ua("Mozilla/5.0");
960 let hash2 = InjectionTracker::hash_ua("Mozilla/5.0");
961 let hash3 = InjectionTracker::hash_ua("Chrome/100");
962
963 assert_eq!(hash1, hash2);
964 assert_ne!(hash1, hash3);
965 assert_eq!(hash1.len(), 16);
966 }
967
968 #[test]
969 fn test_indicators_description() {
970 let indicators = HeadlessIndicators {
971 no_js_execution: true,
972 consistent_timing: false,
973 rapid_requests: true,
974 fingerprint_anomaly: false,
975 timing_variance_ms: 100.0,
976 requests_per_second: 15.0,
977 js_success_rate: 0.0,
978 fingerprint_changes: 0,
979 };
980
981 let desc = indicators.description();
982 assert!(desc.contains("no_js_execution"));
983 assert!(desc.contains("rapid_requests"));
984 assert!(!desc.contains("consistent_timing"));
985 }
986
987 #[test]
988 fn test_indicators_count() {
989 let indicators = HeadlessIndicators {
990 no_js_execution: true,
991 consistent_timing: true,
992 rapid_requests: false,
993 fingerprint_anomaly: false,
994 ..Default::default()
995 };
996
997 assert_eq!(indicators.indicator_count(), 2);
998 assert!(indicators.is_suspicious());
999 }
1000
1001 #[test]
1002 fn test_response_time_variance_calculation() {
1003 let mut record = InjectionRecord::new("192.168.1.1".to_string(), "hash".to_string(), 0);
1004
1005 record.response_times.push_back(100);
1007 assert_eq!(record.response_time_variance(), f64::MAX);
1008
1009 record.response_times.push_back(100);
1011 record.response_times.push_back(100);
1012 assert_eq!(record.response_time_variance(), 0.0); record.response_times.clear();
1016 record.response_times.push_back(50);
1017 record.response_times.push_back(150);
1018 let variance = record.response_time_variance();
1019 assert!(variance > 0.0);
1020 }
1021
1022 #[test]
1023 fn test_requests_per_second_calculation() {
1024 let mut record = InjectionRecord::new("192.168.1.1".to_string(), "hash".to_string(), 1000);
1025 record.request_count = 10;
1026 record.last_seen = 2000; let rps = record.requests_per_second();
1029 assert_eq!(rps, 10.0);
1030 }
1031
1032 #[test]
1033 fn test_clear() {
1034 let tracker = InjectionTracker::new(test_config());
1035
1036 tracker.record_js_attempt("192.168.1.1", "UA1", true, 100, None);
1037 tracker.record_js_attempt("192.168.1.2", "UA2", true, 100, None);
1038 assert_eq!(tracker.len(), 2);
1039
1040 tracker.clear();
1041 assert!(tracker.is_empty());
1042 }
1043
1044 #[test]
1045 fn test_summary_not_found() {
1046 let tracker = InjectionTracker::new(test_config());
1047
1048 let summary = tracker.get_summary("192.168.1.1", "Mozilla/5.0");
1049 assert!(summary.is_none());
1050 }
1051
1052 #[test]
1053 fn test_multiple_weak_signals_trigger_detection() {
1054 let mut config = test_config();
1055 config.timing_variance_threshold_ms = 100.0;
1056 config.min_attempts_for_detection = 5;
1057 let tracker = InjectionTracker::new(config);
1058 let ip = "192.168.1.1";
1059 let ua = "Mozilla/5.0";
1060
1061 for _ in 0..15 {
1064 tracker.record_js_attempt(ip, ua, true, 100, None);
1065 }
1066
1067 let summary = tracker.get_summary(ip, ua).unwrap();
1068
1069 let indicators = &summary.headless_indicators;
1071 let count = indicators.indicator_count();
1072
1073 if count >= 2 {
1075 assert!(summary.is_likely_headless);
1076 }
1077 }
1078}