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}