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}