use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProxyType {
Http,
Https,
#[cfg(feature = "socks")]
Socks4,
#[cfg(feature = "socks")]
Socks5,
CdnEdge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProfiledRequestMode {
Compatible,
Preset,
Strict,
StrictAll,
}
#[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum IpClass {
Mobile,
Isp,
Residential,
Datacenter,
#[default]
Unknown,
}
impl IpClass {
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Mobile => 4,
Self::Isp => 3,
Self::Residential => 2,
Self::Datacenter => 1,
Self::Unknown => 0,
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Mobile => "mobile",
Self::Isp => "isp",
Self::Residential => "residential",
Self::Datacenter => "datacenter",
Self::Unknown => "unknown",
}
}
#[must_use]
pub fn from_label(label: &str) -> Option<Self> {
match label {
"mobile" => Some(Self::Mobile),
"isp" => Some(Self::Isp),
"residential" => Some(Self::Residential),
"datacenter" => Some(Self::Datacenter),
"unknown" => Some(Self::Unknown),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustTier {
Preferred,
Acceptable,
Marginal,
Blocked,
}
impl TrustTier {
#[must_use]
pub const fn rank(self) -> u8 {
match self {
Self::Preferred => 4,
Self::Acceptable => 3,
Self::Marginal => 2,
Self::Blocked => 1,
}
}
#[must_use]
pub const fn is_blocked(self) -> bool {
matches!(self, Self::Blocked)
}
}
#[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum VendorId {
Akamai,
Cloudflare,
DataDome,
PerimeterX,
Hcaptcha,
Recaptcha,
Kasada,
FingerprintCom,
ShapeSecurity,
Imperva,
#[default]
Unknown,
}
impl VendorId {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Akamai => "akamai",
Self::Cloudflare => "cloudflare",
Self::DataDome => "data_dome",
Self::PerimeterX => "perimeter_x",
Self::Hcaptcha => "hcaptcha",
Self::Recaptcha => "recaptcha",
Self::Kasada => "kasada",
Self::FingerprintCom => "fingerprint_com",
Self::ShapeSecurity => "shape_security",
Self::Imperva => "imperva",
Self::Unknown => "unknown",
}
}
#[must_use]
pub fn from_label(label: &str) -> Option<Self> {
match label {
"akamai" => Some(Self::Akamai),
"cloudflare" => Some(Self::Cloudflare),
"data_dome" => Some(Self::DataDome),
"perimeter_x" => Some(Self::PerimeterX),
"hcaptcha" => Some(Self::Hcaptcha),
"recaptcha" => Some(Self::Recaptcha),
"kasada" => Some(Self::Kasada),
"fingerprint_com" => Some(Self::FingerprintCom),
"shape_security" => Some(Self::ShapeSecurity),
"imperva" => Some(Self::Imperva),
"unknown" => Some(Self::Unknown),
_ => None,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", transparent)]
pub struct TargetVendorCompatibility {
defeats: BTreeMap<VendorId, TrustTier>,
}
impl TargetVendorCompatibility {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn default_blocked() -> Self {
let mut defeats = BTreeMap::new();
for vendor in [
VendorId::Akamai,
VendorId::Cloudflare,
VendorId::DataDome,
VendorId::PerimeterX,
VendorId::Hcaptcha,
VendorId::Recaptcha,
VendorId::Kasada,
VendorId::FingerprintCom,
VendorId::ShapeSecurity,
VendorId::Imperva,
] {
defeats.insert(vendor, TrustTier::Blocked);
}
Self { defeats }
}
#[must_use]
pub fn get(&self, vendor: VendorId) -> Option<TrustTier> {
self.defeats.get(&vendor).copied()
}
#[must_use]
pub fn set(mut self, vendor: VendorId, tier: TrustTier) -> Self {
self.defeats.insert(vendor, tier);
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.defeats.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.defeats.len()
}
pub fn iter(&self) -> impl Iterator<Item = (VendorId, TrustTier)> + '_ {
self.defeats.iter().map(|(v, t)| (*v, *t))
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", transparent)]
pub struct IpClassRequirement {
pub minimum: IpClass,
}
impl IpClassRequirement {
#[must_use]
pub const fn is_satisfied_by(&self, proxy: IpClass) -> bool {
proxy.rank() >= self.minimum.rank()
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[allow(clippy::struct_excessive_bools)] pub struct ProxyCapabilities {
#[serde(default)]
pub supports_https_connect: bool,
#[serde(default)]
pub supports_socks5_udp: bool,
#[serde(default)]
pub supports_http3_tunnel: bool,
#[serde(default)]
pub geo_country: Option<String>,
#[serde(default)]
pub geo_confidence: Option<f32>,
#[serde(default)]
pub is_cdn_edge: bool,
#[serde(default)]
pub cdn_provider: Option<String>,
#[serde(default)]
pub tls_profile: Option<String>,
#[serde(default)]
pub ip_class: IpClass,
#[serde(default)]
pub target_compatibility: TargetVendorCompatibility,
#[serde(default)]
pub asn: Option<u32>,
#[serde(default)]
pub city: Option<String>,
#[serde(default)]
pub postal_code: Option<String>,
}
impl ProxyCapabilities {
#[must_use]
pub fn satisfies(&self, req: &CapabilityRequirement) -> bool {
if req.require_https_connect && !self.supports_https_connect {
return false;
}
if req.require_socks5_udp && !self.supports_socks5_udp {
return false;
}
if req.require_http3_tunnel && !self.supports_http3_tunnel {
return false;
}
if let Some(ref required_country) = req.require_geo_country
&& self.geo_country.as_deref() != Some(required_country.as_str())
{
return false;
}
if req.require_cdn_edge && !self.is_cdn_edge {
return false;
}
if let Some(ref required_profile) = req.require_tls_profile
&& self.tls_profile.as_deref() != Some(required_profile.as_str())
{
return false;
}
if let Some(ref minimum_class) = req.require_ip_class
&& !minimum_class.is_satisfied_by(self.ip_class)
{
return false;
}
if let Some(required_vendor) = req.target_vendor
&& self
.target_compatibility
.get(required_vendor)
.is_none_or(TrustTier::is_blocked)
{
return false;
}
if let Some(required_asn) = req.require_asn
&& self.asn != Some(required_asn)
{
return false;
}
if let Some(ref required_city) = req.require_city
&& self.city.as_deref() != Some(required_city.as_str())
{
return false;
}
if let Some(ref required_postal) = req.require_postal_code
&& self.postal_code.as_deref() != Some(required_postal.as_str())
{
return false;
}
true
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[allow(clippy::struct_excessive_bools)] pub struct CapabilityRequirement {
#[serde(default)]
pub require_https_connect: bool,
#[serde(default)]
pub require_socks5_udp: bool,
#[serde(default)]
pub require_http3_tunnel: bool,
#[serde(default)]
pub require_geo_country: Option<String>,
#[serde(default)]
pub require_cdn_edge: bool,
#[serde(default)]
pub require_tls_profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_ip_class: Option<IpClassRequirement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_vendor: Option<VendorId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_asn: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_city: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_postal_code: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RoutingPath {
H1H2OverTcp,
H3OverUdp,
PersistentTcp,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct Proxy {
pub url: String,
pub proxy_type: ProxyType,
pub username: Option<String>,
pub password: Option<String>,
pub weight: u32,
pub tags: Vec<String>,
#[serde(default)]
pub capabilities: ProxyCapabilities,
#[serde(default)]
pub ip_class: IpClass,
#[serde(default)]
pub target_compatibility: TargetVendorCompatibility,
}
impl std::fmt::Debug for Proxy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Proxy")
.field("url", &self.url)
.field("proxy_type", &self.proxy_type)
.field("username", &self.username)
.field("password", &self.password.as_deref().map(|_| "***"))
.field("weight", &self.weight)
.field("tags", &self.tags)
.field("capabilities", &self.capabilities)
.field("ip_class", &self.ip_class)
.field("target_compatibility", &self.target_compatibility)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ProxyRecord {
pub id: Uuid,
pub proxy: Proxy,
#[serde(skip, default = "Instant::now")]
pub added_at: Instant,
}
impl ProxyRecord {
#[must_use]
pub fn new(proxy: Proxy) -> Self {
Self {
id: Uuid::new_v4(),
proxy,
added_at: Instant::now(),
}
}
}
#[derive(Debug, Default)]
pub struct ProxyMetrics {
pub requests_total: AtomicU64,
pub successes: AtomicU64,
pub failures: AtomicU64,
pub total_latency_ms: AtomicU64,
}
impl ProxyMetrics {
#[allow(clippy::cast_precision_loss)]
const fn u64_as_f64(value: u64) -> f64 {
value as f64
}
pub fn success_rate(&self) -> f64 {
let total = self.requests_total.load(Ordering::Relaxed);
if total == 0 {
return 0.0;
}
Self::u64_as_f64(self.successes.load(Ordering::Relaxed)) / Self::u64_as_f64(total)
}
pub fn avg_latency_ms(&self) -> f64 {
let total = self.requests_total.load(Ordering::Relaxed);
if total == 0 {
return 0.0;
}
Self::u64_as_f64(self.total_latency_ms.load(Ordering::Relaxed)) / Self::u64_as_f64(total)
}
}
mod serde_duration_secs {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
d.as_secs().serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
Ok(Duration::from_secs(u64::deserialize(d)?))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ProxyConfig {
pub health_check_url: String,
#[serde(with = "serde_duration_secs")]
pub health_check_interval: Duration,
#[serde(with = "serde_duration_secs")]
pub health_check_timeout: Duration,
#[serde(default = "default_health_check_jitter_pct")]
pub health_check_jitter_pct: f32,
pub circuit_open_threshold: u32,
#[serde(with = "serde_duration_secs")]
pub circuit_half_open_after: Duration,
#[serde(default)]
pub sticky_policy: crate::session::StickyPolicy,
#[serde(default)]
pub profiled_request_mode: Option<ProfiledRequestMode>,
#[serde(default)]
pub max_requests_per_connection: Option<u32>,
#[serde(default)]
pub connection_max_age_secs: Option<u64>,
}
const fn default_health_check_jitter_pct() -> f32 {
0.20
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
health_check_url: "https://httpbin.org/ip".into(),
health_check_interval: Duration::from_mins(1),
health_check_timeout: Duration::from_secs(5),
health_check_jitter_pct: 0.20,
circuit_open_threshold: 5,
circuit_half_open_after: Duration::from_secs(30),
sticky_policy: crate::session::StickyPolicy::default(),
profiled_request_mode: None,
max_requests_per_connection: None,
connection_max_age_secs: None,
}
}
}
pub mod well_known {
pub const KNOWN_ASN_CLOUDFLARE: u32 = 13335;
pub const KNOWN_ASN_AKAMAI: u32 = 20940;
pub const KNOWN_ASN_FASTLY: u32 = 54113;
pub const KNOWN_ASN_CLOUDFRONT: u32 = 16509;
pub const KNOWN_ASN_GOOGLE: u32 = 15169;
pub const KNOWN_ASN_AZURE: u32 = 8075;
pub const KNOWN_ASN_LIMELIGHT: u32 = 22822;
pub const KNOWN_ASN_HIGHWINDS: u32 = 20446;
pub const KNOWN_ASN_EDGECAST: u32 = 15133;
pub const KNOWN_ASN_SUCURI: u32 = 51167;
pub const KNOWN_ASN_OVH: u32 = 16276;
pub const KNOWN_ASN_HETZNER: u32 = 24940;
pub const KNOWN_ASN_DIGITALOCEAN: u32 = 14061;
pub const KNOWN_ASN_LINODE: u32 = 63949;
pub const KNOWN_ASN_VULTR: u32 = 204_957;
pub const ALL_KNOWN_ASNS: &[u32] = &[
KNOWN_ASN_CLOUDFLARE,
KNOWN_ASN_AKAMAI,
KNOWN_ASN_FASTLY,
KNOWN_ASN_CLOUDFRONT,
KNOWN_ASN_GOOGLE,
KNOWN_ASN_AZURE,
KNOWN_ASN_LIMELIGHT,
KNOWN_ASN_HIGHWINDS,
KNOWN_ASN_EDGECAST,
KNOWN_ASN_SUCURI,
KNOWN_ASN_OVH,
KNOWN_ASN_HETZNER,
KNOWN_ASN_DIGITALOCEAN,
KNOWN_ASN_LINODE,
KNOWN_ASN_VULTR,
];
}
pub const CITY_MAX_LEN: usize = 100;
pub const POSTAL_CODE_MAX_LEN: usize = 16;
pub fn validate_asn(asn: u32) -> Result<(), crate::error::ProxyError> {
use crate::error::ProxyError;
if asn == 0 {
return Err(ProxyError::InvalidGeoMetadata {
field: "asn".into(),
reason: "ASN 0 is reserved by IANA and must not be used as a proxy ASN".into(),
});
}
if asn == u32::MAX {
return Err(ProxyError::InvalidGeoMetadata {
field: "asn".into(),
reason: format!("ASN {asn} is the RFC reserved/private-use placeholder"),
});
}
Ok(())
}
pub fn validate_city(city: &str) -> Result<(), crate::error::ProxyError> {
use crate::error::ProxyError;
if city.is_empty() {
return Err(ProxyError::InvalidGeoMetadata {
field: "city".into(),
reason: "city must be 1 character or longer".into(),
});
}
if city.len() > CITY_MAX_LEN {
return Err(ProxyError::InvalidGeoMetadata {
field: "city".into(),
reason: format!(
"city length {} exceeds the {CITY_MAX_LEN}-char limit",
city.len()
),
});
}
Ok(())
}
pub fn validate_postal_code(postal_code: &str) -> Result<(), crate::error::ProxyError> {
use crate::error::ProxyError;
if postal_code.is_empty() {
return Err(ProxyError::InvalidGeoMetadata {
field: "postal_code".into(),
reason: "postal_code must be 1 character or longer".into(),
});
}
if postal_code.len() > POSTAL_CODE_MAX_LEN {
return Err(ProxyError::InvalidGeoMetadata {
field: "postal_code".into(),
reason: format!(
"postal_code length {} exceeds the {POSTAL_CODE_MAX_LEN}-char limit",
postal_code.len()
),
});
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] mod tests {
use super::*;
use crate::well_known::{
KNOWN_ASN_AKAMAI, KNOWN_ASN_CLOUDFLARE, KNOWN_ASN_FASTLY, KNOWN_ASN_OVH,
};
#[test]
fn ip_class_default_is_unknown() {
assert_eq!(IpClass::default(), IpClass::Unknown);
assert_eq!(IpClass::Unknown.rank(), 0);
}
#[test]
fn ip_class_rank_ordering_matches_t95_spec() {
assert!(IpClass::Mobile.rank() > IpClass::Isp.rank());
assert!(IpClass::Isp.rank() > IpClass::Residential.rank());
assert!(IpClass::Residential.rank() > IpClass::Datacenter.rank());
assert!(IpClass::Datacenter.rank() > IpClass::Unknown.rank());
}
#[test]
fn ip_class_round_trips_through_json() {
for variant in [
IpClass::Mobile,
IpClass::Isp,
IpClass::Residential,
IpClass::Datacenter,
IpClass::Unknown,
] {
let json = serde_json::to_string(&variant).expect("serialize IpClass");
let parsed: IpClass = serde_json::from_str(&json).expect("deserialize IpClass");
assert_eq!(parsed, variant, "round-trip for {variant:?}");
}
}
#[test]
fn ip_class_from_label_matches_serde_label() {
for variant in [
IpClass::Mobile,
IpClass::Isp,
IpClass::Residential,
IpClass::Datacenter,
IpClass::Unknown,
] {
assert_eq!(
IpClass::from_label(variant.label()),
Some(variant),
"label/from_label round-trip for {variant:?}"
);
}
assert_eq!(IpClass::from_label("nope"), None);
}
#[test]
fn trust_tier_rank_ordering_matches_t95_spec() {
assert!(TrustTier::Preferred.rank() > TrustTier::Acceptable.rank());
assert!(TrustTier::Acceptable.rank() > TrustTier::Marginal.rank());
assert!(TrustTier::Marginal.rank() > TrustTier::Blocked.rank());
}
#[test]
fn trust_tier_is_blocked() {
assert!(TrustTier::Blocked.is_blocked());
for tier in [
TrustTier::Preferred,
TrustTier::Acceptable,
TrustTier::Marginal,
] {
assert!(!tier.is_blocked());
}
}
#[test]
fn trust_tier_round_trips_through_json() {
for tier in [
TrustTier::Preferred,
TrustTier::Acceptable,
TrustTier::Marginal,
TrustTier::Blocked,
] {
let json = serde_json::to_string(&tier).expect("serialize TrustTier");
let parsed: TrustTier = serde_json::from_str(&json).expect("deserialize TrustTier");
assert_eq!(parsed, tier, "round-trip for {tier:?}");
}
}
#[test]
fn vendor_id_label_matches_serde_wire_format() {
assert_eq!(VendorId::DataDome.label(), "data_dome");
assert_eq!(VendorId::PerimeterX.label(), "perimeter_x");
assert_eq!(VendorId::Cloudflare.label(), "cloudflare");
assert_eq!(VendorId::Akamai.label(), "akamai");
}
#[test]
fn vendor_id_from_label_round_trip() {
for variant in [
VendorId::Akamai,
VendorId::Cloudflare,
VendorId::DataDome,
VendorId::PerimeterX,
VendorId::Hcaptcha,
VendorId::Recaptcha,
VendorId::Kasada,
VendorId::FingerprintCom,
VendorId::ShapeSecurity,
VendorId::Imperva,
VendorId::Unknown,
] {
assert_eq!(VendorId::from_label(variant.label()), Some(variant));
}
assert_eq!(VendorId::from_label("nope"), None);
}
#[test]
fn vendor_id_round_trips_through_json() {
let variant = VendorId::DataDome;
let json = serde_json::to_string(&variant).expect("serialize VendorId");
assert_eq!(json, "\"data_dome\"", "snake_case wire format");
let parsed: VendorId = serde_json::from_str(&json).expect("deserialize VendorId");
assert_eq!(parsed, variant);
}
#[test]
fn target_vendor_compatibility_default_is_empty() {
let c = TargetVendorCompatibility::default();
assert!(c.is_empty());
assert_eq!(c.len(), 0);
assert_eq!(c.get(VendorId::DataDome), None);
}
#[test]
fn target_vendor_compatibility_default_blocked_covers_known_vendors() {
let c = TargetVendorCompatibility::default_blocked();
assert!(!c.is_empty());
assert_eq!(c.get(VendorId::DataDome), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::Akamai), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::Cloudflare), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::PerimeterX), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::Hcaptcha), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::Recaptcha), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::Kasada), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::FingerprintCom), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::ShapeSecurity), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::Imperva), Some(TrustTier::Blocked));
assert_eq!(c.get(VendorId::Unknown), None);
}
#[test]
fn target_vendor_compatibility_set_is_builder_style() {
let c = TargetVendorCompatibility::default()
.set(VendorId::Akamai, TrustTier::Preferred)
.set(VendorId::Cloudflare, TrustTier::Acceptable);
assert_eq!(c.len(), 2);
assert_eq!(c.get(VendorId::Akamai), Some(TrustTier::Preferred));
assert_eq!(c.get(VendorId::Cloudflare), Some(TrustTier::Acceptable));
}
#[test]
fn target_vendor_compatibility_iterates_in_sorted_order() {
let c = TargetVendorCompatibility::default()
.set(VendorId::DataDome, TrustTier::Preferred)
.set(VendorId::Akamai, TrustTier::Acceptable);
let entries: Vec<_> = c.iter().collect();
assert_eq!(entries.first().map(|(v, _)| *v), Some(VendorId::Akamai));
assert_eq!(entries.get(1).map(|(v, _)| *v), Some(VendorId::DataDome));
}
#[test]
fn target_vendor_compatibility_round_trips_through_json() {
let original = TargetVendorCompatibility::default()
.set(VendorId::DataDome, TrustTier::Preferred)
.set(VendorId::Akamai, TrustTier::Acceptable)
.set(VendorId::Cloudflare, TrustTier::Marginal);
let json = serde_json::to_string(&original).expect("serialize");
let parsed: TargetVendorCompatibility = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, original);
}
#[test]
fn target_vendor_compatibility_round_trips_through_toml() {
let original = TargetVendorCompatibility::default()
.set(VendorId::DataDome, TrustTier::Preferred)
.set(VendorId::Akamai, TrustTier::Acceptable);
let toml_str = toml::to_string(&original).expect("serialize toml");
let parsed: TargetVendorCompatibility =
toml::from_str(&toml_str).expect("deserialize toml");
assert_eq!(parsed, original);
}
#[test]
fn target_vendor_compatibility_transparent_serde() {
let original = TargetVendorCompatibility::default()
.set(VendorId::DataDome, TrustTier::Preferred)
.set(VendorId::Akamai, TrustTier::Acceptable);
let json = serde_json::to_string(&original).expect("serialize");
let akamai_pos = json.find("\"akamai\"").expect("akamai present");
let datadome_pos = json.find("\"data_dome\"").expect("data_dome present");
assert!(akamai_pos < datadome_pos, "expected sorted order: {json}");
}
#[test]
fn ip_class_requirement_default_minimum_is_unknown() {
let req = IpClassRequirement::default();
assert_eq!(req.minimum, IpClass::Unknown);
}
#[test]
fn ip_class_requirement_is_satisfied_by_rank_gte_minimum() {
let req = IpClassRequirement {
minimum: IpClass::Isp,
};
assert!(req.is_satisfied_by(IpClass::Mobile));
assert!(req.is_satisfied_by(IpClass::Isp));
assert!(!req.is_satisfied_by(IpClass::Residential));
assert!(!req.is_satisfied_by(IpClass::Datacenter));
assert!(!req.is_satisfied_by(IpClass::Unknown));
}
#[test]
fn ip_class_requirement_round_trips_through_json() {
let req = IpClassRequirement {
minimum: IpClass::Isp,
};
let json = serde_json::to_string(&req).expect("serialize");
let parsed: IpClassRequirement = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, req);
}
#[test]
fn t95_mobile_beats_isp_requirement() {
let compat =
TargetVendorCompatibility::default().set(VendorId::DataDome, TrustTier::Preferred);
let caps = ProxyCapabilities {
ip_class: IpClass::Mobile,
target_compatibility: compat,
..Default::default()
};
let req = CapabilityRequirement {
require_ip_class: Some(IpClassRequirement {
minimum: IpClass::Isp,
}),
..Default::default()
};
assert!(caps.satisfies(&req));
}
#[test]
fn capability_requirement_default_matches_any_proxy() {
let req = CapabilityRequirement::default();
let caps_proxy_datacenter = ProxyCapabilities {
ip_class: IpClass::Datacenter,
..Default::default()
};
let caps_proxy_unknown = ProxyCapabilities::default();
let caps_proxy_full = ProxyCapabilities {
supports_https_connect: true,
supports_socks5_udp: true,
supports_http3_tunnel: true,
geo_country: Some("GB".into()),
geo_confidence: Some(0.9),
is_cdn_edge: true,
cdn_provider: Some("cloudflare".into()),
tls_profile: Some("chrome-131".into()),
asn: Some(KNOWN_ASN_CLOUDFLARE),
city: Some("London".into()),
postal_code: Some("SW1A".into()),
ip_class: IpClass::Mobile,
target_compatibility: TargetVendorCompatibility::default(),
};
assert!(caps_proxy_datacenter.satisfies(&req));
assert!(caps_proxy_unknown.satisfies(&req));
assert!(caps_proxy_full.satisfies(&req));
}
#[test]
fn capability_requirement_target_vendor_blocks_free_list_pool() {
let caps = ProxyCapabilities {
ip_class: IpClass::Datacenter,
target_compatibility: TargetVendorCompatibility::default_blocked(),
..Default::default()
};
let req = CapabilityRequirement {
target_vendor: Some(VendorId::DataDome),
..Default::default()
};
assert!(
!caps.satisfies(&req),
"free-list proxy must not satisfy a DataDome vendor requirement"
);
}
#[test]
fn capability_requirement_target_vendor_accepts_acceptable_tier() {
let caps = ProxyCapabilities {
target_compatibility: TargetVendorCompatibility::default()
.set(VendorId::DataDome, TrustTier::Acceptable),
..Default::default()
};
let req = CapabilityRequirement {
target_vendor: Some(VendorId::DataDome),
..Default::default()
};
assert!(caps.satisfies(&req));
}
#[test]
fn capability_requirement_require_ip_class_fails_for_datacenter() {
let caps = ProxyCapabilities {
ip_class: IpClass::Datacenter,
..Default::default()
};
let req = CapabilityRequirement {
require_ip_class: Some(IpClassRequirement {
minimum: IpClass::Isp,
}),
..Default::default()
};
assert!(!caps.satisfies(&req));
}
#[test]
fn capability_requirement_require_ip_class_fails_for_unknown() {
let caps = ProxyCapabilities::default();
let req = CapabilityRequirement {
require_ip_class: Some(IpClassRequirement {
minimum: IpClass::Isp,
}),
..Default::default()
};
assert!(!caps.satisfies(&req));
}
#[test]
fn proxy_default_ip_class_is_unknown() {
let proxy = Proxy {
url: "http://example.test:8080".into(),
proxy_type: ProxyType::Http,
username: None,
password: None,
weight: 1,
tags: vec![],
capabilities: ProxyCapabilities::default(),
ip_class: IpClass::Unknown,
target_compatibility: TargetVendorCompatibility::default(),
};
assert_eq!(proxy.ip_class, IpClass::Unknown);
assert!(proxy.target_compatibility.is_empty());
}
#[test]
fn proxy_legacy_serde_backward_compatibility() {
let legacy = r#"{
"url": "http://legacy.test:8080",
"proxy_type": "http",
"username": null,
"password": null,
"weight": 1,
"tags": [],
"capabilities": {}
}"#;
let parsed: Proxy = serde_json::from_str(legacy).expect("legacy parses");
assert_eq!(parsed.url, "http://legacy.test:8080");
assert_eq!(parsed.ip_class, IpClass::Unknown);
assert!(parsed.target_compatibility.is_empty());
}
#[test]
fn t98_capabilities_round_trip_through_json_with_all_geo_fields() {
let original = ProxyCapabilities {
asn: Some(KNOWN_ASN_CLOUDFLARE),
city: Some("San Francisco".into()),
postal_code: Some("94110".into()),
..Default::default()
};
let json = serde_json::to_string(&original).expect("serialize");
let parsed: ProxyCapabilities = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, original);
}
#[test]
fn t98_capabilities_round_trip_through_json_with_none_geo_fields() {
let original = ProxyCapabilities::default();
let json = serde_json::to_string(&original).expect("serialize");
let parsed: ProxyCapabilities = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, original);
assert!(parsed.asn.is_none());
assert!(parsed.city.is_none());
assert!(parsed.postal_code.is_none());
}
#[test]
fn t98_capabilities_legacy_wire_payload_deserialises_to_none() {
let legacy = "{}";
let parsed: ProxyCapabilities = serde_json::from_str(legacy).expect("legacy parses");
assert!(parsed.asn.is_none());
assert!(parsed.city.is_none());
assert!(parsed.postal_code.is_none());
}
#[test]
fn t98_capability_requirement_round_trip_through_json_with_geo_fields() {
let original = CapabilityRequirement {
require_asn: Some(KNOWN_ASN_AKAMAI),
require_city: Some("London".into()),
require_postal_code: Some("SW1A".into()),
..Default::default()
};
let json = serde_json::to_string(&original).expect("serialize");
let parsed: CapabilityRequirement = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, original);
}
#[test]
fn t98_empty_requirement_matches_any_proxy() {
let req = CapabilityRequirement::default();
let json = serde_json::to_string(&req).expect("serialize");
let parsed: CapabilityRequirement = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, req);
for caps in [
ProxyCapabilities::default(),
ProxyCapabilities {
asn: Some(KNOWN_ASN_CLOUDFLARE),
city: Some("Anywhere".into()),
postal_code: Some("00000".into()),
..Default::default()
},
] {
assert!(caps.satisfies(&req));
}
}
#[test]
fn t98_require_asn_exact_match() {
let req = CapabilityRequirement {
require_asn: Some(KNOWN_ASN_CLOUDFLARE),
..Default::default()
};
let cf_caps = ProxyCapabilities {
asn: Some(KNOWN_ASN_CLOUDFLARE),
..Default::default()
};
let ak_caps = ProxyCapabilities {
asn: Some(KNOWN_ASN_AKAMAI),
..Default::default()
};
let none_caps = ProxyCapabilities::default();
assert!(cf_caps.satisfies(&req));
assert!(!ak_caps.satisfies(&req));
assert!(!none_caps.satisfies(&req));
}
#[test]
fn t98_require_city_exact_match() {
let req = CapabilityRequirement {
require_city: Some("San Francisco".into()),
..Default::default()
};
let sf_caps = ProxyCapabilities {
city: Some("San Francisco".into()),
..Default::default()
};
let b_caps = ProxyCapabilities {
city: Some("Berlin".into()),
..Default::default()
};
let none_caps = ProxyCapabilities::default();
assert!(sf_caps.satisfies(&req));
assert!(!b_caps.satisfies(&req));
assert!(!none_caps.satisfies(&req));
}
#[test]
fn t98_require_postal_code_exact_match() {
let req = CapabilityRequirement {
require_postal_code: Some("94110".into()),
..Default::default()
};
let sf_caps = ProxyCapabilities {
postal_code: Some("94110".into()),
..Default::default()
};
let ny_caps = ProxyCapabilities {
postal_code: Some("10001".into()),
..Default::default()
};
let none_caps = ProxyCapabilities::default();
assert!(sf_caps.satisfies(&req));
assert!(!ny_caps.satisfies(&req));
assert!(!none_caps.satisfies(&req));
}
#[test]
fn t98_composite_geo_filter_akamai_scrape() {
let req = CapabilityRequirement {
require_asn: Some(KNOWN_ASN_CLOUDFLARE),
require_city: Some("San Francisco".into()),
require_postal_code: Some("94110".into()),
..Default::default()
};
let matching = ProxyCapabilities {
asn: Some(KNOWN_ASN_CLOUDFLARE),
city: Some("San Francisco".into()),
postal_code: Some("94110".into()),
..Default::default()
};
let wrong_asn = ProxyCapabilities {
asn: Some(KNOWN_ASN_OVH),
city: Some("San Francisco".into()),
postal_code: Some("94110".into()),
..Default::default()
};
let wrong_city = ProxyCapabilities {
asn: Some(KNOWN_ASN_CLOUDFLARE),
city: Some("Oakland".into()),
postal_code: Some("94110".into()),
..Default::default()
};
let wrong_zip = ProxyCapabilities {
asn: Some(KNOWN_ASN_CLOUDFLARE),
city: Some("San Francisco".into()),
postal_code: Some("94609".into()),
..Default::default()
};
assert!(matching.satisfies(&req));
assert!(!wrong_asn.satisfies(&req));
assert!(!wrong_city.satisfies(&req));
assert!(!wrong_zip.satisfies(&req));
}
#[test]
fn t98_capabilities_and_requirement_round_trip_through_toml() {
let caps = ProxyCapabilities {
asn: Some(KNOWN_ASN_FASTLY),
city: Some("Berlin".into()),
postal_code: Some("10115".into()),
..Default::default()
};
let caps_toml = toml::to_string(&caps).expect("serialize caps toml");
let parsed_caps: ProxyCapabilities =
toml::from_str(&caps_toml).expect("deserialize caps toml");
assert_eq!(parsed_caps, caps);
let req = CapabilityRequirement {
require_asn: Some(KNOWN_ASN_FASTLY),
require_city: Some("Berlin".into()),
..Default::default()
};
let req_toml = toml::to_string(&req).expect("serialize req toml");
let parsed_req: CapabilityRequirement =
toml::from_str(&req_toml).expect("deserialize req toml");
assert_eq!(parsed_req, req);
}
#[test]
fn t98_capability_requirement_legacy_wire_payload_deserialises_to_none() {
let legacy = r#"{
"require_https_connect": false,
"require_socks5_udp": false,
"require_http3_tunnel": false,
"require_geo_country": null,
"require_cdn_edge": false,
"require_tls_profile": null
}"#;
let parsed: CapabilityRequirement = serde_json::from_str(legacy).expect("legacy parses");
assert!(parsed.require_asn.is_none());
assert!(parsed.require_city.is_none());
assert!(parsed.require_postal_code.is_none());
}
#[test]
fn t98_well_known_asns_match_documented_values() {
assert_eq!(super::well_known::KNOWN_ASN_CLOUDFLARE, 13_335);
assert_eq!(super::well_known::KNOWN_ASN_AKAMAI, 20_940);
assert_eq!(super::well_known::KNOWN_ASN_FASTLY, 54_113);
assert_eq!(super::well_known::KNOWN_ASN_CLOUDFRONT, 16_509);
assert_eq!(super::well_known::KNOWN_ASN_GOOGLE, 15_169);
assert_eq!(super::well_known::KNOWN_ASN_AZURE, 8075);
}
#[test]
fn t98_well_known_all_known_asns_includes_major_cdns() {
let all = super::well_known::ALL_KNOWN_ASNS;
assert!(all.contains(&KNOWN_ASN_CLOUDFLARE));
assert!(all.contains(&KNOWN_ASN_AKAMAI));
assert!(all.contains(&KNOWN_ASN_FASTLY));
assert!(all.contains(&super::well_known::KNOWN_ASN_CLOUDFRONT));
let mut sorted = all.to_vec();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), all.len());
}
#[test]
fn t98_validate_asn_accepts_valid_asns() {
assert!(super::validate_asn(1).is_ok());
assert!(super::validate_asn(KNOWN_ASN_CLOUDFLARE).is_ok());
assert!(super::validate_asn(u32::MAX - 1).is_ok());
}
#[test]
fn t98_validate_asn_rejects_reserved_values() {
let err_zero = super::validate_asn(0).expect_err("0 should fail");
assert!(matches!(
err_zero,
crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "asn"
));
let err_max = super::validate_asn(u32::MAX).expect_err("u32::MAX should fail");
assert!(matches!(
err_max,
crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "asn"
));
}
#[test]
fn t98_validate_city_enforces_length_bounds() {
assert!(super::validate_city("").is_err());
assert!(super::validate_city("A").is_ok());
assert!(super::validate_city("San Francisco").is_ok());
let over = "x".repeat(super::CITY_MAX_LEN + 1);
assert!(super::validate_city(&over).is_err());
}
#[test]
fn t98_validate_postal_code_enforces_length_bounds() {
assert!(super::validate_postal_code("").is_err());
assert!(super::validate_postal_code("94110").is_ok());
assert!(super::validate_postal_code("SW1A 1AA").is_ok());
assert!(super::validate_postal_code("100-0001").is_ok());
let over = "x".repeat(super::POSTAL_CODE_MAX_LEN + 1);
assert!(super::validate_postal_code(&over).is_err());
}
}