Skip to main content

stygian_proxy/
types.rs

1//! Core domain types for proxy management.
2//!
3//! ## IP class and target compatibility
4//!
5//! The 2026 scraping guide (see
6//! `docs/dev/project/scraping-guide-2026-llm-context.md` §"PROXY PROVIDERS
7//! AND TYPES") ranks egress IPs into a four-tier trust hierarchy used by
8//! every Tier-1 anti-bot vendor:
9//!
10//! | Rank | [`IpClass`]     | Typical use                                    |
11//! | ---- | --------------- | ---------------------------------------------- |
12//! | 4    | [`Mobile`](IpClass::Mobile) | 3G/4G/5G carrier egress — defeats `DataDome`, `PerimeterX`, `Kasada` |
13//! | 3    | [`Isp`](IpClass::Isp)      | Static/ISP allocation — defeats `Akamai`, `Cloudflare`, `PerimeterX` |
14//! | 2    | [`Residential`](IpClass::Residential) | Rotating residential pool — defeats most Tier-1 vendors |
15//! | 1    | [`Datacenter`](IpClass::Datacenter) | Hosted VPS / bare-metal — defeated by `DataDome`, `PerimeterX` |
16//! | 0    | [`Unknown`](IpClass::Unknown) | Provider did not tag the egress — fail-secure default |
17//!
18//! Each [`Proxy`] and [`ProxyCapabilities`] carries two typed fields that
19//! drive capability-aware acquisition:
20//!
21//! - `ip_class: IpClass` — the proxy's egress tier. Acquisition matches via
22//!   `ip_class.rank() >= requirement.rank()` so a [`Mobile`](IpClass::Mobile)
23//!   proxy satisfies a request that requires [`Isp`](IpClass::Isp).
24//! - `target_compatibility: TargetVendorCompatibility` — a
25//!   `BTreeMap<VendorId, TrustTier>` mapping each anti-bot vendor to a
26//!   declared effectiveness tier. Free-list fetchers tag every ingested
27//!   proxy as `default_blocked()` (no vendor confirmed) so callers cannot
28//!   accidentally route premium traffic through a public free-list pool.
29//!
30//! ## Geo enrichment
31//!
32//! Operators targeting specific cities / ASNs / postal codes — the
33//! "Infatica-style city, ZIP, and ASN" filter cited by the 2026
34//! scraping guide (L2837) — populate the optional
35//! [`asn`](ProxyCapabilities::asn),
36//! [`city`](ProxyCapabilities::city), and
37//! [`postal_code`](ProxyCapabilities::postal_code) fields on
38//! [`ProxyCapabilities`]. The corresponding
39//! [`require_asn`](CapabilityRequirement::require_asn),
40//! [`require_city`](CapabilityRequirement::require_city), and
41//! [`require_postal_code`](CapabilityRequirement::require_postal_code)
42//! fields on [`CapabilityRequirement`] select proxies whose geo
43//! metadata matches. Empty requirement still matches any proxy (the
44//! existing invariant is preserved).
45//!
46//! ```rust
47//! use stygian_proxy::types::{CapabilityRequirement, ProxyCapabilities};
48//! use stygian_proxy::types::well_known::KNOWN_ASN_CLOUDFLARE;
49//!
50//! // Akamai scrape: insist the egress IP is in Cloudflare's AS.
51//! let caps = ProxyCapabilities {
52//!     asn: Some(KNOWN_ASN_CLOUDFLARE),
53//!     city: Some("San Francisco".into()),
54//!     postal_code: Some("94110".into()),
55//!     ..Default::default()
56//! };
57//! let req = CapabilityRequirement {
58//!     require_asn: Some(KNOWN_ASN_CLOUDFLARE),
59//!     require_city: Some("San Francisco".into()),
60//!     require_postal_code: Some("94110".into()),
61//!     ..Default::default()
62//! };
63//! assert!(caps.satisfies(&req));
64//! ```
65//!
66//! ```rust
67//! use stygian_proxy::types::{IpClass, TargetVendorCompatibility, TrustTier, VendorId};
68//!
69//! // A static-ISP proxy confirmed effective against Akamai and Cloudflare.
70//! let compat = TargetVendorCompatibility::default()
71//!     .set(VendorId::Akamai, TrustTier::Preferred)
72//!     .set(VendorId::Cloudflare, TrustTier::Acceptable);
73//! assert_eq!(compat.get(VendorId::Akamai), Some(TrustTier::Preferred));
74//! assert_eq!(IpClass::Isp.rank(), 3);
75//! ```
76
77use std::collections::BTreeMap;
78use std::sync::atomic::{AtomicU64, Ordering};
79use std::time::{Duration, Instant};
80
81use serde::{Deserialize, Serialize};
82use uuid::Uuid;
83
84/// The protocol variant of a proxy endpoint.
85///
86/// # Example
87/// ```
88/// use stygian_proxy::types::ProxyType;
89/// assert_eq!(ProxyType::Http, ProxyType::Http);
90/// ```
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ProxyType {
94    /// Plain HTTP proxy (CONNECT / forwarding).
95    Http,
96    /// HTTPS proxy over TLS.
97    Https,
98    #[cfg(feature = "socks")]
99    /// SOCKS4 proxy (requires the `socks` feature).
100    Socks4,
101    #[cfg(feature = "socks")]
102    /// SOCKS5 proxy (requires the `socks` feature).
103    Socks5,
104    /// CDN edge relay (`Cloudflare`, `CloudFront`, `Azure Front Door`, etc.).
105    ///
106    /// Traffic egresses through a CDN point-of-presence rather than a traditional proxy
107    /// server.  Provider metadata is carried in
108    /// [`ProxyCapabilities::cdn_provider`].
109    CdnEdge,
110}
111
112/// TLS-profiled request mode for proxy-side HTTP operations.
113///
114/// Used by `tls-profiled` integrations to decide how strictly browser TLS
115/// profiles should be mapped onto rustls.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum ProfiledRequestMode {
119    /// Broad compatibility: skip unknown entries and use safe fallbacks.
120    Compatible,
121    /// Profile-aware preset selected from the profile name.
122    Preset,
123    /// Strict cipher-suite mapping with compatibility group fallback.
124    Strict,
125    /// Strict cipher-suite + group mapping without fallback.
126    StrictAll,
127}
128
129/// IP trust class for a proxy egress.
130///
131/// Encodes the four-tier IP trust hierarchy cited by the 2026 scraping
132/// guide: mobile carriers > static ISP allocations > rotating residential
133/// pools > datacenter ranges. The 5th variant, [`Unknown`](IpClass::Unknown),
134/// is the fail-secure default for any proxy whose provider did not declare
135/// its class.
136///
137/// `Copy + Eq + Hash` so [`IpClass`] can be used as a `BTreeMap` key and
138/// embedded in `Copy` structs without an extra allocation.
139///
140/// # Example
141/// ```
142/// use stygian_proxy::types::IpClass;
143/// assert!(IpClass::Mobile.rank() > IpClass::Isp.rank());
144/// assert!(IpClass::Isp.rank() > IpClass::Residential.rank());
145/// assert!(IpClass::Residential.rank() > IpClass::Datacenter.rank());
146/// assert_eq!(IpClass::default(), IpClass::Unknown);
147/// ```
148#[derive(
149    Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
150)]
151#[serde(rename_all = "snake_case")]
152pub enum IpClass {
153    /// Mobile-carrier (3G/4G/5G) egress.
154    Mobile,
155    /// Static ISP allocation.
156    Isp,
157    /// Rotating residential pool.
158    Residential,
159    /// Datacenter / VPS / bare-metal egress.
160    Datacenter,
161    /// Provider did not tag the egress. Fail-secure default.
162    #[default]
163    Unknown,
164}
165
166impl IpClass {
167    /// Rank used to express "at least this tier" requirements.
168    ///
169    /// Higher rank = higher trust. Mobile (4) outranks ISP (3) which
170    /// outranks Residential (2) which outranks Datacenter (1); `Unknown`
171    /// is the lowest (0) so a proxy that does not declare its class
172    /// never satisfies a non-empty `IpClassRequirement`.
173    ///
174    /// # Example
175    /// ```
176    /// use stygian_proxy::types::IpClass;
177    /// assert_eq!(IpClass::Mobile.rank(), 4);
178    /// assert_eq!(IpClass::Isp.rank(), 3);
179    /// assert_eq!(IpClass::Residential.rank(), 2);
180    /// assert_eq!(IpClass::Datacenter.rank(), 1);
181    /// assert_eq!(IpClass::Unknown.rank(), 0);
182    /// ```
183    #[must_use]
184    pub const fn rank(self) -> u8 {
185        match self {
186            Self::Mobile => 4,
187            Self::Isp => 3,
188            Self::Residential => 2,
189            Self::Datacenter => 1,
190            Self::Unknown => 0,
191        }
192    }
193
194    /// Stable, `snake_case` wire label (matches the [`serde`][Self] representation).
195    ///
196    /// # Example
197    /// ```
198    /// use stygian_proxy::types::IpClass;
199    /// assert_eq!(IpClass::Mobile.label(), "mobile");
200    /// assert_eq!(IpClass::Datacenter.label(), "datacenter");
201    /// assert_eq!(IpClass::Unknown.label(), "unknown");
202    /// ```
203    #[must_use]
204    pub const fn label(self) -> &'static str {
205        match self {
206            Self::Mobile => "mobile",
207            Self::Isp => "isp",
208            Self::Residential => "residential",
209            Self::Datacenter => "datacenter",
210            Self::Unknown => "unknown",
211        }
212    }
213
214    /// Parse an [`IpClass`] from its [`label`][Self] `snake_case` string.
215    ///
216    /// Mirrors [`VendorId::from_label`] so MCP and external configs can use
217    /// the same string vocabulary.
218    ///
219    /// # Example
220    /// ```
221    /// use stygian_proxy::types::IpClass;
222    /// assert_eq!(IpClass::from_label("mobile"), Some(IpClass::Mobile));
223    /// assert_eq!(IpClass::from_label("datacenter"), Some(IpClass::Datacenter));
224    /// assert_eq!(IpClass::from_label("nope"), None);
225    /// ```
226    #[must_use]
227    pub fn from_label(label: &str) -> Option<Self> {
228        match label {
229            "mobile" => Some(Self::Mobile),
230            "isp" => Some(Self::Isp),
231            "residential" => Some(Self::Residential),
232            "datacenter" => Some(Self::Datacenter),
233            "unknown" => Some(Self::Unknown),
234            _ => None,
235        }
236    }
237}
238
239/// Declared effectiveness of a proxy against a given anti-bot vendor.
240///
241/// `Preferred` is the highest trust, `Blocked` means the proxy is
242/// known to be defeated by the vendor (used as the default for free-list
243/// fetches so callers cannot accidentally route premium traffic through
244/// a public free-list pool).
245///
246/// # Example
247/// ```
248/// use stygian_proxy::types::TrustTier;
249/// assert!(TrustTier::Preferred.rank() > TrustTier::Acceptable.rank());
250/// assert!(TrustTier::Acceptable.rank() > TrustTier::Marginal.rank());
251/// assert!(TrustTier::Marginal.rank() > TrustTier::Blocked.rank());
252/// ```
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum TrustTier {
256    /// The proxy is known to defeat the vendor on the first try.
257    Preferred,
258    /// The proxy typically succeeds; expect occasional friction.
259    Acceptable,
260    /// The proxy is hit-or-miss; budget for retries and CAPTCHA solves.
261    Marginal,
262    /// The proxy is known to be blocked. Used as the default for free-list
263    /// fetches so operators must opt-in to using them for premium vendors.
264    Blocked,
265}
266
267impl TrustTier {
268    /// Rank used for tier-comparison helpers.
269    ///
270    /// Higher = better. `Preferred` (4) > `Acceptable` (3) > `Marginal` (2) >
271    /// `Blocked` (1). The value is `1`-based so `Blocked` is still
272    /// "ranked" (i.e. not zero) — that lets `is_blocked()` be a simple
273    /// `rank() == 1` check.
274    ///
275    /// # Example
276    /// ```
277    /// use stygian_proxy::types::TrustTier;
278    /// assert!(TrustTier::Preferred.is_blocked() == false);
279    /// assert!(TrustTier::Blocked.is_blocked());
280    /// ```
281    #[must_use]
282    pub const fn rank(self) -> u8 {
283        match self {
284            Self::Preferred => 4,
285            Self::Acceptable => 3,
286            Self::Marginal => 2,
287            Self::Blocked => 1,
288        }
289    }
290
291    /// `true` when the tier is [`TrustTier::Blocked`].
292    ///
293    /// # Example
294    /// ```
295    /// use stygian_proxy::types::TrustTier;
296    /// assert!(TrustTier::Blocked.is_blocked());
297    /// assert!(!TrustTier::Marginal.is_blocked());
298    /// ```
299    #[must_use]
300    pub const fn is_blocked(self) -> bool {
301        matches!(self, Self::Blocked)
302    }
303}
304
305/// Anti-bot vendor identifier.
306///
307/// This is a local mirror of the same taxonomy used by `stygian-charon`'s
308/// `vendor_classifier::VendorId` so the labels round-trip through
309/// `serde` identically across crates. The wire labels are stable
310/// `snake_case` strings; see [`VendorId::label`].
311#[derive(
312    Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
313)]
314#[serde(rename_all = "snake_case")]
315pub enum VendorId {
316    /// `Akamai` Bot Manager.
317    Akamai,
318    /// `Cloudflare` bot management.
319    Cloudflare,
320    /// `DataDome`.
321    DataDome,
322    /// `PerimeterX` / HUMAN Security.
323    PerimeterX,
324    /// hCaptcha challenge provider.
325    Hcaptcha,
326    /// Google reCAPTCHA challenge provider.
327    Recaptcha,
328    /// Kasada challenge provider.
329    Kasada,
330    /// Fingerprint.com identification.
331    FingerprintCom,
332    /// Shape Security (F5).
333    ShapeSecurity,
334    /// Imperva (Incapsula) bot management.
335    Imperva,
336    /// Catch-all when no vendor was declared.
337    #[default]
338    Unknown,
339}
340
341impl VendorId {
342    /// Stable, lower-case wire label used by [`VendorId::from_label`].
343    ///
344    /// Mirrors the `#[serde(rename_all = "snake_case")]` wire form so
345    /// `serde_json::to_string(&variant) == format!("\"{label}\"")` for
346    /// every variant — see `vendor_id_round_trips_through_json`.
347    ///
348    /// # Example
349    /// ```
350    /// use stygian_proxy::types::VendorId;
351    /// assert_eq!(VendorId::DataDome.label(), "data_dome");
352    /// assert_eq!(VendorId::PerimeterX.label(), "perimeter_x");
353    /// assert_eq!(VendorId::Cloudflare.label(), "cloudflare");
354    /// assert_eq!(VendorId::Akamai.label(), "akamai");
355    /// ```
356    #[must_use]
357    pub const fn label(self) -> &'static str {
358        match self {
359            Self::Akamai => "akamai",
360            Self::Cloudflare => "cloudflare",
361            Self::DataDome => "data_dome",
362            Self::PerimeterX => "perimeter_x",
363            Self::Hcaptcha => "hcaptcha",
364            Self::Recaptcha => "recaptcha",
365            Self::Kasada => "kasada",
366            Self::FingerprintCom => "fingerprint_com",
367            Self::ShapeSecurity => "shape_security",
368            Self::Imperva => "imperva",
369            Self::Unknown => "unknown",
370        }
371    }
372
373    /// Parse a [`VendorId`] from its [`label`][Self::label].
374    ///
375    /// # Example
376    /// ```
377    /// use stygian_proxy::types::VendorId;
378    /// assert_eq!(VendorId::from_label("data_dome"), Some(VendorId::DataDome));
379    /// assert_eq!(VendorId::from_label("cloudflare"), Some(VendorId::Cloudflare));
380    /// assert_eq!(VendorId::from_label("nope"), None);
381    /// ```
382    #[must_use]
383    pub fn from_label(label: &str) -> Option<Self> {
384        match label {
385            "akamai" => Some(Self::Akamai),
386            "cloudflare" => Some(Self::Cloudflare),
387            "data_dome" => Some(Self::DataDome),
388            "perimeter_x" => Some(Self::PerimeterX),
389            "hcaptcha" => Some(Self::Hcaptcha),
390            "recaptcha" => Some(Self::Recaptcha),
391            "kasada" => Some(Self::Kasada),
392            "fingerprint_com" => Some(Self::FingerprintCom),
393            "shape_security" => Some(Self::ShapeSecurity),
394            "imperva" => Some(Self::Imperva),
395            "unknown" => Some(Self::Unknown),
396            _ => None,
397        }
398    }
399}
400
401/// Mapping from anti-bot [`VendorId`] to the proxy's declared
402/// [`TrustTier`] against that vendor.
403///
404/// `default_blocked()` returns a populated map with every known vendor
405/// marked as [`TrustTier::Blocked`], which is the safe choice for
406/// free-list ingest: callers must explicitly opt-in to trusting a
407/// free-list pool for premium vendors.
408#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(rename_all = "snake_case", transparent)]
410pub struct TargetVendorCompatibility {
411    /// Per-vendor trust tier.
412    defeats: BTreeMap<VendorId, TrustTier>,
413}
414
415impl TargetVendorCompatibility {
416    /// Empty compatibility — every vendor falls back to
417    /// [`TrustTier::Blocked`] at the requirement gate.
418    ///
419    /// # Example
420    /// ```
421    /// use stygian_proxy::types::TargetVendorCompatibility;
422    /// let c = TargetVendorCompatibility::default();
423    /// assert!(c.is_empty());
424    /// assert_eq!(c.get(stygian_proxy::types::VendorId::DataDome), None);
425    /// ```
426    #[must_use]
427    pub fn new() -> Self {
428        Self::default()
429    }
430
431    /// Returns a compatibility map with every known vendor marked
432    /// [`TrustTier::Blocked`].
433    ///
434    /// Used by free-list fetchers to fail-secure on ingest: a free-list
435    /// proxy cannot satisfy a `target_vendor` capability requirement
436    /// unless the operator explicitly upgrades the tier.
437    ///
438    /// # Example
439    /// ```
440    /// use stygian_proxy::types::{TargetVendorCompatibility, TrustTier, VendorId};
441    /// let c = TargetVendorCompatibility::default_blocked();
442    /// assert_eq!(c.get(VendorId::DataDome), Some(TrustTier::Blocked));
443    /// assert_eq!(c.get(VendorId::Cloudflare), Some(TrustTier::Blocked));
444    /// assert!(!c.is_empty());
445    /// ```
446    #[must_use]
447    pub fn default_blocked() -> Self {
448        let mut defeats = BTreeMap::new();
449        for vendor in [
450            VendorId::Akamai,
451            VendorId::Cloudflare,
452            VendorId::DataDome,
453            VendorId::PerimeterX,
454            VendorId::Hcaptcha,
455            VendorId::Recaptcha,
456            VendorId::Kasada,
457            VendorId::FingerprintCom,
458            VendorId::ShapeSecurity,
459            VendorId::Imperva,
460        ] {
461            defeats.insert(vendor, TrustTier::Blocked);
462        }
463        Self { defeats }
464    }
465
466    /// Returns the declared tier for `vendor`, or `None` when unknown.
467    ///
468    /// # Example
469    /// ```
470    /// use stygian_proxy::types::{TargetVendorCompatibility, TrustTier, VendorId};
471    /// let c = TargetVendorCompatibility::default().set(VendorId::Akamai, TrustTier::Preferred);
472    /// assert_eq!(c.get(VendorId::Akamai), Some(TrustTier::Preferred));
473    /// assert_eq!(c.get(VendorId::DataDome), None);
474    /// ```
475    #[must_use]
476    pub fn get(&self, vendor: VendorId) -> Option<TrustTier> {
477        self.defeats.get(&vendor).copied()
478    }
479
480    /// Set the declared tier for `vendor`, replacing any prior value.
481    ///
482    /// Builder-style: takes `self` by value and returns the updated
483    /// `TargetVendorCompatibility` so calls can be chained.
484    ///
485    /// # Example
486    /// ```
487    /// use stygian_proxy::types::{TargetVendorCompatibility, TrustTier, VendorId};
488    /// let c = TargetVendorCompatibility::default()
489    ///     .set(VendorId::Cloudflare, TrustTier::Acceptable)
490    ///     .set(VendorId::Akamai, TrustTier::Preferred);
491    /// assert_eq!(c.get(VendorId::Cloudflare), Some(TrustTier::Acceptable));
492    /// assert_eq!(c.get(VendorId::Akamai), Some(TrustTier::Preferred));
493    /// ```
494    #[must_use]
495    pub fn set(mut self, vendor: VendorId, tier: TrustTier) -> Self {
496        self.defeats.insert(vendor, tier);
497        self
498    }
499
500    /// Returns `true` when no vendor tiers have been declared.
501    ///
502    /// # Example
503    /// ```
504    /// use stygian_proxy::types::{TargetVendorCompatibility, TrustTier, VendorId};
505    /// assert!(TargetVendorCompatibility::default().is_empty());
506    /// assert!(!TargetVendorCompatibility::default_blocked().is_empty());
507    /// let c = TargetVendorCompatibility::default().set(VendorId::Cloudflare, TrustTier::Preferred);
508    /// assert!(!c.is_empty());
509    /// ```
510    #[must_use]
511    pub fn is_empty(&self) -> bool {
512        self.defeats.is_empty()
513    }
514
515    /// Returns the number of declared vendor tiers.
516    ///
517    /// # Example
518    /// ```
519    /// use stygian_proxy::types::{TargetVendorCompatibility, TrustTier, VendorId};
520    /// let c = TargetVendorCompatibility::default();
521    /// assert_eq!(c.len(), 0);
522    /// let c = TargetVendorCompatibility::default().set(VendorId::Cloudflare, TrustTier::Preferred);
523    /// assert_eq!(c.len(), 1);
524    /// ```
525    #[must_use]
526    pub fn len(&self) -> usize {
527        self.defeats.len()
528    }
529
530    /// Iterate `(vendor, tier)` pairs in deterministic (sorted) order.
531    ///
532    /// # Example
533    /// ```
534    /// use stygian_proxy::types::{TargetVendorCompatibility, TrustTier, VendorId};
535    /// let c = TargetVendorCompatibility::default()
536    ///     .set(VendorId::DataDome, TrustTier::Preferred)
537    ///     .set(VendorId::Akamai, TrustTier::Acceptable);
538    /// let entries: Vec<_> = c.iter().collect();
539    /// // Sorted by VendorId discriminant order (Akamai < DataDome).
540    /// assert_eq!(entries.first().map(|(v, _)| *v), Some(VendorId::Akamai));
541    /// assert_eq!(entries.get(1).map(|(v, _)| *v), Some(VendorId::DataDome));
542    /// ```
543    pub fn iter(&self) -> impl Iterator<Item = (VendorId, TrustTier)> + '_ {
544        self.defeats.iter().map(|(v, t)| (*v, *t))
545    }
546}
547
548/// Minimum [`IpClass`] required for a capability-aware acquisition.
549///
550/// A `Mobile` proxy satisfies a `Isp` requirement because
551/// `Mobile.rank() > Isp.rank()`. The reverse (`Isp` does not satisfy
552/// `Mobile`) is also true. The default is [`IpClass::Unknown`] which
553/// matches every non-empty proxy (and is also the only way to match an
554/// `Unknown` proxy).
555///
556/// # Example
557/// ```
558/// use stygian_proxy::types::{IpClass, IpClassRequirement};
559/// let req = IpClassRequirement { minimum: IpClass::Isp };
560/// assert!(req.is_satisfied_by(IpClass::Mobile));
561/// assert!(req.is_satisfied_by(IpClass::Isp));
562/// assert!(!req.is_satisfied_by(IpClass::Residential));
563/// assert!(!req.is_satisfied_by(IpClass::Datacenter));
564/// ```
565#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
566#[serde(rename_all = "snake_case", transparent)]
567pub struct IpClassRequirement {
568    /// Minimum IP class rank required.
569    pub minimum: IpClass,
570}
571
572impl IpClassRequirement {
573    /// Returns `true` when `proxy` meets or exceeds the requirement.
574    ///
575    /// # Example
576    /// ```
577    /// use stygian_proxy::types::{IpClass, IpClassRequirement};
578    /// let req = IpClassRequirement { minimum: IpClass::Isp };
579    /// assert!(req.is_satisfied_by(IpClass::Mobile));
580    /// assert!(req.is_satisfied_by(IpClass::Isp));
581    /// assert!(!req.is_satisfied_by(IpClass::Datacenter));
582    /// ```
583    #[must_use]
584    pub const fn is_satisfied_by(&self, proxy: IpClass) -> bool {
585        proxy.rank() >= self.minimum.rank()
586    }
587}
588
589/// Protocol-level capabilities advertised by a proxy endpoint.
590///
591/// These flags are set when the proxy is registered and consulted during
592/// capability-aware selection (see [`crate::manager::ProxyManager::acquire_with_capabilities`]).
593///
594/// # Example
595/// ```
596/// use stygian_proxy::types::ProxyCapabilities;
597/// let caps = ProxyCapabilities::default();
598/// assert!(!caps.supports_https_connect);
599/// assert!(!caps.supports_socks5_udp);
600/// assert!(!caps.supports_http3_tunnel);
601/// assert_eq!(caps.ip_class, stygian_proxy::types::IpClass::Unknown);
602/// assert!(caps.target_compatibility.is_empty());
603/// ```
604#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
605#[serde(rename_all = "snake_case")]
606#[allow(clippy::struct_excessive_bools)] // 4 capability flags are clearer as named bools than a u8 bitmask
607pub struct ProxyCapabilities {
608    /// Proxy supports the `CONNECT` method for HTTPS tunnelling.
609    #[serde(default)]
610    pub supports_https_connect: bool,
611    /// Proxy supports SOCKS5 with UDP relay (for UDP-based transports).
612    #[serde(default)]
613    pub supports_socks5_udp: bool,
614    /// Proxy supports HTTP/3 (QUIC) tunnelling — future-compatible flag.
615    #[serde(default)]
616    pub supports_http3_tunnel: bool,
617    /// Optional ISO-3166-1 alpha-2 country code for the proxy egress location.
618    #[serde(default)]
619    pub geo_country: Option<String>,
620    /// Confidence score `[0.0, 1.0]` for the geo-location data.
621    ///
622    /// `None` means the provider did not supply confidence metadata.
623    #[serde(default)]
624    pub geo_confidence: Option<f32>,
625    /// `true` when this proxy routes through a CDN edge node rather than a
626    /// traditional SOCKS/HTTP proxy server.
627    #[serde(default)]
628    pub is_cdn_edge: bool,
629    /// CDN provider name when `is_cdn_edge` is `true`.
630    ///
631    /// Advisory — used for monitoring and routing hints.
632    /// Examples: `"cloudflare"`, `"cloudfront"`, `"azure-front-door"`.
633    #[serde(default)]
634    pub cdn_provider: Option<String>,
635    /// TLS fingerprint profile this proxy presents toward the upstream target.
636    ///
637    /// Advisory identifier such as `"chrome-131"`, `"firefox-120"`, or
638    /// `"curl"`.  Use with [`CapabilityRequirement::require_tls_profile`] to
639    /// select proxies by their TLS stack identity.  `None` means unknown.
640    #[serde(default)]
641    pub tls_profile: Option<String>,
642    /// IP trust class for the proxy egress.
643    ///
644    /// Defaults to [`IpClass::Unknown`] so legacy serialised proxies
645    /// deserialize cleanly. See the module-level docs for the four-tier
646    /// trust hierarchy and routing rationale.
647    #[serde(default)]
648    pub ip_class: IpClass,
649    /// Per-vendor trust tier overrides.
650    ///
651    /// Free-list fetchers populate this with
652    /// [`TargetVendorCompatibility::default_blocked`] so callers cannot
653    /// accidentally route premium traffic through a public free-list
654    /// pool. Operator-curated pools typically leave this empty and rely
655    /// on per-vendor metadata from the provider.
656    #[serde(default)]
657    pub target_compatibility: TargetVendorCompatibility,
658    /// Autonomous System Number (ASN) of the proxy's egress IP.
659    ///
660    /// Cited by the 2026 guide (L2837) as a "filter by ASN" feature
661    /// offered by commercial providers (e.g. Infatica). `None` means
662    /// the provider did not tag the proxy's AS; an exact-match
663    /// [`CapabilityRequirement::require_asn`] filter is the only way to
664    /// surface it. See the [`well_known`] module for the
665    /// `Cloudflare` / `Akamai` / `Fastly` / `CloudFront` ASN constants.
666    #[serde(default)]
667    pub asn: Option<u32>,
668    /// City of the proxy's egress IP (operator-declared, no validation
669    /// beyond UTF-8).
670    ///
671    /// Format follows the operator's convention; the 2026 guide does
672    /// not mandate a particular scheme. `None` means the city is
673    /// unknown.
674    #[serde(default)]
675    pub city: Option<String>,
676    /// Postal / ZIP code of the proxy's egress IP (operator-declared,
677    /// no format enforced).
678    ///
679    /// The 2026 guide cites ZIP-level filtering as a commercial
680    /// provider capability (L2837); the format is per-country (e.g.
681    /// `"94110"` for US ZIP, `"SW1A 1AA"` for UK). `None` means the
682    /// postal code is unknown.
683    #[serde(default)]
684    pub postal_code: Option<String>,
685}
686
687impl ProxyCapabilities {
688    /// Returns `true` if every required flag in `req` is satisfied by `self`.
689    ///
690    /// # Example
691    /// ```
692    /// use stygian_proxy::types::{ProxyCapabilities, CapabilityRequirement};
693    /// let caps = ProxyCapabilities { supports_https_connect: true, ..Default::default() };
694    /// let req = CapabilityRequirement { require_https_connect: true, ..Default::default() };
695    /// assert!(caps.satisfies(&req));
696    /// let req2 = CapabilityRequirement { require_socks5_udp: true, ..Default::default() };
697    /// assert!(!caps.satisfies(&req2));
698    /// ```
699    #[must_use]
700    pub fn satisfies(&self, req: &CapabilityRequirement) -> bool {
701        if req.require_https_connect && !self.supports_https_connect {
702            return false;
703        }
704        if req.require_socks5_udp && !self.supports_socks5_udp {
705            return false;
706        }
707        if req.require_http3_tunnel && !self.supports_http3_tunnel {
708            return false;
709        }
710        if let Some(ref required_country) = req.require_geo_country
711            && self.geo_country.as_deref() != Some(required_country.as_str())
712        {
713            return false;
714        }
715        if req.require_cdn_edge && !self.is_cdn_edge {
716            return false;
717        }
718        if let Some(ref required_profile) = req.require_tls_profile
719            && self.tls_profile.as_deref() != Some(required_profile.as_str())
720        {
721            return false;
722        }
723        if let Some(ref minimum_class) = req.require_ip_class
724            && !minimum_class.is_satisfied_by(self.ip_class)
725        {
726            return false;
727        }
728        if let Some(required_vendor) = req.target_vendor
729            && self
730                .target_compatibility
731                .get(required_vendor)
732                .is_none_or(TrustTier::is_blocked)
733        {
734            return false;
735        }
736        if let Some(required_asn) = req.require_asn
737            && self.asn != Some(required_asn)
738        {
739            return false;
740        }
741        if let Some(ref required_city) = req.require_city
742            && self.city.as_deref() != Some(required_city.as_str())
743        {
744            return false;
745        }
746        if let Some(ref required_postal) = req.require_postal_code
747            && self.postal_code.as_deref() != Some(required_postal.as_str())
748        {
749            return false;
750        }
751        true
752    }
753}
754
755/// Required capability set used as a filter when acquiring a proxy.
756///
757/// All fields default to `false`/`None` — an empty requirement matches any proxy.
758///
759/// # Example
760/// ```
761/// use stygian_proxy::types::CapabilityRequirement;
762/// let req = CapabilityRequirement::default();
763/// // empty requirement — any proxy qualifies
764/// assert!(!req.require_https_connect);
765/// assert!(req.require_ip_class.is_none());
766/// assert!(req.target_vendor.is_none());
767/// ```
768#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
769#[serde(rename_all = "snake_case")]
770#[allow(clippy::struct_excessive_bools)] // 4 requirement flags mirror ProxyCapabilities 1:1; bitmask refactor would be a breaking change
771pub struct CapabilityRequirement {
772    /// Require `supports_https_connect`.
773    #[serde(default)]
774    pub require_https_connect: bool,
775    /// Require `supports_socks5_udp`.
776    #[serde(default)]
777    pub require_socks5_udp: bool,
778    /// Require `supports_http3_tunnel`.
779    #[serde(default)]
780    pub require_http3_tunnel: bool,
781    /// Require a specific egress country (ISO-3166-1 alpha-2).
782    #[serde(default)]
783    pub require_geo_country: Option<String>,
784    /// Require a CDN-edge proxy (`is_cdn_edge` must be `true`).
785    #[serde(default)]
786    pub require_cdn_edge: bool,
787    /// Require a specific TLS fingerprint profile.
788    ///
789    /// When `Some`, only proxies whose [`ProxyCapabilities::tls_profile`]
790    /// matches this value exactly are eligible.  Examples: `"chrome-131"`,
791    /// `"firefox-120"`, `"curl"`.
792    #[serde(default)]
793    pub require_tls_profile: Option<String>,
794    /// Minimum IP trust class required.
795    ///
796    /// When `Some`, the proxy's [`IpClass`] must outrank
797    /// `require_ip_class.minimum`. A [`Mobile`](IpClass::Mobile) proxy
798    /// satisfies an `Isp` requirement because
799    /// `Mobile.rank() > Isp.rank()`.
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub require_ip_class: Option<IpClassRequirement>,
802    /// When `Some`, the proxy's [`TargetVendorCompatibility`] must carry
803    /// a tier other than [`TrustTier::Blocked`] for this vendor (or be
804    /// absent, in which case the requirement is treated as blocked).
805    ///
806    /// Used to gate free-list pools away from premium vendors: a proxy
807    /// with `target_compatibility.get(DataDome) == Some(Blocked)` does
808    /// not satisfy `target_vendor = Some(DataDome)`.
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub target_vendor: Option<VendorId>,
811    /// When `Some`, the proxy's [`ProxyCapabilities::asn`] must equal
812    /// this value exactly. `None` on the proxy side never satisfies a
813    /// `Some` requirement (no enrichment, no match). See
814    /// [`crate::types::well_known`] for the canonical CDN ASN constants.
815    #[serde(default, skip_serializing_if = "Option::is_none")]
816    pub require_asn: Option<u32>,
817    /// When `Some`, the proxy's [`ProxyCapabilities::city`] must equal
818    /// this value exactly. `None` on the proxy side never satisfies a
819    /// `Some` requirement.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub require_city: Option<String>,
822    /// When `Some`, the proxy's [`ProxyCapabilities::postal_code`] must
823    /// equal this value exactly. `None` on the proxy side never
824    /// satisfies a `Some` requirement.
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub require_postal_code: Option<String>,
827}
828
829/// The protocol routing path resolved for an outbound request.
830///
831/// Returned by [`crate::routing::resolve_routing_path`] to indicate how the
832/// proxy should forward the connection.
833///
834/// # Example
835/// ```
836/// use stygian_proxy::types::RoutingPath;
837/// let path = RoutingPath::H1H2OverTcp;
838/// assert_eq!(format!("{path:?}"), "H1H2OverTcp");
839/// ```
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
841#[serde(rename_all = "snake_case")]
842pub enum RoutingPath {
843    /// HTTP/1.1 or HTTP/2 multiplexed over a TCP CONNECT tunnel.
844    H1H2OverTcp,
845    /// HTTP/3 (QUIC) over a UDP relay — requires `supports_http3_tunnel`.
846    H3OverUdp,
847    /// Persistent TCP CONNECT tunnel — connection is kept alive between requests.
848    ///
849    /// Selected when [`crate::routing::TransportPreference::PersistentTcp`] is used.
850    PersistentTcp,
851}
852
853/// A proxy endpoint with optional authentication credentials.
854///
855/// `Debug` output masks `password` to prevent accidental credential logging.
856///
857/// # Example
858/// ```
859/// use stygian_proxy::types::{IpClass, Proxy, ProxyCapabilities, ProxyType, TrustTier, VendorId};
860/// let compat = stygian_proxy::types::TargetVendorCompatibility::default()
861///     .set(VendorId::Akamai, TrustTier::Preferred);
862/// let p = Proxy {
863///     url: "http://proxy.example.com:8080".into(),
864///     proxy_type: ProxyType::Http,
865///     username: Some("alice".into()),
866///     password: Some("secret".into()),
867///     weight: 1,
868///     tags: vec!["prod".into()],
869///     capabilities: ProxyCapabilities::default(),
870///     ip_class: IpClass::Isp,
871///     target_compatibility: compat,
872/// };
873/// let debug = format!("{p:?}");
874/// assert!(debug.contains("***"), "password must be masked in Debug output");
875/// assert_eq!(p.ip_class, IpClass::Isp);
876/// assert_eq!(p.target_compatibility.get(VendorId::Akamai), Some(TrustTier::Preferred));
877/// ```
878#[derive(Clone, Serialize, Deserialize)]
879#[serde(rename_all = "snake_case")]
880pub struct Proxy {
881    /// The proxy URL, e.g. `http://proxy.example.com:8080`.
882    pub url: String,
883    pub proxy_type: ProxyType,
884    pub username: Option<String>,
885    pub password: Option<String>,
886    /// Relative selection weight for weighted rotation (default: `1`).
887    pub weight: u32,
888    /// User-defined tags for filtering and grouping.
889    pub tags: Vec<String>,
890    /// Protocol-level capabilities advertised by this proxy.
891    #[serde(default)]
892    pub capabilities: ProxyCapabilities,
893    /// IP trust class for this proxy's egress.
894    ///
895    /// Defaults to [`IpClass::Unknown`] when not provided. Free-list
896    /// fetchers tag every ingested proxy as
897    /// [`IpClass::Datacenter`]; operator-curated pools can override
898    /// per-proxy. See the module-level docs for the trust hierarchy.
899    #[serde(default)]
900    pub ip_class: IpClass,
901    /// Per-vendor trust tier overrides for this proxy.
902    ///
903    /// Defaults to an empty map (no overrides). Free-list fetchers
904    /// populate this with
905    /// [`TargetVendorCompatibility::default_blocked`] to prevent
906    /// accidental use of free-list pools against premium vendors.
907    #[serde(default)]
908    pub target_compatibility: TargetVendorCompatibility,
909}
910
911impl std::fmt::Debug for Proxy {
912    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
913        f.debug_struct("Proxy")
914            .field("url", &self.url)
915            .field("proxy_type", &self.proxy_type)
916            .field("username", &self.username)
917            .field("password", &self.password.as_deref().map(|_| "***"))
918            .field("weight", &self.weight)
919            .field("tags", &self.tags)
920            .field("capabilities", &self.capabilities)
921            .field("ip_class", &self.ip_class)
922            .field("target_compatibility", &self.target_compatibility)
923            .finish()
924    }
925}
926
927/// A [`Proxy`] with a stable identity and insertion timestamp.
928///
929/// # Example
930/// ```
931/// use stygian_proxy::types::{IpClass, Proxy, ProxyRecord, ProxyType};
932/// let proxy = Proxy {
933///     url: "http://proxy.example.com:8080".into(),
934///     proxy_type: ProxyType::Http,
935///     username: None,
936///     password: None,
937///     weight: 1,
938///     tags: vec![],
939///     capabilities: Default::default(),
940///     ip_class: IpClass::Unknown,
941///     target_compatibility: Default::default(),
942/// };
943/// let record = ProxyRecord::new(proxy);
944/// assert!(!record.id.is_nil());
945/// ```
946#[derive(Debug, Clone, Serialize, Deserialize)]
947#[serde(rename_all = "snake_case")]
948pub struct ProxyRecord {
949    pub id: Uuid,
950    pub proxy: Proxy,
951    /// Wall-clock time the proxy was added. Not serialized — `Instant` is
952    /// not meaningfully portable; defaults to `Instant::now()` on deserialization.
953    #[serde(skip, default = "Instant::now")]
954    pub added_at: Instant,
955}
956
957impl ProxyRecord {
958    /// Create a new [`ProxyRecord`] wrapping `proxy` with a freshly generated UUID.
959    #[must_use]
960    pub fn new(proxy: Proxy) -> Self {
961        Self {
962            id: Uuid::new_v4(),
963            proxy,
964            added_at: Instant::now(),
965        }
966    }
967}
968
969/// Per-proxy runtime metrics using lock-free atomic counters.
970///
971/// Intended to be shared via `Arc<ProxyMetrics>`.
972///
973/// # Example
974/// ```
975/// use stygian_proxy::types::ProxyMetrics;
976/// let m = ProxyMetrics::default();
977/// assert_eq!(m.success_rate(), 0.0);
978/// assert_eq!(m.avg_latency_ms(), 0.0);
979/// ```
980#[derive(Debug, Default)]
981pub struct ProxyMetrics {
982    pub requests_total: AtomicU64,
983    pub successes: AtomicU64,
984    pub failures: AtomicU64,
985    pub total_latency_ms: AtomicU64,
986}
987
988impl ProxyMetrics {
989    /// Cast a `u64` counter to `f64` for ratio computation.
990    ///
991    /// `u64` can represent values up to ~1.8 × 10¹⁹; `f64` has 53-bit
992    /// mantissa, so precision loss begins around 9 × 10¹⁵.  For long-running
993    /// proxies that number is never reached in practice, and direct casting
994    /// preserves ratios correctly (unlike saturating to `u32::MAX`).
995    #[allow(clippy::cast_precision_loss)]
996    const fn u64_as_f64(value: u64) -> f64 {
997        value as f64
998    }
999
1000    /// Returns the fraction of requests that succeeded, in `[0.0, 1.0]`.
1001    ///
1002    /// Returns `0.0` when no requests have been recorded.
1003    ///
1004    /// # Example
1005    /// ```
1006    /// use stygian_proxy::types::ProxyMetrics;
1007    /// use std::sync::atomic::Ordering;
1008    /// let m = ProxyMetrics::default();
1009    /// m.requests_total.store(10, Ordering::Relaxed);
1010    /// m.successes.store(8, Ordering::Relaxed);
1011    /// assert!((m.success_rate() - 0.8).abs() < f64::EPSILON);
1012    /// ```
1013    pub fn success_rate(&self) -> f64 {
1014        let total = self.requests_total.load(Ordering::Relaxed);
1015        if total == 0 {
1016            return 0.0;
1017        }
1018        Self::u64_as_f64(self.successes.load(Ordering::Relaxed)) / Self::u64_as_f64(total)
1019    }
1020
1021    /// Returns the average request latency in milliseconds.
1022    ///
1023    /// Returns `0.0` when no requests have been recorded.
1024    ///
1025    /// # Example
1026    /// ```
1027    /// use stygian_proxy::types::ProxyMetrics;
1028    /// use std::sync::atomic::Ordering;
1029    /// let m = ProxyMetrics::default();
1030    /// m.requests_total.store(4, Ordering::Relaxed);
1031    /// m.total_latency_ms.store(400, Ordering::Relaxed);
1032    /// assert!((m.avg_latency_ms() - 100.0).abs() < f64::EPSILON);
1033    /// ```
1034    pub fn avg_latency_ms(&self) -> f64 {
1035        let total = self.requests_total.load(Ordering::Relaxed);
1036        if total == 0 {
1037            return 0.0;
1038        }
1039        Self::u64_as_f64(self.total_latency_ms.load(Ordering::Relaxed)) / Self::u64_as_f64(total)
1040    }
1041}
1042
1043mod serde_duration_secs {
1044    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1045    use std::time::Duration;
1046
1047    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
1048        d.as_secs().serialize(s)
1049    }
1050
1051    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
1052        Ok(Duration::from_secs(u64::deserialize(d)?))
1053    }
1054}
1055
1056/// Configuration governing health checking and circuit-breaker behaviour.
1057///
1058/// Duration fields serialize as integer seconds for TOML/JSON compatibility.
1059///
1060/// # Example
1061/// ```
1062/// use stygian_proxy::types::ProxyConfig;
1063/// use std::time::Duration;
1064/// let cfg = ProxyConfig::default();
1065/// assert_eq!(cfg.health_check_url, "https://httpbin.org/ip");
1066/// assert_eq!(cfg.health_check_interval, Duration::from_secs(60));
1067/// assert_eq!(cfg.health_check_timeout, Duration::from_secs(5));
1068/// assert_eq!(cfg.circuit_open_threshold, 5);
1069/// assert_eq!(cfg.circuit_half_open_after, Duration::from_secs(30));
1070/// assert!(cfg.profiled_request_mode.is_none());
1071/// assert_eq!(cfg.health_check_jitter_pct, 0.20_f32);
1072/// assert!(cfg.max_requests_per_connection.is_none());
1073/// assert!(cfg.connection_max_age_secs.is_none());
1074/// ```
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076#[serde(rename_all = "snake_case")]
1077pub struct ProxyConfig {
1078    /// URL called during health checks to verify proxy liveness.
1079    pub health_check_url: String,
1080    /// How often to run health checks (seconds).
1081    #[serde(with = "serde_duration_secs")]
1082    pub health_check_interval: Duration,
1083    /// Per-probe HTTP timeout (seconds).
1084    #[serde(with = "serde_duration_secs")]
1085    pub health_check_timeout: Duration,
1086    /// Jitter factor applied to the health-check sleep window.
1087    ///
1088    /// `0.20` distributes each check window uniformly over
1089    /// `interval × [0.80, 1.20)`, preventing synchronised fleet-wide check
1090    /// storms.  Set to `0.0` to disable jitter.  Clamped to `[0.0, 0.99]`
1091    /// at runtime.
1092    ///
1093    /// Default: `0.20` (±20 %).
1094    #[serde(default = "default_health_check_jitter_pct")]
1095    pub health_check_jitter_pct: f32,
1096    /// Consecutive failures before the circuit trips to OPEN.
1097    pub circuit_open_threshold: u32,
1098    /// How long to wait in OPEN before transitioning to HALF-OPEN (seconds).
1099    #[serde(with = "serde_duration_secs")]
1100    pub circuit_half_open_after: Duration,
1101    /// Sticky-session policy for domain→proxy binding.
1102    #[serde(default)]
1103    pub sticky_policy: crate::session::StickyPolicy,
1104    /// Optional default mode for TLS-profiled helper clients.
1105    ///
1106    /// When set and `tls-profiled` is enabled, `ProxyManager` initializes its
1107    /// `HealthChecker` with a Chrome-profiled requester using this mode.
1108    ///
1109    /// Ignored when `tls-profiled` is disabled.
1110    #[serde(default)]
1111    pub profiled_request_mode: Option<ProfiledRequestMode>,
1112    /// Maximum requests routed through one persistent TCP connection before it
1113    /// is recycled.  `None` means no limit.  Only consulted when
1114    /// [`crate::routing::TransportPreference::PersistentTcp`] is active.
1115    #[serde(default)]
1116    pub max_requests_per_connection: Option<u32>,
1117    /// Maximum age of a persistent TCP connection in seconds before it is
1118    /// replaced.  `None` means no age limit.
1119    #[serde(default)]
1120    pub connection_max_age_secs: Option<u64>,
1121}
1122
1123const fn default_health_check_jitter_pct() -> f32 {
1124    0.20
1125}
1126
1127impl Default for ProxyConfig {
1128    fn default() -> Self {
1129        Self {
1130            health_check_url: "https://httpbin.org/ip".into(),
1131            health_check_interval: Duration::from_mins(1),
1132            health_check_timeout: Duration::from_secs(5),
1133            health_check_jitter_pct: 0.20,
1134            circuit_open_threshold: 5,
1135            circuit_half_open_after: Duration::from_secs(30),
1136            sticky_policy: crate::session::StickyPolicy::default(),
1137            profiled_request_mode: None,
1138            max_requests_per_connection: None,
1139            connection_max_age_secs: None,
1140        }
1141    }
1142}
1143
1144// ─────────────────────────────────────────────────────────────────────────────
1145// well_known CDN ASN constants
1146// ─────────────────────────────────────────────────────────────────────────────
1147
1148/// Canonical ASN values for major public CDNs and infrastructure providers.
1149///
1150/// Use these as the source-of-truth
1151/// `CapabilityRequirement::require_asn` / `ProxyCapabilities::asn`
1152/// values when filtering the proxy pool by Autonomous System.
1153///
1154/// The list is ordered by frequency-of-use in scraping guides and the
1155/// 2026 guide's coverage of vendor fingerprinting. Values come from the
1156/// public IANA AS-number registry; they are stable and do not change
1157/// without a formal re-assignment.
1158///
1159/// # Example
1160///
1161/// ```
1162/// use stygian_proxy::types::{CapabilityRequirement, ProxyCapabilities};
1163/// use stygian_proxy::types::well_known::KNOWN_ASN_CLOUDFLARE;
1164///
1165/// let caps = ProxyCapabilities {
1166///     asn: Some(KNOWN_ASN_CLOUDFLARE),
1167///     ..Default::default()
1168/// };
1169/// let req = CapabilityRequirement {
1170///     require_asn: Some(KNOWN_ASN_CLOUDFLARE),
1171///     ..Default::default()
1172/// };
1173/// assert!(caps.satisfies(&req));
1174/// ```
1175pub mod well_known {
1176    /// `Cloudflare` AS — `13335`.
1177    pub const KNOWN_ASN_CLOUDFLARE: u32 = 13335;
1178    /// `Akamai` AS — `20940` (the public AS used by most Akamai edges).
1179    pub const KNOWN_ASN_AKAMAI: u32 = 20940;
1180    /// `Fastly` AS — `54113`.
1181    pub const KNOWN_ASN_FASTLY: u32 = 54113;
1182    /// `Amazon CloudFront` AS — `16509`.
1183    pub const KNOWN_ASN_CLOUDFRONT: u32 = 16509;
1184    /// `Google` AS — `15169` (covers `Google Cloud`, `YouTube` egress).
1185    pub const KNOWN_ASN_GOOGLE: u32 = 15169;
1186    /// `Microsoft Azure` AS — `8075`.
1187    pub const KNOWN_ASN_AZURE: u32 = 8075;
1188    /// `Limelight Networks` AS — `22822`.
1189    pub const KNOWN_ASN_LIMELIGHT: u32 = 22822;
1190    /// `StackPath / Highwinds` AS — `20446`.
1191    pub const KNOWN_ASN_HIGHWINDS: u32 = 20446;
1192    /// `Verizon Digital Media Services` (Edgecast) AS — `15133`.
1193    pub const KNOWN_ASN_EDGECAST: u32 = 15133;
1194    /// `Sucuri` AS — `51167`.
1195    pub const KNOWN_ASN_SUCURI: u32 = 51167;
1196    /// `OVH` AS — `16276` (a common datacenter provider that shows up on
1197    /// free-list feeds).
1198    pub const KNOWN_ASN_OVH: u32 = 16276;
1199    /// `Hetzner` AS — `24940` (a common datacenter provider that shows up
1200    /// on free-list feeds).
1201    pub const KNOWN_ASN_HETZNER: u32 = 24940;
1202    /// `DigitalOcean` AS — `14061` (a common datacenter provider that
1203    /// shows up on free-list feeds).
1204    pub const KNOWN_ASN_DIGITALOCEAN: u32 = 14061;
1205    /// `Linode / Akamai Connected Cloud` AS — `63949`.
1206    pub const KNOWN_ASN_LINODE: u32 = 63949;
1207    /// `Vultr` AS — `204957`.
1208    pub const KNOWN_ASN_VULTR: u32 = 204_957;
1209
1210    /// Every constant in this module, in declaration order. Useful for
1211    /// exhaustiveness checks and operators that want a quick
1212    /// "is this AS a known CDN / major provider?" lookup.
1213    pub const ALL_KNOWN_ASNS: &[u32] = &[
1214        KNOWN_ASN_CLOUDFLARE,
1215        KNOWN_ASN_AKAMAI,
1216        KNOWN_ASN_FASTLY,
1217        KNOWN_ASN_CLOUDFRONT,
1218        KNOWN_ASN_GOOGLE,
1219        KNOWN_ASN_AZURE,
1220        KNOWN_ASN_LIMELIGHT,
1221        KNOWN_ASN_HIGHWINDS,
1222        KNOWN_ASN_EDGECAST,
1223        KNOWN_ASN_SUCURI,
1224        KNOWN_ASN_OVH,
1225        KNOWN_ASN_HETZNER,
1226        KNOWN_ASN_DIGITALOCEAN,
1227        KNOWN_ASN_LINODE,
1228        KNOWN_ASN_VULTR,
1229    ];
1230}
1231
1232// ─────────────────────────────────────────────────────────────────────────────
1233// Geo-metadata ingest validation
1234// ─────────────────────────────────────────────────────────────────────────────
1235
1236/// Maximum length of an operator-supplied `city` string.
1237pub const CITY_MAX_LEN: usize = 100;
1238/// Maximum length of an operator-supplied `postal_code` string.
1239pub const POSTAL_CODE_MAX_LEN: usize = 16;
1240
1241/// Returns `Ok(())` when `asn` is a valid public Autonomous System
1242/// Number, otherwise an [`crate::error::ProxyError::InvalidGeoMetadata`]
1243/// describing the failure.
1244///
1245/// A "public" AS number is `1..=u32::MAX - 1` — `0` is reserved by
1246/// IANA and `u32::MAX` is the RFC-defined "private use / reserved"
1247/// placeholder, neither of which is meaningful on the wire.
1248///
1249/// # Example
1250///
1251/// ```
1252/// use stygian_proxy::types::validate_asn;
1253/// assert!(validate_asn(13_335).is_ok());
1254/// assert!(validate_asn(0).is_err());
1255/// assert!(validate_asn(u32::MAX).is_err());
1256/// ```
1257pub fn validate_asn(asn: u32) -> Result<(), crate::error::ProxyError> {
1258    use crate::error::ProxyError;
1259    if asn == 0 {
1260        return Err(ProxyError::InvalidGeoMetadata {
1261            field: "asn".into(),
1262            reason: "ASN 0 is reserved by IANA and must not be used as a proxy ASN".into(),
1263        });
1264    }
1265    if asn == u32::MAX {
1266        return Err(ProxyError::InvalidGeoMetadata {
1267            field: "asn".into(),
1268            reason: format!("ASN {asn} is the RFC reserved/private-use placeholder"),
1269        });
1270    }
1271    Ok(())
1272}
1273
1274/// Returns `Ok(())` when `city` is a valid operator-supplied city
1275/// label, otherwise an [`crate::error::ProxyError::InvalidGeoMetadata`].
1276///
1277/// `city` must be 1-100 characters (UTF-8 byte length). The format is
1278/// operator-defined; common conventions include `"San Francisco"`,
1279/// `"São Paulo"`, `"Saint-Étienne"`.
1280pub fn validate_city(city: &str) -> Result<(), crate::error::ProxyError> {
1281    use crate::error::ProxyError;
1282    if city.is_empty() {
1283        return Err(ProxyError::InvalidGeoMetadata {
1284            field: "city".into(),
1285            reason: "city must be 1 character or longer".into(),
1286        });
1287    }
1288    if city.len() > CITY_MAX_LEN {
1289        return Err(ProxyError::InvalidGeoMetadata {
1290            field: "city".into(),
1291            reason: format!(
1292                "city length {} exceeds the {CITY_MAX_LEN}-char limit",
1293                city.len()
1294            ),
1295        });
1296    }
1297    Ok(())
1298}
1299
1300/// Returns `Ok(())` when `postal_code` is a valid operator-supplied
1301/// postal / ZIP code, otherwise an
1302/// [`crate::error::ProxyError::InvalidGeoMetadata`].
1303///
1304/// `postal_code` must be 1-16 characters. The format is per-country
1305/// (e.g. `"94110"` for US ZIP, `"SW1A 1AA"` for UK, `"100-0001"` for
1306/// Japan); the validator only enforces a length ceiling and rejects
1307/// empty strings.
1308pub fn validate_postal_code(postal_code: &str) -> Result<(), crate::error::ProxyError> {
1309    use crate::error::ProxyError;
1310    if postal_code.is_empty() {
1311        return Err(ProxyError::InvalidGeoMetadata {
1312            field: "postal_code".into(),
1313            reason: "postal_code must be 1 character or longer".into(),
1314        });
1315    }
1316    if postal_code.len() > POSTAL_CODE_MAX_LEN {
1317        return Err(ProxyError::InvalidGeoMetadata {
1318            field: "postal_code".into(),
1319            reason: format!(
1320                "postal_code length {} exceeds the {POSTAL_CODE_MAX_LEN}-char limit",
1321                postal_code.len()
1322            ),
1323        });
1324    }
1325    Ok(())
1326}
1327
1328// ─────────────────────────────────────────────────────────────────────────────
1329// Tests
1330// ─────────────────────────────────────────────────────────────────────────────
1331
1332#[cfg(test)]
1333#[allow(
1334    clippy::unwrap_used,
1335    clippy::expect_used,
1336    clippy::panic,
1337    clippy::indexing_slicing
1338)] // serde round-trips and unwraps in test fixtures are deterministic
1339mod tests {
1340    use super::*;
1341    use crate::well_known::{
1342        KNOWN_ASN_AKAMAI, KNOWN_ASN_CLOUDFLARE, KNOWN_ASN_FASTLY, KNOWN_ASN_OVH,
1343    };
1344
1345    // ── IpClass ─────────────────────────────────────────────────────────────
1346
1347    #[test]
1348    fn ip_class_default_is_unknown() {
1349        assert_eq!(IpClass::default(), IpClass::Unknown);
1350        assert_eq!(IpClass::Unknown.rank(), 0);
1351    }
1352
1353    #[test]
1354    fn ip_class_rank_ordering_matches_t95_spec() {
1355        assert!(IpClass::Mobile.rank() > IpClass::Isp.rank());
1356        assert!(IpClass::Isp.rank() > IpClass::Residential.rank());
1357        assert!(IpClass::Residential.rank() > IpClass::Datacenter.rank());
1358        assert!(IpClass::Datacenter.rank() > IpClass::Unknown.rank());
1359    }
1360
1361    #[test]
1362    fn ip_class_round_trips_through_json() {
1363        for variant in [
1364            IpClass::Mobile,
1365            IpClass::Isp,
1366            IpClass::Residential,
1367            IpClass::Datacenter,
1368            IpClass::Unknown,
1369        ] {
1370            let json = serde_json::to_string(&variant).expect("serialize IpClass");
1371            let parsed: IpClass = serde_json::from_str(&json).expect("deserialize IpClass");
1372            assert_eq!(parsed, variant, "round-trip for {variant:?}");
1373        }
1374    }
1375
1376    #[test]
1377    fn ip_class_from_label_matches_serde_label() {
1378        for variant in [
1379            IpClass::Mobile,
1380            IpClass::Isp,
1381            IpClass::Residential,
1382            IpClass::Datacenter,
1383            IpClass::Unknown,
1384        ] {
1385            assert_eq!(
1386                IpClass::from_label(variant.label()),
1387                Some(variant),
1388                "label/from_label round-trip for {variant:?}"
1389            );
1390        }
1391        assert_eq!(IpClass::from_label("nope"), None);
1392    }
1393
1394    // ── TrustTier ───────────────────────────────────────────────────────────
1395
1396    #[test]
1397    fn trust_tier_rank_ordering_matches_t95_spec() {
1398        assert!(TrustTier::Preferred.rank() > TrustTier::Acceptable.rank());
1399        assert!(TrustTier::Acceptable.rank() > TrustTier::Marginal.rank());
1400        assert!(TrustTier::Marginal.rank() > TrustTier::Blocked.rank());
1401    }
1402
1403    #[test]
1404    fn trust_tier_is_blocked() {
1405        assert!(TrustTier::Blocked.is_blocked());
1406        for tier in [
1407            TrustTier::Preferred,
1408            TrustTier::Acceptable,
1409            TrustTier::Marginal,
1410        ] {
1411            assert!(!tier.is_blocked());
1412        }
1413    }
1414
1415    #[test]
1416    fn trust_tier_round_trips_through_json() {
1417        for tier in [
1418            TrustTier::Preferred,
1419            TrustTier::Acceptable,
1420            TrustTier::Marginal,
1421            TrustTier::Blocked,
1422        ] {
1423            let json = serde_json::to_string(&tier).expect("serialize TrustTier");
1424            let parsed: TrustTier = serde_json::from_str(&json).expect("deserialize TrustTier");
1425            assert_eq!(parsed, tier, "round-trip for {tier:?}");
1426        }
1427    }
1428
1429    // ── VendorId ────────────────────────────────────────────────────────────
1430
1431    #[test]
1432    fn vendor_id_label_matches_serde_wire_format() {
1433        assert_eq!(VendorId::DataDome.label(), "data_dome");
1434        assert_eq!(VendorId::PerimeterX.label(), "perimeter_x");
1435        assert_eq!(VendorId::Cloudflare.label(), "cloudflare");
1436        assert_eq!(VendorId::Akamai.label(), "akamai");
1437    }
1438
1439    #[test]
1440    fn vendor_id_from_label_round_trip() {
1441        for variant in [
1442            VendorId::Akamai,
1443            VendorId::Cloudflare,
1444            VendorId::DataDome,
1445            VendorId::PerimeterX,
1446            VendorId::Hcaptcha,
1447            VendorId::Recaptcha,
1448            VendorId::Kasada,
1449            VendorId::FingerprintCom,
1450            VendorId::ShapeSecurity,
1451            VendorId::Imperva,
1452            VendorId::Unknown,
1453        ] {
1454            assert_eq!(VendorId::from_label(variant.label()), Some(variant));
1455        }
1456        assert_eq!(VendorId::from_label("nope"), None);
1457    }
1458
1459    #[test]
1460    fn vendor_id_round_trips_through_json() {
1461        let variant = VendorId::DataDome;
1462        let json = serde_json::to_string(&variant).expect("serialize VendorId");
1463        // `#[serde(rename_all = "snake_case")]` on the enum rewrites
1464        // `DataDome` to `data_dome` (the `O` boundary is preserved).
1465        assert_eq!(json, "\"data_dome\"", "snake_case wire format");
1466        let parsed: VendorId = serde_json::from_str(&json).expect("deserialize VendorId");
1467        assert_eq!(parsed, variant);
1468    }
1469
1470    // ── TargetVendorCompatibility ───────────────────────────────────────────
1471
1472    #[test]
1473    fn target_vendor_compatibility_default_is_empty() {
1474        let c = TargetVendorCompatibility::default();
1475        assert!(c.is_empty());
1476        assert_eq!(c.len(), 0);
1477        assert_eq!(c.get(VendorId::DataDome), None);
1478    }
1479
1480    #[test]
1481    fn target_vendor_compatibility_default_blocked_covers_known_vendors() {
1482        let c = TargetVendorCompatibility::default_blocked();
1483        assert!(!c.is_empty());
1484        assert_eq!(c.get(VendorId::DataDome), Some(TrustTier::Blocked));
1485        assert_eq!(c.get(VendorId::Akamai), Some(TrustTier::Blocked));
1486        assert_eq!(c.get(VendorId::Cloudflare), Some(TrustTier::Blocked));
1487        assert_eq!(c.get(VendorId::PerimeterX), Some(TrustTier::Blocked));
1488        assert_eq!(c.get(VendorId::Hcaptcha), Some(TrustTier::Blocked));
1489        assert_eq!(c.get(VendorId::Recaptcha), Some(TrustTier::Blocked));
1490        assert_eq!(c.get(VendorId::Kasada), Some(TrustTier::Blocked));
1491        assert_eq!(c.get(VendorId::FingerprintCom), Some(TrustTier::Blocked));
1492        assert_eq!(c.get(VendorId::ShapeSecurity), Some(TrustTier::Blocked));
1493        assert_eq!(c.get(VendorId::Imperva), Some(TrustTier::Blocked));
1494        assert_eq!(c.get(VendorId::Unknown), None);
1495    }
1496
1497    #[test]
1498    fn target_vendor_compatibility_set_is_builder_style() {
1499        let c = TargetVendorCompatibility::default()
1500            .set(VendorId::Akamai, TrustTier::Preferred)
1501            .set(VendorId::Cloudflare, TrustTier::Acceptable);
1502        assert_eq!(c.len(), 2);
1503        assert_eq!(c.get(VendorId::Akamai), Some(TrustTier::Preferred));
1504        assert_eq!(c.get(VendorId::Cloudflare), Some(TrustTier::Acceptable));
1505    }
1506
1507    #[test]
1508    fn target_vendor_compatibility_iterates_in_sorted_order() {
1509        let c = TargetVendorCompatibility::default()
1510            .set(VendorId::DataDome, TrustTier::Preferred)
1511            .set(VendorId::Akamai, TrustTier::Acceptable);
1512        let entries: Vec<_> = c.iter().collect();
1513        assert_eq!(entries.first().map(|(v, _)| *v), Some(VendorId::Akamai));
1514        assert_eq!(entries.get(1).map(|(v, _)| *v), Some(VendorId::DataDome));
1515    }
1516
1517    #[test]
1518    fn target_vendor_compatibility_round_trips_through_json() {
1519        let original = TargetVendorCompatibility::default()
1520            .set(VendorId::DataDome, TrustTier::Preferred)
1521            .set(VendorId::Akamai, TrustTier::Acceptable)
1522            .set(VendorId::Cloudflare, TrustTier::Marginal);
1523        let json = serde_json::to_string(&original).expect("serialize");
1524        let parsed: TargetVendorCompatibility = serde_json::from_str(&json).expect("deserialize");
1525        assert_eq!(parsed, original);
1526    }
1527
1528    #[test]
1529    fn target_vendor_compatibility_round_trips_through_toml() {
1530        let original = TargetVendorCompatibility::default()
1531            .set(VendorId::DataDome, TrustTier::Preferred)
1532            .set(VendorId::Akamai, TrustTier::Acceptable);
1533        let toml_str = toml::to_string(&original).expect("serialize toml");
1534        let parsed: TargetVendorCompatibility =
1535            toml::from_str(&toml_str).expect("deserialize toml");
1536        assert_eq!(parsed, original);
1537    }
1538
1539    #[test]
1540    fn target_vendor_compatibility_transparent_serde() {
1541        // `#[serde(transparent)]` on TargetVendorCompatibility means the
1542        // wire form is the BTreeMap directly. Verify that BTreeMap
1543        // ordering on the wire matches the sorted VendorId discriminant.
1544        let original = TargetVendorCompatibility::default()
1545            .set(VendorId::DataDome, TrustTier::Preferred)
1546            .set(VendorId::Akamai, TrustTier::Acceptable);
1547        let json = serde_json::to_string(&original).expect("serialize");
1548        // Akamai < DataDome in discriminant order, so `akamai` must
1549        // appear before `data_dome` in the serialised map.
1550        let akamai_pos = json.find("\"akamai\"").expect("akamai present");
1551        let datadome_pos = json.find("\"data_dome\"").expect("data_dome present");
1552        assert!(akamai_pos < datadome_pos, "expected sorted order: {json}");
1553    }
1554
1555    // ── IpClassRequirement ──────────────────────────────────────────────────
1556
1557    #[test]
1558    fn ip_class_requirement_default_minimum_is_unknown() {
1559        let req = IpClassRequirement::default();
1560        assert_eq!(req.minimum, IpClass::Unknown);
1561    }
1562
1563    #[test]
1564    fn ip_class_requirement_is_satisfied_by_rank_gte_minimum() {
1565        let req = IpClassRequirement {
1566            minimum: IpClass::Isp,
1567        };
1568        assert!(req.is_satisfied_by(IpClass::Mobile));
1569        assert!(req.is_satisfied_by(IpClass::Isp));
1570        assert!(!req.is_satisfied_by(IpClass::Residential));
1571        assert!(!req.is_satisfied_by(IpClass::Datacenter));
1572        assert!(!req.is_satisfied_by(IpClass::Unknown));
1573    }
1574
1575    #[test]
1576    fn ip_class_requirement_round_trips_through_json() {
1577        let req = IpClassRequirement {
1578            minimum: IpClass::Isp,
1579        };
1580        let json = serde_json::to_string(&req).expect("serialize");
1581        let parsed: IpClassRequirement = serde_json::from_str(&json).expect("deserialize");
1582        assert_eq!(parsed, req);
1583    }
1584
1585    // ── T95 mobile beats isp via rank ordering ──────────────────────────────
1586
1587    /// The headline test from T95: a `Mobile` proxy with
1588    /// `defeats[DataDome] = Preferred` passes
1589    /// `require_ip_class = Some(Isp) for an Akamai target
1590    /// (Mobile > Isp wins via tier ordering)`.
1591    #[test]
1592    fn t95_mobile_beats_isp_requirement() {
1593        let compat =
1594            TargetVendorCompatibility::default().set(VendorId::DataDome, TrustTier::Preferred);
1595        let caps = ProxyCapabilities {
1596            ip_class: IpClass::Mobile,
1597            target_compatibility: compat,
1598            ..Default::default()
1599        };
1600        let req = CapabilityRequirement {
1601            require_ip_class: Some(IpClassRequirement {
1602                minimum: IpClass::Isp,
1603            }),
1604            ..Default::default()
1605        };
1606        assert!(caps.satisfies(&req));
1607    }
1608
1609    // ── CapabilityRequirement backward compatibility ───────────────────────
1610
1611    #[test]
1612    fn capability_requirement_default_matches_any_proxy() {
1613        let req = CapabilityRequirement::default();
1614        let caps_proxy_datacenter = ProxyCapabilities {
1615            ip_class: IpClass::Datacenter,
1616            ..Default::default()
1617        };
1618        let caps_proxy_unknown = ProxyCapabilities::default();
1619        let caps_proxy_full = ProxyCapabilities {
1620            supports_https_connect: true,
1621            supports_socks5_udp: true,
1622            supports_http3_tunnel: true,
1623            geo_country: Some("GB".into()),
1624            geo_confidence: Some(0.9),
1625            is_cdn_edge: true,
1626            cdn_provider: Some("cloudflare".into()),
1627            tls_profile: Some("chrome-131".into()),
1628            asn: Some(KNOWN_ASN_CLOUDFLARE),
1629            city: Some("London".into()),
1630            postal_code: Some("SW1A".into()),
1631            ip_class: IpClass::Mobile,
1632            target_compatibility: TargetVendorCompatibility::default(),
1633        };
1634        assert!(caps_proxy_datacenter.satisfies(&req));
1635        assert!(caps_proxy_unknown.satisfies(&req));
1636        assert!(caps_proxy_full.satisfies(&req));
1637    }
1638
1639    #[test]
1640    fn capability_requirement_target_vendor_blocks_free_list_pool() {
1641        // A free-list proxy has every vendor marked Blocked; a
1642        // target_vendor requirement rejects it (fail-secure).
1643        let caps = ProxyCapabilities {
1644            ip_class: IpClass::Datacenter,
1645            target_compatibility: TargetVendorCompatibility::default_blocked(),
1646            ..Default::default()
1647        };
1648        let req = CapabilityRequirement {
1649            target_vendor: Some(VendorId::DataDome),
1650            ..Default::default()
1651        };
1652        assert!(
1653            !caps.satisfies(&req),
1654            "free-list proxy must not satisfy a DataDome vendor requirement"
1655        );
1656    }
1657
1658    #[test]
1659    fn capability_requirement_target_vendor_accepts_acceptable_tier() {
1660        // A proxy with `defeats[DataDome] = Acceptable` passes the
1661        // target_vendor requirement (Blocked is the only disqualifier).
1662        let caps = ProxyCapabilities {
1663            target_compatibility: TargetVendorCompatibility::default()
1664                .set(VendorId::DataDome, TrustTier::Acceptable),
1665            ..Default::default()
1666        };
1667        let req = CapabilityRequirement {
1668            target_vendor: Some(VendorId::DataDome),
1669            ..Default::default()
1670        };
1671        assert!(caps.satisfies(&req));
1672    }
1673
1674    #[test]
1675    fn capability_requirement_require_ip_class_fails_for_datacenter() {
1676        // An ISP requirement must reject a Datacenter proxy.
1677        let caps = ProxyCapabilities {
1678            ip_class: IpClass::Datacenter,
1679            ..Default::default()
1680        };
1681        let req = CapabilityRequirement {
1682            require_ip_class: Some(IpClassRequirement {
1683                minimum: IpClass::Isp,
1684            }),
1685            ..Default::default()
1686        };
1687        assert!(!caps.satisfies(&req));
1688    }
1689
1690    #[test]
1691    fn capability_requirement_require_ip_class_fails_for_unknown() {
1692        // An ISP requirement must reject an Unknown proxy (Unknown rank 0).
1693        let caps = ProxyCapabilities::default();
1694        let req = CapabilityRequirement {
1695            require_ip_class: Some(IpClassRequirement {
1696                minimum: IpClass::Isp,
1697            }),
1698            ..Default::default()
1699        };
1700        assert!(!caps.satisfies(&req));
1701    }
1702
1703    // ── Proxy backward compatibility ────────────────────────────────────────
1704
1705    /// Existing `Proxy` literals (from the `make_proxy` test helper in
1706    /// storage/manager/etc.) build with `IpClass::Unknown` defaults.
1707    #[test]
1708    fn proxy_default_ip_class_is_unknown() {
1709        let proxy = Proxy {
1710            url: "http://example.test:8080".into(),
1711            proxy_type: ProxyType::Http,
1712            username: None,
1713            password: None,
1714            weight: 1,
1715            tags: vec![],
1716            capabilities: ProxyCapabilities::default(),
1717            ip_class: IpClass::Unknown,
1718            target_compatibility: TargetVendorCompatibility::default(),
1719        };
1720        assert_eq!(proxy.ip_class, IpClass::Unknown);
1721        assert!(proxy.target_compatibility.is_empty());
1722    }
1723
1724    /// Existing serialised proxies deserialize cleanly with the new
1725    /// `IpClass::Unknown` and empty compatibility.
1726    #[test]
1727    fn proxy_legacy_serde_backward_compatibility() {
1728        // Pre-T95 wire format: no `ip_class`, no `target_compatibility`.
1729        let legacy = r#"{
1730            "url": "http://legacy.test:8080",
1731            "proxy_type": "http",
1732            "username": null,
1733            "password": null,
1734            "weight": 1,
1735            "tags": [],
1736            "capabilities": {}
1737        }"#;
1738        let parsed: Proxy = serde_json::from_str(legacy).expect("legacy parses");
1739        assert_eq!(parsed.url, "http://legacy.test:8080");
1740        assert_eq!(parsed.ip_class, IpClass::Unknown);
1741        assert!(parsed.target_compatibility.is_empty());
1742    }
1743
1744    // ── T98: ASN / city / postal_code capability fields ────────────────────
1745
1746    /// Headline round-trip: every new T98 field populates and
1747    /// deserialises back identically.
1748    #[test]
1749    fn t98_capabilities_round_trip_through_json_with_all_geo_fields() {
1750        let original = ProxyCapabilities {
1751            asn: Some(KNOWN_ASN_CLOUDFLARE),
1752            city: Some("San Francisco".into()),
1753            postal_code: Some("94110".into()),
1754            ..Default::default()
1755        };
1756        let json = serde_json::to_string(&original).expect("serialize");
1757        let parsed: ProxyCapabilities = serde_json::from_str(&json).expect("deserialize");
1758        assert_eq!(parsed, original);
1759    }
1760
1761    /// Round-trip with all three T98 fields set to `None` (the
1762    /// default) — confirms the `#[serde(default)]` annotations
1763    /// preserve the no-enrichment path.
1764    #[test]
1765    fn t98_capabilities_round_trip_through_json_with_none_geo_fields() {
1766        let original = ProxyCapabilities::default();
1767        let json = serde_json::to_string(&original).expect("serialize");
1768        let parsed: ProxyCapabilities = serde_json::from_str(&json).expect("deserialize");
1769        assert_eq!(parsed, original);
1770        assert!(parsed.asn.is_none());
1771        assert!(parsed.city.is_none());
1772        assert!(parsed.postal_code.is_none());
1773    }
1774
1775    /// `#[serde(default)]` on the new fields means a pre-T98 wire
1776    /// payload (no `asn` / `city` / `postal_code` keys) deserialises
1777    /// cleanly with `None` values.
1778    #[test]
1779    fn t98_capabilities_legacy_wire_payload_deserialises_to_none() {
1780        // Pre-T98 wire format: no `asn`, no `city`, no `postal_code`.
1781        let legacy = "{}";
1782        let parsed: ProxyCapabilities = serde_json::from_str(legacy).expect("legacy parses");
1783        assert!(parsed.asn.is_none());
1784        assert!(parsed.city.is_none());
1785        assert!(parsed.postal_code.is_none());
1786    }
1787
1788    /// `CapabilityRequirement` round-trips with all three new fields
1789    /// populated; the `skip_serializing_if = "Option::is_none"` pattern
1790    /// keeps the wire form compact for empty requirements.
1791    #[test]
1792    fn t98_capability_requirement_round_trip_through_json_with_geo_fields() {
1793        let original = CapabilityRequirement {
1794            require_asn: Some(KNOWN_ASN_AKAMAI),
1795            require_city: Some("London".into()),
1796            require_postal_code: Some("SW1A".into()),
1797            ..Default::default()
1798        };
1799        let json = serde_json::to_string(&original).expect("serialize");
1800        let parsed: CapabilityRequirement = serde_json::from_str(&json).expect("deserialize");
1801        assert_eq!(parsed, original);
1802    }
1803
1804    /// Empty requirement still serialises to `{}` (not the
1805    /// pre-T98 wire form) and matches every proxy.
1806    #[test]
1807    fn t98_empty_requirement_matches_any_proxy() {
1808        let req = CapabilityRequirement::default();
1809        let json = serde_json::to_string(&req).expect("serialize");
1810        // All `Option` fields are skipped → `{}` (or whatever the
1811        // existing default-derive shape is).
1812        let parsed: CapabilityRequirement = serde_json::from_str(&json).expect("deserialize");
1813        assert_eq!(parsed, req);
1814        // And it still satisfies every proxy variant.
1815        for caps in [
1816            ProxyCapabilities::default(),
1817            ProxyCapabilities {
1818                asn: Some(KNOWN_ASN_CLOUDFLARE),
1819                city: Some("Anywhere".into()),
1820                postal_code: Some("00000".into()),
1821                ..Default::default()
1822            },
1823        ] {
1824            assert!(caps.satisfies(&req));
1825        }
1826    }
1827
1828    /// `require_asn` exact-match: a Cloudflare-tagged proxy matches
1829    /// `require_asn = Some(CLOUDFLARE)`; an Akamai-tagged proxy does
1830    /// not.
1831    #[test]
1832    fn t98_require_asn_exact_match() {
1833        let req = CapabilityRequirement {
1834            require_asn: Some(KNOWN_ASN_CLOUDFLARE),
1835            ..Default::default()
1836        };
1837        let cf_caps = ProxyCapabilities {
1838            asn: Some(KNOWN_ASN_CLOUDFLARE),
1839            ..Default::default()
1840        };
1841        let ak_caps = ProxyCapabilities {
1842            asn: Some(KNOWN_ASN_AKAMAI),
1843            ..Default::default()
1844        };
1845        let none_caps = ProxyCapabilities::default();
1846        assert!(cf_caps.satisfies(&req));
1847        assert!(!ak_caps.satisfies(&req));
1848        assert!(!none_caps.satisfies(&req));
1849    }
1850
1851    /// `require_city` exact-match: "San Francisco" matches; "Berlin"
1852    /// and `None` do not.
1853    #[test]
1854    fn t98_require_city_exact_match() {
1855        let req = CapabilityRequirement {
1856            require_city: Some("San Francisco".into()),
1857            ..Default::default()
1858        };
1859        let sf_caps = ProxyCapabilities {
1860            city: Some("San Francisco".into()),
1861            ..Default::default()
1862        };
1863        let b_caps = ProxyCapabilities {
1864            city: Some("Berlin".into()),
1865            ..Default::default()
1866        };
1867        let none_caps = ProxyCapabilities::default();
1868        assert!(sf_caps.satisfies(&req));
1869        assert!(!b_caps.satisfies(&req));
1870        assert!(!none_caps.satisfies(&req));
1871    }
1872
1873    /// `require_postal_code` exact-match: "94110" matches; "10001"
1874    /// and `None` do not.
1875    #[test]
1876    fn t98_require_postal_code_exact_match() {
1877        let req = CapabilityRequirement {
1878            require_postal_code: Some("94110".into()),
1879            ..Default::default()
1880        };
1881        let sf_caps = ProxyCapabilities {
1882            postal_code: Some("94110".into()),
1883            ..Default::default()
1884        };
1885        let ny_caps = ProxyCapabilities {
1886            postal_code: Some("10001".into()),
1887            ..Default::default()
1888        };
1889        let none_caps = ProxyCapabilities::default();
1890        assert!(sf_caps.satisfies(&req));
1891        assert!(!ny_caps.satisfies(&req));
1892        assert!(!none_caps.satisfies(&req));
1893    }
1894
1895    /// Composite filter — the headline "Akamai scrape: Cloudflare AS +
1896    /// SF city + 94110 ZIP" example from the task spec.
1897    #[test]
1898    fn t98_composite_geo_filter_akamai_scrape() {
1899        let req = CapabilityRequirement {
1900            require_asn: Some(KNOWN_ASN_CLOUDFLARE),
1901            require_city: Some("San Francisco".into()),
1902            require_postal_code: Some("94110".into()),
1903            ..Default::default()
1904        };
1905        let matching = ProxyCapabilities {
1906            asn: Some(KNOWN_ASN_CLOUDFLARE),
1907            city: Some("San Francisco".into()),
1908            postal_code: Some("94110".into()),
1909            ..Default::default()
1910        };
1911        let wrong_asn = ProxyCapabilities {
1912            asn: Some(KNOWN_ASN_OVH),
1913            city: Some("San Francisco".into()),
1914            postal_code: Some("94110".into()),
1915            ..Default::default()
1916        };
1917        let wrong_city = ProxyCapabilities {
1918            asn: Some(KNOWN_ASN_CLOUDFLARE),
1919            city: Some("Oakland".into()),
1920            postal_code: Some("94110".into()),
1921            ..Default::default()
1922        };
1923        let wrong_zip = ProxyCapabilities {
1924            asn: Some(KNOWN_ASN_CLOUDFLARE),
1925            city: Some("San Francisco".into()),
1926            postal_code: Some("94609".into()),
1927            ..Default::default()
1928        };
1929        assert!(matching.satisfies(&req));
1930        assert!(!wrong_asn.satisfies(&req));
1931        assert!(!wrong_city.satisfies(&req));
1932        assert!(!wrong_zip.satisfies(&req));
1933    }
1934
1935    /// Round-trip through TOML for both `ProxyCapabilities` and
1936    /// `CapabilityRequirement` — covers operators that store config in
1937    /// `stygian.toml` files.
1938    #[test]
1939    fn t98_capabilities_and_requirement_round_trip_through_toml() {
1940        let caps = ProxyCapabilities {
1941            asn: Some(KNOWN_ASN_FASTLY),
1942            city: Some("Berlin".into()),
1943            postal_code: Some("10115".into()),
1944            ..Default::default()
1945        };
1946        let caps_toml = toml::to_string(&caps).expect("serialize caps toml");
1947        let parsed_caps: ProxyCapabilities =
1948            toml::from_str(&caps_toml).expect("deserialize caps toml");
1949        assert_eq!(parsed_caps, caps);
1950
1951        let req = CapabilityRequirement {
1952            require_asn: Some(KNOWN_ASN_FASTLY),
1953            require_city: Some("Berlin".into()),
1954            ..Default::default()
1955        };
1956        let req_toml = toml::to_string(&req).expect("serialize req toml");
1957        let parsed_req: CapabilityRequirement =
1958            toml::from_str(&req_toml).expect("deserialize req toml");
1959        assert_eq!(parsed_req, req);
1960    }
1961
1962    /// `#[serde(default, skip_serializing_if = "Option::is_none")]` on
1963    /// the new requirement fields means a legacy requirement payload
1964    /// (no `require_asn` / `require_city` / `require_postal_code`
1965    /// keys) deserialises cleanly with `None` values.
1966    #[test]
1967    fn t98_capability_requirement_legacy_wire_payload_deserialises_to_none() {
1968        let legacy = r#"{
1969            "require_https_connect": false,
1970            "require_socks5_udp": false,
1971            "require_http3_tunnel": false,
1972            "require_geo_country": null,
1973            "require_cdn_edge": false,
1974            "require_tls_profile": null
1975        }"#;
1976        let parsed: CapabilityRequirement = serde_json::from_str(legacy).expect("legacy parses");
1977        assert!(parsed.require_asn.is_none());
1978        assert!(parsed.require_city.is_none());
1979        assert!(parsed.require_postal_code.is_none());
1980    }
1981
1982    // ── T98: well_known ASN constants ───────────────────────────────────────
1983
1984    /// The `well_known` constants match the documented public ASNs.
1985    #[test]
1986    fn t98_well_known_asns_match_documented_values() {
1987        assert_eq!(super::well_known::KNOWN_ASN_CLOUDFLARE, 13_335);
1988        assert_eq!(super::well_known::KNOWN_ASN_AKAMAI, 20_940);
1989        assert_eq!(super::well_known::KNOWN_ASN_FASTLY, 54_113);
1990        assert_eq!(super::well_known::KNOWN_ASN_CLOUDFRONT, 16_509);
1991        assert_eq!(super::well_known::KNOWN_ASN_GOOGLE, 15_169);
1992        assert_eq!(super::well_known::KNOWN_ASN_AZURE, 8075);
1993    }
1994
1995    /// `ALL_KNOWN_ASNS` is a non-empty slice; every constant is
1996    /// unique (no duplicates); the well-known CDNs are present.
1997    #[test]
1998    fn t98_well_known_all_known_asns_includes_major_cdns() {
1999        let all = super::well_known::ALL_KNOWN_ASNS;
2000        assert!(all.contains(&KNOWN_ASN_CLOUDFLARE));
2001        assert!(all.contains(&KNOWN_ASN_AKAMAI));
2002        assert!(all.contains(&KNOWN_ASN_FASTLY));
2003        assert!(all.contains(&super::well_known::KNOWN_ASN_CLOUDFRONT));
2004        // No duplicates.
2005        let mut sorted = all.to_vec();
2006        sorted.sort_unstable();
2007        sorted.dedup();
2008        assert_eq!(sorted.len(), all.len());
2009    }
2010
2011    // ── T98: ingest validation helpers ─────────────────────────────────────
2012
2013    /// `validate_asn` accepts every valid public ASN.
2014    #[test]
2015    fn t98_validate_asn_accepts_valid_asns() {
2016        assert!(super::validate_asn(1).is_ok());
2017        assert!(super::validate_asn(KNOWN_ASN_CLOUDFLARE).is_ok());
2018        assert!(super::validate_asn(u32::MAX - 1).is_ok());
2019    }
2020
2021    /// `validate_asn` rejects the reserved `0` and `u32::MAX` values.
2022    #[test]
2023    fn t98_validate_asn_rejects_reserved_values() {
2024        let err_zero = super::validate_asn(0).expect_err("0 should fail");
2025        assert!(matches!(
2026            err_zero,
2027            crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "asn"
2028        ));
2029        let err_max = super::validate_asn(u32::MAX).expect_err("u32::MAX should fail");
2030        assert!(matches!(
2031            err_max,
2032            crate::error::ProxyError::InvalidGeoMetadata { ref field, .. } if field == "asn"
2033        ));
2034    }
2035
2036    /// `validate_city` rejects empty and over-length strings; accepts
2037    /// the documented length range `[1, 100]`.
2038    #[test]
2039    fn t98_validate_city_enforces_length_bounds() {
2040        assert!(super::validate_city("").is_err());
2041        assert!(super::validate_city("A").is_ok());
2042        assert!(super::validate_city("San Francisco").is_ok());
2043        let over = "x".repeat(super::CITY_MAX_LEN + 1);
2044        assert!(super::validate_city(&over).is_err());
2045    }
2046
2047    /// `validate_postal_code` enforces the `[1, 16]` length ceiling
2048    /// without enforcing a country-specific format.
2049    #[test]
2050    fn t98_validate_postal_code_enforces_length_bounds() {
2051        assert!(super::validate_postal_code("").is_err());
2052        assert!(super::validate_postal_code("94110").is_ok());
2053        assert!(super::validate_postal_code("SW1A 1AA").is_ok());
2054        assert!(super::validate_postal_code("100-0001").is_ok());
2055        let over = "x".repeat(super::POSTAL_CODE_MAX_LEN + 1);
2056        assert!(super::validate_postal_code(&over).is_err());
2057    }
2058}