1use serde::{Deserialize, Serialize};
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Signal {
13 StatusCode { code: u16, expected: u16 },
15 BodyMarker(String),
17 SuccessMarker(String),
19 ResponseTimeAnomaly { baseline_ms: u64, actual_ms: u64 },
21 ConnectionBehavior(ConnectionBehavior),
23 H2Goaway(String),
25 FingerprintDrift(String),
27 ChallengePlatform(String),
29}
30
31impl fmt::Display for Signal {
32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::StatusCode { code, expected } => {
35 write!(f, "status {code} (expected {expected})")
36 }
37 Self::BodyMarker(m) => write!(f, "body marker: {m}"),
38 Self::SuccessMarker(m) => write!(f, "success marker: {m}"),
39 Self::ResponseTimeAnomaly {
40 baseline_ms,
41 actual_ms,
42 } => {
43 write!(f, "response time {actual_ms}ms (baseline {baseline_ms}ms)")
44 }
45 Self::ConnectionBehavior(c) => write!(f, "connection: {c}"),
46 Self::H2Goaway(reason) => write!(f, "h2 goaway: {reason}"),
47 Self::FingerprintDrift(d) => write!(f, "fingerprint drift: {d}"),
48 Self::ChallengePlatform(p) => write!(f, "challenge platform: {p}"),
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55pub enum ConnectionBehavior {
56 TcpReset,
58 OkWithImmediateClose,
60 OkWithBlockPage,
62 GracefulClose,
64 Timeout,
66 TlsError,
68}
69
70impl fmt::Display for ConnectionBehavior {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 Self::TcpReset => f.write_str("TCP reset"),
74 Self::OkWithImmediateClose => f.write_str("200 OK with immediate close"),
75 Self::OkWithBlockPage => f.write_str("200 OK with block page"),
76 Self::GracefulClose => f.write_str("graceful close"),
77 Self::Timeout => f.write_str("timeout"),
78 Self::TlsError => f.write_str("TLS error"),
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
85pub enum BlockReason {
86 RuleId(String),
88 RuleCategory(String),
90 VendorReason(String),
92 IpReputation,
94 GeoBlock,
96 CustomBlockPage(String),
98 Unknown,
100}
101
102impl fmt::Display for BlockReason {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::RuleId(id) => write!(f, "rule {id}"),
106 Self::RuleCategory(c) => write!(f, "category {c}"),
107 Self::VendorReason(r) => write!(f, "vendor: {r}"),
108 Self::IpReputation => f.write_str("IP reputation"),
109 Self::GeoBlock => f.write_str("geo block"),
110 Self::CustomBlockPage(p) => write!(f, "block page: {p}"),
111 Self::Unknown => f.write_str("unknown reason"),
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
118pub enum Verdict {
119 Blocked {
121 reason: Option<BlockReason>,
123 signals: Vec<Signal>,
125 },
126 Allowed {
128 signals: Vec<Signal>,
130 },
131 RateLimited {
133 signals: Vec<Signal>,
135 },
136 ChallengeRequired {
139 platform: Option<String>,
141 signals: Vec<Signal>,
143 },
144 ServerError {
146 signals: Vec<Signal>,
148 },
149 Partial {
151 reason: Option<BlockReason>,
153 signals: Vec<Signal>,
155 },
156 Ambiguous {
158 competing: Vec<(Verdict, Vec<Signal>)>,
160 explanation: String,
162 },
163}
164
165impl Verdict {
166 #[must_use]
168 pub fn blocked(signals: Vec<Signal>) -> Self {
169 Self::Blocked {
170 reason: None,
171 signals,
172 }
173 }
174
175 #[must_use]
177 pub fn blocked_with_reason(reason: BlockReason, signals: Vec<Signal>) -> Self {
178 Self::Blocked {
179 reason: Some(reason),
180 signals,
181 }
182 }
183
184 #[must_use]
186 pub fn allowed(signals: Vec<Signal>) -> Self {
187 Self::Allowed { signals }
188 }
189
190 #[must_use]
192 pub fn rate_limited(signals: Vec<Signal>) -> Self {
193 Self::RateLimited { signals }
194 }
195
196 #[must_use]
198 pub fn challenge_required(platform: Option<String>, signals: Vec<Signal>) -> Self {
199 Self::ChallengeRequired { platform, signals }
200 }
201
202 #[must_use]
204 pub fn server_error(signals: Vec<Signal>) -> Self {
205 Self::ServerError { signals }
206 }
207
208 #[must_use]
210 pub fn partial(reason: Option<BlockReason>, signals: Vec<Signal>) -> Self {
211 Self::Partial { reason, signals }
212 }
213
214 #[must_use]
216 pub fn is_blocked(&self) -> bool {
217 matches!(self, Self::Blocked { .. })
218 }
219
220 #[must_use]
222 pub fn is_challenge(&self) -> bool {
223 matches!(self, Self::ChallengeRequired { .. })
224 }
225
226 #[must_use]
228 pub fn is_ambiguous(&self) -> bool {
229 matches!(self, Self::Ambiguous { .. })
230 }
231
232 #[must_use]
234 pub fn is_allowed(&self) -> bool {
235 matches!(self, Self::Allowed { .. })
236 }
237
238 #[must_use]
240 pub fn signals(&self) -> Vec<Signal> {
241 match self {
242 Self::Blocked { signals, .. }
243 | Self::Allowed { signals }
244 | Self::RateLimited { signals }
245 | Self::ChallengeRequired { signals, .. }
246 | Self::ServerError { signals }
247 | Self::Partial { signals, .. } => signals.clone(),
248 Self::Ambiguous { competing, .. } => {
249 competing.iter().flat_map(|(v, _)| v.signals()).collect()
250 }
251 }
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn verdict_blocked_creation() {
261 let v = Verdict::blocked(vec![Signal::StatusCode {
262 code: 403,
263 expected: 200,
264 }]);
265 assert!(v.is_blocked());
266 assert!(!v.is_allowed());
267 }
268
269 #[test]
270 fn verdict_challenge_creation() {
271 let v = Verdict::challenge_required(
272 Some("cloudflare".into()),
273 vec![Signal::ChallengePlatform("cloudflare".into())],
274 );
275 assert!(v.is_challenge());
276 assert!(!v.is_blocked());
277 }
278
279 #[test]
280 fn verdict_ambiguous_signals_flatten() {
281 let v = Verdict::Ambiguous {
282 competing: vec![
283 (
284 Verdict::blocked(vec![Signal::BodyMarker("denied".into())]),
285 vec![Signal::BodyMarker("denied".into())],
286 ),
287 (
288 Verdict::allowed(vec![Signal::SuccessMarker("ok".into())]),
289 vec![Signal::SuccessMarker("ok".into())],
290 ),
291 ],
292 explanation: "conflict".into(),
293 };
294 let signals = v.signals();
295 assert_eq!(signals.len(), 2);
296 assert!(signals.contains(&Signal::BodyMarker("denied".into())));
297 assert!(signals.contains(&Signal::SuccessMarker("ok".into())));
298 }
299
300 #[test]
301 fn block_reason_display() {
302 assert_eq!(BlockReason::RuleId("1001".into()).to_string(), "rule 1001");
303 assert_eq!(BlockReason::IpReputation.to_string(), "IP reputation");
304 }
305}