Skip to main content

stygian_proxy/ports/
coherence.rs

1//! Network-identity coherence port trait and supporting types.
2//!
3//! The 2026 scraping guide
4//! (`docs/dev/project/scraping-guide-2026-llm-context.md` §"WebRTC
5//! coherence rule", L2839) requires five orthogonal vectors to agree
6//! before a request is sent:
7//!
8//! 1. Proxy exit IP country
9//! 2. DNS resolver country
10//! 3. WebRTC public IP (must be in the same `/16` as the proxy exit)
11//! 4. Browser timezone (IANA TZ database, e.g. `America/New_York`)
12//! 5. Browser `Accept-Language`
13//!
14//! A mismatch on any of these vectors is the "WebRTC Trap" (L3135-3138) —
15//! one of the highest-signal anti-bot tells in the field. The
16//! [`CoherencePort`] trait captures this check as a pure, stateless
17//! function that consumes a [`CoherenceContext`] and returns a
18//! [`CoherenceVerdict`]; the default implementation in
19//! `adapters::coherence::DefaultCoherenceValidator` (behind the
20//! `coherence-validation` cargo feature) applies the rule above.
21//!
22//! The trait lives in the always-compiled `ports::coherence` module so the
23//! [`crate::manager::ProxyManager`] plumbing can reference it uniformly
24//! with or without the feature; only the default validator is
25//! feature-gated, mirroring the T96 `BayesianObserver` / `ThompsonStrategy`
26//! pattern.
27//!
28//! ## Module-level example
29//!
30//! ```
31//! use std::net::IpAddr;
32//! use std::str::FromStr;
33//! use stygian_proxy::ports::coherence::{
34//!     AcceptLanguage, CoherenceContext, CoherencePolicy, CoherencePort,
35//!     CoherenceVerdict, IsoCountry, Locale, MismatchField, MismatchSeverity, Tz,
36//! };
37//!
38//! // A clean US context: every vector agrees.
39//! let ctx = CoherenceContext {
40//!     proxy_geo_country: Some(IsoCountry::new("US").unwrap()),
41//!     dns_resolver_country: Some(IsoCountry::new("US").unwrap()),
42//!     browser_locale: Locale::new("en-US").unwrap(),
43//!     browser_timezone: Tz::new("America/New_York").unwrap(),
44//!     accept_language: AcceptLanguage::new("en-US,en;q=0.9").unwrap(),
45//!     webrtc_local_ip: None,
46//!     webrtc_public_ip: Some(IpAddr::from_str("192.0.2.42").unwrap()),
47//!     proxy_ip: Some(IpAddr::from_str("192.0.2.7").unwrap()),
48//! };
49//!
50//! // Without a validator the verdict is "Unknown"; the trait itself is
51//! // always-compiled but the default implementation lives behind the
52//! // `coherence-validation` feature.
53//! let verdict = ctx.evaluate();
54//! assert!(verdict.is_unknown());
55//! assert_eq!(verdict.unknown_reason(), Some("no_coherence_validator"));
56//!
57//! // Policies are independent of the validator: the default is
58//! // advisory-only.
59//! let policy = CoherencePolicy::advisory();
60//! assert!(!policy.is_hard_fail(MismatchField::ProxyGeoVsDns));
61//! let policy = CoherencePolicy::hard_fail_on(MismatchField::ProxyGeoVsDns);
62//! assert!(policy.is_hard_fail(MismatchField::ProxyGeoVsDns));
63//! assert_eq!(policy.severity(MismatchField::ProxyGeoVsDns), MismatchSeverity::Hard);
64//! let _ = ctx; // suppress unused warning under no-features build
65//! ```
66
67use std::net::IpAddr;
68
69use serde::{Deserialize, Serialize};
70
71/// ISO-3166-1 alpha-2 country code (e.g. `US`, `GB`, `PK`).
72///
73/// Validated on construction so a downstream validator never has to
74/// reject malformed strings. Stored upper-case for stable hashing and
75/// serde output; comparisons are case-insensitive at the boundary via
76/// [`IsoCountry::eq_ignore_ascii_case`].
77///
78/// # Example
79/// ```
80/// use stygian_proxy::ports::coherence::IsoCountry;
81/// let us = IsoCountry::new("us").unwrap();
82/// assert_eq!(us.as_str(), "US");
83/// assert!(IsoCountry::new("USA").is_none());
84/// assert!(IsoCountry::new("u").is_none());
85/// ```
86#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
87#[serde(transparent)]
88pub struct IsoCountry(String);
89
90impl IsoCountry {
91    /// Parse and uppercase a two-letter country code.
92    ///
93    /// Returns `None` when `raw` is not exactly two ASCII alpha
94    /// characters; the validator treats unknown / malformed codes as
95    /// [`CoherenceVerdict::Unknown`] rather than emitting false
96    /// mismatches.
97    #[must_use]
98    pub fn new(raw: &str) -> Option<Self> {
99        let upper = raw.trim().to_ascii_uppercase();
100        if upper.len() == 2 && upper.chars().all(|c| c.is_ascii_alphabetic()) {
101            Some(Self(upper))
102        } else {
103            None
104        }
105    }
106
107    /// Returns the upper-case two-letter code.
108    #[must_use]
109    pub fn as_str(&self) -> &str {
110        &self.0
111    }
112
113    /// Case-insensitive equality check for inbound config that may not
114    /// have been normalised.
115    ///
116    /// # Example
117    /// ```
118    /// use stygian_proxy::ports::coherence::IsoCountry;
119    /// let us = IsoCountry::new("US").unwrap();
120    /// assert!(us.eq_ignore_ascii_case("us"));
121    /// assert!(us.eq_ignore_ascii_case("Us"));
122    /// assert!(!us.eq_ignore_ascii_case("GB"));
123    /// ```
124    #[must_use]
125    pub fn eq_ignore_ascii_case(&self, other: &str) -> bool {
126        self.0.eq_ignore_ascii_case(other)
127    }
128}
129
130impl std::fmt::Display for IsoCountry {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.write_str(&self.0)
133    }
134}
135
136/// IANA timezone identifier (e.g. `America/New_York`, `Europe/London`).
137///
138/// The validator only inspects the leading path segment (`America`,
139/// `Europe`, `Asia`, …) plus the city when relevant; the wrapper
140/// itself stores the canonical IANA string verbatim so the existing
141/// `Intl.DateTimeFormat().resolvedOptions().timeZone` output round-trips
142/// without translation.
143///
144/// # Example
145/// ```
146/// use stygian_proxy::ports::coherence::Tz;
147/// let tz = Tz::new("America/New_York").unwrap();
148/// assert_eq!(tz.as_str(), "America/New_York");
149/// assert_eq!(tz.region(), Some("America"));
150/// assert_eq!(tz.city(), Some("New_York"));
151/// assert!(Tz::new("").is_none());
152/// ```
153#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
154#[serde(transparent)]
155pub struct Tz(String);
156
157impl Tz {
158    /// Parse an IANA timezone string. Accepts the canonical
159    /// `Region/City` form and a leading `Etc/UTC` shortcut. Returns
160    /// `None` when the string is empty.
161    #[must_use]
162    pub fn new(raw: &str) -> Option<Self> {
163        let trimmed = raw.trim();
164        if trimmed.is_empty() {
165            return None;
166        }
167        Some(Self(trimmed.to_owned()))
168    }
169
170    /// Returns the raw IANA string.
171    #[must_use]
172    pub fn as_str(&self) -> &str {
173        &self.0
174    }
175
176    /// Leading path segment (`America`, `Europe`, …). Always present for
177    /// valid IANA ids; `None` for the synthetic `UTC` shortcut.
178    #[must_use]
179    pub fn region(&self) -> Option<&str> {
180        self.0.split('/').next()
181    }
182
183    /// City segment after the first `/`.
184    #[must_use]
185    pub fn city(&self) -> Option<&str> {
186        self.0.split_once('/').map(|(_, city)| city)
187    }
188}
189
190impl std::fmt::Display for Tz {
191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192        f.write_str(&self.0)
193    }
194}
195
196/// BCP-47 locale tag (e.g. `en-US`, `fr-FR`).
197///
198/// Stores the lower-cased language and upper-cased region so locale
199/// vectors from different OS surfaces (`navigator.language` versus
200/// `Accept-Language` versus a manual `setlocale` call) collapse to the
201/// same canonical form.
202///
203/// # Example
204/// ```
205/// use stygian_proxy::ports::coherence::Locale;
206/// let l = Locale::new("en-us").unwrap();
207/// assert_eq!(l.as_str(), "en-US");
208/// assert_eq!(l.language(), "en");
209/// assert_eq!(l.region().unwrap(), "US");
210/// assert!(Locale::new("en").is_none());
211/// ```
212#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
213#[serde(transparent)]
214pub struct Locale(String);
215
216impl Locale {
217    /// Parse a BCP-47 locale with both a language and a region
218    /// subtag (e.g. `en-US`, `fr-FR`). Returns `None` for bare
219    /// language tags (`en`) since the country↔locale check requires
220    /// a region.
221    #[must_use]
222    pub fn new(raw: &str) -> Option<Self> {
223        let normalized = raw.trim().replace('_', "-");
224        let (lang, region) = normalized.split_once('-')?;
225        let lang = lang.to_ascii_lowercase();
226        let region = region.to_ascii_uppercase();
227        if lang.len() < 2
228            || !lang.chars().all(|c| c.is_ascii_alphabetic())
229            || region.len() != 2
230            || !region.chars().all(|c| c.is_ascii_alphabetic())
231        {
232            return None;
233        }
234        Some(Self(format!("{lang}-{region}")))
235    }
236
237    /// Returns the canonical `lang-REGION` form.
238    #[must_use]
239    pub fn as_str(&self) -> &str {
240        &self.0
241    }
242
243    /// Lower-cased language subtag (`en`, `fr`, …).
244    #[must_use]
245    pub fn language(&self) -> &str {
246        self.0.split_once('-').map_or(&self.0, |(l, _)| l)
247    }
248
249    /// Upper-cased region subtag (`US`, `FR`, …).
250    #[must_use]
251    pub fn region(&self) -> Option<&str> {
252        self.0.split_once('-').map(|(_, r)| r)
253    }
254}
255
256impl std::fmt::Display for Locale {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        f.write_str(&self.0)
259    }
260}
261
262/// `Accept-Language` header value (RFC 7231 §5.3.5).
263///
264/// Captures the full header — `en-US,en;q=0.9,fr;q=0.8` — so the
265/// validator can inspect the region of the highest-q entry without
266/// parsing on every call. The first / highest-priority entry is the one
267/// that drives the country agreement check.
268///
269/// # Example
270/// ```
271/// use stygian_proxy::ports::coherence::AcceptLanguage;
272/// let al = AcceptLanguage::new("en-US,en;q=0.9").unwrap();
273/// assert_eq!(al.as_str(), "en-US,en;q=0.9");
274/// let primary = al.primary_region().unwrap();
275/// assert_eq!(primary.as_str(), "en-US");
276/// ```
277#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
278#[serde(transparent)]
279pub struct AcceptLanguage(String);
280
281impl AcceptLanguage {
282    /// Capture the raw header. Empty strings are rejected so the
283    /// validator never sees a phantom "missing language" mismatch.
284    #[must_use]
285    pub fn new(raw: &str) -> Option<Self> {
286        let trimmed = raw.trim();
287        if trimmed.is_empty() {
288            return None;
289        }
290        Some(Self(trimmed.to_owned()))
291    }
292
293    /// Returns the raw header value verbatim.
294    #[must_use]
295    pub fn as_str(&self) -> &str {
296        &self.0
297    }
298
299    /// Highest-priority region parsed as a [`Locale`] (best-effort).
300    ///
301    /// Returns `None` when the primary entry has no region subtag
302    /// (e.g. `en;q=1.0`).
303    #[must_use]
304    pub fn primary_region(&self) -> Option<Locale> {
305        let primary = self.0.split(',').next()?;
306        // Strip any `;q=...` quality value before locale parsing.
307        let tag = primary.split(';').next()?.trim();
308        Locale::new(tag)
309    }
310}
311
312impl std::fmt::Display for AcceptLanguage {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        f.write_str(&self.0)
315    }
316}
317
318/// Field on which two coherence vectors disagreed.
319///
320/// Every variant has a fixed [`MismatchSeverity`] (advisory vs hard)
321/// embedded in the validator — the port just enumerates the possible
322/// mismatch sites so [`CoherencePolicy::hard_fail_on`] can target a
323/// specific vector.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
325#[serde(rename_all = "snake_case")]
326pub enum MismatchField {
327    /// `proxy_geo_country` vs `dns_resolver_country`.
328    ProxyGeoVsDns,
329    /// `webrtc_public_ip` not in the same `/16` as the proxy exit.
330    WebRtcPublicIp,
331    /// `browser_timezone` disagrees with the proxy / DNS country.
332    Timezone,
333    /// `browser_locale` region disagrees with the proxy / DNS country.
334    Locale,
335    /// `accept_language` primary region disagrees with the proxy / DNS country.
336    AcceptLanguage,
337}
338
339impl MismatchField {
340    /// Stable `snake_case` wire label.
341    ///
342    /// # Example
343    /// ```
344    /// use stygian_proxy::ports::coherence::MismatchField;
345    /// assert_eq!(MismatchField::ProxyGeoVsDns.label(), "proxy_geo_vs_dns");
346    /// assert_eq!(MismatchField::WebRtcPublicIp.label(), "web_rtc_public_ip");
347    /// ```
348    #[must_use]
349    pub const fn label(self) -> &'static str {
350        match self {
351            Self::ProxyGeoVsDns => "proxy_geo_vs_dns",
352            Self::WebRtcPublicIp => "web_rtc_public_ip",
353            Self::Timezone => "timezone",
354            Self::Locale => "locale",
355            Self::AcceptLanguage => "accept_language",
356        }
357    }
358}
359
360impl std::fmt::Display for MismatchField {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        f.write_str(self.label())
363    }
364}
365
366/// How serious a given mismatch is for downstream routing.
367///
368/// - `Advisory` — recoverable drift (locale / timezone); logged but the
369///   request still goes through.
370/// - `Hard` — geo / WebRTC divergence that the major anti-bot vendors
371///   treat as an immediate block signal.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
373#[serde(rename_all = "snake_case")]
374pub enum MismatchSeverity {
375    /// Mismatch is logged; the request proceeds.
376    Advisory,
377    /// Mismatch triggers an immediate block under `CoherencePolicy::hard_fail_on`.
378    Hard,
379}
380
381impl MismatchSeverity {
382    /// Stable `snake_case` wire label.
383    ///
384    /// # Example
385    /// ```
386    /// use stygian_proxy::ports::coherence::MismatchSeverity;
387    /// assert_eq!(MismatchSeverity::Advisory.label(), "advisory");
388    /// assert_eq!(MismatchSeverity::Hard.label(), "hard");
389    /// ```
390    #[must_use]
391    pub const fn label(self) -> &'static str {
392        match self {
393            Self::Advisory => "advisory",
394            Self::Hard => "hard",
395        }
396    }
397
398    /// `true` when the variant is [`MismatchSeverity::Hard`].
399    #[must_use]
400    pub const fn is_hard(self) -> bool {
401        matches!(self, Self::Hard)
402    }
403}
404
405impl std::fmt::Display for MismatchSeverity {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        f.write_str(self.label())
408    }
409}
410
411/// Outcome of a single coherence evaluation.
412///
413/// Constructed by [`CoherencePort::evaluate`]. The `Mismatch` variant
414/// carries enough information for the caller to map the verdict to a
415/// [`CoherencePolicy`] decision (`Advisory` → log + proceed;
416/// `Hard` + `policy.hard_fail_on(field)` → return error).
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "snake_case", tag = "outcome")]
419pub enum CoherenceVerdict {
420    /// Every checked vector agreed (or no vectors were available).
421    Coherent,
422    /// A specific vector disagreed.
423    Mismatch {
424        /// Which vector disagreed.
425        field: MismatchField,
426        /// How serious the disagreement is.
427        severity: MismatchSeverity,
428    },
429    /// Verdict could not be reached (missing data, disabled feature, etc.).
430    ///
431    /// The `String` reason is intended for structured logs and test
432    /// assertions, never user-facing copy. Use
433    /// [`CoherenceVerdict::unknown`] to build a verdict from a
434    /// `&'static str` reason without spelling out the `String`
435    /// constructor at every call site.
436    Unknown(String),
437}
438
439impl CoherenceVerdict {
440    /// Convenience constructor for [`CoherenceVerdict::Unknown`] from a
441    /// static reason. Keeps the hot-path call site concise while still
442    /// allowing external callers to wrap an arbitrary string.
443    ///
444    /// # Example
445    /// ```
446    /// use stygian_proxy::ports::coherence::CoherenceVerdict;
447    /// let v = CoherenceVerdict::unknown("missing_dns");
448    /// assert!(v.is_unknown());
449    /// assert_eq!(v.unknown_reason(), Some("missing_dns"));
450    /// ```
451    #[must_use]
452    pub fn unknown(reason: &'static str) -> Self {
453        Self::Unknown(reason.to_owned())
454    }
455
456    /// Returns the [`Unknown`](Self::Unknown) reason as a string slice,
457    /// or `None` for `Coherent` / `Mismatch`.
458    #[must_use]
459    pub const fn unknown_reason(&self) -> Option<&str> {
460        match self {
461            Self::Unknown(reason) => Some(reason.as_str()),
462            _ => None,
463        }
464    }
465
466    /// Returns `true` when the verdict is [`CoherenceVerdict::Coherent`].
467    #[must_use]
468    pub const fn is_coherent(&self) -> bool {
469        matches!(self, Self::Coherent)
470    }
471
472    /// Returns `true` when the verdict is [`CoherenceVerdict::Unknown`].
473    #[must_use]
474    pub const fn is_unknown(&self) -> bool {
475        matches!(self, Self::Unknown(_))
476    }
477}
478
479impl std::fmt::Display for CoherenceVerdict {
480    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481        match self {
482            Self::Coherent => f.write_str("coherent"),
483            Self::Mismatch { field, severity } => {
484                write!(f, "mismatch:{severity}:{field}")
485            }
486            Self::Unknown(reason) => write!(f, "unknown:{reason}"),
487        }
488    }
489}
490
491/// Policy configuring how [`crate::manager::ProxyManager::acquire_proxy_with_coherence`]
492/// reacts to a [`CoherenceVerdict::Mismatch`].
493///
494/// The default [`CoherencePolicy::advisory`] policy never blocks; every
495/// mismatch is logged and the proxy is returned. Operators opt into
496/// blocking via [`CoherencePolicy::hard_fail_on`] or the
497/// [`CoherencePolicy::with_hard_fail`] builder step.
498#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
499#[serde(rename_all = "snake_case")]
500pub struct CoherencePolicy {
501    /// Fields whose `Hard` mismatch should fail the acquisition.
502    #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
503    hard_fail_on: std::collections::BTreeSet<MismatchField>,
504}
505
506impl CoherencePolicy {
507    /// Advisory-only policy: every mismatch is logged, none block.
508    ///
509    /// # Example
510    /// ```
511    /// use stygian_proxy::ports::coherence::{CoherencePolicy, MismatchField};
512    /// let policy = CoherencePolicy::advisory();
513    /// assert!(!policy.is_hard_fail(MismatchField::ProxyGeoVsDns));
514    /// assert!(policy.is_advisory_only());
515    /// ```
516    #[must_use]
517    pub const fn advisory() -> Self {
518        Self {
519            hard_fail_on: std::collections::BTreeSet::new(),
520        }
521    }
522
523    /// Build a policy that fails on a single hard-mismatch field.
524    ///
525    /// # Example
526    /// ```
527    /// use stygian_proxy::ports::coherence::{CoherencePolicy, MismatchField, MismatchSeverity};
528    /// let policy = CoherencePolicy::hard_fail_on(MismatchField::WebRtcPublicIp);
529    /// assert!(policy.is_hard_fail(MismatchField::WebRtcPublicIp));
530    /// assert_eq!(policy.severity(MismatchField::WebRtcPublicIp), MismatchSeverity::Hard);
531    /// assert!(!policy.is_hard_fail(MismatchField::Timezone));
532    /// ```
533    #[must_use]
534    pub fn hard_fail_on(field: MismatchField) -> Self {
535        let mut hard_fail_on = std::collections::BTreeSet::new();
536        hard_fail_on.insert(field);
537        Self { hard_fail_on }
538    }
539
540    /// Builder step: register an additional hard-fail field.
541    ///
542    /// # Example
543    /// ```
544    /// use stygian_proxy::ports::coherence::{CoherencePolicy, MismatchField};
545    /// let policy = CoherencePolicy::advisory()
546    ///     .with_hard_fail(MismatchField::ProxyGeoVsDns)
547    ///     .with_hard_fail(MismatchField::WebRtcPublicIp);
548    /// assert!(policy.is_hard_fail(MismatchField::ProxyGeoVsDns));
549    /// assert!(policy.is_hard_fail(MismatchField::WebRtcPublicIp));
550    /// assert!(!policy.is_hard_fail(MismatchField::Timezone));
551    /// ```
552    #[must_use]
553    pub fn with_hard_fail(mut self, field: MismatchField) -> Self {
554        self.hard_fail_on.insert(field);
555        self
556    }
557
558    /// Returns `true` when `field` is registered as a hard-fail vector.
559    ///
560    /// # Example
561    /// ```
562    /// use stygian_proxy::ports::coherence::{CoherencePolicy, MismatchField};
563    /// let policy = CoherencePolicy::hard_fail_on(MismatchField::Timezone);
564    /// assert!(policy.is_hard_fail(MismatchField::Timezone));
565    /// assert!(!policy.is_hard_fail(MismatchField::Locale));
566    /// ```
567    #[must_use]
568    pub fn contains(&self, field: MismatchField) -> bool {
569        self.hard_fail_on.contains(&field)
570    }
571
572    /// Alias for [`CoherencePolicy::contains`] matching the
573    /// `policy.is_hard_fail(field)` shape used in the rustdoc examples
574    /// above.
575    #[must_use]
576    pub fn is_hard_fail(&self, field: MismatchField) -> bool {
577        self.contains(field)
578    }
579
580    /// Returns `true` when no field is registered for hard-fail.
581    #[must_use]
582    pub fn is_advisory_only(&self) -> bool {
583        self.hard_fail_on.is_empty()
584    }
585
586    /// Severity under which the policy blocks a request on `field`.
587    ///
588    /// Always [`MismatchSeverity::Hard`] for registered fields and
589    /// [`MismatchSeverity::Advisory`] otherwise. Used by the manager
590    /// to decide whether a given [`CoherenceVerdict::Mismatch`] should
591    /// fail the acquisition or just be logged.
592    #[must_use]
593    pub fn severity(&self, field: MismatchField) -> MismatchSeverity {
594        if self.hard_fail_on.contains(&field) {
595            MismatchSeverity::Hard
596        } else {
597            MismatchSeverity::Advisory
598        }
599    }
600
601    /// Number of registered hard-fail fields.
602    #[must_use]
603    pub fn hard_fail_count(&self) -> usize {
604        self.hard_fail_on.len()
605    }
606}
607
608/// Snapshot of the network-identity vectors that the [`CoherencePort`]
609/// validates.
610///
611/// The browser (or test harness) builds a `CoherenceContext` from the
612/// live page + the proxy that is about to be used; the port then
613/// decides whether the request is safe to send.
614///
615/// `proxy_ip` is optional because the manager can build the context
616/// from the [`crate::types::Proxy`] record (which carries `url` but not
617/// the resolved IP) before the proxy is actually contacted; the
618/// validator treats a missing `proxy_ip` as a soft `Unknown` rather
619/// than a hard mismatch so the integration does not require an extra
620/// DNS lookup on the hot path.
621///
622/// # Example
623/// ```
624/// use std::net::IpAddr;
625/// use std::str::FromStr;
626/// use stygian_proxy::ports::coherence::{AcceptLanguage, CoherenceContext, IsoCountry, Locale, Tz};
627///
628/// let ctx = CoherenceContext {
629///     proxy_geo_country: Some(IsoCountry::new("US").unwrap()),
630///     dns_resolver_country: Some(IsoCountry::new("US").unwrap()),
631///     browser_locale: Locale::new("en-US").unwrap(),
632///     browser_timezone: Tz::new("America/New_York").unwrap(),
633///     accept_language: AcceptLanguage::new("en-US,en;q=0.9").unwrap(),
634///     webrtc_local_ip: None,
635///     webrtc_public_ip: Some(IpAddr::from_str("192.0.2.42").unwrap()),
636///     proxy_ip: Some(IpAddr::from_str("192.0.2.7").unwrap()),
637/// };
638/// assert_eq!(ctx.proxy_geo_country.as_ref().map(IsoCountry::as_str), Some("US"));
639/// ```
640#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub struct CoherenceContext {
643    /// ISO-3166-1 alpha-2 country of the proxy exit (provider-supplied).
644    pub proxy_geo_country: Option<IsoCountry>,
645    /// ISO-3166-1 alpha-2 country of the recursive DNS resolver.
646    pub dns_resolver_country: Option<IsoCountry>,
647    /// Browser locale reported by `navigator.language`.
648    pub browser_locale: Locale,
649    /// Browser timezone reported by `Intl.DateTimeFormat().resolvedOptions().timeZone`.
650    pub browser_timezone: Tz,
651    /// `Accept-Language` header value.
652    pub accept_language: AcceptLanguage,
653    /// WebRTC local (LAN) candidate IP, if the browser exposed one.
654    pub webrtc_local_ip: Option<IpAddr>,
655    /// WebRTC public (server-reflexive) candidate IP, if the browser
656    /// exposed one.
657    pub webrtc_public_ip: Option<IpAddr>,
658    /// Resolved IP of the proxy exit. `None` skips the WebRTC /16 check.
659    pub proxy_ip: Option<IpAddr>,
660}
661
662impl CoherenceContext {
663    /// Compute the canonical `/16` prefix of an `IpAddr`.
664    ///
665    /// Returns `None` for IPv6 addresses (the heuristic is IPv4-only) and
666    /// for addresses that fall outside the routable unicast space. Used
667    /// by [`crate::adapters::coherence::DefaultCoherenceValidator`] for
668    /// the WebRTC public-IP agreement check.
669    ///
670    /// # Example
671    /// ```
672    /// use std::net::IpAddr;
673    /// use std::str::FromStr;
674    /// use stygian_proxy::ports::coherence::CoherenceContext;
675    /// let ip = IpAddr::from_str("192.0.2.42").unwrap();
676    /// assert_eq!(CoherenceContext::same_slash_16(ip, IpAddr::from_str("192.0.2.7").unwrap()), Some(true));
677    /// assert_eq!(CoherenceContext::same_slash_16(ip, IpAddr::from_str("203.0.113.5").unwrap()), Some(false));
678    /// ```
679    #[must_use]
680    pub fn same_slash_16(a: IpAddr, b: IpAddr) -> Option<bool> {
681        let (IpAddr::V4(a), IpAddr::V4(b)) = (a, b) else {
682            return None;
683        };
684        let a_prefix = u32::from(a) >> 16;
685        let b_prefix = u32::from(b) >> 16;
686        Some(a_prefix == b_prefix)
687    }
688
689    /// Convenience: evaluate this context with no validator wired in.
690    ///
691    /// Without the `coherence-validation` cargo feature enabled, the
692    /// [`ProxyManager`](crate::manager::ProxyManager) has no validator
693    /// installed and every call returns
694    /// `CoherenceVerdict::Unknown("no_coherence_validator")`. External
695    /// callers that want a no-op verdict should call this method rather
696    /// than reaching into a private field on the manager.
697    #[must_use]
698    pub fn evaluate(&self) -> CoherenceVerdict {
699        CoherenceVerdict::unknown("no_coherence_validator")
700    }
701}
702
703// ─────────────────────────────────────────────────────────────────────────────
704// CoherencePort
705// ─────────────────────────────────────────────────────────────────────────────
706
707/// Network-identity coherence port.
708///
709/// Implementors decide whether `ctx` is safe to send through: a clean
710/// context returns [`CoherenceVerdict::Coherent`], a disagreement on a
711/// specific vector returns
712/// [`CoherenceVerdict::Mismatch`] with the
713/// matching [`MismatchField`] and [`MismatchSeverity`], and a missing
714/// observation (no DNS data, WebRTC disabled, …) returns
715/// [`CoherenceVerdict::Unknown`].
716///
717/// `Send + Sync + 'static` so the implementation can live behind an
718/// `Arc<dyn CoherencePort>` on the manager. The default implementation
719/// in
720/// [`crate::adapters::coherence::DefaultCoherenceValidator`]
721/// is `Send + Sync + 'static` and stateless; an alternative adapter
722/// that needed caching would add a Mutex but the trait itself never
723/// requires one.
724///
725/// # Example
726///
727/// ```rust,no_run
728/// use std::net::IpAddr;
729/// use std::str::FromStr;
730/// use stygian_proxy::ports::coherence::{
731///     AcceptLanguage, CoherenceContext, CoherencePort, CoherenceVerdict,
732///     IsoCountry, Locale, MismatchField, MismatchSeverity, Tz,
733/// };
734///
735/// // Custom adapter: only fail when proxy country disagrees with DNS.
736/// struct StrictGeo;
737///
738/// impl CoherencePort for StrictGeo {
739///     fn evaluate(&self, ctx: &CoherenceContext) -> CoherenceVerdict {
740///         match (ctx.proxy_geo_country.as_ref(), ctx.dns_resolver_country.as_ref()) {
741///             (Some(proxy), Some(dns)) if proxy == dns => CoherenceVerdict::Coherent,
742///             (Some(_), Some(_)) => CoherenceVerdict::Mismatch {
743///                 field: MismatchField::ProxyGeoVsDns,
744///                 severity: MismatchSeverity::Hard,
745///             },
746///             _ => CoherenceVerdict::unknown("missing_geo"),
747///         }
748///     }
749/// }
750///
751/// let ctx = CoherenceContext {
752///     proxy_geo_country: Some(IsoCountry::new("US").unwrap()),
753///     dns_resolver_country: Some(IsoCountry::new("PK").unwrap()),
754///     browser_locale: Locale::new("en-US").unwrap(),
755///     browser_timezone: Tz::new("America/New_York").unwrap(),
756///     accept_language: AcceptLanguage::new("en-US").unwrap(),
757///     webrtc_local_ip: None,
758///     webrtc_public_ip: Some(IpAddr::from_str("192.0.2.7").unwrap()),
759///     proxy_ip: Some(IpAddr::from_str("192.0.2.7").unwrap()),
760/// };
761/// let v = StrictGeo.evaluate(&ctx);
762/// assert!(matches!(
763///     v,
764///     CoherenceVerdict::Mismatch {
765///         field: MismatchField::ProxyGeoVsDns,
766///         severity: MismatchSeverity::Hard,
767///     }
768/// ));
769/// ```
770pub trait CoherencePort: Send + Sync + 'static {
771    /// Run the coherence check on `ctx`.
772    fn evaluate(&self, ctx: &CoherenceContext) -> CoherenceVerdict;
773}
774
775/// Shared-ownership type alias for a [`CoherencePort`] implementation.
776///
777/// Mirrors [`crate::strategy::BoxedRotationStrategy`] and
778/// [`crate::strategy::BoxedBayesianObserver`] so the manager holds a
779/// single `Arc<dyn CoherencePort>` regardless of which adapter
780/// implementation it was built with.
781pub type BoxedCoherencePort = std::sync::Arc<dyn CoherencePort>;
782
783// ─────────────────────────────────────────────────────────────────────────────
784// Tests
785// ─────────────────────────────────────────────────────────────────────────────
786
787#[cfg(test)]
788#[allow(
789    clippy::unwrap_used,
790    clippy::expect_used,
791    clippy::panic,
792    clippy::indexing_slicing
793)]
794mod tests {
795    use super::*;
796    use std::str::FromStr;
797
798    fn us_country() -> IsoCountry {
799        IsoCountry::new("US").unwrap()
800    }
801
802    fn en_us_locale() -> Locale {
803        Locale::new("en-US").unwrap()
804    }
805
806    fn ny_tz() -> Tz {
807        Tz::new("America/New_York").unwrap()
808    }
809
810    fn en_us_al() -> AcceptLanguage {
811        AcceptLanguage::new("en-US,en;q=0.9").unwrap()
812    }
813
814    fn ctx_us() -> CoherenceContext {
815        CoherenceContext {
816            proxy_geo_country: Some(us_country()),
817            dns_resolver_country: Some(us_country()),
818            browser_locale: en_us_locale(),
819            browser_timezone: ny_tz(),
820            accept_language: en_us_al(),
821            webrtc_local_ip: None,
822            webrtc_public_ip: Some(IpAddr::from_str("192.0.2.42").unwrap()),
823            proxy_ip: Some(IpAddr::from_str("192.0.2.7").unwrap()),
824        }
825    }
826
827    // ── IsoCountry ───────────────────────────────────────────────────────────
828
829    #[test]
830    fn iso_country_normalises_case() {
831        assert_eq!(IsoCountry::new("us").unwrap().as_str(), "US");
832        assert_eq!(IsoCountry::new("Gb").unwrap().as_str(), "GB");
833    }
834
835    #[test]
836    fn iso_country_rejects_invalid_lengths() {
837        assert!(IsoCountry::new("USA").is_none());
838        assert!(IsoCountry::new("U").is_none());
839        assert!(IsoCountry::new("").is_none());
840    }
841
842    #[test]
843    fn iso_country_rejects_non_alpha() {
844        assert!(IsoCountry::new("U1").is_none());
845        assert!(IsoCountry::new("12").is_none());
846    }
847
848    #[test]
849    fn iso_country_eq_ignore_ascii_case_works() {
850        let us = us_country();
851        assert!(us.eq_ignore_ascii_case("us"));
852        assert!(us.eq_ignore_ascii_case("US"));
853        assert!(us.eq_ignore_ascii_case("Us"));
854        assert!(!us.eq_ignore_ascii_case("GB"));
855    }
856
857    #[test]
858    fn iso_country_round_trips_through_json() {
859        let us = us_country();
860        let json = serde_json::to_string(&us).expect("serialize");
861        assert_eq!(json, "\"US\"");
862        let parsed: IsoCountry = serde_json::from_str(&json).expect("deserialize");
863        assert_eq!(parsed, us);
864    }
865
866    // ── Tz ────────────────────────────────────────────────────────────────────
867
868    #[test]
869    fn tz_region_and_city() {
870        let tz = ny_tz();
871        assert_eq!(tz.region(), Some("America"));
872        assert_eq!(tz.city(), Some("New_York"));
873    }
874
875    #[test]
876    fn tz_rejects_empty() {
877        assert!(Tz::new("").is_none());
878        assert!(Tz::new("   ").is_none());
879    }
880
881    #[test]
882    fn tz_round_trips_through_json() {
883        let tz = ny_tz();
884        let json = serde_json::to_string(&tz).expect("serialize");
885        let parsed: Tz = serde_json::from_str(&json).expect("deserialize");
886        assert_eq!(parsed, tz);
887    }
888
889    // ── Locale ───────────────────────────────────────────────────────────────
890
891    #[test]
892    fn locale_normalises_case_and_underscore() {
893        let l = Locale::new("en_us").unwrap();
894        assert_eq!(l.as_str(), "en-US");
895        assert_eq!(l.language(), "en");
896        assert_eq!(l.region(), Some("US"));
897    }
898
899    #[test]
900    fn locale_rejects_bare_language_tag() {
901        assert!(Locale::new("en").is_none());
902        assert!(Locale::new("EN").is_none());
903    }
904
905    #[test]
906    fn locale_rejects_malformed_region() {
907        assert!(Locale::new("en-USA").is_none());
908        assert!(Locale::new("en-U1").is_none());
909    }
910
911    #[test]
912    fn locale_round_trips_through_json() {
913        let l = en_us_locale();
914        let json = serde_json::to_string(&l).expect("serialize");
915        let parsed: Locale = serde_json::from_str(&json).expect("deserialize");
916        assert_eq!(parsed, l);
917    }
918
919    // ── AcceptLanguage ───────────────────────────────────────────────────────
920
921    #[test]
922    fn accept_language_primary_region() {
923        let al = en_us_al();
924        let primary = al.primary_region().unwrap();
925        assert_eq!(primary.as_str(), "en-US");
926    }
927
928    #[test]
929    fn accept_language_strips_quality_value() {
930        let al = AcceptLanguage::new("fr-FR;q=0.8").unwrap();
931        assert_eq!(al.primary_region().unwrap().as_str(), "fr-FR");
932    }
933
934    #[test]
935    fn accept_language_rejects_empty() {
936        assert!(AcceptLanguage::new("").is_none());
937    }
938
939    #[test]
940    fn accept_language_bare_language_tag_yields_none_primary() {
941        let al = AcceptLanguage::new("en;q=1.0").unwrap();
942        assert!(al.primary_region().is_none());
943    }
944
945    // ── MismatchField / MismatchSeverity ─────────────────────────────────────
946
947    #[test]
948    fn mismatch_field_labels_are_stable() {
949        assert_eq!(MismatchField::ProxyGeoVsDns.label(), "proxy_geo_vs_dns");
950        assert_eq!(MismatchField::WebRtcPublicIp.label(), "web_rtc_public_ip");
951        assert_eq!(MismatchField::Timezone.label(), "timezone");
952        assert_eq!(MismatchField::Locale.label(), "locale");
953        assert_eq!(MismatchField::AcceptLanguage.label(), "accept_language");
954    }
955
956    #[test]
957    fn mismatch_severity_labels_are_stable() {
958        assert_eq!(MismatchSeverity::Advisory.label(), "advisory");
959        assert_eq!(MismatchSeverity::Hard.label(), "hard");
960        assert!(!MismatchSeverity::Advisory.is_hard());
961        assert!(MismatchSeverity::Hard.is_hard());
962    }
963
964    // ── CoherenceVerdict ─────────────────────────────────────────────────────
965
966    #[test]
967    fn verdict_display_is_stable() {
968        assert_eq!(CoherenceVerdict::Coherent.to_string(), "coherent");
969        let v = CoherenceVerdict::Mismatch {
970            field: MismatchField::ProxyGeoVsDns,
971            severity: MismatchSeverity::Hard,
972        };
973        assert_eq!(v.to_string(), "mismatch:hard:proxy_geo_vs_dns");
974        let v = CoherenceVerdict::unknown("missing_dns");
975        assert_eq!(v.to_string(), "unknown:missing_dns");
976        assert_eq!(v.unknown_reason(), Some("missing_dns"));
977    }
978
979    #[test]
980    fn verdict_is_coherent_and_unknown_helpers() {
981        assert!(CoherenceVerdict::Coherent.is_coherent());
982        assert!(!CoherenceVerdict::Coherent.is_unknown());
983        assert!(CoherenceVerdict::unknown("x").is_unknown());
984        assert!(!CoherenceVerdict::unknown("x").is_coherent());
985    }
986
987    #[test]
988    fn verdict_round_trips_through_json() {
989        let v = CoherenceVerdict::Mismatch {
990            field: MismatchField::WebRtcPublicIp,
991            severity: MismatchSeverity::Hard,
992        };
993        let json = serde_json::to_string(&v).expect("serialize");
994        let parsed: CoherenceVerdict = serde_json::from_str(&json).expect("deserialize");
995        assert_eq!(parsed, v);
996    }
997
998    #[test]
999    fn coherent_verdict_round_trips_through_json() {
1000        let v = CoherenceVerdict::Coherent;
1001        let json = serde_json::to_string(&v).expect("serialize");
1002        let parsed: CoherenceVerdict = serde_json::from_str(&json).expect("deserialize");
1003        assert_eq!(parsed, v);
1004    }
1005
1006    // ── CoherencePolicy ──────────────────────────────────────────────────────
1007
1008    #[test]
1009    fn advisory_policy_blocks_nothing() {
1010        let p = CoherencePolicy::advisory();
1011        assert!(p.is_advisory_only());
1012        assert!(!p.is_hard_fail(MismatchField::ProxyGeoVsDns));
1013        assert_eq!(
1014            p.severity(MismatchField::ProxyGeoVsDns),
1015            MismatchSeverity::Advisory
1016        );
1017        assert_eq!(p.hard_fail_count(), 0);
1018    }
1019
1020    #[test]
1021    fn hard_fail_on_policy_blocks_a_single_field() {
1022        let p = CoherencePolicy::hard_fail_on(MismatchField::WebRtcPublicIp);
1023        assert!(p.is_hard_fail(MismatchField::WebRtcPublicIp));
1024        assert!(!p.is_hard_fail(MismatchField::ProxyGeoVsDns));
1025        assert_eq!(
1026            p.severity(MismatchField::WebRtcPublicIp),
1027            MismatchSeverity::Hard
1028        );
1029        assert_eq!(
1030            p.severity(MismatchField::Timezone),
1031            MismatchSeverity::Advisory
1032        );
1033        assert_eq!(p.hard_fail_count(), 1);
1034        assert!(!p.is_advisory_only());
1035    }
1036
1037    #[test]
1038    fn with_hard_fail_accumulates() {
1039        let p = CoherencePolicy::advisory()
1040            .with_hard_fail(MismatchField::ProxyGeoVsDns)
1041            .with_hard_fail(MismatchField::Timezone);
1042        assert!(p.is_hard_fail(MismatchField::ProxyGeoVsDns));
1043        assert!(p.is_hard_fail(MismatchField::Timezone));
1044        assert!(!p.is_hard_fail(MismatchField::Locale));
1045        assert_eq!(p.hard_fail_count(), 2);
1046    }
1047
1048    #[test]
1049    fn policy_round_trips_through_json() {
1050        let p = CoherencePolicy::advisory()
1051            .with_hard_fail(MismatchField::ProxyGeoVsDns)
1052            .with_hard_fail(MismatchField::WebRtcPublicIp);
1053        let json = serde_json::to_string(&p).expect("serialize");
1054        let parsed: CoherencePolicy = serde_json::from_str(&json).expect("deserialize");
1055        assert_eq!(parsed, p);
1056    }
1057
1058    #[test]
1059    fn empty_policy_round_trips_through_json() {
1060        let p = CoherencePolicy::advisory();
1061        let json = serde_json::to_string(&p).expect("serialize");
1062        // `skip_serializing_if = "BTreeSet::is_empty"` keeps the wire
1063        // form empty rather than emitting `"hard_fail_on": []`.
1064        assert!(!json.contains("hard_fail_on"));
1065        let parsed: CoherencePolicy = serde_json::from_str(&json).expect("deserialize");
1066        assert_eq!(parsed, p);
1067    }
1068
1069    // ── CoherenceContext ─────────────────────────────────────────────────────
1070
1071    #[test]
1072    fn slash_16_agrees_within_prefix() {
1073        let a = IpAddr::from_str("192.0.2.42").unwrap();
1074        let b = IpAddr::from_str("192.0.2.7").unwrap();
1075        let c = IpAddr::from_str("203.0.113.5").unwrap();
1076        assert_eq!(CoherenceContext::same_slash_16(a, b), Some(true));
1077        assert_eq!(CoherenceContext::same_slash_16(a, c), Some(false));
1078    }
1079
1080    #[test]
1081    fn slash_16_returns_none_for_ipv6() {
1082        let v4 = IpAddr::from_str("192.0.2.42").unwrap();
1083        let v6 = IpAddr::from_str("2001:db8::1").unwrap();
1084        assert_eq!(CoherenceContext::same_slash_16(v4, v6), None);
1085    }
1086
1087    #[test]
1088    fn evaluate_with_no_validator_returns_unknown() {
1089        let ctx = ctx_us();
1090        assert!(matches!(
1091            ctx.evaluate(),
1092            CoherenceVerdict::Unknown(_) if ctx.evaluate().unknown_reason() == Some("no_coherence_validator")
1093        ));
1094    }
1095
1096    #[test]
1097    fn context_round_trips_through_json() {
1098        let ctx = ctx_us();
1099        let json = serde_json::to_string(&ctx).expect("serialize");
1100        let parsed: CoherenceContext = serde_json::from_str(&json).expect("deserialize");
1101        assert_eq!(parsed, ctx);
1102    }
1103
1104    // ── Trait object dispatch ─────────────────────────────────────────────────
1105
1106    /// `CoherencePort` is dyn-safe: `BoxedCoherencePort` must compile.
1107    #[test]
1108    fn boxed_coherence_port_is_object_safe() {
1109        fn _assert_object_safe(_: BoxedCoherencePort) {}
1110        // The closure form also compiles via the manager's `Arc<dyn ...>`.
1111        let _boxed: BoxedCoherencePort = std::sync::Arc::new(NoopCoherenceValidator);
1112    }
1113
1114    /// Always-coherent stub adapter used to exercise the object-safe
1115    /// path. Lives in the test module because no production code needs
1116    /// it; production code is wired to
1117    /// `adapters::coherence::DefaultCoherenceValidator`.
1118    #[derive(Debug)]
1119    struct NoopCoherenceValidator;
1120
1121    impl CoherencePort for NoopCoherenceValidator {
1122        fn evaluate(&self, _: &CoherenceContext) -> CoherenceVerdict {
1123            CoherenceVerdict::Coherent
1124        }
1125    }
1126
1127    #[test]
1128    fn trait_object_dispatches_through_arc() {
1129        let v: BoxedCoherencePort = std::sync::Arc::new(NoopCoherenceValidator);
1130        let ctx = ctx_us();
1131        assert_eq!(v.evaluate(&ctx), CoherenceVerdict::Coherent);
1132    }
1133}