use std::future::Future;
use std::pin::Pin;
use tokio::sync::RwLock;
use tokio::time::{Duration, Instant};
use tracing::debug;
use vti_common::error::AppError;
use zeroize::Zeroizing;
use super::SeedStore;
type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
struct CacheEntry {
seed: Zeroizing<Vec<u8>>,
expires_at: Instant,
}
struct CacheState {
entry: Option<CacheEntry>,
generation: u64,
}
pub struct CachingSeedStore {
inner: Box<dyn SeedStore>,
ttl: Duration,
state: RwLock<CacheState>,
}
impl CachingSeedStore {
pub fn new(inner: Box<dyn SeedStore>, ttl: Duration) -> Self {
Self {
inner,
ttl,
state: RwLock::new(CacheState {
entry: None,
generation: 0,
}),
}
}
async fn invalidate(&self) {
let mut state = self.state.write().await;
state.entry = None;
state.generation = state.generation.wrapping_add(1);
}
}
impl SeedStore for CachingSeedStore {
fn get(&self) -> BoxFuture<'_, Result<Option<Vec<u8>>, AppError>> {
Box::pin(async move {
let generation_at_start = {
let state = self.state.read().await;
if let Some(ref entry) = state.entry
&& Instant::now() < entry.expires_at
{
return Ok(Some(entry.seed.to_vec()));
}
state.generation
};
let fetched = self.inner.get().await?;
let Some(seed) = fetched else {
let mut state = self.state.write().await;
state.entry = None;
return Ok(None);
};
{
let mut state = self.state.write().await;
if state.generation == generation_at_start {
state.entry = Some(CacheEntry {
seed: Zeroizing::new(seed.clone()),
expires_at: Instant::now() + self.ttl,
});
} else {
debug!("seed read raced a write — returning the value but not caching it");
}
}
Ok(Some(seed))
})
}
fn set(&self, secret: &[u8]) -> BoxFuture<'_, Result<(), AppError>> {
let secret = secret.to_vec();
Box::pin(async move {
self.invalidate().await;
let result = self.inner.set(&secret).await;
self.invalidate().await;
result
})
}
fn delete(&self) -> BoxFuture<'_, Result<(), AppError>> {
Box::pin(async move {
self.invalidate().await;
let result = self.inner.delete().await;
self.invalidate().await;
result
})
}
fn set_persists_across_restart(&self) -> bool {
self.inner.set_persists_across_restart()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
struct CountingStore {
seed: std::sync::Mutex<Option<Vec<u8>>>,
reads: Arc<AtomicUsize>,
}
impl CountingStore {
fn new(seed: Option<Vec<u8>>) -> (Self, Arc<AtomicUsize>) {
let reads = Arc::new(AtomicUsize::new(0));
(
Self {
seed: std::sync::Mutex::new(seed),
reads: reads.clone(),
},
reads,
)
}
}
impl SeedStore for CountingStore {
fn get(&self) -> BoxFuture<'_, Result<Option<Vec<u8>>, AppError>> {
Box::pin(async {
self.reads.fetch_add(1, Ordering::SeqCst);
Ok(self.seed.lock().expect("seed lock").clone())
})
}
fn set(&self, secret: &[u8]) -> BoxFuture<'_, Result<(), AppError>> {
let secret = secret.to_vec();
Box::pin(async move {
*self.seed.lock().expect("seed lock") = Some(secret);
Ok(())
})
}
fn delete(&self) -> BoxFuture<'_, Result<(), AppError>> {
Box::pin(async {
*self.seed.lock().expect("seed lock") = None;
Ok(())
})
}
}
fn cache(seed: Option<Vec<u8>>, ttl_secs: u64) -> (CachingSeedStore, Arc<AtomicUsize>) {
let (inner, reads) = CountingStore::new(seed);
(
CachingSeedStore::new(Box::new(inner), Duration::from_secs(ttl_secs)),
reads,
)
}
#[tokio::test(start_paused = true)]
async fn repeated_reads_within_ttl_hit_the_backend_once() {
let (store, reads) = cache(Some(vec![7u8; 32]), 60);
for _ in 0..50 {
assert_eq!(store.get().await.expect("get"), Some(vec![7u8; 32]));
}
assert_eq!(
reads.load(Ordering::SeqCst),
1,
"50 reads inside the TTL must consult the backend exactly once"
);
}
#[tokio::test(start_paused = true)]
async fn read_after_ttl_consults_the_backend_again() {
let (store, reads) = cache(Some(vec![1u8; 32]), 60);
store.get().await.expect("first get");
tokio::time::advance(Duration::from_secs(61)).await;
store.get().await.expect("second get");
assert_eq!(
reads.load(Ordering::SeqCst),
2,
"a read past the TTL must go back to the backend"
);
}
#[tokio::test(start_paused = true)]
async fn write_then_read_returns_the_new_seed_not_the_cached_one() {
let (store, _reads) = cache(Some(vec![0xAA; 32]), 3600);
assert_eq!(store.get().await.expect("prime"), Some(vec![0xAA; 32]));
store.set(&[0xBB; 32]).await.expect("set");
assert_eq!(
store.get().await.expect("read back"),
Some(vec![0xBB; 32]),
"the read after a write must observe the written seed, even well \
inside the TTL — this is the seed-rotation re-encryption path"
);
}
#[tokio::test(start_paused = true)]
async fn delete_invalidates_the_cached_seed() {
let (store, _reads) = cache(Some(vec![0xCC; 32]), 3600);
store.get().await.expect("prime");
store.delete().await.expect("delete");
assert_eq!(
store.get().await.expect("read back"),
None,
"a deleted seed must not keep being served from the cache"
);
}
#[tokio::test(start_paused = true)]
async fn absent_seed_is_never_cached() {
let (store, reads) = cache(None, 3600);
for _ in 0..3 {
assert_eq!(store.get().await.expect("get"), None);
}
assert_eq!(
reads.load(Ordering::SeqCst),
3,
"a missing secret must be re-checked every time, never cached"
);
}
#[tokio::test(start_paused = true)]
async fn persistence_flag_is_delegated_to_the_backend() {
struct Ephemeral;
impl SeedStore for Ephemeral {
fn get(&self) -> BoxFuture<'_, Result<Option<Vec<u8>>, AppError>> {
Box::pin(async { Ok(None) })
}
fn set(&self, _: &[u8]) -> BoxFuture<'_, Result<(), AppError>> {
Box::pin(async { Ok(()) })
}
fn set_persists_across_restart(&self) -> bool {
false
}
}
let store = CachingSeedStore::new(Box::new(Ephemeral), Duration::from_secs(60));
assert!(
!store.set_persists_across_restart(),
"the wrapper must not claim durability the backend disclaims"
);
}
}