use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use secrecy::SecretString;
use crate::secret_path::SecretPath;
pub const DEFAULT_BASE_TTL: Duration = Duration::from_secs(15 * 60);
pub trait CacheClock: Send + Sync {
fn now(&self) -> Instant;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemClock;
impl CacheClock for SystemClock {
fn now(&self) -> Instant {
Instant::now()
}
}
#[derive(Debug, Clone)]
pub struct ManualClock(Arc<Mutex<Instant>>);
impl ManualClock {
pub fn new(initial: Instant) -> Self {
Self(Arc::new(Mutex::new(initial)))
}
pub fn advance(&self, delta: Duration) {
let mut g = self.0.lock().expect("ManualClock mutex poisoned");
*g += delta;
}
}
impl CacheClock for ManualClock {
fn now(&self) -> Instant {
*self.0.lock().expect("ManualClock mutex poisoned")
}
}
struct CacheEntry {
value: SecretString,
expires_at: Instant,
}
pub struct AdaptiveCache {
base_ttl: Duration,
clock: Arc<dyn CacheClock>,
entries: Mutex<HashMap<SecretPath, CacheEntry>>,
}
impl AdaptiveCache {
pub fn new(base_ttl: Duration) -> Self {
Self::with_clock(base_ttl, Arc::new(SystemClock))
}
pub fn with_clock(base_ttl: Duration, clock: Arc<dyn CacheClock>) -> Self {
Self {
base_ttl,
clock,
entries: Mutex::new(HashMap::new()),
}
}
pub fn base_ttl(&self) -> Duration {
self.base_ttl
}
pub fn clock(&self) -> &Arc<dyn CacheClock> {
&self.clock
}
pub fn get(&self, path: &SecretPath) -> Option<SecretString> {
let mut g = self.entries.lock().expect("AdaptiveCache mutex poisoned");
let now = self.clock.now();
let mut hit = None;
if let Some(entry) = g.get(path) {
if entry.expires_at > now {
hit = Some(entry.value.clone());
} else {
g.remove(path);
}
}
hit
}
pub fn put(
&self,
path: &SecretPath,
value: SecretString,
lease_duration: Option<Duration>,
max_ttl: Option<Duration>,
) -> bool {
let ttl = match self.effective_ttl(lease_duration, max_ttl) {
Some(t) => t,
None => return false,
};
let expires_at = self.clock.now() + ttl;
let mut g = self.entries.lock().expect("AdaptiveCache mutex poisoned");
g.insert(path.clone(), CacheEntry { value, expires_at });
true
}
fn effective_ttl(
&self,
lease_duration: Option<Duration>,
max_ttl: Option<Duration>,
) -> Option<Duration> {
let mut ttl = self.base_ttl;
if let Some(lease) = lease_duration {
if lease.is_zero() {
return None;
}
if lease < ttl {
ttl = lease;
}
}
if let Some(cap) = max_ttl
&& cap < ttl
{
ttl = cap;
}
if ttl.is_zero() { None } else { Some(ttl) }
}
pub fn invalidate(&self, path: &SecretPath) {
let mut g = self.entries.lock().expect("AdaptiveCache mutex poisoned");
g.remove(path);
}
pub fn invalidate_all(&self) {
let mut g = self.entries.lock().expect("AdaptiveCache mutex poisoned");
g.clear();
}
pub fn len(&self) -> usize {
self.entries
.lock()
.expect("AdaptiveCache mutex poisoned")
.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl std::fmt::Debug for AdaptiveCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let count = self.entries.lock().map(|g| g.len()).unwrap_or(0);
f.debug_struct("AdaptiveCache")
.field("base_ttl", &self.base_ttl)
.field("entries", &format!("<{count} redacted>"))
.field("clock", &"<dyn CacheClock>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
fn p(s: &str) -> SecretPath {
SecretPath::parse(s).unwrap()
}
fn manual_cache(base_ttl: Duration) -> (AdaptiveCache, ManualClock) {
let clock = ManualClock::new(Instant::now());
let cache = AdaptiveCache::with_clock(base_ttl, Arc::new(clock.clone()));
(cache, clock)
}
fn secret(s: &str) -> SecretString {
SecretString::from(s.to_owned())
}
fn exposed(v: &Option<SecretString>) -> Option<&str> {
v.as_ref().map(|s| s.expose_secret())
}
#[test]
fn put_then_get_returns_value_within_ttl() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("v"), None, None);
let got = cache.get(&p("a/b/c"));
assert_eq!(exposed(&got), Some("v"));
}
#[test]
fn missing_key_returns_none() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
assert!(cache.get(&p("a/b/c")).is_none());
}
#[test]
fn distinct_paths_do_not_collide() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("v1"), None, None);
cache.put(&p("d/e/f"), secret("v2"), None, None);
assert_eq!(exposed(&cache.get(&p("a/b/c"))), Some("v1"));
assert_eq!(exposed(&cache.get(&p("d/e/f"))), Some("v2"));
}
#[test]
fn put_overwrites_previous_value() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("v1"), None, None);
cache.put(&p("a/b/c"), secret("v2"), None, None);
assert_eq!(exposed(&cache.get(&p("a/b/c"))), Some("v2"));
}
#[test]
fn expired_entry_returns_none_and_is_evicted() {
let (cache, clock) = manual_cache(Duration::from_secs(10));
cache.put(&p("a/b/c"), secret("v"), None, None);
clock.advance(Duration::from_secs(11));
assert!(cache.get(&p("a/b/c")).is_none());
assert_eq!(cache.len(), 0);
}
#[test]
fn entry_at_exact_expiry_is_treated_as_expired() {
let (cache, clock) = manual_cache(Duration::from_secs(10));
cache.put(&p("a/b/c"), secret("v"), None, None);
clock.advance(Duration::from_secs(10));
assert!(cache.get(&p("a/b/c")).is_none());
}
#[test]
fn lease_duration_zero_disables_caching() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
let cached = cache.put(&p("a/b/c"), secret("v"), Some(Duration::from_secs(0)), None);
assert!(!cached, "lease_duration=0 must suppress caching");
assert!(cache.get(&p("a/b/c")).is_none());
}
#[test]
fn lease_below_base_lowers_effective_ttl() {
let (cache, clock) = manual_cache(Duration::from_secs(60));
cache.put(
&p("a/b/c"),
secret("v"),
Some(Duration::from_secs(10)),
None,
);
clock.advance(Duration::from_secs(11));
assert!(
cache.get(&p("a/b/c")).is_none(),
"lease=10s should evict at 11s"
);
}
#[test]
fn lease_above_base_does_not_extend_ttl() {
let (cache, clock) = manual_cache(Duration::from_secs(60));
cache.put(
&p("a/b/c"),
secret("v"),
Some(Duration::from_secs(3600)),
None,
);
clock.advance(Duration::from_secs(61));
assert!(
cache.get(&p("a/b/c")).is_none(),
"lease=3600s should NOT raise the 60s base TTL"
);
}
#[test]
fn max_ttl_cap_lowers_below_base() {
let (cache, clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("v"), None, Some(Duration::from_secs(5)));
clock.advance(Duration::from_secs(6));
assert!(
cache.get(&p("a/b/c")).is_none(),
"max_ttl=5s should evict at 6s"
);
}
#[test]
fn max_ttl_cap_does_not_raise_above_base() {
let (cache, clock) = manual_cache(Duration::from_secs(10));
cache.put(
&p("a/b/c"),
secret("v"),
None,
Some(Duration::from_secs(3600)),
);
clock.advance(Duration::from_secs(11));
assert!(
cache.get(&p("a/b/c")).is_none(),
"max_ttl=3600s with base=10s should still expire at 10s"
);
}
#[test]
fn lease_and_max_ttl_both_lower_taken_jointly() {
let (cache, clock) = manual_cache(Duration::from_secs(60));
cache.put(
&p("a/b/c"),
secret("v"),
Some(Duration::from_secs(30)),
Some(Duration::from_secs(10)),
);
clock.advance(Duration::from_secs(11));
assert!(cache.get(&p("a/b/c")).is_none());
}
#[test]
fn invalidate_drops_one_entry() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("v1"), None, None);
cache.put(&p("d/e/f"), secret("v2"), None, None);
cache.invalidate(&p("a/b/c"));
assert!(cache.get(&p("a/b/c")).is_none());
assert_eq!(exposed(&cache.get(&p("d/e/f"))), Some("v2"));
}
#[test]
fn invalidate_unknown_path_is_a_noop() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.invalidate(&p("a/b/c")); assert_eq!(cache.len(), 0);
}
#[test]
fn invalidate_all_drops_everything() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("v1"), None, None);
cache.put(&p("d/e/f"), secret("v2"), None, None);
cache.put(&p("g/h/i"), secret("v3"), None, None);
assert_eq!(cache.len(), 3);
cache.invalidate_all();
assert!(cache.is_empty());
}
#[test]
fn drop_clears_entries_and_releases_secret_strings() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("v"), None, None);
let entries_arc = std::sync::Arc::new(());
let weak = std::sync::Arc::downgrade(&entries_arc);
drop(entries_arc);
assert!(weak.upgrade().is_none());
drop(cache);
}
#[test]
fn debug_does_not_leak_plaintext() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
cache.put(&p("a/b/c"), secret("super-secret-value"), None, None);
let dbg = format!("{cache:?}");
assert!(!dbg.contains("super-secret-value"));
assert!(dbg.contains("AdaptiveCache"));
assert!(dbg.contains("redacted"));
}
#[test]
fn default_base_ttl_matches_adr_021_900_seconds() {
assert_eq!(DEFAULT_BASE_TTL, Duration::from_secs(15 * 60));
assert_eq!(DEFAULT_BASE_TTL.as_secs(), 900);
}
#[test]
fn base_ttl_accessor_returns_constructor_value() {
let cache = AdaptiveCache::new(Duration::from_secs(123));
assert_eq!(cache.base_ttl(), Duration::from_secs(123));
}
#[test]
fn len_counts_inserted_entries() {
let (cache, _clock) = manual_cache(Duration::from_secs(60));
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
cache.put(&p("a/b/c"), secret("v"), None, None);
assert_eq!(cache.len(), 1);
cache.put(&p("d/e/f"), secret("v2"), None, None);
assert_eq!(cache.len(), 2);
}
}