use std::net::IpAddr;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct IsoCountry(String);
impl IsoCountry {
#[must_use]
pub fn new(raw: &str) -> Option<Self> {
let upper = raw.trim().to_ascii_uppercase();
if upper.len() == 2 && upper.chars().all(|c| c.is_ascii_alphabetic()) {
Some(Self(upper))
} else {
None
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn eq_ignore_ascii_case(&self, other: &str) -> bool {
self.0.eq_ignore_ascii_case(other)
}
}
impl std::fmt::Display for IsoCountry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Tz(String);
impl Tz {
#[must_use]
pub fn new(raw: &str) -> Option<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Some(Self(trimmed.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn region(&self) -> Option<&str> {
self.0.split('/').next()
}
#[must_use]
pub fn city(&self) -> Option<&str> {
self.0.split_once('/').map(|(_, city)| city)
}
}
impl std::fmt::Display for Tz {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Locale(String);
impl Locale {
#[must_use]
pub fn new(raw: &str) -> Option<Self> {
let normalized = raw.trim().replace('_', "-");
let (lang, region) = normalized.split_once('-')?;
let lang = lang.to_ascii_lowercase();
let region = region.to_ascii_uppercase();
if lang.len() < 2
|| !lang.chars().all(|c| c.is_ascii_alphabetic())
|| region.len() != 2
|| !region.chars().all(|c| c.is_ascii_alphabetic())
{
return None;
}
Some(Self(format!("{lang}-{region}")))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn language(&self) -> &str {
self.0.split_once('-').map_or(&self.0, |(l, _)| l)
}
#[must_use]
pub fn region(&self) -> Option<&str> {
self.0.split_once('-').map(|(_, r)| r)
}
}
impl std::fmt::Display for Locale {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct AcceptLanguage(String);
impl AcceptLanguage {
#[must_use]
pub fn new(raw: &str) -> Option<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Some(Self(trimmed.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn primary_region(&self) -> Option<Locale> {
let primary = self.0.split(',').next()?;
let tag = primary.split(';').next()?.trim();
Locale::new(tag)
}
}
impl std::fmt::Display for AcceptLanguage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MismatchField {
ProxyGeoVsDns,
WebRtcPublicIp,
Timezone,
Locale,
AcceptLanguage,
}
impl MismatchField {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::ProxyGeoVsDns => "proxy_geo_vs_dns",
Self::WebRtcPublicIp => "web_rtc_public_ip",
Self::Timezone => "timezone",
Self::Locale => "locale",
Self::AcceptLanguage => "accept_language",
}
}
}
impl std::fmt::Display for MismatchField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MismatchSeverity {
Advisory,
Hard,
}
impl MismatchSeverity {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Advisory => "advisory",
Self::Hard => "hard",
}
}
#[must_use]
pub const fn is_hard(self) -> bool {
matches!(self, Self::Hard)
}
}
impl std::fmt::Display for MismatchSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "outcome")]
pub enum CoherenceVerdict {
Coherent,
Mismatch {
field: MismatchField,
severity: MismatchSeverity,
},
Unknown(String),
}
impl CoherenceVerdict {
#[must_use]
pub fn unknown(reason: &'static str) -> Self {
Self::Unknown(reason.to_owned())
}
#[must_use]
pub const fn unknown_reason(&self) -> Option<&str> {
match self {
Self::Unknown(reason) => Some(reason.as_str()),
_ => None,
}
}
#[must_use]
pub const fn is_coherent(&self) -> bool {
matches!(self, Self::Coherent)
}
#[must_use]
pub const fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown(_))
}
}
impl std::fmt::Display for CoherenceVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Coherent => f.write_str("coherent"),
Self::Mismatch { field, severity } => {
write!(f, "mismatch:{severity}:{field}")
}
Self::Unknown(reason) => write!(f, "unknown:{reason}"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CoherencePolicy {
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
hard_fail_on: std::collections::BTreeSet<MismatchField>,
}
impl CoherencePolicy {
#[must_use]
pub const fn advisory() -> Self {
Self {
hard_fail_on: std::collections::BTreeSet::new(),
}
}
#[must_use]
pub fn hard_fail_on(field: MismatchField) -> Self {
let mut hard_fail_on = std::collections::BTreeSet::new();
hard_fail_on.insert(field);
Self { hard_fail_on }
}
#[must_use]
pub fn with_hard_fail(mut self, field: MismatchField) -> Self {
self.hard_fail_on.insert(field);
self
}
#[must_use]
pub fn contains(&self, field: MismatchField) -> bool {
self.hard_fail_on.contains(&field)
}
#[must_use]
pub fn is_hard_fail(&self, field: MismatchField) -> bool {
self.contains(field)
}
#[must_use]
pub fn is_advisory_only(&self) -> bool {
self.hard_fail_on.is_empty()
}
#[must_use]
pub fn severity(&self, field: MismatchField) -> MismatchSeverity {
if self.hard_fail_on.contains(&field) {
MismatchSeverity::Hard
} else {
MismatchSeverity::Advisory
}
}
#[must_use]
pub fn hard_fail_count(&self) -> usize {
self.hard_fail_on.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CoherenceContext {
pub proxy_geo_country: Option<IsoCountry>,
pub dns_resolver_country: Option<IsoCountry>,
pub browser_locale: Locale,
pub browser_timezone: Tz,
pub accept_language: AcceptLanguage,
pub webrtc_local_ip: Option<IpAddr>,
pub webrtc_public_ip: Option<IpAddr>,
pub proxy_ip: Option<IpAddr>,
}
impl CoherenceContext {
#[must_use]
pub fn same_slash_16(a: IpAddr, b: IpAddr) -> Option<bool> {
let (IpAddr::V4(a), IpAddr::V4(b)) = (a, b) else {
return None;
};
let a_prefix = u32::from(a) >> 16;
let b_prefix = u32::from(b) >> 16;
Some(a_prefix == b_prefix)
}
#[must_use]
pub fn evaluate(&self) -> CoherenceVerdict {
CoherenceVerdict::unknown("no_coherence_validator")
}
}
pub trait CoherencePort: Send + Sync + 'static {
fn evaluate(&self, ctx: &CoherenceContext) -> CoherenceVerdict;
}
pub type BoxedCoherencePort = std::sync::Arc<dyn CoherencePort>;
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use std::str::FromStr;
fn us_country() -> IsoCountry {
IsoCountry::new("US").unwrap()
}
fn en_us_locale() -> Locale {
Locale::new("en-US").unwrap()
}
fn ny_tz() -> Tz {
Tz::new("America/New_York").unwrap()
}
fn en_us_al() -> AcceptLanguage {
AcceptLanguage::new("en-US,en;q=0.9").unwrap()
}
fn ctx_us() -> CoherenceContext {
CoherenceContext {
proxy_geo_country: Some(us_country()),
dns_resolver_country: Some(us_country()),
browser_locale: en_us_locale(),
browser_timezone: ny_tz(),
accept_language: en_us_al(),
webrtc_local_ip: None,
webrtc_public_ip: Some(IpAddr::from_str("192.0.2.42").unwrap()),
proxy_ip: Some(IpAddr::from_str("192.0.2.7").unwrap()),
}
}
#[test]
fn iso_country_normalises_case() {
assert_eq!(IsoCountry::new("us").unwrap().as_str(), "US");
assert_eq!(IsoCountry::new("Gb").unwrap().as_str(), "GB");
}
#[test]
fn iso_country_rejects_invalid_lengths() {
assert!(IsoCountry::new("USA").is_none());
assert!(IsoCountry::new("U").is_none());
assert!(IsoCountry::new("").is_none());
}
#[test]
fn iso_country_rejects_non_alpha() {
assert!(IsoCountry::new("U1").is_none());
assert!(IsoCountry::new("12").is_none());
}
#[test]
fn iso_country_eq_ignore_ascii_case_works() {
let us = us_country();
assert!(us.eq_ignore_ascii_case("us"));
assert!(us.eq_ignore_ascii_case("US"));
assert!(us.eq_ignore_ascii_case("Us"));
assert!(!us.eq_ignore_ascii_case("GB"));
}
#[test]
fn iso_country_round_trips_through_json() {
let us = us_country();
let json = serde_json::to_string(&us).expect("serialize");
assert_eq!(json, "\"US\"");
let parsed: IsoCountry = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, us);
}
#[test]
fn tz_region_and_city() {
let tz = ny_tz();
assert_eq!(tz.region(), Some("America"));
assert_eq!(tz.city(), Some("New_York"));
}
#[test]
fn tz_rejects_empty() {
assert!(Tz::new("").is_none());
assert!(Tz::new(" ").is_none());
}
#[test]
fn tz_round_trips_through_json() {
let tz = ny_tz();
let json = serde_json::to_string(&tz).expect("serialize");
let parsed: Tz = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, tz);
}
#[test]
fn locale_normalises_case_and_underscore() {
let l = Locale::new("en_us").unwrap();
assert_eq!(l.as_str(), "en-US");
assert_eq!(l.language(), "en");
assert_eq!(l.region(), Some("US"));
}
#[test]
fn locale_rejects_bare_language_tag() {
assert!(Locale::new("en").is_none());
assert!(Locale::new("EN").is_none());
}
#[test]
fn locale_rejects_malformed_region() {
assert!(Locale::new("en-USA").is_none());
assert!(Locale::new("en-U1").is_none());
}
#[test]
fn locale_round_trips_through_json() {
let l = en_us_locale();
let json = serde_json::to_string(&l).expect("serialize");
let parsed: Locale = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, l);
}
#[test]
fn accept_language_primary_region() {
let al = en_us_al();
let primary = al.primary_region().unwrap();
assert_eq!(primary.as_str(), "en-US");
}
#[test]
fn accept_language_strips_quality_value() {
let al = AcceptLanguage::new("fr-FR;q=0.8").unwrap();
assert_eq!(al.primary_region().unwrap().as_str(), "fr-FR");
}
#[test]
fn accept_language_rejects_empty() {
assert!(AcceptLanguage::new("").is_none());
}
#[test]
fn accept_language_bare_language_tag_yields_none_primary() {
let al = AcceptLanguage::new("en;q=1.0").unwrap();
assert!(al.primary_region().is_none());
}
#[test]
fn mismatch_field_labels_are_stable() {
assert_eq!(MismatchField::ProxyGeoVsDns.label(), "proxy_geo_vs_dns");
assert_eq!(MismatchField::WebRtcPublicIp.label(), "web_rtc_public_ip");
assert_eq!(MismatchField::Timezone.label(), "timezone");
assert_eq!(MismatchField::Locale.label(), "locale");
assert_eq!(MismatchField::AcceptLanguage.label(), "accept_language");
}
#[test]
fn mismatch_severity_labels_are_stable() {
assert_eq!(MismatchSeverity::Advisory.label(), "advisory");
assert_eq!(MismatchSeverity::Hard.label(), "hard");
assert!(!MismatchSeverity::Advisory.is_hard());
assert!(MismatchSeverity::Hard.is_hard());
}
#[test]
fn verdict_display_is_stable() {
assert_eq!(CoherenceVerdict::Coherent.to_string(), "coherent");
let v = CoherenceVerdict::Mismatch {
field: MismatchField::ProxyGeoVsDns,
severity: MismatchSeverity::Hard,
};
assert_eq!(v.to_string(), "mismatch:hard:proxy_geo_vs_dns");
let v = CoherenceVerdict::unknown("missing_dns");
assert_eq!(v.to_string(), "unknown:missing_dns");
assert_eq!(v.unknown_reason(), Some("missing_dns"));
}
#[test]
fn verdict_is_coherent_and_unknown_helpers() {
assert!(CoherenceVerdict::Coherent.is_coherent());
assert!(!CoherenceVerdict::Coherent.is_unknown());
assert!(CoherenceVerdict::unknown("x").is_unknown());
assert!(!CoherenceVerdict::unknown("x").is_coherent());
}
#[test]
fn verdict_round_trips_through_json() {
let v = CoherenceVerdict::Mismatch {
field: MismatchField::WebRtcPublicIp,
severity: MismatchSeverity::Hard,
};
let json = serde_json::to_string(&v).expect("serialize");
let parsed: CoherenceVerdict = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, v);
}
#[test]
fn coherent_verdict_round_trips_through_json() {
let v = CoherenceVerdict::Coherent;
let json = serde_json::to_string(&v).expect("serialize");
let parsed: CoherenceVerdict = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, v);
}
#[test]
fn advisory_policy_blocks_nothing() {
let p = CoherencePolicy::advisory();
assert!(p.is_advisory_only());
assert!(!p.is_hard_fail(MismatchField::ProxyGeoVsDns));
assert_eq!(
p.severity(MismatchField::ProxyGeoVsDns),
MismatchSeverity::Advisory
);
assert_eq!(p.hard_fail_count(), 0);
}
#[test]
fn hard_fail_on_policy_blocks_a_single_field() {
let p = CoherencePolicy::hard_fail_on(MismatchField::WebRtcPublicIp);
assert!(p.is_hard_fail(MismatchField::WebRtcPublicIp));
assert!(!p.is_hard_fail(MismatchField::ProxyGeoVsDns));
assert_eq!(
p.severity(MismatchField::WebRtcPublicIp),
MismatchSeverity::Hard
);
assert_eq!(
p.severity(MismatchField::Timezone),
MismatchSeverity::Advisory
);
assert_eq!(p.hard_fail_count(), 1);
assert!(!p.is_advisory_only());
}
#[test]
fn with_hard_fail_accumulates() {
let p = CoherencePolicy::advisory()
.with_hard_fail(MismatchField::ProxyGeoVsDns)
.with_hard_fail(MismatchField::Timezone);
assert!(p.is_hard_fail(MismatchField::ProxyGeoVsDns));
assert!(p.is_hard_fail(MismatchField::Timezone));
assert!(!p.is_hard_fail(MismatchField::Locale));
assert_eq!(p.hard_fail_count(), 2);
}
#[test]
fn policy_round_trips_through_json() {
let p = CoherencePolicy::advisory()
.with_hard_fail(MismatchField::ProxyGeoVsDns)
.with_hard_fail(MismatchField::WebRtcPublicIp);
let json = serde_json::to_string(&p).expect("serialize");
let parsed: CoherencePolicy = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, p);
}
#[test]
fn empty_policy_round_trips_through_json() {
let p = CoherencePolicy::advisory();
let json = serde_json::to_string(&p).expect("serialize");
assert!(!json.contains("hard_fail_on"));
let parsed: CoherencePolicy = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, p);
}
#[test]
fn slash_16_agrees_within_prefix() {
let a = IpAddr::from_str("192.0.2.42").unwrap();
let b = IpAddr::from_str("192.0.2.7").unwrap();
let c = IpAddr::from_str("203.0.113.5").unwrap();
assert_eq!(CoherenceContext::same_slash_16(a, b), Some(true));
assert_eq!(CoherenceContext::same_slash_16(a, c), Some(false));
}
#[test]
fn slash_16_returns_none_for_ipv6() {
let v4 = IpAddr::from_str("192.0.2.42").unwrap();
let v6 = IpAddr::from_str("2001:db8::1").unwrap();
assert_eq!(CoherenceContext::same_slash_16(v4, v6), None);
}
#[test]
fn evaluate_with_no_validator_returns_unknown() {
let ctx = ctx_us();
assert!(matches!(
ctx.evaluate(),
CoherenceVerdict::Unknown(_) if ctx.evaluate().unknown_reason() == Some("no_coherence_validator")
));
}
#[test]
fn context_round_trips_through_json() {
let ctx = ctx_us();
let json = serde_json::to_string(&ctx).expect("serialize");
let parsed: CoherenceContext = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed, ctx);
}
#[test]
fn boxed_coherence_port_is_object_safe() {
fn _assert_object_safe(_: BoxedCoherencePort) {}
let _boxed: BoxedCoherencePort = std::sync::Arc::new(NoopCoherenceValidator);
}
#[derive(Debug)]
struct NoopCoherenceValidator;
impl CoherencePort for NoopCoherenceValidator {
fn evaluate(&self, _: &CoherenceContext) -> CoherenceVerdict {
CoherenceVerdict::Coherent
}
}
#[test]
fn trait_object_dispatches_through_arc() {
let v: BoxedCoherencePort = std::sync::Arc::new(NoopCoherenceValidator);
let ctx = ctx_us();
assert_eq!(v.evaluate(&ctx), CoherenceVerdict::Coherent);
}
}