Skip to main content

synapse_pingora/interrogator/
injection_tracker.rs

1//! Injection Tracker - Headless Browser Detection
2//!
3//! Tracks the effectiveness of JavaScript injection challenges and detects headless
4//! browsers through behavioral analysis. This module correlates challenge attempts
5//! with response timing and fingerprint patterns to identify automated clients.
6//!
7//! # Detection Signals
8//!
9//! The tracker monitors multiple signals to identify headless browsers:
10//!
11//! 1. **No JS Execution**: 0% success rate after 5+ attempts indicates JS is disabled
12//! 2. **Consistent Timing**: Low variance in response times suggests automation
13//! 3. **Rapid Requests**: >10 requests/second indicates bot behavior
14//! 4. **Fingerprint Anomaly**: Fingerprint never changes or changes too frequently
15//!
16//! # Architecture
17//!
18//! ```text
19//! +-------------------+     +------------------+     +------------------+
20//! | JS Challenge      | --> | InjectionTracker | --> | Headless         |
21//! | Attempts/Results  |     | (Correlation)    |     | Detection        |
22//! +-------------------+     +------------------+     +------------------+
23//!                                   |
24//!                                   v
25//!                           +------------------+
26//!                           | Block Decision   |
27//!                           | (should_block)   |
28//!                           +------------------+
29//! ```
30//!
31//! # Thread Safety
32//!
33//! Uses DashMap for lock-free concurrent access, suitable for high-throughput
34//! request processing in the proxy pipeline.
35
36use dashmap::DashMap;
37use sha2::{Digest, Sha256};
38use std::collections::VecDeque;
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::time::{SystemTime, UNIX_EPOCH};
41
42/// Configuration for the injection tracker.
43///
44/// All fields have sensible defaults via `Default` implementation.
45/// Can be serialized/deserialized for configuration file support.
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47#[serde(default)]
48pub struct InjectionTrackerConfig {
49    /// Maximum number of records to keep (default: 100_000)
50    pub max_records: usize,
51    /// Record time-to-live in seconds (default: 3600 = 1 hour)
52    pub record_ttl_secs: u64,
53    /// Minimum attempts before making headless determination (default: 5)
54    pub min_attempts_for_detection: u32,
55    /// Response time variance threshold (ms) below which is suspicious (default: 50.0)
56    /// Low variance indicates automated timing
57    pub timing_variance_threshold_ms: f64,
58    /// Requests per second threshold above which is suspicious (default: 10.0)
59    pub rapid_request_threshold_rps: f64,
60    /// Maximum fingerprint changes allowed before anomaly (default: 20)
61    /// Excessive changes indicate fingerprint spoofing
62    pub max_fingerprint_changes: u32,
63    /// JS success rate threshold below which is suspicious (default: 0.1 = 10%)
64    pub js_success_rate_threshold: f64,
65    /// Number of response times to keep for variance calculation (default: 20)
66    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/// Record of injection tracking for an actor (IP + UA combination)
85#[derive(Debug, Clone)]
86pub struct InjectionRecord {
87    /// IP address
88    pub ip: String,
89    /// Hash of User-Agent (for privacy)
90    pub ua_hash: String,
91    /// Number of JS challenge attempts
92    pub js_attempts: u32,
93    /// Number of JS challenge successes
94    pub js_successes: u32,
95    /// Number of cookie challenge attempts
96    pub cookie_attempts: u32,
97    /// Number of cookie challenge successes
98    pub cookie_successes: u32,
99    /// Recent response times (ms) for variance calculation
100    pub response_times: VecDeque<u64>,
101    /// Set of fingerprint hashes seen (HashSet for O(1) lookup)
102    pub fingerprints_seen: std::collections::HashSet<String>,
103    /// Ordered list of fingerprints for tracking changes (VecDeque for O(1) removal)
104    fingerprints_order: VecDeque<String>,
105    /// Number of times fingerprint changed
106    pub fingerprint_changes: u32,
107    /// First request timestamp (unix ms)
108    pub first_seen: u64,
109    /// Last request timestamp (unix ms)
110    pub last_seen: u64,
111    /// Total request count for rate calculation
112    pub request_count: u64,
113}
114
115impl InjectionRecord {
116    /// Create a new injection record
117    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    /// Calculate response time variance (standard deviation)
136    fn response_time_variance(&self) -> f64 {
137        if self.response_times.len() < 2 {
138            return f64::MAX; // Not enough data
139        }
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    /// Calculate requests per second
149    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; // All requests in same ms
153        }
154        (self.request_count as f64) / (duration_ms as f64 / 1000.0)
155    }
156
157    /// Calculate JS success rate
158    fn js_success_rate(&self) -> f64 {
159        if self.js_attempts == 0 {
160            return 1.0; // No attempts = not suspicious (yet)
161        }
162        (self.js_successes as f64) / (self.js_attempts as f64)
163    }
164}
165
166/// Indicators that an actor may be a headless browser
167#[derive(Debug, Clone, Default, serde::Serialize)]
168pub struct HeadlessIndicators {
169    /// No JavaScript execution detected (0% success after min attempts)
170    pub no_js_execution: bool,
171    /// Response timing is suspiciously consistent (low variance)
172    pub consistent_timing: bool,
173    /// Request rate is too rapid (>threshold RPS)
174    pub rapid_requests: bool,
175    /// Fingerprint behavior is anomalous (never changes or changes too much)
176    pub fingerprint_anomaly: bool,
177    /// Response time variance (ms) - lower is more suspicious
178    pub timing_variance_ms: f64,
179    /// Current requests per second
180    pub requests_per_second: f64,
181    /// JS challenge success rate
182    pub js_success_rate: f64,
183    /// Number of fingerprint changes observed
184    pub fingerprint_changes: u32,
185}
186
187impl HeadlessIndicators {
188    /// Check if any headless indicator is triggered.
189    #[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    /// Count how many indicators are triggered.
198    #[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    /// Get human-readable description of triggered indicators.
207    #[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/// Summary of injection tracking for an actor
243#[derive(Debug, Clone, serde::Serialize)]
244pub struct InjectionSummary {
245    /// IP address
246    pub ip: String,
247    /// User-Agent hash
248    pub ua_hash: String,
249    /// JS challenge success rate
250    pub js_success_rate: f64,
251    /// Cookie challenge success rate
252    pub cookie_success_rate: f64,
253    /// Total JS attempts
254    pub js_attempts: u32,
255    /// Total cookie attempts
256    pub cookie_attempts: u32,
257    /// Response time variance (ms)
258    pub response_time_variance_ms: f64,
259    /// Requests per second
260    pub requests_per_second: f64,
261    /// Headless detection indicators
262    pub headless_indicators: HeadlessIndicators,
263    /// Whether this actor is likely a headless browser
264    pub is_likely_headless: bool,
265    /// First seen timestamp
266    pub first_seen: u64,
267    /// Last seen timestamp
268    pub last_seen: u64,
269    /// Total requests observed
270    pub total_requests: u64,
271}
272
273/// Statistics for the injection tracker
274#[derive(Debug, Default)]
275pub struct InjectionTrackerStats {
276    /// Total JS attempts recorded
277    pub js_attempts_total: AtomicU64,
278    /// Total JS successes recorded
279    pub js_successes_total: AtomicU64,
280    /// Total cookie attempts recorded
281    pub cookie_attempts_total: AtomicU64,
282    /// Total cookie successes recorded
283    pub cookie_successes_total: AtomicU64,
284    /// Actors detected as headless
285    pub headless_detected: AtomicU64,
286    /// Block decisions made
287    pub blocks_issued: AtomicU64,
288    /// Records cleaned up due to expiration
289    pub records_expired: AtomicU64,
290    /// Records cleaned up due to capacity
291    pub records_evicted: AtomicU64,
292}
293
294impl InjectionTrackerStats {
295    /// Create a snapshot of current stats
296    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/// Snapshot of stats for serialization
311#[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/// Thread-safe injection tracker for headless browser detection.
324///
325/// Implements `Default` for convenient construction with default configuration.
326#[derive(Debug)]
327pub struct InjectionTracker {
328    /// Records by actor key (ip:ua_hash)
329    records: DashMap<String, InjectionRecord>,
330    /// Configuration
331    config: InjectionTrackerConfig,
332    /// Statistics
333    stats: InjectionTrackerStats,
334}
335
336impl Default for InjectionTracker {
337    fn default() -> Self {
338        Self::new(InjectionTrackerConfig::default())
339    }
340}
341
342impl InjectionTracker {
343    /// Create a new injection tracker with the given configuration.
344    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    /// Get the configuration
353    pub fn config(&self) -> &InjectionTrackerConfig {
354        &self.config
355    }
356
357    /// Generate actor key from IP and User-Agent
358    fn actor_key(ip: &str, ua: &str) -> String {
359        let ua_hash = hash_string(ua);
360        format!("{}:{}", ip, ua_hash)
361    }
362
363    /// Hash User-Agent for privacy (first 16 hex chars of SHA256)
364    fn hash_ua(ua: &str) -> String {
365        hash_string(ua)
366    }
367
368    /// Record a JavaScript challenge attempt
369    ///
370    /// # Arguments
371    /// * `ip` - Client IP address
372    /// * `ua` - User-Agent string
373    /// * `success` - Whether the challenge was passed
374    /// * `response_time_ms` - Time to complete challenge (ms)
375    /// * `fingerprint` - Optional browser fingerprint
376    ///
377    /// # Returns
378    /// Headless indicators based on current data
379    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        // Ensure we have capacity
392        self.ensure_capacity();
393
394        // Update or create record
395        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        // Track response time
412        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        // Track fingerprint using HashSet for O(1) lookup
418        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                // Limit fingerprint history - O(1) removal with VecDeque
427                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        // Calculate indicators
438        self.calculate_indicators(record)
439    }
440
441    /// Record a cookie challenge attempt
442    ///
443    /// # Arguments
444    /// * `ip` - Client IP address
445    /// * `ua` - User-Agent string
446    /// * `success` - Whether the cookie was accepted/returned
447    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        // Ensure we have capacity
453        self.ensure_capacity();
454
455        // Update or create record
456        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    /// Get a summary of injection tracking for an actor
478    ///
479    /// # Arguments
480    /// * `ip` - Client IP address
481    /// * `ua` - User-Agent string
482    ///
483    /// # Returns
484    /// Summary if actor has been tracked, None otherwise
485    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    /// Determine if an actor should be blocked
514    ///
515    /// # Arguments
516    /// * `ip` - Client IP address
517    /// * `ua` - User-Agent string
518    ///
519    /// # Returns
520    /// Tuple of (should_block, optional_reason)
521    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    /// Remove expired records
541    ///
542    /// # Returns
543    /// Number of records removed
544    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    /// Get statistics
565    pub fn stats(&self) -> &InjectionTrackerStats {
566        &self.stats
567    }
568
569    /// Get number of tracked actors
570    pub fn len(&self) -> usize {
571        self.records.len()
572    }
573
574    /// Check if no actors are tracked
575    pub fn is_empty(&self) -> bool {
576        self.records.is_empty()
577    }
578
579    /// Clear all records
580    pub fn clear(&self) {
581        self.records.clear();
582    }
583
584    // --- Private helpers ---
585
586    /// Calculate headless indicators for a record
587    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        // Check for no JS execution
593        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        // Check for consistent timing (low variance)
597        let consistent_timing = record.response_times.len() >= 5
598            && timing_variance < self.config.timing_variance_threshold_ms;
599
600        // Check for rapid requests
601        let rapid_requests =
602            record.request_count >= 10 && rps > self.config.rapid_request_threshold_rps;
603
604        // Check for fingerprint anomaly
605        // Anomaly = never changes after many requests OR changes too frequently
606        let fingerprint_anomaly = if record.request_count >= 10 {
607            // Never changes after many requests (static fingerprint or none)
608            let never_changes =
609                record.fingerprints_seen.len() <= 1 && record.fingerprint_changes == 0;
610            // Changes too frequently (spoofing)
611            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    /// Determine if actor is likely a headless browser
630    fn is_likely_headless(
631        &self,
632        record: &InjectionRecord,
633        indicators: &HeadlessIndicators,
634    ) -> bool {
635        // Need minimum data before making determination
636        if record.js_attempts < self.config.min_attempts_for_detection {
637            return false;
638        }
639
640        // Strong signal: no JS execution at all
641        if indicators.no_js_execution {
642            self.stats.headless_detected.fetch_add(1, Ordering::Relaxed);
643            return true;
644        }
645
646        // Multiple weak signals combined
647        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    /// Ensure we have capacity for new records (evict oldest if needed)
656    /// Uses probabilistic sampling to avoid O(n) collection of all records.
657    fn ensure_capacity(&self) {
658        if self.records.len() >= self.config.max_records {
659            // Evict ~10% of oldest records using SAMPLING (not full collection)
660            // Sample up to 1000 records to find eviction candidates
661            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            // Sample records (DashMap iteration is already semi-random due to sharding)
669            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            // Sort sample by last_seen (oldest first)
675            candidates.sort_unstable_by_key(|(_, last_seen)| *last_seen);
676
677            // Evict oldest from sample
678            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/// Get current time in milliseconds since Unix epoch
687#[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
695/// Hash a string using SHA256, return first 16 hex chars
696fn 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]) // First 8 bytes = 16 hex chars
701}
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()); // Not enough attempts yet
748        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        // Make 5+ failed attempts
762        for i in 0..6 {
763            let indicators = tracker.record_js_attempt(ip, ua, false, 100 + i, None);
764            if i >= 4 {
765                // After 5 attempts with 0% success
766                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; // Higher threshold for test
779        let tracker = InjectionTracker::new(config);
780        let ip = "192.168.1.1";
781        let ua = "Mozilla/5.0";
782
783        // Make attempts with very consistent timing (all 100ms)
784        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; // Lower threshold so high variance is not suspicious
797        let tracker = InjectionTracker::new(config);
798        let ip = "192.168.1.1";
799        let ua = "Mozilla/5.0";
800
801        // Make attempts with highly variable timing (std dev ~54ms)
802        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        // High variance (>30ms) should NOT trigger consistent_timing
809        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        // Make many requests "quickly" (we can't actually control time, but
824        // request_count/duration will give high RPS if all in same moment)
825        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        // All requests have same timestamp, so RPS will be very high
831        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        // Record with fingerprint
841        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        // Make many requests with different fingerprints
859        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        // Make enough failed attempts to trigger headless detection
901        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; // Immediate expiration
915        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        // Sleep to ensure expiration
922        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        // Add more records than max
939        for i in 0..15 {
940            tracker.record_js_attempt(&format!("192.168.1.{}", i), "UA", true, 100, None);
941        }
942
943        // Should have evicted some records
944        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        // Not enough data
1006        record.response_times.push_back(100);
1007        assert_eq!(record.response_time_variance(), f64::MAX);
1008
1009        // Add more data
1010        record.response_times.push_back(100);
1011        record.response_times.push_back(100);
1012        assert_eq!(record.response_time_variance(), 0.0); // All same = 0 variance
1013
1014        // Variable data
1015        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; // 1 second later
1027
1028        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        // Make requests with consistent timing and no fingerprint changes
1062        // but still passing JS (so no_js_execution is false)
1063        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        // Should have consistent_timing and fingerprint_anomaly (never changes)
1070        let indicators = &summary.headless_indicators;
1071        let count = indicators.indicator_count();
1072
1073        // With 2+ signals, should be detected as headless
1074        if count >= 2 {
1075            assert!(summary.is_likely_headless);
1076        }
1077    }
1078}