Skip to main content

keyhog_verifier/
cache.rs

1//! Verification cache: avoids re-verifying the same credential across scans.
2//!
3//! Stores `(credential_hash, detector_id) -> (result, expiry)` mappings.
4//! TTLs matter because live/dead status changes over time, and the cache stores
5//! only hashes so plaintext credentials are not retained in memory longer than needed.
6
7use std::collections::HashMap;
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use dashmap::DashMap;
13use keyhog_core::VerificationResult;
14use sha2::{Digest, Sha256};
15
16/// Bounded in-memory cache for verification outcomes.
17///
18/// # Examples
19///
20/// ```rust
21/// use keyhog_verifier::cache::VerificationCache;
22/// use std::time::Duration;
23///
24/// let cache = VerificationCache::new(Duration::from_secs(60));
25/// assert!(cache.is_empty());
26/// ```
27pub struct VerificationCache {
28    /// Sharded concurrent map. DashMap (per-shard locking, default 64 shards
29    /// based on parallelism) replaces the previous single global RwLock so
30    /// concurrent `get`/`put` calls touch different shards and never block
31    /// each other on cacheline bouncing - see audits/legendary-2026-04-26.
32    entries: DashMap<CacheKey, CacheEntry>,
33    inserts: AtomicUsize,
34    max_entries: usize,
35    ttl: Duration,
36    /// Concurrent FIFO queue for fast eviction of the oldest entries
37    /// without locking all DashMap shards.
38    queue: parking_lot::Mutex<std::collections::VecDeque<CacheKey>>,
39}
40
41#[derive(Hash, Eq, PartialEq, Clone)]
42struct CacheKey {
43    credential_hash: [u8; VerificationCache::HASH_BYTES],
44    detector_id_hash: [u8; VerificationCache::HASH_BYTES],
45    detector_id: Arc<str>,
46}
47
48struct CacheEntry {
49    result: VerificationResult,
50    metadata: HashMap<String, String>,
51    expires_at: Instant,
52}
53
54impl VerificationCache {
55    const DEFAULT_TTL_SECS: u64 = 300;
56    const DEFAULT_MAX_ENTRIES: usize = 10_000;
57    const EVICTION_INTERVAL: usize = 64;
58    pub(crate) const HASH_BYTES: usize = 32;
59    const MAX_DETECTOR_ID_BYTES: usize = 128;
60    const MAX_METADATA_ENTRIES: usize = 16;
61    const MAX_METADATA_KEY_BYTES: usize = 64;
62    const MAX_METADATA_VALUE_BYTES: usize = 256;
63
64    /// Create a new cache with the given TTL.
65    ///
66    /// # Examples
67    ///
68    /// ```rust
69    /// use keyhog_verifier::cache::VerificationCache;
70    /// use std::time::Duration;
71    ///
72    /// let cache = VerificationCache::new(Duration::from_secs(60));
73    /// assert!(cache.is_empty());
74    /// ```
75    pub fn new(ttl: Duration) -> Self {
76        Self::with_max_entries(ttl, Self::DEFAULT_MAX_ENTRIES)
77    }
78
79    /// Create a new cache with the given TTL and an explicit size bound.
80    ///
81    /// # Examples
82    ///
83    /// ```rust
84    /// use keyhog_verifier::cache::VerificationCache;
85    /// use std::time::Duration;
86    ///
87    /// let cache = VerificationCache::with_max_entries(Duration::from_secs(60), 32);
88    /// assert!(cache.is_empty());
89    /// ```
90    pub fn with_max_entries(ttl: Duration, max_entries: usize) -> Self {
91        Self {
92            entries: DashMap::new(),
93            inserts: AtomicUsize::new(0),
94            max_entries: max_entries.max(1),
95            ttl,
96            queue: parking_lot::Mutex::new(std::collections::VecDeque::new()),
97        }
98    }
99
100    /// Default cache: 5 minute TTL.
101    ///
102    /// # Examples
103    ///
104    /// ```rust
105    /// use keyhog_verifier::cache::VerificationCache;
106    ///
107    /// let cache = VerificationCache::default_ttl();
108    /// assert!(cache.is_empty());
109    /// ```
110    pub fn default_ttl() -> Self {
111        Self::new(Duration::from_secs(Self::DEFAULT_TTL_SECS))
112    }
113
114    /// Look up a cached result.
115    ///
116    /// # Examples
117    ///
118    /// ```rust
119    /// use keyhog_core::VerificationResult;
120    /// use keyhog_verifier::cache::VerificationCache;
121    /// use std::collections::HashMap;
122    /// use std::time::Duration;
123    ///
124    /// let cache = VerificationCache::new(Duration::from_secs(60));
125    /// cache.put("secret", "detector", VerificationResult::Live, HashMap::new());
126    /// assert!(cache.get("secret", "detector").is_some());
127    /// ```
128    pub fn get(
129        &self,
130        credential: &str,
131        detector_id: &str,
132    ) -> Option<(VerificationResult, HashMap<String, String>)> {
133        let key = cache_key(credential, detector_id);
134        let now = Instant::now();
135
136        // Per-shard read: O(1) hash, lock just one shard. Hot path for
137        // unexpired entries. `?` on `Option` returns None for a miss; an
138        // expired hit falls through to the eviction path below.
139        let entry = self.entries.get(&key)?;
140        if now < entry.expires_at {
141            return Some((entry.result.clone(), entry.metadata.clone()));
142        }
143        drop(entry);
144
145        // Expired: lock the shard for removal. dashmap's Entry API gives us
146        // CAS-style replacement so concurrent writers don't double-evict.
147        if let dashmap::mapref::entry::Entry::Occupied(entry) = self.entries.entry(key) {
148            if now >= entry.get().expires_at {
149                entry.remove();
150            } else {
151                let entry = entry.get();
152                return Some((entry.result.clone(), entry.metadata.clone()));
153            }
154        }
155        None
156    }
157
158    /// Store a verification result.
159    ///
160    /// # Examples
161    ///
162    /// ```rust
163    /// use keyhog_core::VerificationResult;
164    /// use keyhog_verifier::cache::VerificationCache;
165    /// use std::collections::HashMap;
166    /// use std::time::Duration;
167    ///
168    /// let cache = VerificationCache::new(Duration::from_secs(60));
169    /// cache.put("secret", "detector", VerificationResult::Live, HashMap::new());
170    /// assert_eq!(cache.len(), 1);
171    /// ```
172    pub fn put(
173        &self,
174        credential: &str,
175        detector_id: &str,
176        result: VerificationResult,
177        metadata: HashMap<String, String>,
178    ) {
179        let key = cache_key(credential, detector_id);
180
181        let insert_count = self.inserts.fetch_add(1, Ordering::Relaxed) + 1;
182        if insert_count.is_multiple_of(Self::EVICTION_INTERVAL) {
183            // SAFETY: cache bounded by MAX_CACHE_ENTRIES, eviction runs on every 64th
184            // insert. In this implementation MAX_CACHE_ENTRIES is the configured
185            // max_entries bound, and we also trim back to that bound after each insert.
186            self.evict_expired();
187        }
188
189        let key_clone = key.clone();
190        self.entries.insert(
191            key,
192            CacheEntry {
193                result,
194                metadata: sanitize_metadata(metadata),
195                expires_at: Instant::now() + self.ttl,
196            },
197        );
198        self.queue.lock().push_back(key_clone);
199
200        if self.entries.len() > self.max_entries {
201            self.evict_one_oldest();
202        }
203    }
204
205    /// Number of cached entries.
206    ///
207    /// # Examples
208    ///
209    /// ```rust
210    /// use keyhog_verifier::cache::VerificationCache;
211    /// use std::time::Duration;
212    ///
213    /// let cache = VerificationCache::new(Duration::from_secs(60));
214    /// assert_eq!(cache.len(), 0);
215    /// ```
216    pub fn len(&self) -> usize {
217        self.entries.len()
218    }
219
220    /// Return `true` when the cache contains no live entries.
221    ///
222    /// # Examples
223    ///
224    /// ```rust
225    /// use keyhog_verifier::cache::VerificationCache;
226    /// use std::time::Duration;
227    ///
228    /// let cache = VerificationCache::new(Duration::from_secs(60));
229    /// assert!(cache.is_empty());
230    /// ```
231    pub fn is_empty(&self) -> bool {
232        self.entries.is_empty()
233    }
234
235    /// Evict expired entries.
236    ///
237    /// # Examples
238    ///
239    /// ```rust
240    /// use keyhog_verifier::cache::VerificationCache;
241    /// use std::time::Duration;
242    ///
243    /// let cache = VerificationCache::new(Duration::from_secs(60));
244    /// cache.evict_expired();
245    /// assert!(cache.is_empty());
246    /// ```
247    pub fn evict_expired(&self) {
248        let now = Instant::now();
249        self.entries.retain(|_, entry| now < entry.expires_at);
250    }
251
252    fn evict_one_oldest(&self) {
253        let mut queue = self.queue.lock();
254        while let Some(key) = queue.pop_front() {
255            if self.entries.remove(&key).is_some() {
256                break;
257            }
258        }
259    }
260}
261
262fn hash_credential(credential: &str) -> [u8; VerificationCache::HASH_BYTES] {
263    Sha256::digest(credential.as_bytes()).into()
264}
265
266fn cache_key(credential: &str, detector_id: &str) -> CacheKey {
267    CacheKey {
268        credential_hash: hash_credential(credential),
269        detector_id_hash: hash_credential(detector_id),
270        detector_id: Arc::<str>::from(truncate_to_char_boundary(
271            detector_id,
272            VerificationCache::MAX_DETECTOR_ID_BYTES,
273        )),
274    }
275}
276
277fn sanitize_metadata(metadata: HashMap<String, String>) -> HashMap<String, String> {
278    metadata
279        .into_iter()
280        .take(VerificationCache::MAX_METADATA_ENTRIES)
281        .map(|(key, value)| {
282            (
283                truncate_to_char_boundary(&key, VerificationCache::MAX_METADATA_KEY_BYTES),
284                truncate_to_char_boundary(&value, VerificationCache::MAX_METADATA_VALUE_BYTES),
285            )
286        })
287        .collect()
288}
289
290fn truncate_to_char_boundary(value: &str, max_bytes: usize) -> String {
291    if value.len() <= max_bytes {
292        return value.to_string();
293    }
294
295    let mut end = max_bytes;
296    while end > 0 && !value.is_char_boundary(end) {
297        end -= 1;
298    }
299    value[..end].to_string()
300}