496 lines
16 KiB
Rust
496 lines
16 KiB
Rust
//! Tier F video scorer — behavioural detection for video abuse.
|
||
//!
|
||
//! Computes a `legitimacy ∈ [0, 1]` score over a 5–15 s observation window.
|
||
//! Features: keyframe periodicity (CoV), I/P frame ratio, BWE responsiveness.
|
||
|
||
use std::collections::VecDeque;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use wzp_proto::{MediaHeader, MediaType};
|
||
|
||
use crate::verdict::Verdict;
|
||
|
||
/// Maximum keyframe inter-arrival samples kept.
|
||
const MAX_KF_SAMPLES: usize = 50;
|
||
|
||
/// Minimum packets before a legitimacy score is produced.
|
||
const MIN_PACKETS: u32 = 30;
|
||
|
||
/// Packet threshold after which zero keyframes is treated as abusive.
|
||
const NO_KEYFRAME_THRESHOLD: u32 = 120;
|
||
|
||
/// Packet threshold after which all-I-frame streams are penalised.
|
||
const ALL_I_FRAME_THRESHOLD: u32 = 30;
|
||
|
||
/// Video-specific behavioural scorer (Tier F).
|
||
pub struct VideoScorer {
|
||
/// Rolling inter-arrival times between keyframes.
|
||
keyframe_iat_samples: VecDeque<Duration>,
|
||
last_keyframe_at: Option<Instant>,
|
||
|
||
/// I-frame count in current observation window.
|
||
i_frame_count: u32,
|
||
/// P-frame count in current observation window.
|
||
p_frame_count: u32,
|
||
|
||
/// Bitrate window.
|
||
window_start: Instant,
|
||
window_bytes: u64,
|
||
|
||
/// BWE responsiveness tracking.
|
||
last_bwe_kbps: Option<u32>,
|
||
bitrate_at_last_bwe: Option<f64>,
|
||
responsive_count: u32,
|
||
unresponsive_count: u32,
|
||
|
||
/// Total video packets observed.
|
||
total_packets: u32,
|
||
}
|
||
|
||
impl VideoScorer {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
keyframe_iat_samples: VecDeque::with_capacity(MAX_KF_SAMPLES),
|
||
last_keyframe_at: None,
|
||
i_frame_count: 0,
|
||
p_frame_count: 0,
|
||
window_start: Instant::now(),
|
||
window_bytes: 0,
|
||
last_bwe_kbps: None,
|
||
bitrate_at_last_bwe: None,
|
||
responsive_count: 0,
|
||
unresponsive_count: 0,
|
||
total_packets: 0,
|
||
}
|
||
}
|
||
|
||
/// Feed one packet into the scorer.
|
||
///
|
||
/// `bwe_kbps` is the most recent downstream bandwidth estimate, if any.
|
||
pub fn observe(
|
||
&mut self,
|
||
header: &MediaHeader,
|
||
payload_len: usize,
|
||
now: Instant,
|
||
bwe_kbps: Option<u32>,
|
||
) {
|
||
// Ignore non-video traffic.
|
||
if header.media_type != MediaType::Video {
|
||
return;
|
||
}
|
||
|
||
if self.total_packets == 0 {
|
||
self.window_start = now;
|
||
}
|
||
self.total_packets += 1;
|
||
|
||
// Track keyframes vs P-frames.
|
||
if header.is_keyframe() {
|
||
self.i_frame_count += 1;
|
||
if let Some(last) = self.last_keyframe_at {
|
||
let iat = now.saturating_duration_since(last);
|
||
self.keyframe_iat_samples.push_back(iat);
|
||
if self.keyframe_iat_samples.len() > MAX_KF_SAMPLES {
|
||
self.keyframe_iat_samples.pop_front();
|
||
}
|
||
}
|
||
self.last_keyframe_at = Some(now);
|
||
} else {
|
||
self.p_frame_count += 1;
|
||
}
|
||
|
||
// Track bitrate window.
|
||
self.window_bytes += (MediaHeader::WIRE_SIZE + payload_len) as u64;
|
||
|
||
// BWE responsiveness check.
|
||
if let Some(bwe) = bwe_kbps {
|
||
let current_rate = self.current_bitrate(now);
|
||
if let Some(last_bwe) = self.last_bwe_kbps {
|
||
let bwe_drop = if last_bwe > 0 {
|
||
(last_bwe as f64 - bwe as f64) / last_bwe as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
if bwe_drop > 0.30 {
|
||
let last_rate = self.bitrate_at_last_bwe.unwrap_or(0.0);
|
||
let rate_drop = if last_rate > 0.0 {
|
||
(last_rate - current_rate) / last_rate
|
||
} else {
|
||
0.0
|
||
};
|
||
if rate_drop >= 0.10 {
|
||
self.responsive_count += 1;
|
||
} else {
|
||
self.unresponsive_count += 1;
|
||
}
|
||
}
|
||
}
|
||
self.last_bwe_kbps = Some(bwe);
|
||
self.bitrate_at_last_bwe = Some(current_rate);
|
||
self.window_start = now;
|
||
self.window_bytes = 0;
|
||
}
|
||
}
|
||
|
||
/// Compute legitimacy score ∈ [0, 1].
|
||
///
|
||
/// Higher = more legitimate. Returns `None` when insufficient samples
|
||
/// have been collected (< 30 packets).
|
||
pub fn legitimacy(&self) -> Option<f32> {
|
||
if self.total_packets < MIN_PACKETS {
|
||
return None;
|
||
}
|
||
|
||
let mut score = 1.0f32;
|
||
|
||
// 1. Keyframe regularity (0.35 weight).
|
||
if let Some(reg) = self.keyframe_regularity() {
|
||
score -= (1.0 - reg as f32) * 0.35;
|
||
} else if self.i_frame_count == 0 && self.total_packets > NO_KEYFRAME_THRESHOLD {
|
||
score -= 0.50;
|
||
} else {
|
||
score -= 0.10;
|
||
}
|
||
|
||
// 2. I/P ratio (0.30 weight).
|
||
if self.p_frame_count == 0 && self.total_packets > ALL_I_FRAME_THRESHOLD {
|
||
score -= 0.60;
|
||
} else if let Some(ip) = self.ip_ratio() {
|
||
score -= (1.0 - ip as f32) * 0.30;
|
||
} else {
|
||
score -= 0.10;
|
||
}
|
||
|
||
// 3. BWE responsiveness (0.40 weight).
|
||
if let Some(bwe) = self.bwe_responsiveness() {
|
||
score -= (1.0 - bwe as f32) * 0.40;
|
||
} else {
|
||
score -= 0.15;
|
||
}
|
||
|
||
Some(score.clamp(0.0, 1.0))
|
||
}
|
||
|
||
/// Map legitimacy score to a [`Verdict`].
|
||
pub fn verdict(&self) -> Option<Verdict> {
|
||
self.legitimacy().map(|s| {
|
||
if s >= 0.7 {
|
||
Verdict::Legitimate
|
||
} else if s >= 0.3 {
|
||
Verdict::Suspect
|
||
} else {
|
||
Verdict::Abusive
|
||
}
|
||
})
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Feature extractors
|
||
// ------------------------------------------------------------------
|
||
|
||
/// Keyframe regularity score ∈ [0, 1] where 1 = perfectly regular.
|
||
fn keyframe_regularity(&self) -> Option<f64> {
|
||
if self.keyframe_iat_samples.len() < 3 {
|
||
return None;
|
||
}
|
||
let mean = self
|
||
.keyframe_iat_samples
|
||
.iter()
|
||
.map(|d| d.as_secs_f64())
|
||
.sum::<f64>()
|
||
/ self.keyframe_iat_samples.len() as f64;
|
||
if mean == 0.0 {
|
||
return None;
|
||
}
|
||
let variance = self
|
||
.keyframe_iat_samples
|
||
.iter()
|
||
.map(|d| {
|
||
let diff = d.as_secs_f64() - mean;
|
||
diff * diff
|
||
})
|
||
.sum::<f64>()
|
||
/ self.keyframe_iat_samples.len() as f64;
|
||
let std = variance.sqrt();
|
||
let cov = std / mean;
|
||
// Map CoV to regularity: cov = 0 → 1.0, cov → ∞ → 0.0.
|
||
Some(1.0 / (1.0 + cov))
|
||
}
|
||
|
||
/// I/P ratio score ∈ [0, 1] where 1 = healthy GOP, 0 = all-I-frames.
|
||
fn ip_ratio(&self) -> Option<f64> {
|
||
if self.i_frame_count == 0 {
|
||
return None;
|
||
}
|
||
if self.p_frame_count == 0 {
|
||
return Some(0.0);
|
||
}
|
||
let p_per_i = self.p_frame_count as f64 / self.i_frame_count as f64;
|
||
// Legitimate: P-per-I ≥ 29 (GOP 30).
|
||
// Abusive: P-per-I < 5 (too many I-frames).
|
||
let score = if p_per_i >= 29.0 {
|
||
1.0
|
||
} else if p_per_i <= 5.0 {
|
||
0.0
|
||
} else {
|
||
(p_per_i - 5.0) / (29.0 - 5.0)
|
||
};
|
||
Some(score)
|
||
}
|
||
|
||
/// BWE responsiveness score ∈ [0, 1] where 1 = always responsive.
|
||
fn bwe_responsiveness(&self) -> Option<f64> {
|
||
let total = self.responsive_count + self.unresponsive_count;
|
||
if total == 0 {
|
||
return None;
|
||
}
|
||
let responsive = self.responsive_count as f64 / total as f64;
|
||
Some(responsive)
|
||
}
|
||
|
||
/// Current bitrate in kbps over the active window.
|
||
fn current_bitrate(&self, now: Instant) -> f64 {
|
||
let elapsed = now
|
||
.saturating_duration_since(self.window_start)
|
||
.as_secs_f64();
|
||
if elapsed > 0.0 {
|
||
self.window_bytes as f64 * 8.0 / 1000.0 / elapsed
|
||
} else {
|
||
0.0
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for VideoScorer {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use wzp_proto::{CodecId, MediaType};
|
||
|
||
fn video_header(is_keyframe: bool) -> MediaHeader {
|
||
MediaHeader {
|
||
version: 2,
|
||
flags: if is_keyframe {
|
||
MediaHeader::FLAG_KEYFRAME
|
||
} else {
|
||
0
|
||
},
|
||
media_type: MediaType::Video,
|
||
codec_id: CodecId::H264Baseline,
|
||
stream_id: 0,
|
||
fec_ratio: 0,
|
||
seq: 0,
|
||
timestamp: 0,
|
||
fec_block: 0,
|
||
}
|
||
}
|
||
|
||
fn audio_header() -> MediaHeader {
|
||
MediaHeader {
|
||
version: 2,
|
||
flags: 0,
|
||
media_type: MediaType::Audio,
|
||
codec_id: CodecId::Opus24k,
|
||
stream_id: 0,
|
||
fec_ratio: 0,
|
||
seq: 0,
|
||
timestamp: 0,
|
||
fec_block: 0,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn video_scorer_ignores_audio() {
|
||
let mut scorer = VideoScorer::new();
|
||
let h = audio_header();
|
||
scorer.observe(&h, 100, Instant::now(), None);
|
||
assert_eq!(scorer.total_packets, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn video_scorer_counts_packets() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
for i in 0..35 {
|
||
let h = video_header(i % 30 == 0);
|
||
scorer.observe(&h, 500, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
assert_eq!(scorer.total_packets, 35);
|
||
assert!(scorer.legitimacy().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn video_scorer_insufficient_samples() {
|
||
let scorer = VideoScorer::new();
|
||
assert_eq!(scorer.legitimacy(), None);
|
||
assert_eq!(scorer.verdict(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn video_scorer_legitimate_traffic() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
// Simulate 150 packets of legitimate 30 fps video:
|
||
// GOP 30 (keyframe every 30 frames ≈ 1 s).
|
||
for i in 0..150 {
|
||
let is_kf = i % 30 == 0;
|
||
let payload = if is_kf { 2000 } else { 500 };
|
||
let h = video_header(is_kf);
|
||
let now = base + Duration::from_millis(i * 33);
|
||
let bwe = if i == 60 {
|
||
Some(4000)
|
||
} else if i == 120 {
|
||
Some(4000)
|
||
} else {
|
||
None
|
||
};
|
||
scorer.observe(&h, payload, now, bwe);
|
||
}
|
||
let leg = scorer.legitimacy().unwrap();
|
||
assert!(
|
||
leg >= 0.6,
|
||
"legitimate traffic should score ≥ 0.6, got {leg}"
|
||
);
|
||
assert_eq!(scorer.verdict(), Some(Verdict::Legitimate));
|
||
}
|
||
|
||
#[test]
|
||
fn video_scorer_abusive_no_keyframes() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
// 150 packets, no keyframes at all.
|
||
for i in 0..150 {
|
||
let h = video_header(false);
|
||
scorer.observe(&h, 500, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
let leg = scorer.legitimacy().unwrap();
|
||
assert!(
|
||
leg < 0.3,
|
||
"no-keyframe traffic should score < 0.3, got {leg}"
|
||
);
|
||
assert_eq!(scorer.verdict(), Some(Verdict::Abusive));
|
||
}
|
||
|
||
#[test]
|
||
fn video_scorer_ip_ratio_out_of_range() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
// 100 packets, all keyframes (all-I-frame stream).
|
||
for i in 0..100 {
|
||
let h = video_header(true);
|
||
scorer.observe(&h, 2000, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
let leg = scorer.legitimacy().unwrap();
|
||
assert!(
|
||
leg < 0.3,
|
||
"all-I-frame traffic should score < 0.3, got {leg}"
|
||
);
|
||
assert_eq!(scorer.verdict(), Some(Verdict::Abusive));
|
||
}
|
||
|
||
#[test]
|
||
fn video_scorer_abusive_bwe_unresponsive() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
// 60 packets at constant rate.
|
||
for i in 0..60 {
|
||
let h = video_header(i % 30 == 0);
|
||
let payload = if i % 30 == 0 { 2000 } else { 500 };
|
||
scorer.observe(&h, payload, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
// BWE = 4000 kbps.
|
||
let h = video_header(false);
|
||
scorer.observe(&h, 500, base + Duration::from_millis(60 * 33), Some(4000));
|
||
|
||
// Another 60 packets at the same rate despite lower BWE.
|
||
for i in 60..120 {
|
||
let h = video_header(i % 30 == 0);
|
||
let payload = if i % 30 == 0 { 2000 } else { 500 };
|
||
scorer.observe(&h, payload, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
// BWE drops 50 % but bitrate unchanged → unresponsive.
|
||
let h = video_header(false);
|
||
scorer.observe(&h, 500, base + Duration::from_millis(120 * 33), Some(2000));
|
||
|
||
let bwe = scorer.bwe_responsiveness().unwrap();
|
||
assert!(
|
||
bwe < 0.5,
|
||
"unresponsive stream should have low BWE score, got {bwe}"
|
||
);
|
||
let leg = scorer.legitimacy().unwrap();
|
||
assert!(
|
||
leg < 0.7,
|
||
"BWE-unresponsive traffic should score < 0.7, got {leg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn keyframe_regularity_perfect_gop() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
// 120 packets → 4 keyframes → 3 IAT samples (needs ≥ 3).
|
||
for i in 0..120 {
|
||
let h = video_header(i % 30 == 0);
|
||
scorer.observe(&h, 500, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
let reg = scorer.keyframe_regularity().unwrap();
|
||
assert!(
|
||
reg > 0.9,
|
||
"perfect GOP should have very high regularity, got {reg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn keyframe_regularity_random() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
// Explicitly irregular keyframe spacing.
|
||
let kf_positions = [5, 15, 65, 80, 150, 165, 230, 260, 310];
|
||
for i in 0..320 {
|
||
let is_kf = kf_positions.contains(&i);
|
||
let h = video_header(is_kf);
|
||
scorer.observe(&h, 500, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
let reg = scorer.keyframe_regularity().unwrap();
|
||
assert!(
|
||
reg < 0.8,
|
||
"random GOP should have lower regularity, got {reg}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn bwe_responsive_drop() {
|
||
let mut scorer = VideoScorer::new();
|
||
let base = Instant::now();
|
||
|
||
// First window: high rate.
|
||
for i in 0..60 {
|
||
let h = video_header(i % 30 == 0);
|
||
let payload = if i % 30 == 0 { 2000 } else { 1000 };
|
||
scorer.observe(&h, payload, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
let h = video_header(false);
|
||
scorer.observe(&h, 1000, base + Duration::from_millis(60 * 33), Some(4000));
|
||
|
||
// Second window: lower rate (responsive to BWE drop).
|
||
for i in 60..120 {
|
||
let h = video_header(i % 30 == 0);
|
||
let payload = if i % 30 == 0 { 500 } else { 250 };
|
||
scorer.observe(&h, payload, base + Duration::from_millis(i * 33), None);
|
||
}
|
||
let h = video_header(false);
|
||
scorer.observe(&h, 250, base + Duration::from_millis(120 * 33), Some(1500));
|
||
|
||
let bwe = scorer.bwe_responsiveness().unwrap();
|
||
assert!(
|
||
bwe > 0.5,
|
||
"responsive stream should have high BWE score, got {bwe}"
|
||
);
|
||
}
|
||
}
|