use std::collections::HashMap;
use std::time::{Duration, Instant};
use tracing::{info, warn};
#[derive(Debug, Clone, PartialEq)]
pub enum AbuseAction {
Allow,
Slowdown(Duration),
Quarantine(Duration),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum SignalType {
RateBurst,
PolicyViolation,
RepetitionSpam,
SessionChurn,
SensitiveProbe,
}
impl SignalType {
pub fn as_str(&self) -> &'static str {
match self {
Self::RateBurst => "rate_burst",
Self::PolicyViolation => "policy_violation",
Self::RepetitionSpam => "repetition_spam",
Self::SessionChurn => "session_churn",
Self::SensitiveProbe => "sensitive_probe",
}
}
}
#[derive(Debug, Clone)]
struct ActorSignals {
events: Vec<(SignalType, Instant)>,
quarantine_until: Option<Instant>,
slowdown_until: Option<Instant>,
}
impl ActorSignals {
fn new() -> Self {
Self {
events: Vec::new(),
quarantine_until: None,
slowdown_until: None,
}
}
fn prune_stale(&mut self, window: Duration) {
let cutoff = Instant::now() - window;
self.events.retain(|(_, ts)| *ts > cutoff);
}
}
#[derive(Debug, Clone)]
pub struct AbuseConfig {
pub window: Duration,
pub slowdown_threshold: f64,
pub quarantine_threshold: f64,
pub slowdown_duration: Duration,
pub quarantine_duration: Duration,
pub max_tracked_actors: usize,
}
impl Default for AbuseConfig {
fn default() -> Self {
Self {
window: Duration::from_secs(300),
slowdown_threshold: 0.5,
quarantine_threshold: 0.8,
slowdown_duration: Duration::from_secs(5),
quarantine_duration: Duration::from_secs(60),
max_tracked_actors: 10_000,
}
}
}
#[derive(Debug)]
pub struct AbuseTracker {
config: AbuseConfig,
actors: HashMap<String, ActorSignals>,
}
impl AbuseTracker {
pub fn new(config: AbuseConfig) -> Self {
Self {
config,
actors: HashMap::new(),
}
}
pub fn record_signal(&mut self, actor_id: &str, signal: SignalType) {
let entry = self
.actors
.entry(actor_id.to_string())
.or_insert_with(ActorSignals::new);
entry.events.push((signal, Instant::now()));
if self.actors.len() > self.config.max_tracked_actors {
self.evict_oldest();
}
}
pub fn evaluate(&mut self, actor_id: &str) -> (AbuseAction, f64) {
let entry = match self.actors.get_mut(actor_id) {
Some(e) => e,
None => return (AbuseAction::Allow, 0.0),
};
if let Some(until) = entry.quarantine_until {
if Instant::now() < until {
return (AbuseAction::Quarantine(until - Instant::now()), 1.0);
}
entry.quarantine_until = None;
}
if let Some(until) = entry.slowdown_until {
if Instant::now() < until {
return (
AbuseAction::Slowdown(until - Instant::now()),
self.config.slowdown_threshold,
);
}
entry.slowdown_until = None;
}
entry.prune_stale(self.config.window);
let score = Self::compute_score(&entry.events);
if score >= self.config.quarantine_threshold {
let until = Instant::now() + self.config.quarantine_duration;
entry.quarantine_until = Some(until);
warn!(actor = %actor_id, score, "abuse quarantine triggered");
(
AbuseAction::Quarantine(self.config.quarantine_duration),
score,
)
} else if score >= self.config.slowdown_threshold {
let until = Instant::now() + self.config.slowdown_duration;
entry.slowdown_until = Some(until);
info!(actor = %actor_id, score, "abuse slowdown triggered");
(AbuseAction::Slowdown(self.config.slowdown_duration), score)
} else {
(AbuseAction::Allow, score)
}
}
fn compute_score(events: &[(SignalType, Instant)]) -> f64 {
let mut total: f64 = 0.0;
for (sig, _) in events {
total += match sig {
SignalType::RateBurst => 0.15,
SignalType::PolicyViolation => 0.25,
SignalType::RepetitionSpam => 0.10,
SignalType::SessionChurn => 0.10,
SignalType::SensitiveProbe => 0.30,
};
}
total.min(1.0)
}
fn evict_oldest(&mut self) {
let oldest = self
.actors
.iter()
.filter_map(|(id, sig)| sig.events.last().map(|(_, ts)| (id.clone(), *ts)))
.min_by_key(|(_, ts)| *ts);
if let Some((id, _)) = oldest {
self.actors.remove(&id);
}
}
pub fn snapshot(&mut self) -> Vec<(String, f64, usize)> {
let window = self.config.window;
self.actors
.iter_mut()
.map(|(id, sig)| {
sig.prune_stale(window);
let score = Self::compute_score(&sig.events);
(id.clone(), score, sig.events.len())
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> AbuseConfig {
AbuseConfig {
window: Duration::from_secs(60),
slowdown_threshold: 0.4,
quarantine_threshold: 0.7,
slowdown_duration: Duration::from_millis(100),
quarantine_duration: Duration::from_millis(500),
max_tracked_actors: 100,
}
}
#[test]
fn clean_actor_is_allowed() {
let mut tracker = AbuseTracker::new(test_config());
let (action, score) = tracker.evaluate("clean-user");
assert_eq!(action, AbuseAction::Allow);
assert!((score - 0.0).abs() < f64::EPSILON);
}
#[test]
fn single_signal_below_threshold() {
let mut tracker = AbuseTracker::new(test_config());
tracker.record_signal("user-1", SignalType::RateBurst);
let (action, score) = tracker.evaluate("user-1");
assert_eq!(action, AbuseAction::Allow);
assert!((score - 0.15).abs() < f64::EPSILON);
}
#[test]
fn accumulated_signals_trigger_slowdown() {
let mut tracker = AbuseTracker::new(test_config());
for _ in 0..3 {
tracker.record_signal("user-2", SignalType::RateBurst);
}
let (action, score) = tracker.evaluate("user-2");
assert!(matches!(action, AbuseAction::Slowdown(_)));
assert!((score - 0.45).abs() < f64::EPSILON);
}
#[test]
fn severe_signals_trigger_quarantine() {
let mut tracker = AbuseTracker::new(test_config());
tracker.record_signal("attacker", SignalType::SensitiveProbe);
tracker.record_signal("attacker", SignalType::PolicyViolation);
tracker.record_signal("attacker", SignalType::RateBurst);
let (action, score) = tracker.evaluate("attacker");
assert!(matches!(action, AbuseAction::Quarantine(_)));
assert!((score - 0.70).abs() < f64::EPSILON);
}
#[test]
fn score_clamps_to_one() {
let mut tracker = AbuseTracker::new(test_config());
for _ in 0..20 {
tracker.record_signal("spammer", SignalType::SensitiveProbe);
}
let (_, score) = tracker.evaluate("spammer");
assert!((score - 1.0).abs() < f64::EPSILON);
}
#[test]
fn eviction_respects_cap() {
let cfg = AbuseConfig {
max_tracked_actors: 3,
..test_config()
};
let mut tracker = AbuseTracker::new(cfg);
for i in 0..5 {
tracker.record_signal(&format!("actor-{i}"), SignalType::RateBurst);
}
assert!(tracker.actors.len() <= 3);
}
#[test]
fn snapshot_returns_all_tracked() {
let mut tracker = AbuseTracker::new(test_config());
tracker.record_signal("a", SignalType::RateBurst);
tracker.record_signal("b", SignalType::PolicyViolation);
let snap = tracker.snapshot();
assert_eq!(snap.len(), 2);
}
}