1use std::collections::BTreeSet;
30
31use serde::{Deserialize, Serialize};
32
33use crate::freshness::FreshnessReport;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ContextKind {
41 Top,
43 Iframe,
45 Worker,
47}
48
49impl ContextKind {
50 #[must_use]
52 pub const fn label(self) -> &'static str {
53 match self {
54 Self::Top => "top",
55 Self::Iframe => "iframe",
56 Self::Worker => "worker",
57 }
58 }
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
88pub struct IdentitySurface {
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub user_agent: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub platform: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub languages: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub hardware_concurrency: Option<u32>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub device_memory: Option<u32>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub timezone: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub screen_width: Option<u32>,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub screen_height: Option<u32>,
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub color_depth: Option<u32>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub webdriver: Option<bool>,
119}
120
121impl IdentitySurface {
122 #[must_use]
125 pub const fn is_empty(&self) -> bool {
126 self.user_agent.is_none()
127 && self.platform.is_none()
128 && self.languages.is_none()
129 && self.hardware_concurrency.is_none()
130 && self.device_memory.is_none()
131 && self.timezone.is_none()
132 && self.screen_width.is_none()
133 && self.screen_height.is_none()
134 && self.color_depth.is_none()
135 && self.webdriver.is_none()
136 }
137
138 #[must_use]
146 pub fn signature_parts(&self) -> Vec<String> {
147 vec![
148 self.user_agent.clone().unwrap_or_else(|| "-".to_string()),
149 self.platform.clone().unwrap_or_else(|| "-".to_string()),
150 self.languages.clone().unwrap_or_else(|| "-".to_string()),
151 self.timezone.clone().unwrap_or_else(|| "-".to_string()),
152 self.screen_width
153 .map_or_else(|| "-".to_string(), |v| v.to_string()),
154 self.screen_height
155 .map_or_else(|| "-".to_string(), |v| v.to_string()),
156 self.color_depth
157 .map_or_else(|| "-".to_string(), |v| v.to_string()),
158 ]
159 }
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(tag = "status", rename_all = "snake_case")]
169pub enum ContextObservation {
170 Observed {
173 surface: IdentitySurface,
175 },
176 Skipped {
179 reason: String,
181 },
182}
183
184impl ContextObservation {
185 #[must_use]
187 pub const fn observed(surface: IdentitySurface) -> Self {
188 Self::Observed { surface }
189 }
190
191 #[must_use]
193 pub fn skipped(reason: impl Into<String>) -> Self {
194 Self::Skipped {
195 reason: reason.into(),
196 }
197 }
198
199 #[must_use]
202 pub const fn is_observed(&self) -> bool {
203 matches!(self, Self::Observed { .. })
204 }
205
206 #[must_use]
209 pub const fn is_skipped(&self) -> bool {
210 matches!(self, Self::Skipped { .. })
211 }
212
213 #[must_use]
215 pub const fn surface(&self) -> Option<&IdentitySurface> {
216 match self {
217 Self::Observed { surface } => Some(surface),
218 Self::Skipped { .. } => None,
219 }
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub enum DriftSeverity {
229 Hard,
232 KnownLimitation,
235}
236
237impl DriftSeverity {
238 #[must_use]
240 pub const fn label(self) -> &'static str {
241 match self {
242 Self::Hard => "hard",
243 Self::KnownLimitation => "known_limitation",
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct DriftDiagnostic {
252 pub context_a: ContextKind,
254 pub context_b: ContextKind,
256 pub field: String,
258 pub observed_a: String,
260 pub observed_b: String,
262 pub severity: DriftSeverity,
264}
265
266impl DriftDiagnostic {
267 #[must_use]
269 pub fn reason_tag(&self) -> String {
270 format!(
271 "{}:{}:{}:{}",
272 self.context_a.label(),
273 self.context_b.label(),
274 self.field,
275 self.severity.label()
276 )
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314pub struct CoherenceDriftReport {
315 pub top: ContextObservation,
317 pub iframe: ContextObservation,
319 pub worker: ContextObservation,
321 pub drifts: Vec<DriftDiagnostic>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub freshness: Option<FreshnessReport>,
327}
328
329impl CoherenceDriftReport {
330 #[must_use]
334 pub const fn is_coherent(&self) -> bool {
335 self.drifts.is_empty()
336 }
337
338 #[must_use]
341 pub fn has_hard_drift(&self) -> bool {
342 self.drifts
343 .iter()
344 .any(|d| d.severity == DriftSeverity::Hard)
345 }
346
347 #[must_use]
349 pub fn observed_context_count(&self) -> usize {
350 [&self.top, &self.iframe, &self.worker]
351 .iter()
352 .filter(|o| o.is_observed())
353 .count()
354 }
355
356 #[must_use]
358 pub fn skipped_context_count(&self) -> usize {
359 [&self.top, &self.iframe, &self.worker]
360 .iter()
361 .filter(|o| o.is_skipped())
362 .count()
363 }
364
365 pub fn hard_drifts(&self) -> impl Iterator<Item = &DriftDiagnostic> {
367 self.drifts
368 .iter()
369 .filter(|d| d.severity == DriftSeverity::Hard)
370 }
371
372 pub fn known_limitations(&self) -> impl Iterator<Item = &DriftDiagnostic> {
374 self.drifts
375 .iter()
376 .filter(|d| d.severity == DriftSeverity::KnownLimitation)
377 }
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
384pub enum ContextPair {
385 TopIframe,
387 TopWorker,
389 IframeWorker,
391}
392
393impl ContextPair {
394 pub const ALL: [Self; 3] = [Self::TopIframe, Self::TopWorker, Self::IframeWorker];
396
397 #[must_use]
399 pub const fn sides(self) -> (ContextKind, ContextKind) {
400 match self {
401 Self::TopIframe => (ContextKind::Top, ContextKind::Iframe),
402 Self::TopWorker => (ContextKind::Top, ContextKind::Worker),
403 Self::IframeWorker => (ContextKind::Iframe, ContextKind::Worker),
404 }
405 }
406}
407
408const HARD_FIELDS: &[&str] = &["user_agent", "platform", "languages", "webdriver"];
410
411#[allow(dead_code)]
414const KNOWN_LIMITATION_FIELDS: &[&str] = &[
415 "hardware_concurrency",
416 "device_memory",
417 "screen_width",
418 "screen_height",
419 "color_depth",
420 "timezone",
421];
422
423#[must_use]
425pub fn field_severity(field: &str) -> DriftSeverity {
426 if HARD_FIELDS.contains(&field) {
427 DriftSeverity::Hard
428 } else {
429 DriftSeverity::KnownLimitation
430 }
431}
432
433#[must_use]
440pub fn diff_surfaces(
441 pair: ContextPair,
442 a: &IdentitySurface,
443 b: &IdentitySurface,
444) -> Vec<DriftDiagnostic> {
445 let (kind_a, kind_b) = pair.sides();
446 let mut drifts = Vec::new();
447
448 let pairs: [(&str, Option<String>, Option<String>); 10] = [
449 ("user_agent", a.user_agent.clone(), b.user_agent.clone()),
450 ("platform", a.platform.clone(), b.platform.clone()),
451 ("languages", a.languages.clone(), b.languages.clone()),
452 (
453 "hardware_concurrency",
454 a.hardware_concurrency.map(|v| v.to_string()),
455 b.hardware_concurrency.map(|v| v.to_string()),
456 ),
457 (
458 "device_memory",
459 a.device_memory.map(|v| v.to_string()),
460 b.device_memory.map(|v| v.to_string()),
461 ),
462 ("timezone", a.timezone.clone(), b.timezone.clone()),
463 (
464 "screen_width",
465 a.screen_width.map(|v| v.to_string()),
466 b.screen_width.map(|v| v.to_string()),
467 ),
468 (
469 "screen_height",
470 a.screen_height.map(|v| v.to_string()),
471 b.screen_height.map(|v| v.to_string()),
472 ),
473 (
474 "color_depth",
475 a.color_depth.map(|v| v.to_string()),
476 b.color_depth.map(|v| v.to_string()),
477 ),
478 (
479 "webdriver",
480 a.webdriver.map(|v| v.to_string()),
481 b.webdriver.map(|v| v.to_string()),
482 ),
483 ];
484
485 for (field, va, vb) in pairs {
486 if va == vb {
487 continue;
488 }
489 let observed_a = va.unwrap_or_else(|| "<absent>".to_string());
493 let observed_b = vb.unwrap_or_else(|| "<absent>".to_string());
494 drifts.push(DriftDiagnostic {
495 context_a: kind_a,
496 context_b: kind_b,
497 field: field.to_string(),
498 observed_a,
499 observed_b,
500 severity: field_severity(field),
501 });
502 }
503
504 drifts
505}
506
507#[must_use]
513pub fn build_report(
514 top: ContextObservation,
515 iframe: ContextObservation,
516 worker: ContextObservation,
517 freshness: Option<FreshnessReport>,
518) -> CoherenceDriftReport {
519 let mut drifts = Vec::new();
520 let observed = [
521 (ContextKind::Top, &top),
522 (ContextKind::Iframe, &iframe),
523 (ContextKind::Worker, &worker),
524 ];
525
526 for pair in ContextPair::ALL {
527 let (ka, kb) = pair.sides();
528 let surface_a = observed
529 .iter()
530 .find(|(k, _)| *k == ka)
531 .and_then(|(_, o)| o.surface());
532 let surface_b = observed
533 .iter()
534 .find(|(k, _)| *k == kb)
535 .and_then(|(_, o)| o.surface());
536 if let (Some(sa), Some(sb)) = (surface_a, surface_b) {
537 drifts.extend(diff_surfaces(pair, sa, sb));
538 }
539 }
540
541 CoherenceDriftReport {
542 top,
543 iframe,
544 worker,
545 drifts,
546 freshness,
547 }
548}
549
550#[must_use]
575pub fn surface_signature(surface: &IdentitySurface) -> String {
576 let parts = surface.signature_parts();
577 let borrowed: Vec<&str> = parts.iter().map(String::as_str).collect();
578 crate::freshness::signature_hash(&borrowed)
579}
580
581#[must_use]
584pub fn signature_field_names() -> &'static BTreeSet<&'static str> {
585 static NAMES: std::sync::OnceLock<BTreeSet<&'static str>> = std::sync::OnceLock::new();
589 NAMES.get_or_init(|| {
590 [
591 "user_agent",
592 "platform",
593 "languages",
594 "timezone",
595 "screen_width",
596 "screen_height",
597 "color_depth",
598 ]
599 .into_iter()
600 .collect()
601 })
602}
603
604#[cfg(test)]
607#[allow(
608 clippy::unwrap_used,
609 clippy::expect_used,
610 clippy::panic,
611 clippy::indexing_slicing
612)]
613mod tests {
614 use super::*;
615 use crate::freshness::{
616 DomainClass, FreshnessCheckInput, FreshnessContract, FreshnessPolicyKind,
617 };
618 use std::time::Duration;
619
620 fn surface_a() -> IdentitySurface {
621 IdentitySurface {
622 user_agent: Some("Mozilla/5.0".to_string()),
623 platform: Some("MacIntel".to_string()),
624 languages: Some("en-US,en".to_string()),
625 hardware_concurrency: Some(8),
626 device_memory: Some(8),
627 timezone: Some("America/Los_Angeles".to_string()),
628 screen_width: Some(1920),
629 screen_height: Some(1080),
630 color_depth: Some(24),
631 webdriver: Some(false),
632 }
633 }
634
635 fn surface_b_drift_ua_platform() -> IdentitySurface {
636 IdentitySurface {
637 user_agent: Some("Mozilla/4.0".to_string()),
638 platform: Some("Win32".to_string()),
639 languages: Some("en-US,en".to_string()),
640 hardware_concurrency: Some(8),
641 device_memory: Some(8),
642 timezone: Some("America/Los_Angeles".to_string()),
643 screen_width: Some(1920),
644 screen_height: Some(1080),
645 color_depth: Some(24),
646 webdriver: Some(false),
647 }
648 }
649
650 fn surface_worker_differs_in_hardware() -> IdentitySurface {
651 IdentitySurface {
652 user_agent: Some("Mozilla/5.0".to_string()),
653 platform: Some("MacIntel".to_string()),
654 languages: Some("en-US,en".to_string()),
655 hardware_concurrency: Some(2), device_memory: None, timezone: Some("America/Los_Angeles".to_string()),
658 screen_width: None, screen_height: None,
660 color_depth: None,
661 webdriver: Some(false),
662 }
663 }
664
665 #[test]
666 fn diff_surfaces_empty_when_identical() {
667 let a = surface_a();
668 let b = a.clone();
669 let drifts = diff_surfaces(ContextPair::TopIframe, &a, &b);
670 assert!(drifts.is_empty());
671 }
672
673 #[test]
674 fn diff_surfaces_emits_hard_drift_for_ua_and_platform() {
675 let a = surface_a();
676 let b = surface_b_drift_ua_platform();
677 let drifts = diff_surfaces(ContextPair::TopIframe, &a, &b);
678 let fields: Vec<&str> = drifts.iter().map(|d| d.field.as_str()).collect();
679 assert!(fields.contains(&"user_agent"));
680 assert!(fields.contains(&"platform"));
681 assert!(!fields.contains(&"languages"));
682 for d in &drifts {
684 assert_eq!(d.severity, DriftSeverity::Hard);
685 assert_eq!(d.context_a, ContextKind::Top);
686 assert_eq!(d.context_b, ContextKind::Iframe);
687 }
688 }
689
690 #[test]
691 fn diff_surfaces_classifies_worker_hardware_drift_as_known_limitation() {
692 let a = surface_a();
693 let b = surface_worker_differs_in_hardware();
694 let drifts = diff_surfaces(ContextPair::TopWorker, &a, &b);
695 let hardware: Vec<&DriftDiagnostic> = drifts
696 .iter()
697 .filter(|d| d.field == "hardware_concurrency")
698 .collect();
699 assert_eq!(hardware.len(), 1);
700 assert_eq!(hardware[0].severity, DriftSeverity::KnownLimitation);
701
702 let device_memory: Vec<&DriftDiagnostic> = drifts
703 .iter()
704 .filter(|d| d.field == "device_memory")
705 .collect();
706 assert_eq!(device_memory.len(), 1);
707 assert_eq!(device_memory[0].severity, DriftSeverity::KnownLimitation);
708 assert_eq!(device_memory[0].observed_b, "<absent>");
710 }
711
712 #[test]
713 fn build_report_skips_unavailable_contexts_without_panic() {
714 let top = ContextObservation::observed(surface_a());
715 let iframe = ContextObservation::skipped("iframe blocked by CSP");
716 let worker = ContextObservation::skipped("Worker unsupported");
717 let report = build_report(top, iframe, worker, None);
718 assert!(report.drifts.is_empty());
720 assert_eq!(report.observed_context_count(), 1);
721 assert_eq!(report.skipped_context_count(), 2);
722 assert!(report.is_coherent());
723 assert!(!report.has_hard_drift());
724 }
725
726 #[test]
727 fn build_report_flags_hard_drift_between_top_and_iframe() {
728 let top = ContextObservation::observed(surface_a());
729 let iframe = ContextObservation::observed(surface_b_drift_ua_platform());
730 let worker = ContextObservation::skipped("Worker unsupported");
731 let report = build_report(top, iframe, worker, None);
732 assert!(!report.is_coherent());
733 assert!(report.has_hard_drift());
734 let hard_count = report.hard_drifts().count();
735 assert!(hard_count >= 2); }
737
738 #[test]
739 fn build_report_flags_only_known_limitations_for_worker_drift() {
740 let top = ContextObservation::observed(surface_a());
741 let iframe = ContextObservation::observed(surface_a());
742 let worker = ContextObservation::observed(surface_worker_differs_in_hardware());
743 let report = build_report(top, iframe, worker, None);
744 let top_iframe_drift_exists = report
746 .drifts
747 .iter()
748 .any(|d| d.context_a == ContextKind::Top && d.context_b == ContextKind::Iframe);
749 assert!(!top_iframe_drift_exists);
750 for d in &report.drifts {
753 assert_eq!(d.severity, DriftSeverity::KnownLimitation);
754 }
755 assert!(!report.has_hard_drift());
756 assert!(report.known_limitations().count() > 0);
757 }
758
759 #[test]
760 fn surface_signature_is_deterministic_and_starts_with_fnv64() {
761 let a = surface_a();
762 let h1 = surface_signature(&a);
763 let h2 = surface_signature(&a);
764 assert_eq!(h1, h2);
765 assert!(h1.starts_with("fnv64:"));
766 }
767
768 #[test]
769 fn surface_signature_changes_with_user_agent() {
770 let a = surface_a();
771 let mut b = a.clone();
772 b.user_agent = Some("Mozilla/4.0".to_string());
773 assert_ne!(surface_signature(&a), surface_signature(&b));
774 }
775
776 #[test]
777 fn report_carries_freshness_when_supplied() {
778 let contract = FreshnessContract::with_signature(
779 "example.com",
780 surface_signature(&surface_a()).as_str(),
781 1_700_000_000_000,
782 Duration::from_mins(1),
783 FreshnessPolicyKind::Standard,
784 )
785 .expect("contract");
786 let input = FreshnessCheckInput::new(
787 "example.com",
788 Some(surface_signature(&surface_a()).as_str()),
789 1_700_000_030_000,
790 );
791 let report = build_report(
792 ContextObservation::observed(surface_a()),
793 ContextObservation::observed(surface_a()),
794 ContextObservation::skipped("Worker unsupported"),
795 Some(FreshnessReport::evaluate(&contract, &input)),
796 );
797 let fr = report
798 .freshness
799 .as_ref()
800 .expect("freshness report attached");
801 assert!(fr.decision.is_valid());
802 assert_eq!(fr.domain_class, DomainClass::Default);
803 }
804
805 #[test]
806 fn drift_reason_tag_is_stable() {
807 let d = DriftDiagnostic {
808 context_a: ContextKind::Top,
809 context_b: ContextKind::Worker,
810 field: "user_agent".to_string(),
811 observed_a: "a".to_string(),
812 observed_b: "b".to_string(),
813 severity: DriftSeverity::Hard,
814 };
815 assert_eq!(d.reason_tag(), "top:worker:user_agent:hard");
816 }
817
818 #[test]
819 fn context_kind_label_is_stable() {
820 assert_eq!(ContextKind::Top.label(), "top");
821 assert_eq!(ContextKind::Iframe.label(), "iframe");
822 assert_eq!(ContextKind::Worker.label(), "worker");
823 }
824
825 #[test]
826 fn context_observation_accessors() {
827 let o = ContextObservation::observed(IdentitySurface::default());
828 assert!(o.is_observed());
829 assert!(!o.is_skipped());
830 assert!(o.surface().is_some());
831
832 let s = ContextObservation::skipped("nope");
833 assert!(s.is_skipped());
834 assert!(!s.is_observed());
835 assert!(s.surface().is_none());
836 }
837
838 #[test]
839 fn empty_surface_reports_empty() {
840 let s = IdentitySurface::default();
841 assert!(s.is_empty());
842 let full = surface_a();
843 assert!(!full.is_empty());
844 }
845
846 #[test]
847 fn json_roundtrip_preserves_report() {
848 let report = build_report(
849 ContextObservation::observed(surface_a()),
850 ContextObservation::observed(surface_a()),
851 ContextObservation::skipped("Worker unsupported"),
852 None,
853 );
854 let json = serde_json::to_string(&report).expect("serialize");
855 let back: CoherenceDriftReport = serde_json::from_str(&json).expect("deserialize");
856 assert_eq!(report, back);
857 }
858}