use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const MAX_MAP_ENTRIES: usize = 1024;
#[derive(Debug, Clone, Copy)]
pub struct SessionAnchor {
pub token: u64,
pub ac: u32,
pub issued_at: Instant,
}
#[derive(Default)]
pub struct SessionAnchors {
by_uid: Mutex<HashMap<String, SessionAnchor>>,
}
impl SessionAnchors {
pub fn new() -> Self {
Self::default()
}
pub fn issue(&self, uid: &str, token: u64, ac: u32, now: Instant) -> bool {
let mut map = self.by_uid.lock().expect("SessionAnchors mutex poisoned");
if map.len() >= MAX_MAP_ENTRIES && !map.contains_key(uid) {
return false;
}
map.insert(
uid.to_string(),
SessionAnchor {
token,
ac,
issued_at: now,
},
);
true
}
pub fn purge_stale(&self, now: Instant, stale_after: Duration) -> Vec<String> {
let mut map = self.by_uid.lock().expect("SessionAnchors mutex poisoned");
let stale: Vec<String> = map
.iter()
.filter(|(_, a)| now.saturating_duration_since(a.issued_at) > stale_after)
.map(|(uid, _)| uid.clone())
.collect();
for uid in &stale {
map.remove(uid);
}
stale
}
pub fn lookup(&self, uid: &str, now: Instant, stale_after: Duration) -> Option<SessionAnchor> {
self.by_uid
.lock()
.expect("SessionAnchors mutex poisoned")
.get(uid)
.copied()
.filter(|a| now.saturating_duration_since(a.issued_at) <= stale_after)
}
pub fn len(&self) -> usize {
self.by_uid
.lock()
.expect("SessionAnchors mutex poisoned")
.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[derive(Debug, Clone)]
pub struct CameraEntry {
pub addr: SocketAddr,
pub token: u64,
pub last_seen: Instant,
}
#[derive(Default)]
pub struct CameraRegistry {
cameras: Mutex<HashMap<String, CameraEntry>>,
}
impl CameraRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn upsert(&self, uid: &str, addr: SocketAddr, token: u64, now: Instant) -> Option<bool> {
let mut map = self.cameras.lock().expect("CameraRegistry mutex poisoned");
if map.len() >= MAX_MAP_ENTRIES && !map.contains_key(uid) {
return None;
}
let prev = map.insert(
uid.to_string(),
CameraEntry {
addr,
token,
last_seen: now,
},
);
Some(prev.is_none())
}
pub fn purge_stale(&self, now: Instant, stale_after: Duration) -> Vec<String> {
let mut map = self.cameras.lock().expect("CameraRegistry mutex poisoned");
let stale: Vec<String> = map
.iter()
.filter(|(_, e)| now.saturating_duration_since(e.last_seen) > stale_after)
.map(|(uid, _)| uid.clone())
.collect();
for uid in &stale {
map.remove(uid);
}
stale
}
pub fn lookup_fresh(
&self,
uid: &str,
now: Instant,
stale_after: Duration,
) -> Option<CameraEntry> {
let map = self.cameras.lock().expect("CameraRegistry mutex poisoned");
let fresh = |e: &CameraEntry| now.saturating_duration_since(e.last_seen) <= stale_after;
if let Some(e) = map.get(uid).filter(|e| fresh(e)) {
return Some(e.clone());
}
map.iter()
.find(|(stored, e)| stored.starts_with(uid) && fresh(e))
.map(|(_, e)| e.clone())
}
pub fn lookup_by_ip(
&self,
ip: std::net::IpAddr,
now: Instant,
stale_after: Duration,
) -> Option<(String, CameraEntry)> {
let map = self.cameras.lock().expect("CameraRegistry mutex poisoned");
map.iter()
.find(|(_, e)| {
e.addr.ip() == ip && now.saturating_duration_since(e.last_seen) <= stale_after
})
.map(|(uid, e)| (uid.clone(), e.clone()))
}
pub fn len(&self) -> usize {
self.cameras
.lock()
.expect("CameraRegistry mutex poisoned")
.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::SocketAddr;
fn addr(s: &str) -> SocketAddr {
s.parse().unwrap()
}
fn now() -> Instant {
Instant::now()
}
#[test]
fn upsert_then_lookup_returns_entry() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("UID1", addr("10.0.0.1:5000"), 42, t);
let e = reg
.lookup_fresh("UID1", t, Duration::from_secs(60))
.unwrap();
assert_eq!(e.addr, addr("10.0.0.1:5000"));
assert_eq!(e.token, 42);
}
#[test]
fn lookup_missing_returns_none() {
let reg = CameraRegistry::new();
assert!(reg
.lookup_fresh("NOPE", now(), Duration::from_secs(60))
.is_none());
}
#[test]
fn upsert_overwrites_address_and_token() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("UID1", addr("10.0.0.1:5000"), 42, t);
let t2 = t + Duration::from_secs(1);
reg.upsert("UID1", addr("10.0.0.2:6000"), 99, t2);
let e = reg
.lookup_fresh("UID1", t2, Duration::from_secs(60))
.unwrap();
assert_eq!(e.addr, addr("10.0.0.2:6000"));
assert_eq!(e.token, 99);
}
#[test]
fn lookup_returns_none_past_ttl() {
let reg = CameraRegistry::new();
let inserted = now();
reg.upsert("UID1", addr("10.0.0.1:5000"), 1, inserted);
let later = inserted + Duration::from_secs(120);
let stale = Duration::from_secs(60);
assert!(reg.lookup_fresh("UID1", later, stale).is_none());
}
#[test]
fn lookup_at_ttl_boundary_returns_some() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("UID1", addr("10.0.0.1:5000"), 1, t);
let stale = Duration::from_secs(60);
let on_edge = t + stale;
assert!(reg.lookup_fresh("UID1", on_edge, stale).is_some());
}
#[test]
fn is_empty_returns_true_when_unpopulated_then_false_after_upsert() {
let reg = CameraRegistry::new();
assert!(reg.is_empty());
reg.upsert("UID1", addr("10.0.0.1:5000"), 1, now());
assert!(!reg.is_empty());
}
#[test]
fn lookup_by_ip_finds_entry_when_ip_matches() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("UID1", addr("10.0.0.1:5000"), 1, t);
reg.upsert("UID2", addr("10.0.0.2:6000"), 2, t);
let (uid, entry) = reg
.lookup_by_ip("10.0.0.2".parse().unwrap(), t, Duration::from_secs(60))
.expect("entry for 10.0.0.2");
assert_eq!(uid, "UID2");
assert_eq!(entry.token, 2);
}
#[test]
fn lookup_by_ip_returns_none_when_no_ip_matches() {
let reg = CameraRegistry::new();
reg.upsert("UID1", addr("10.0.0.1:5000"), 1, now());
assert!(reg
.lookup_by_ip("10.0.0.9".parse().unwrap(), now(), Duration::from_secs(60))
.is_none());
}
#[test]
fn lookup_by_ip_skips_stale_entries() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("UID1", addr("10.0.0.1:5000"), 1, t);
let later = t + Duration::from_secs(120);
assert!(reg
.lookup_by_ip("10.0.0.1".parse().unwrap(), later, Duration::from_secs(60))
.is_none());
}
#[test]
fn len_tracks_distinct_uids() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("A", addr("10.0.0.1:1"), 1, t);
reg.upsert("B", addr("10.0.0.2:2"), 2, t);
reg.upsert("A", addr("10.0.0.3:3"), 3, t);
assert_eq!(reg.len(), 2);
}
#[test]
fn upsert_returns_some_true_on_first_insert_some_false_on_refresh() {
let reg = CameraRegistry::new();
let t = now();
assert_eq!(reg.upsert("UID1", addr("10.0.0.1:1"), 1, t), Some(true));
assert_eq!(reg.upsert("UID1", addr("10.0.0.1:1"), 2, t), Some(false));
assert_eq!(reg.upsert("UID2", addr("10.0.0.2:2"), 3, t), Some(true));
}
#[test]
fn upsert_rejects_new_uid_when_at_capacity_but_allows_refresh() {
let reg = CameraRegistry::new();
let t = now();
for i in 0..MAX_MAP_ENTRIES {
let uid = format!("UID{i}");
assert_eq!(
reg.upsert(&uid, addr(&format!("10.0.0.{}:1", i % 250 + 1)), 1, t),
Some(true)
);
}
assert_eq!(reg.upsert("OVERFLOW", addr("10.0.0.1:1"), 1, t), None);
assert_eq!(
reg.upsert("UID0", addr("10.0.0.1:1"), 99, t + Duration::from_secs(1)),
Some(false)
);
}
#[test]
fn purge_stale_evicts_only_past_ttl_and_returns_uids() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("OLD", addr("10.0.0.1:1"), 1, t);
reg.upsert("FRESH", addr("10.0.0.2:2"), 2, t + Duration::from_secs(50));
let later = t + Duration::from_secs(80);
let evicted = reg.purge_stale(later, Duration::from_secs(60));
assert_eq!(evicted, vec!["OLD".to_string()]);
assert_eq!(reg.len(), 1);
assert!(reg
.lookup_fresh("FRESH", later, Duration::from_secs(60))
.is_some());
}
#[test]
fn purge_stale_is_noop_when_nothing_is_stale() {
let reg = CameraRegistry::new();
let t = now();
reg.upsert("UID1", addr("10.0.0.1:1"), 1, t);
assert!(reg.purge_stale(t, Duration::from_secs(60)).is_empty());
assert_eq!(reg.len(), 1);
}
}