use std::collections::HashMap;
use std::hash::Hash;
use std::time::Duration;
use cratefield_core::Clock;
#[allow(clippy::disallowed_types)]
type Guarded<T> = std::sync::Mutex<T>;
struct Entry {
token: String,
minted_unix: i64,
ttl_secs: i64,
}
impl Entry {
fn fresh_at(&self, now_unix: i64) -> bool {
now_unix.saturating_sub(self.minted_unix) < self.ttl_secs
}
}
pub struct CachedToken<K = ()> {
ttl: Duration,
#[allow(clippy::disallowed_types)]
entries: Guarded<HashMap<K, Entry>>,
}
impl<K: Eq + Hash + Clone> CachedToken<K> {
pub const CAPACITY: usize = 64;
#[must_use]
pub fn new(ttl: Duration) -> Self {
Self {
ttl,
entries: Guarded::new(HashMap::new()),
}
}
#[must_use]
pub fn ttl(&self) -> Duration {
self.ttl
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.lock().expect("push-auth token cache").len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn get_or_mint(
&self,
clock: &dyn Clock,
key: &K,
mint: impl FnOnce(i64) -> String,
) -> String {
let now_unix = clock.now().unix_timestamp();
let ttl = self.ttl_secs();
let mut entries = self.entries.lock().expect("push-auth token cache");
if let Some(cached) = entries.get(key)
&& cached.fresh_at(now_unix)
{
return cached.token.clone();
}
let token = mint(now_unix);
Self::make_room(&mut entries, now_unix);
entries.insert(
key.clone(),
Entry {
token: token.clone(),
minted_unix: now_unix,
ttl_secs: ttl,
},
);
token
}
#[must_use]
pub fn cached(&self, clock: &dyn Clock, key: &K) -> Option<String> {
let now_unix = clock.now().unix_timestamp();
let entries = self.entries.lock().expect("push-auth token cache");
entries
.get(key)
.filter(|cached| cached.fresh_at(now_unix))
.map(|cached| cached.token.clone())
}
pub fn store(&self, clock: &dyn Clock, key: &K, token: impl Into<String>, lifetime: Duration) {
let now_unix = clock.now().unix_timestamp();
let ttl = self
.ttl_secs()
.min(i64::try_from(lifetime.as_secs()).unwrap_or(i64::MAX));
let mut entries = self.entries.lock().expect("push-auth token cache");
Self::make_room(&mut entries, now_unix);
entries.insert(
key.clone(),
Entry {
token: token.into(),
minted_unix: now_unix,
ttl_secs: ttl,
},
);
}
fn ttl_secs(&self) -> i64 {
i64::try_from(self.ttl.as_secs()).unwrap_or(i64::MAX)
}
fn make_room(entries: &mut HashMap<K, Entry>, now_unix: i64) {
entries.retain(|_, minted| minted.fresh_at(now_unix));
while entries.len() >= Self::CAPACITY {
let Some(oldest) = entries
.iter()
.min_by_key(|(_, minted)| minted.minted_unix)
.map(|(key, _)| key.clone())
else {
break;
};
entries.remove(&oldest);
}
}
pub fn invalidate(&self, key: &K) {
self.entries
.lock()
.expect("push-auth token cache")
.remove(key);
}
pub fn clear(&self) {
self.entries.lock().expect("push-auth token cache").clear();
}
}
impl<K: Eq + Hash + Clone> std::fmt::Debug for CachedToken<K> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let entries = self
.entries
.lock()
.map_or(0, |entries: std::sync::MutexGuard<'_, _>| entries.len());
f.debug_struct("CachedToken")
.field("ttl", &self.ttl)
.field("entries", &entries)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
struct StepClock(AtomicI64);
impl StepClock {
fn at(secs: i64) -> Self {
Self(AtomicI64::new(secs))
}
fn advance(&self, secs: i64) {
self.0.fetch_add(secs, Ordering::Relaxed);
}
}
#[async_trait::async_trait]
impl Clock for StepClock {
fn now(&self) -> time::OffsetDateTime {
time::OffsetDateTime::from_unix_timestamp(self.0.load(Ordering::Relaxed))
.expect("in range")
}
}
#[derive(Default)]
struct Minter(AtomicUsize);
impl Minter {
fn mint(&self) -> impl FnOnce(i64) -> String + '_ {
move |now| {
let n = self.0.fetch_add(1, Ordering::Relaxed);
format!("token-{n}-at-{now}")
}
}
fn count(&self) -> usize {
self.0.load(Ordering::Relaxed)
}
}
#[test]
fn reuses_within_the_ttl_and_remints_after() {
let clock = StepClock::at(1_000);
let minter = Minter::default();
let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
let first = cache.get_or_mint(&clock, &(), minter.mint());
assert_eq!(first, "token-0-at-1000");
clock.advance(2_400);
assert_eq!(cache.get_or_mint(&clock, &(), minter.mint()), first);
assert_eq!(minter.count(), 1, "reused within the TTL");
clock.advance(1_200);
let second = cache.get_or_mint(&clock, &(), minter.mint());
assert_ne!(second, first, "re-minted past the TTL");
assert_eq!(minter.count(), 2);
}
#[test]
fn invalidate_forces_a_remint() {
let clock = StepClock::at(0);
let minter = Minter::default();
let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
let first = cache.get_or_mint(&clock, &(), minter.mint());
cache.invalidate(&());
let second = cache.get_or_mint(&clock, &(), minter.mint());
assert_ne!(second, first);
assert_eq!(minter.count(), 2);
}
#[test]
fn a_key_gets_its_own_token() {
let clock = StepClock::at(0);
let minter = Minter::default();
let cache: CachedToken<String> = CachedToken::new(Duration::from_mins(50));
let chrome = "https://fcm.googleapis.com".to_owned();
let firefox = "https://updates.push.services.mozilla.com".to_owned();
let a = cache.get_or_mint(&clock, &chrome, minter.mint());
let b = cache.get_or_mint(&clock, &firefox, minter.mint());
assert_ne!(a, b, "VAPID `aud` differs per push service");
assert_eq!(minter.count(), 2);
assert_eq!(cache.get_or_mint(&clock, &chrome, minter.mint()), a);
assert_eq!(cache.get_or_mint(&clock, &firefox, minter.mint()), b);
assert_eq!(minter.count(), 2);
cache.invalidate(&chrome);
assert_ne!(cache.get_or_mint(&clock, &chrome, minter.mint()), a);
assert_eq!(cache.get_or_mint(&clock, &firefox, minter.mint()), b);
assert_eq!(minter.count(), 3);
cache.clear();
assert_ne!(cache.get_or_mint(&clock, &firefox, minter.mint()), b);
}
#[test]
fn a_race_on_a_cold_cache_mints_exactly_once() {
use std::sync::Barrier;
use std::sync::atomic::AtomicUsize;
const THREADS: usize = 8;
let clock = StepClock::at(1_000);
let mints = AtomicUsize::new(0);
let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
let gate = Barrier::new(THREADS);
let tokens: Vec<String> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..THREADS)
.map(|_| {
scope.spawn(|| {
gate.wait();
cache.get_or_mint(&clock, &(), |now| {
let n = mints.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
format!("token-{n}-at-{now}")
})
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().expect("thread"))
.collect()
});
assert_eq!(
mints.load(Ordering::SeqCst),
1,
"a cold-cache race must present one provider token, not {THREADS}"
);
assert!(
tokens.windows(2).all(|pair| pair[0] == pair[1]),
"every racing caller gets the same token: {tokens:?}"
);
}
#[test]
fn expired_entries_are_pruned_and_the_map_is_bounded() {
let clock = StepClock::at(0);
let minter = Minter::default();
let cache: CachedToken<String> = CachedToken::new(Duration::from_mins(50));
for n in 0..10 {
cache.get_or_mint(&clock, &format!("https://push-{n}.example"), minter.mint());
}
assert_eq!(cache.len(), 10);
clock.advance(4_000); cache.get_or_mint(&clock, &"https://fresh.example".to_owned(), minter.mint());
assert_eq!(cache.len(), 1, "the ten expired entries were pruned");
for n in 0..(CachedToken::<String>::CAPACITY * 2) {
cache.get_or_mint(&clock, &format!("https://live-{n}.example"), minter.mint());
assert!(
cache.len() <= CachedToken::<String>::CAPACITY,
"cache grew past its bound at {n}: {}",
cache.len()
);
}
assert_eq!(cache.len(), CachedToken::<String>::CAPACITY);
assert!(!cache.is_empty());
}
#[test]
fn cached_reads_without_minting_and_store_writes() {
let clock = StepClock::at(1_000);
let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(55));
assert_eq!(cache.cached(&clock, &()), None, "nothing cached yet");
assert!(cache.is_empty(), "a miss must not insert anything");
cache.store(&clock, &(), "ya29.bearer", Duration::from_mins(55));
assert_eq!(cache.cached(&clock, &()), Some("ya29.bearer".to_owned()));
clock.advance(3_299);
assert_eq!(cache.cached(&clock, &()), Some("ya29.bearer".to_owned()));
clock.advance(1);
assert_eq!(cache.cached(&clock, &()), None, "past its lifetime");
}
#[test]
fn a_stored_lifetime_is_capped_by_the_caches_own_ttl() {
let clock = StepClock::at(0);
let cache: CachedToken<()> = CachedToken::new(Duration::from_secs(600));
cache.store(&clock, &(), "long", Duration::from_secs(3_600));
clock.advance(600);
assert_eq!(cache.cached(&clock, &()), None, "capped at the cache TTL");
cache.store(&clock, &(), "short", Duration::from_secs(30));
clock.advance(30);
assert_eq!(cache.cached(&clock, &()), None, "the provider said 30s");
cache.store(&clock, &(), "already-stale", Duration::ZERO);
assert_eq!(cache.cached(&clock, &()), None);
}
#[test]
fn invalidate_drops_a_stored_token_too() {
let clock = StepClock::at(0);
let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
cache.store(&clock, &(), "ya29.bearer", Duration::from_mins(55));
cache.invalidate(&());
assert_eq!(
cache.cached(&clock, &()),
None,
"a 401 drops the cache so the next send re-exchanges"
);
}
#[test]
fn stored_entries_are_pruned_and_bounded_like_minted_ones() {
let clock = StepClock::at(0);
let cache: CachedToken<String> = CachedToken::new(Duration::from_mins(50));
for n in 0..(CachedToken::<String>::CAPACITY * 2) {
cache.store(
&clock,
&format!("account-{n}"),
format!("token-{n}"),
Duration::from_mins(55),
);
assert!(
cache.len() <= CachedToken::<String>::CAPACITY,
"cache grew past its bound at {n}: {}",
cache.len()
);
}
}
#[test]
fn the_two_halves_share_one_entry() {
let clock = StepClock::at(0);
let minter = Minter::default();
let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
cache.store(&clock, &(), "exchanged", Duration::from_mins(55));
assert_eq!(cache.get_or_mint(&clock, &(), minter.mint()), "exchanged");
assert_eq!(minter.count(), 0, "nothing was minted over a live entry");
cache.invalidate(&());
let signed = cache.get_or_mint(&clock, &(), minter.mint());
assert_eq!(cache.cached(&clock, &()), Some(signed));
}
#[test]
fn a_clock_that_goes_backwards_does_not_remint_forever() {
let clock = StepClock::at(10_000);
let minter = Minter::default();
let cache: CachedToken<()> = CachedToken::new(Duration::from_mins(50));
let first = cache.get_or_mint(&clock, &(), minter.mint());
clock.advance(-9_000);
assert_eq!(cache.get_or_mint(&clock, &(), minter.mint()), first);
assert_eq!(minter.count(), 1);
}
}