use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EscalationTier {
HttpPlain = 0,
HttpTlsProfiled = 1,
BrowserBasic = 2,
BrowserAdvanced = 3,
}
impl EscalationTier {
pub const fn next(self) -> Option<Self> {
match self {
Self::HttpPlain => Some(Self::HttpTlsProfiled),
Self::HttpTlsProfiled => Some(Self::BrowserBasic),
Self::BrowserBasic => Some(Self::BrowserAdvanced),
Self::BrowserAdvanced => None,
}
}
}
impl std::fmt::Display for EscalationTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::HttpPlain => f.write_str("http_plain"),
Self::HttpTlsProfiled => f.write_str("http_tls_profiled"),
Self::BrowserBasic => f.write_str("browser_basic"),
Self::BrowserAdvanced => f.write_str("browser_advanced"),
}
}
}
#[derive(Debug, Clone)]
pub struct ResponseContext {
pub status: u16,
pub body_empty: bool,
pub has_cloudflare_challenge: bool,
pub has_captcha: bool,
}
#[derive(Debug, Clone)]
pub struct EscalationResult<T> {
pub final_tier: EscalationTier,
pub response: T,
pub escalation_path: Vec<EscalationTier>,
}
pub trait EscalationPolicy: Send + Sync {
fn initial_tier(&self) -> EscalationTier;
fn should_escalate(
&self,
ctx: &ResponseContext,
current: EscalationTier,
) -> Option<EscalationTier>;
fn max_tier(&self) -> EscalationTier;
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
struct DefaultPolicy;
impl EscalationPolicy for DefaultPolicy {
fn initial_tier(&self) -> EscalationTier {
EscalationTier::HttpPlain
}
fn should_escalate(
&self,
ctx: &ResponseContext,
current: EscalationTier,
) -> Option<EscalationTier> {
if current >= self.max_tier() {
return None;
}
let needs_escalation = ctx.status == 403
|| ctx.has_cloudflare_challenge
|| ctx.has_captcha
|| (ctx.body_empty && current >= EscalationTier::HttpTlsProfiled);
if needs_escalation {
current.next()
} else {
None
}
}
fn max_tier(&self) -> EscalationTier {
EscalationTier::BrowserAdvanced
}
}
#[test]
fn starts_at_http_plain() {
let policy = DefaultPolicy;
assert_eq!(policy.initial_tier(), EscalationTier::HttpPlain);
}
#[test]
fn escalates_on_403() {
let policy = DefaultPolicy;
let ctx = ResponseContext {
status: 403,
body_empty: false,
has_cloudflare_challenge: false,
has_captcha: false,
};
assert_eq!(
policy.should_escalate(&ctx, EscalationTier::HttpPlain),
Some(EscalationTier::HttpTlsProfiled)
);
}
#[test]
fn escalates_on_cloudflare_challenge() {
let policy = DefaultPolicy;
let ctx = ResponseContext {
status: 503,
body_empty: false,
has_cloudflare_challenge: true,
has_captcha: false,
};
assert_eq!(
policy.should_escalate(&ctx, EscalationTier::HttpTlsProfiled),
Some(EscalationTier::BrowserBasic)
);
}
#[test]
fn max_tier_prevents_further_escalation() {
let policy = DefaultPolicy;
let ctx = ResponseContext {
status: 403,
body_empty: false,
has_cloudflare_challenge: false,
has_captcha: false,
};
assert_eq!(
policy.should_escalate(&ctx, EscalationTier::BrowserAdvanced),
None
);
}
#[test]
fn no_escalation_on_success() {
let policy = DefaultPolicy;
let ctx = ResponseContext {
status: 200,
body_empty: false,
has_cloudflare_challenge: false,
has_captcha: false,
};
assert_eq!(
policy.should_escalate(&ctx, EscalationTier::HttpPlain),
None
);
}
#[test]
fn no_escalation_on_redirect() {
let policy = DefaultPolicy;
let ctx = ResponseContext {
status: 301,
body_empty: false,
has_cloudflare_challenge: false,
has_captcha: false,
};
assert_eq!(
policy.should_escalate(&ctx, EscalationTier::HttpPlain),
None
);
}
#[test]
fn tier_ordering() {
assert!(EscalationTier::HttpPlain < EscalationTier::HttpTlsProfiled);
assert!(EscalationTier::HttpTlsProfiled < EscalationTier::BrowserBasic);
assert!(EscalationTier::BrowserBasic < EscalationTier::BrowserAdvanced);
}
#[test]
fn next_tier_chain() {
assert_eq!(
EscalationTier::HttpPlain.next(),
Some(EscalationTier::HttpTlsProfiled)
);
assert_eq!(
EscalationTier::HttpTlsProfiled.next(),
Some(EscalationTier::BrowserBasic)
);
assert_eq!(
EscalationTier::BrowserBasic.next(),
Some(EscalationTier::BrowserAdvanced)
);
assert_eq!(EscalationTier::BrowserAdvanced.next(), None);
}
#[test]
fn tier_display() {
assert_eq!(EscalationTier::HttpPlain.to_string(), "http_plain");
assert_eq!(
EscalationTier::BrowserAdvanced.to_string(),
"browser_advanced"
);
}
#[test]
fn tier_serde_roundtrip() {
let tier = EscalationTier::BrowserBasic;
let json = serde_json::to_string(&tier).unwrap();
let back: EscalationTier = serde_json::from_str(&json).unwrap();
assert_eq!(tier, back);
}
}