Files
wz-phone/crates/wzp-relay/src/response_policy.rs

208 lines
6.7 KiB
Rust

//! 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:?}"),
}
}
}