use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::freshness::FreshnessReport;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextKind {
Top,
Iframe,
Worker,
}
impl ContextKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Top => "top",
Self::Iframe => "iframe",
Self::Worker => "worker",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdentitySurface {
#[serde(skip_serializing_if = "Option::is_none")]
pub user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub languages: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hardware_concurrency: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_memory: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_width: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screen_height: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color_depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webdriver: Option<bool>,
}
impl IdentitySurface {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.user_agent.is_none()
&& self.platform.is_none()
&& self.languages.is_none()
&& self.hardware_concurrency.is_none()
&& self.device_memory.is_none()
&& self.timezone.is_none()
&& self.screen_width.is_none()
&& self.screen_height.is_none()
&& self.color_depth.is_none()
&& self.webdriver.is_none()
}
#[must_use]
pub fn signature_parts(&self) -> Vec<String> {
vec![
self.user_agent.clone().unwrap_or_else(|| "-".to_string()),
self.platform.clone().unwrap_or_else(|| "-".to_string()),
self.languages.clone().unwrap_or_else(|| "-".to_string()),
self.timezone.clone().unwrap_or_else(|| "-".to_string()),
self.screen_width
.map_or_else(|| "-".to_string(), |v| v.to_string()),
self.screen_height
.map_or_else(|| "-".to_string(), |v| v.to_string()),
self.color_depth
.map_or_else(|| "-".to_string(), |v| v.to_string()),
]
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ContextObservation {
Observed {
surface: IdentitySurface,
},
Skipped {
reason: String,
},
}
impl ContextObservation {
#[must_use]
pub const fn observed(surface: IdentitySurface) -> Self {
Self::Observed { surface }
}
#[must_use]
pub fn skipped(reason: impl Into<String>) -> Self {
Self::Skipped {
reason: reason.into(),
}
}
#[must_use]
pub const fn is_observed(&self) -> bool {
matches!(self, Self::Observed { .. })
}
#[must_use]
pub const fn is_skipped(&self) -> bool {
matches!(self, Self::Skipped { .. })
}
#[must_use]
pub const fn surface(&self) -> Option<&IdentitySurface> {
match self {
Self::Observed { surface } => Some(surface),
Self::Skipped { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DriftSeverity {
Hard,
KnownLimitation,
}
impl DriftSeverity {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Hard => "hard",
Self::KnownLimitation => "known_limitation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DriftDiagnostic {
pub context_a: ContextKind,
pub context_b: ContextKind,
pub field: String,
pub observed_a: String,
pub observed_b: String,
pub severity: DriftSeverity,
}
impl DriftDiagnostic {
#[must_use]
pub fn reason_tag(&self) -> String {
format!(
"{}:{}:{}:{}",
self.context_a.label(),
self.context_b.label(),
self.field,
self.severity.label()
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoherenceDriftReport {
pub top: ContextObservation,
pub iframe: ContextObservation,
pub worker: ContextObservation,
pub drifts: Vec<DriftDiagnostic>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub freshness: Option<FreshnessReport>,
}
impl CoherenceDriftReport {
#[must_use]
pub const fn is_coherent(&self) -> bool {
self.drifts.is_empty()
}
#[must_use]
pub fn has_hard_drift(&self) -> bool {
self.drifts
.iter()
.any(|d| d.severity == DriftSeverity::Hard)
}
#[must_use]
pub fn observed_context_count(&self) -> usize {
[&self.top, &self.iframe, &self.worker]
.iter()
.filter(|o| o.is_observed())
.count()
}
#[must_use]
pub fn skipped_context_count(&self) -> usize {
[&self.top, &self.iframe, &self.worker]
.iter()
.filter(|o| o.is_skipped())
.count()
}
pub fn hard_drifts(&self) -> impl Iterator<Item = &DriftDiagnostic> {
self.drifts
.iter()
.filter(|d| d.severity == DriftSeverity::Hard)
}
pub fn known_limitations(&self) -> impl Iterator<Item = &DriftDiagnostic> {
self.drifts
.iter()
.filter(|d| d.severity == DriftSeverity::KnownLimitation)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContextPair {
TopIframe,
TopWorker,
IframeWorker,
}
impl ContextPair {
pub const ALL: [Self; 3] = [Self::TopIframe, Self::TopWorker, Self::IframeWorker];
#[must_use]
pub const fn sides(self) -> (ContextKind, ContextKind) {
match self {
Self::TopIframe => (ContextKind::Top, ContextKind::Iframe),
Self::TopWorker => (ContextKind::Top, ContextKind::Worker),
Self::IframeWorker => (ContextKind::Iframe, ContextKind::Worker),
}
}
}
const HARD_FIELDS: &[&str] = &["user_agent", "platform", "languages", "webdriver"];
#[allow(dead_code)]
const KNOWN_LIMITATION_FIELDS: &[&str] = &[
"hardware_concurrency",
"device_memory",
"screen_width",
"screen_height",
"color_depth",
"timezone",
];
#[must_use]
pub fn field_severity(field: &str) -> DriftSeverity {
if HARD_FIELDS.contains(&field) {
DriftSeverity::Hard
} else {
DriftSeverity::KnownLimitation
}
}
#[must_use]
pub fn diff_surfaces(
pair: ContextPair,
a: &IdentitySurface,
b: &IdentitySurface,
) -> Vec<DriftDiagnostic> {
let (kind_a, kind_b) = pair.sides();
let mut drifts = Vec::new();
let pairs: [(&str, Option<String>, Option<String>); 10] = [
("user_agent", a.user_agent.clone(), b.user_agent.clone()),
("platform", a.platform.clone(), b.platform.clone()),
("languages", a.languages.clone(), b.languages.clone()),
(
"hardware_concurrency",
a.hardware_concurrency.map(|v| v.to_string()),
b.hardware_concurrency.map(|v| v.to_string()),
),
(
"device_memory",
a.device_memory.map(|v| v.to_string()),
b.device_memory.map(|v| v.to_string()),
),
("timezone", a.timezone.clone(), b.timezone.clone()),
(
"screen_width",
a.screen_width.map(|v| v.to_string()),
b.screen_width.map(|v| v.to_string()),
),
(
"screen_height",
a.screen_height.map(|v| v.to_string()),
b.screen_height.map(|v| v.to_string()),
),
(
"color_depth",
a.color_depth.map(|v| v.to_string()),
b.color_depth.map(|v| v.to_string()),
),
(
"webdriver",
a.webdriver.map(|v| v.to_string()),
b.webdriver.map(|v| v.to_string()),
),
];
for (field, va, vb) in pairs {
if va == vb {
continue;
}
let observed_a = va.unwrap_or_else(|| "<absent>".to_string());
let observed_b = vb.unwrap_or_else(|| "<absent>".to_string());
drifts.push(DriftDiagnostic {
context_a: kind_a,
context_b: kind_b,
field: field.to_string(),
observed_a,
observed_b,
severity: field_severity(field),
});
}
drifts
}
#[must_use]
pub fn build_report(
top: ContextObservation,
iframe: ContextObservation,
worker: ContextObservation,
freshness: Option<FreshnessReport>,
) -> CoherenceDriftReport {
let mut drifts = Vec::new();
let observed = [
(ContextKind::Top, &top),
(ContextKind::Iframe, &iframe),
(ContextKind::Worker, &worker),
];
for pair in ContextPair::ALL {
let (ka, kb) = pair.sides();
let surface_a = observed
.iter()
.find(|(k, _)| *k == ka)
.and_then(|(_, o)| o.surface());
let surface_b = observed
.iter()
.find(|(k, _)| *k == kb)
.and_then(|(_, o)| o.surface());
if let (Some(sa), Some(sb)) = (surface_a, surface_b) {
drifts.extend(diff_surfaces(pair, sa, sb));
}
}
CoherenceDriftReport {
top,
iframe,
worker,
drifts,
freshness,
}
}
#[must_use]
pub fn surface_signature(surface: &IdentitySurface) -> String {
let parts = surface.signature_parts();
let borrowed: Vec<&str> = parts.iter().map(String::as_str).collect();
crate::freshness::signature_hash(&borrowed)
}
#[must_use]
pub fn signature_field_names() -> &'static BTreeSet<&'static str> {
static NAMES: std::sync::OnceLock<BTreeSet<&'static str>> = std::sync::OnceLock::new();
NAMES.get_or_init(|| {
[
"user_agent",
"platform",
"languages",
"timezone",
"screen_width",
"screen_height",
"color_depth",
]
.into_iter()
.collect()
})
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
use crate::freshness::{
DomainClass, FreshnessCheckInput, FreshnessContract, FreshnessPolicyKind,
};
use std::time::Duration;
fn surface_a() -> IdentitySurface {
IdentitySurface {
user_agent: Some("Mozilla/5.0".to_string()),
platform: Some("MacIntel".to_string()),
languages: Some("en-US,en".to_string()),
hardware_concurrency: Some(8),
device_memory: Some(8),
timezone: Some("America/Los_Angeles".to_string()),
screen_width: Some(1920),
screen_height: Some(1080),
color_depth: Some(24),
webdriver: Some(false),
}
}
fn surface_b_drift_ua_platform() -> IdentitySurface {
IdentitySurface {
user_agent: Some("Mozilla/4.0".to_string()),
platform: Some("Win32".to_string()),
languages: Some("en-US,en".to_string()),
hardware_concurrency: Some(8),
device_memory: Some(8),
timezone: Some("America/Los_Angeles".to_string()),
screen_width: Some(1920),
screen_height: Some(1080),
color_depth: Some(24),
webdriver: Some(false),
}
}
fn surface_worker_differs_in_hardware() -> IdentitySurface {
IdentitySurface {
user_agent: Some("Mozilla/5.0".to_string()),
platform: Some("MacIntel".to_string()),
languages: Some("en-US,en".to_string()),
hardware_concurrency: Some(2), device_memory: None, timezone: Some("America/Los_Angeles".to_string()),
screen_width: None, screen_height: None,
color_depth: None,
webdriver: Some(false),
}
}
#[test]
fn diff_surfaces_empty_when_identical() {
let a = surface_a();
let b = a.clone();
let drifts = diff_surfaces(ContextPair::TopIframe, &a, &b);
assert!(drifts.is_empty());
}
#[test]
fn diff_surfaces_emits_hard_drift_for_ua_and_platform() {
let a = surface_a();
let b = surface_b_drift_ua_platform();
let drifts = diff_surfaces(ContextPair::TopIframe, &a, &b);
let fields: Vec<&str> = drifts.iter().map(|d| d.field.as_str()).collect();
assert!(fields.contains(&"user_agent"));
assert!(fields.contains(&"platform"));
assert!(!fields.contains(&"languages"));
for d in &drifts {
assert_eq!(d.severity, DriftSeverity::Hard);
assert_eq!(d.context_a, ContextKind::Top);
assert_eq!(d.context_b, ContextKind::Iframe);
}
}
#[test]
fn diff_surfaces_classifies_worker_hardware_drift_as_known_limitation() {
let a = surface_a();
let b = surface_worker_differs_in_hardware();
let drifts = diff_surfaces(ContextPair::TopWorker, &a, &b);
let hardware: Vec<&DriftDiagnostic> = drifts
.iter()
.filter(|d| d.field == "hardware_concurrency")
.collect();
assert_eq!(hardware.len(), 1);
assert_eq!(hardware[0].severity, DriftSeverity::KnownLimitation);
let device_memory: Vec<&DriftDiagnostic> = drifts
.iter()
.filter(|d| d.field == "device_memory")
.collect();
assert_eq!(device_memory.len(), 1);
assert_eq!(device_memory[0].severity, DriftSeverity::KnownLimitation);
assert_eq!(device_memory[0].observed_b, "<absent>");
}
#[test]
fn build_report_skips_unavailable_contexts_without_panic() {
let top = ContextObservation::observed(surface_a());
let iframe = ContextObservation::skipped("iframe blocked by CSP");
let worker = ContextObservation::skipped("Worker unsupported");
let report = build_report(top, iframe, worker, None);
assert!(report.drifts.is_empty());
assert_eq!(report.observed_context_count(), 1);
assert_eq!(report.skipped_context_count(), 2);
assert!(report.is_coherent());
assert!(!report.has_hard_drift());
}
#[test]
fn build_report_flags_hard_drift_between_top_and_iframe() {
let top = ContextObservation::observed(surface_a());
let iframe = ContextObservation::observed(surface_b_drift_ua_platform());
let worker = ContextObservation::skipped("Worker unsupported");
let report = build_report(top, iframe, worker, None);
assert!(!report.is_coherent());
assert!(report.has_hard_drift());
let hard_count = report.hard_drifts().count();
assert!(hard_count >= 2); }
#[test]
fn build_report_flags_only_known_limitations_for_worker_drift() {
let top = ContextObservation::observed(surface_a());
let iframe = ContextObservation::observed(surface_a());
let worker = ContextObservation::observed(surface_worker_differs_in_hardware());
let report = build_report(top, iframe, worker, None);
let top_iframe_drift_exists = report
.drifts
.iter()
.any(|d| d.context_a == ContextKind::Top && d.context_b == ContextKind::Iframe);
assert!(!top_iframe_drift_exists);
for d in &report.drifts {
assert_eq!(d.severity, DriftSeverity::KnownLimitation);
}
assert!(!report.has_hard_drift());
assert!(report.known_limitations().count() > 0);
}
#[test]
fn surface_signature_is_deterministic_and_starts_with_fnv64() {
let a = surface_a();
let h1 = surface_signature(&a);
let h2 = surface_signature(&a);
assert_eq!(h1, h2);
assert!(h1.starts_with("fnv64:"));
}
#[test]
fn surface_signature_changes_with_user_agent() {
let a = surface_a();
let mut b = a.clone();
b.user_agent = Some("Mozilla/4.0".to_string());
assert_ne!(surface_signature(&a), surface_signature(&b));
}
#[test]
fn report_carries_freshness_when_supplied() {
let contract = FreshnessContract::with_signature(
"example.com",
surface_signature(&surface_a()).as_str(),
1_700_000_000_000,
Duration::from_mins(1),
FreshnessPolicyKind::Standard,
)
.expect("contract");
let input = FreshnessCheckInput::new(
"example.com",
Some(surface_signature(&surface_a()).as_str()),
1_700_000_030_000,
);
let report = build_report(
ContextObservation::observed(surface_a()),
ContextObservation::observed(surface_a()),
ContextObservation::skipped("Worker unsupported"),
Some(FreshnessReport::evaluate(&contract, &input)),
);
let fr = report
.freshness
.as_ref()
.expect("freshness report attached");
assert!(fr.decision.is_valid());
assert_eq!(fr.domain_class, DomainClass::Default);
}
#[test]
fn drift_reason_tag_is_stable() {
let d = DriftDiagnostic {
context_a: ContextKind::Top,
context_b: ContextKind::Worker,
field: "user_agent".to_string(),
observed_a: "a".to_string(),
observed_b: "b".to_string(),
severity: DriftSeverity::Hard,
};
assert_eq!(d.reason_tag(), "top:worker:user_agent:hard");
}
#[test]
fn context_kind_label_is_stable() {
assert_eq!(ContextKind::Top.label(), "top");
assert_eq!(ContextKind::Iframe.label(), "iframe");
assert_eq!(ContextKind::Worker.label(), "worker");
}
#[test]
fn context_observation_accessors() {
let o = ContextObservation::observed(IdentitySurface::default());
assert!(o.is_observed());
assert!(!o.is_skipped());
assert!(o.surface().is_some());
let s = ContextObservation::skipped("nope");
assert!(s.is_skipped());
assert!(!s.is_observed());
assert!(s.surface().is_none());
}
#[test]
fn empty_surface_reports_empty() {
let s = IdentitySurface::default();
assert!(s.is_empty());
let full = surface_a();
assert!(!full.is_empty());
}
#[test]
fn json_roundtrip_preserves_report() {
let report = build_report(
ContextObservation::observed(surface_a()),
ContextObservation::observed(surface_a()),
ContextObservation::skipped("Worker unsupported"),
None,
);
let json = serde_json::to_string(&report).expect("serialize");
let back: CoherenceDriftReport = serde_json::from_str(&json).expect("deserialize");
assert_eq!(report, back);
}
}