//! Tier G response policy — maps conformance verdicts to enforcement actions. //! //! Actions: //! - `Legitimate` → no action //! - `Suspect` → tighten Tier E quota, emit metric //! - `Abusive` → typed Hangup + 1 h fingerprint cool-down //! - `RepeatAbusive` → relay-local block 24 h use std::collections::HashMap; use std::time::{Duration, Instant}; use wzp_proto::packet::{HangupReason, ViolationCode}; use crate::verdict::Verdict; /// Enforcement action recommended by the response policy. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Action { /// Pass through unchanged. Allow, /// Throttle to tighter quota (Tier E). Throttle, /// Close the session with a typed Hangup signal. Close { reason: HangupReason }, /// Block the fingerprint from joining any room for 24 h. Block, } /// Tracks fingerprint-level abuse history and applies escalation. pub struct ResponsePolicy { /// `(fingerprint, violation_code)` → last abusive instant. cooldowns: HashMap<(String, ViolationCode), Instant>, /// Block duration for repeat abuse. block_duration: Duration, } impl ResponsePolicy { pub fn new() -> Self { Self { cooldowns: HashMap::new(), block_duration: Duration::from_secs(86400), // 24 h } } /// Evaluate a verdict and produce the corresponding [`Action`]. /// /// `fingerprint` is the participant's identity string (or IP as fallback). /// `code` is the specific violation type that triggered the verdict. pub fn evaluate(&mut self, fingerprint: &str, code: ViolationCode, verdict: Verdict) -> Action { match verdict { Verdict::Legitimate => Action::Allow, Verdict::Suspect => Action::Throttle, Verdict::Abusive => { let key = (fingerprint.to_string(), code); let now = Instant::now(); // Check if this fingerprint was already abusive recently. let is_repeat = self .cooldowns .get(&key) .map(|last| now.duration_since(*last) < self.block_duration) .unwrap_or(false); if is_repeat { Action::Block } else { self.cooldowns.insert(key, now); Action::Close { reason: HangupReason::PolicyViolation { code, reason: format!("Tier G enforcement: {code:?}"), }, } } } } } /// Returns true if the fingerprint is currently blocked (repeat abuse). pub fn is_blocked(&self, fingerprint: &str) -> bool { let now = Instant::now(); self.cooldowns.iter().any(|((fp, _), last)| { fp == fingerprint && now.duration_since(*last) < self.block_duration }) } /// Clean up expired cooldown entries. pub fn prune(&mut self) { let now = Instant::now(); self.cooldowns .retain(|_, last| now.duration_since(*last) < self.block_duration); } /// Number of tracked cooldown entries. pub fn len(&self) -> usize { self.cooldowns.len() } pub fn is_empty(&self) -> bool { self.cooldowns.is_empty() } } impl Default for ResponsePolicy { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn legitimate_allowed() { let mut policy = ResponsePolicy::new(); assert_eq!( policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Legitimate), Action::Allow ); } #[test] fn suspect_throttled() { let mut policy = ResponsePolicy::new(); assert_eq!( policy.evaluate("alice", ViolationCode::Entropy, Verdict::Suspect), Action::Throttle ); } #[test] fn abusive_gets_close() { let mut policy = ResponsePolicy::new(); let action = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive); assert!( matches!(action, Action::Close { .. }), "first-time abuse should close session" ); } #[test] fn repeat_abusive_gets_block() { let mut policy = ResponsePolicy::new(); // First abuse let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive); // Second abuse within window → block let action = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive); assert_eq!(action, Action::Block, "repeat abuse should block"); } #[test] fn different_violation_codes_are_independent() { let mut policy = ResponsePolicy::new(); // Abuse on bitrate let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive); // Abuse on entropy is treated as first-time for that code let action = policy.evaluate("alice", ViolationCode::Entropy, Verdict::Abusive); assert!( matches!(action, Action::Close { .. }), "different violation code should not trigger repeat" ); } #[test] fn is_blocked_true_after_repeat() { let mut policy = ResponsePolicy::new(); let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive); let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive); assert!(policy.is_blocked("alice")); } #[test] fn is_blocked_false_for_legitimate() { let policy = ResponsePolicy::new(); assert!(!policy.is_blocked("alice")); } #[test] fn prune_removes_expired() { let mut policy = ResponsePolicy::new(); let _ = policy.evaluate("alice", ViolationCode::Bitrate, Verdict::Abusive); assert_eq!(policy.len(), 1); // Manually expire by moving cooldown back policy.cooldowns.insert( ("alice".to_string(), ViolationCode::Bitrate), Instant::now() - Duration::from_secs(90000), ); policy.prune(); assert!(policy.is_empty()); } #[test] fn close_reason_contains_code() { let mut policy = ResponsePolicy::new(); let action = policy.evaluate("alice", ViolationCode::Entropy, Verdict::Abusive); match action { Action::Close { reason } => match reason { HangupReason::PolicyViolation { code, .. } => { assert_eq!(code, ViolationCode::Entropy); } other => panic!("expected PolicyViolation, got {other:?}"), }, other => panic!("expected Close, got {other:?}"), } } }