use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Signal {
StatusCode { code: u16, expected: u16 },
BodyMarker(String),
SuccessMarker(String),
ResponseTimeAnomaly { baseline_ms: u64, actual_ms: u64 },
ConnectionBehavior(ConnectionBehavior),
H2Goaway(String),
FingerprintDrift(String),
ChallengePlatform(String),
}
impl fmt::Display for Signal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::StatusCode { code, expected } => {
write!(f, "status {code} (expected {expected})")
}
Self::BodyMarker(m) => write!(f, "body marker: {m}"),
Self::SuccessMarker(m) => write!(f, "success marker: {m}"),
Self::ResponseTimeAnomaly {
baseline_ms,
actual_ms,
} => {
write!(f, "response time {actual_ms}ms (baseline {baseline_ms}ms)")
}
Self::ConnectionBehavior(c) => write!(f, "connection: {c}"),
Self::H2Goaway(reason) => write!(f, "h2 goaway: {reason}"),
Self::FingerprintDrift(d) => write!(f, "fingerprint drift: {d}"),
Self::ChallengePlatform(p) => write!(f, "challenge platform: {p}"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConnectionBehavior {
TcpReset,
OkWithImmediateClose,
OkWithBlockPage,
GracefulClose,
Timeout,
TlsError,
}
impl fmt::Display for ConnectionBehavior {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TcpReset => f.write_str("TCP reset"),
Self::OkWithImmediateClose => f.write_str("200 OK with immediate close"),
Self::OkWithBlockPage => f.write_str("200 OK with block page"),
Self::GracefulClose => f.write_str("graceful close"),
Self::Timeout => f.write_str("timeout"),
Self::TlsError => f.write_str("TLS error"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BlockReason {
RuleId(String),
RuleCategory(String),
VendorReason(String),
IpReputation,
GeoBlock,
CustomBlockPage(String),
Unknown,
}
impl fmt::Display for BlockReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RuleId(id) => write!(f, "rule {id}"),
Self::RuleCategory(c) => write!(f, "category {c}"),
Self::VendorReason(r) => write!(f, "vendor: {r}"),
Self::IpReputation => f.write_str("IP reputation"),
Self::GeoBlock => f.write_str("geo block"),
Self::CustomBlockPage(p) => write!(f, "block page: {p}"),
Self::Unknown => f.write_str("unknown reason"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Verdict {
Blocked {
reason: Option<BlockReason>,
signals: Vec<Signal>,
},
Allowed {
signals: Vec<Signal>,
},
RateLimited {
signals: Vec<Signal>,
},
ChallengeRequired {
platform: Option<String>,
signals: Vec<Signal>,
},
ServerError {
signals: Vec<Signal>,
},
Partial {
reason: Option<BlockReason>,
signals: Vec<Signal>,
},
Ambiguous {
competing: Vec<(Verdict, Vec<Signal>)>,
explanation: String,
},
}
impl Verdict {
#[must_use]
pub fn blocked(signals: Vec<Signal>) -> Self {
Self::Blocked {
reason: None,
signals,
}
}
#[must_use]
pub fn blocked_with_reason(reason: BlockReason, signals: Vec<Signal>) -> Self {
Self::Blocked {
reason: Some(reason),
signals,
}
}
#[must_use]
pub fn allowed(signals: Vec<Signal>) -> Self {
Self::Allowed { signals }
}
#[must_use]
pub fn rate_limited(signals: Vec<Signal>) -> Self {
Self::RateLimited { signals }
}
#[must_use]
pub fn challenge_required(platform: Option<String>, signals: Vec<Signal>) -> Self {
Self::ChallengeRequired { platform, signals }
}
#[must_use]
pub fn server_error(signals: Vec<Signal>) -> Self {
Self::ServerError { signals }
}
#[must_use]
pub fn partial(reason: Option<BlockReason>, signals: Vec<Signal>) -> Self {
Self::Partial { reason, signals }
}
#[must_use]
pub fn is_blocked(&self) -> bool {
matches!(self, Self::Blocked { .. })
}
#[must_use]
pub fn is_challenge(&self) -> bool {
matches!(self, Self::ChallengeRequired { .. })
}
#[must_use]
pub fn is_ambiguous(&self) -> bool {
matches!(self, Self::Ambiguous { .. })
}
#[must_use]
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allowed { .. })
}
#[must_use]
pub fn signals(&self) -> Vec<Signal> {
match self {
Self::Blocked { signals, .. }
| Self::Allowed { signals }
| Self::RateLimited { signals }
| Self::ChallengeRequired { signals, .. }
| Self::ServerError { signals }
| Self::Partial { signals, .. } => signals.clone(),
Self::Ambiguous { competing, .. } => {
competing.iter().flat_map(|(v, _)| v.signals()).collect()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verdict_blocked_creation() {
let v = Verdict::blocked(vec![Signal::StatusCode {
code: 403,
expected: 200,
}]);
assert!(v.is_blocked());
assert!(!v.is_allowed());
}
#[test]
fn verdict_challenge_creation() {
let v = Verdict::challenge_required(
Some("cloudflare".into()),
vec![Signal::ChallengePlatform("cloudflare".into())],
);
assert!(v.is_challenge());
assert!(!v.is_blocked());
}
#[test]
fn verdict_ambiguous_signals_flatten() {
let v = Verdict::Ambiguous {
competing: vec![
(
Verdict::blocked(vec![Signal::BodyMarker("denied".into())]),
vec![Signal::BodyMarker("denied".into())],
),
(
Verdict::allowed(vec![Signal::SuccessMarker("ok".into())]),
vec![Signal::SuccessMarker("ok".into())],
),
],
explanation: "conflict".into(),
};
let signals = v.signals();
assert_eq!(signals.len(), 2);
assert!(signals.contains(&Signal::BodyMarker("denied".into())));
assert!(signals.contains(&Signal::SuccessMarker("ok".into())));
}
#[test]
fn block_reason_display() {
assert_eq!(BlockReason::RuleId("1001".into()).to_string(), "rule 1001");
assert_eq!(BlockReason::IpReputation.to_string(), "IP reputation");
}
}