use std::{
collections::{BTreeMap, HashMap},
sync::{
Mutex, PoisonError,
atomic::{AtomicU64, Ordering},
},
};
use super::component::Stored;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FingerprintRecord {
pub components: BTreeMap<String, Stored>,
pub freshness: BTreeMap<String, u64>,
pub first_seen: u64,
pub last_seen: u64,
pub observation_count: u64,
}
pub trait FingerprintStore: Send + Sync {
fn observe(&self, visitor: &str, components: BTreeMap<String, Stored>, now_ms: u64) -> bool;
fn get(&self, visitor: &str) -> Option<FingerprintRecord>;
fn remove(&self, visitor: &str) -> bool;
fn purge_older_than(&self, _now_ms: u64, _max_age_ms: u64) -> u64 {
0
}
}
#[derive(Debug, Default)]
pub struct RecordStore {
records: Mutex<HashMap<String, FingerprintRecord>>,
max_records: Option<usize>,
record_ttl_ms: Option<u64>,
evicted: AtomicU64,
}
impl RecordStore {
pub fn new() -> Self {
Self::default()
}
pub fn with_capacity(max_records: Option<usize>, record_ttl_ms: Option<u64>) -> Self {
Self {
records: Mutex::new(HashMap::new()),
max_records,
record_ttl_ms,
evicted: AtomicU64::new(0),
}
}
pub fn observe(
&self,
visitor: &str,
components: BTreeMap<String, Stored>,
now_ms: u64,
) -> bool {
let mut records = self.lock();
let is_new = if let Some(record) = records.get_mut(visitor) {
for (name, value) in components {
record.freshness.insert(name.clone(), now_ms);
record.components.insert(name, value);
}
record.last_seen = now_ms;
record.observation_count += 1;
false
} else {
let freshness = components.keys().map(|k| (k.clone(), now_ms)).collect();
records.insert(
visitor.to_string(),
FingerprintRecord {
components,
freshness,
first_seen: now_ms,
last_seen: now_ms,
observation_count: 1,
},
);
true
};
self.evict(&mut records, visitor, now_ms);
is_new
}
fn evict(&self, records: &mut HashMap<String, FingerprintRecord>, visitor: &str, now_ms: u64) {
let mut evicted = 0u64;
if let Some(ttl) = self.record_ttl_ms {
let stale: Vec<String> = records
.iter()
.filter(|(_, r)| now_ms.saturating_sub(r.last_seen) > ttl)
.map(|(id, _)| id.clone())
.collect();
for id in stale {
records.remove(&id);
evicted += 1;
}
}
if let Some(cap) = self.max_records {
while records.len() > cap {
let Some(oldest) = records
.iter()
.filter(|(id, _)| id.as_str() != visitor)
.min_by_key(|(_, r)| r.last_seen)
.map(|(id, _)| id.clone())
else {
break;
};
records.remove(&oldest);
evicted += 1;
}
}
if evicted > 0 {
self.evicted.fetch_add(evicted, Ordering::Relaxed);
}
}
pub fn evicted(&self) -> u64 {
self.evicted.load(Ordering::Relaxed)
}
pub fn remove(&self, visitor: &str) -> bool {
self.lock().remove(visitor).is_some()
}
pub fn purge_older_than(&self, now_ms: u64, max_age_ms: u64) -> u64 {
let mut records = self.lock();
let stale: Vec<String> = records
.iter()
.filter(|(_, r)| now_ms.saturating_sub(r.last_seen) > max_age_ms)
.map(|(id, _)| id.clone())
.collect();
let mut purged = 0u64;
for id in stale {
records.remove(&id);
purged += 1;
}
if purged > 0 {
self.evicted.fetch_add(purged, Ordering::Relaxed);
}
purged
}
pub fn get(&self, visitor: &str) -> Option<FingerprintRecord> {
self.lock().get(visitor).cloned()
}
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.lock().is_empty()
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, FingerprintRecord>> {
self.records.lock().unwrap_or_else(PoisonError::into_inner)
}
}
impl FingerprintStore for RecordStore {
fn observe(&self, visitor: &str, components: BTreeMap<String, Stored>, now_ms: u64) -> bool {
RecordStore::observe(self, visitor, components, now_ms)
}
fn get(&self, visitor: &str) -> Option<FingerprintRecord> {
RecordStore::get(self, visitor)
}
fn remove(&self, visitor: &str) -> bool {
RecordStore::remove(self, visitor)
}
fn purge_older_than(&self, now_ms: u64, max_age_ms: u64) -> u64 {
RecordStore::purge_older_than(self, now_ms, max_age_ms)
}
}
#[cfg(test)]
mod tests {
use super::RecordStore;
use crate::fuzzy::component::{Salt, Stored};
use std::collections::BTreeMap;
fn category(salt: &Salt, name: &str, value: &str) -> BTreeMap<String, Stored> {
let mut map = BTreeMap::new();
map.insert(name.to_string(), Stored::Category(salt.hash(value)));
map
}
#[test]
fn first_observation_creates_record() {
let salt = Salt::random();
let store = RecordStore::new();
let is_new = store.observe("v1", category(&salt, "ua", "Chrome/120"), 1_000);
assert!(is_new);
let record = store.get("v1").unwrap();
assert_eq!(record.first_seen, 1_000);
assert_eq!(record.last_seen, 1_000);
assert_eq!(record.observation_count, 1);
assert_eq!(record.freshness.get("ua"), Some(&1_000));
}
#[test]
fn revisit_updates_recent_value_and_advances_counters() {
let salt = Salt::random();
let store = RecordStore::new();
store.observe("v1", category(&salt, "ua", "Chrome/120"), 1_000);
let is_new = store.observe("v1", category(&salt, "ua", "Chrome/121"), 2_000);
assert!(!is_new);
let record = store.get("v1").unwrap();
assert_eq!(record.first_seen, 1_000); assert_eq!(record.last_seen, 2_000); assert_eq!(record.observation_count, 2);
assert_eq!(
record.components.get("ua"),
Some(&Stored::Category(salt.hash("Chrome/121")))
);
assert_eq!(record.freshness.get("ua"), Some(&2_000));
}
#[test]
fn absent_components_retain_prior_freshness() {
let salt = Salt::random();
let store = RecordStore::new();
let mut both = category(&salt, "ua", "Chrome/120");
both.insert("tz".to_string(), Stored::Category(salt.hash("UTC")));
store.observe("v1", both, 1_000);
store.observe("v1", category(&salt, "ua", "Chrome/121"), 2_000);
let record = store.get("v1").unwrap();
assert_eq!(record.freshness.get("ua"), Some(&2_000));
assert_eq!(record.freshness.get("tz"), Some(&1_000));
}
#[test]
fn cap_evicts_oldest_and_counts() {
let salt = Salt::random();
let store = RecordStore::with_capacity(Some(3), None);
for i in 1..=5u64 {
store.observe(&format!("v{i}"), category(&salt, "ua", "x"), i * 1_000);
}
assert_eq!(store.len(), 3);
assert_eq!(store.evicted(), 2);
assert!(store.get("v1").is_none());
assert!(store.get("v2").is_none());
assert!(store.get("v3").is_some());
assert!(store.get("v4").is_some());
assert!(store.get("v5").is_some());
}
#[test]
fn ttl_drops_stale_entry_on_later_observe() {
let salt = Salt::random();
let store = RecordStore::with_capacity(None, Some(1_000));
store.observe("v1", category(&salt, "ua", "x"), 1_000);
assert_eq!(store.len(), 1);
store.observe("v2", category(&salt, "ua", "y"), 3_000);
assert!(store.get("v1").is_none());
assert!(store.get("v2").is_some());
assert_eq!(store.evicted(), 1);
}
#[test]
fn remove_erases_record_and_reports_prior_existence() {
let salt = Salt::random();
let store = RecordStore::new();
store.observe("v1", category(&salt, "ua", "x"), 1_000);
assert!(store.remove("v1"));
assert!(store.get("v1").is_none());
assert!(!store.remove("v1"));
}
#[test]
fn purge_older_than_drops_only_aged_records_and_counts() {
let salt = Salt::random();
let store = RecordStore::new();
store.observe("old", category(&salt, "ua", "x"), 1_000);
store.observe("fresh", category(&salt, "ua", "y"), 9_000);
let purged = store.purge_older_than(10_000, 5_000);
assert_eq!(purged, 1);
assert!(store.get("old").is_none());
assert!(store.get("fresh").is_some());
assert_eq!(store.evicted(), 1);
assert_eq!(store.purge_older_than(10_000, 5_000), 0);
}
#[test]
fn distinct_visitors_are_separate() {
let salt = Salt::random();
let store = RecordStore::new();
store.observe("v1", category(&salt, "ua", "x"), 1);
store.observe("v2", category(&salt, "ua", "y"), 1);
assert_eq!(store.len(), 2);
assert!(!store.is_empty());
}
}