Skip to main content

vellaveto_engine/
cache.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! Decision cache for policy evaluation results.
9//!
10//! Provides an LRU-based cache that stores [`Verdict`] results keyed by
11//! [`Action`] identity (tool, function, paths, domains) and optional
12//! agent identity. Cached verdicts are invalidated when the policy
13//! generation counter is bumped (e.g., on policy reload).
14//!
15//! # Security
16//!
17//! - **Context-dependent results are NOT cached.** When the
18//!   [`EvaluationContext`] carries session-dependent state (call counts,
19//!   previous actions, time windows, call chains, capability tokens, session
20//!   state), the result depends on mutable session state and must be
21//!   evaluated fresh every time.
22//! - **Fail-closed on lock poisoning.** If the internal `RwLock` is
23//!   poisoned, `get` returns `None` (cache miss) and `insert` is a no-op.
24//!   This ensures a poisoned cache never serves stale Allow verdicts.
25//! - **Bounded memory.** The cache enforces [`MAX_CACHE_ENTRIES`] and
26//!   evicts the least-recently-used entry when at capacity.
27//! - **Counters use `fetch_add`.** Hit/miss/eviction counters use `u64`
28//!   atomics, which cannot practically overflow (584-year wraparound at
29//!   1 GHz increment rate). The LRU access counter uses `SeqCst` ordering.
30
31use std::collections::{BTreeMap, HashMap};
32use std::hash::{Hash, Hasher};
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::RwLock;
35use std::time::{Duration, Instant};
36
37use vellaveto_types::{Action, EvaluationContext, Verdict};
38
39/// Absolute upper bound on cache entries to prevent memory exhaustion.
40pub const MAX_CACHE_ENTRIES: usize = 100_000;
41
42/// Minimum allowed TTL in seconds.
43pub const MIN_TTL_SECS: u64 = 1;
44
45/// Maximum allowed TTL in seconds (1 hour).
46pub const MAX_TTL_SECS: u64 = 3600;
47
48/// Hash-based key for cached policy decisions.
49///
50/// Each field is a pre-computed `u64` hash of the corresponding [`Action`]
51/// component. This avoids storing the full action data in the cache and
52/// provides O(1) key comparison.
53#[derive(Hash, Eq, PartialEq, Clone, Debug)]
54struct CacheKey {
55    tool_hash: u64,
56    function_hash: u64,
57    paths_hash: u64,
58    domains_hash: u64,
59    /// SECURITY (R228-ENG-1): Include resolved IPs in cache key to prevent
60    /// DNS rebinding attacks from hitting a stale Allow verdict cached for
61    /// a different IP resolution of the same domain.
62    resolved_ips_hash: u64,
63    identity_hash: u64,
64    /// SECURITY (R245-ENG-2): Include parameters hash in cache key to prevent
65    /// verdict poisoning. Without this, a cached Allow for safe parameters
66    /// would be served for a request with malicious parameters (same tool/paths).
67    parameters_hash: u64,
68}
69
70/// A single cached verdict with insertion metadata.
71struct CacheEntry {
72    verdict: Verdict,
73    inserted_at: Instant,
74    generation: u64,
75    /// Monotonic counter tracking last access time for LRU eviction.
76    last_accessed: u64,
77}
78
79/// Aggregate cache performance statistics.
80#[derive(Debug, Clone, Default)]
81pub struct CacheStats {
82    pub hits: u64,
83    pub misses: u64,
84    pub evictions: u64,
85    pub insertions: u64,
86    pub invalidations: u64,
87}
88
89/// Interior of the cache behind the RwLock.
90///
91/// The `lru_index` BTreeMap provides O(log n) eviction by mapping
92/// `last_accessed` counters to their corresponding `CacheKey`.
93/// This replaces the previous O(n) linear scan on eviction.
94struct CacheInner {
95    entries: HashMap<CacheKey, CacheEntry>,
96    /// Maps access-order counter → CacheKey for O(log n) LRU eviction.
97    lru_index: BTreeMap<u64, CacheKey>,
98}
99
100/// LRU decision cache for policy evaluation results.
101///
102/// Thread-safe via `RwLock`. Lock poisoning is handled fail-closed
103/// (cache miss on read, no-op on write).
104pub struct DecisionCache {
105    inner: RwLock<CacheInner>,
106    max_entries: usize,
107    ttl: Duration,
108    policy_generation: AtomicU64,
109    // Stats counters — u64 atomics, practically unbounded.
110    hits: AtomicU64,
111    misses: AtomicU64,
112    evictions: AtomicU64,
113    insertions: AtomicU64,
114    invalidations: AtomicU64,
115    /// Monotonic counter for LRU ordering.
116    access_counter: AtomicU64,
117}
118
119impl std::fmt::Debug for DecisionCache {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.debug_struct("DecisionCache")
122            .field("max_entries", &self.max_entries)
123            .field("ttl", &self.ttl)
124            .field(
125                "policy_generation",
126                &self.policy_generation.load(Ordering::SeqCst),
127            )
128            .field(
129                "current_size",
130                &self
131                    .inner
132                    .read()
133                    .map(|c| c.entries.len())
134                    .unwrap_or_default(),
135            )
136            .finish()
137    }
138}
139
140impl DecisionCache {
141    /// Create a new decision cache.
142    ///
143    /// # Arguments
144    ///
145    /// * `max_entries` — Maximum number of cached verdicts. Clamped to
146    ///   `[1, MAX_CACHE_ENTRIES]`.
147    /// * `ttl` — Time-to-live for each entry. Clamped to
148    ///   `[MIN_TTL_SECS, MAX_TTL_SECS]` seconds.
149    pub fn new(max_entries: usize, ttl: Duration) -> Self {
150        let clamped_max = max_entries.clamp(1, MAX_CACHE_ENTRIES);
151        let clamped_ttl_secs = ttl.as_secs().clamp(MIN_TTL_SECS, MAX_TTL_SECS);
152        let clamped_ttl = Duration::from_secs(clamped_ttl_secs);
153
154        Self {
155            inner: RwLock::new(CacheInner {
156                entries: HashMap::with_capacity(clamped_max.min(1024)),
157                lru_index: BTreeMap::new(),
158            }),
159            max_entries: clamped_max,
160            ttl: clamped_ttl,
161            policy_generation: AtomicU64::new(0),
162            hits: AtomicU64::new(0),
163            misses: AtomicU64::new(0),
164            evictions: AtomicU64::new(0),
165            insertions: AtomicU64::new(0),
166            invalidations: AtomicU64::new(0),
167            access_counter: AtomicU64::new(0),
168        }
169    }
170
171    /// Look up a cached verdict for the given action and optional context.
172    ///
173    /// Returns `None` (cache miss) if:
174    /// - The context is session-dependent (non-cacheable)
175    /// - A risk score is present (dynamic continuous authorization)
176    /// - No entry exists for this action
177    /// - The entry's TTL has expired
178    /// - The entry's policy generation is stale
179    /// - The internal lock is poisoned (fail-closed)
180    ///
181    /// # Arguments
182    ///
183    /// * `has_risk_score` — Set to `true` when the request context carries a
184    ///   risk score from continuous authorization. This forces a cache miss
185    ///   because the ABAC verdict depends on the current risk score.
186    pub fn get_with_risk(
187        &self,
188        action: &Action,
189        context: Option<&EvaluationContext>,
190        has_risk_score: bool,
191    ) -> Option<Verdict> {
192        if !Self::is_cacheable_context(context, has_risk_score) {
193            self.misses.fetch_add(1, Ordering::Relaxed);
194            return None;
195        }
196
197        let key = Self::build_key(action, context);
198        let current_gen = self.policy_generation.load(Ordering::SeqCst);
199
200        // Fail-closed: poisoned lock → cache miss
201        let inner = match self.inner.read() {
202            Ok(guard) => guard,
203            Err(_) => {
204                self.misses.fetch_add(1, Ordering::Relaxed);
205                return None;
206            }
207        };
208
209        match inner.entries.get(&key) {
210            Some(entry)
211                if entry.generation == current_gen && entry.inserted_at.elapsed() < self.ttl =>
212            {
213                self.hits.fetch_add(1, Ordering::Relaxed);
214                // Note: We do not update last_accessed here under a read lock
215                // to avoid upgrading to a write lock on every hit. The LRU
216                // eviction is approximate — this is acceptable for a cache
217                // that also has TTL-based expiry.
218                Some(entry.verdict.clone())
219            }
220            _ => {
221                self.misses.fetch_add(1, Ordering::Relaxed);
222                None
223            }
224        }
225    }
226
227    /// Look up a cached verdict (backward-compatible, assumes no risk score).
228    ///
229    /// Equivalent to `get_with_risk(action, context, false)`.
230    pub fn get(&self, action: &Action, context: Option<&EvaluationContext>) -> Option<Verdict> {
231        self.get_with_risk(action, context, false)
232    }
233
234    /// Insert a verdict into the cache for the given action.
235    ///
236    /// If the context is session-dependent or a risk score is present,
237    /// this is a no-op (the result should not be cached). If the cache
238    /// is at capacity, the least-recently-used entry is evicted.
239    ///
240    /// No-op if the internal lock is poisoned (fail-closed: we do not
241    /// serve stale data from a potentially corrupted map).
242    ///
243    /// # Arguments
244    ///
245    /// * `has_risk_score` — Set to `true` when the request context carries a
246    ///   risk score from continuous authorization.
247    pub fn insert_with_risk(
248        &self,
249        action: &Action,
250        context: Option<&EvaluationContext>,
251        verdict: &Verdict,
252        has_risk_score: bool,
253    ) {
254        if !Self::is_cacheable_context(context, has_risk_score) {
255            return;
256        }
257
258        let key = Self::build_key(action, context);
259        let current_gen = self.policy_generation.load(Ordering::SeqCst);
260        // SECURITY (R229-ENG-7): SeqCst for LRU ordering counter — Relaxed could
261        // allow reordering that causes incorrect eviction of recently-used entries.
262        let access_order = self.access_counter.fetch_add(1, Ordering::SeqCst);
263
264        // Fail-closed: poisoned lock → no-op
265        let mut inner = match self.inner.write() {
266            Ok(guard) => guard,
267            Err(_) => return,
268        };
269
270        // If overwriting an existing entry, remove its old LRU index entry.
271        if let Some(old_access) = inner.entries.get(&key).map(|e| e.last_accessed) {
272            inner.lru_index.remove(&old_access);
273        }
274
275        // Evict LRU if at capacity and this is a new key
276        if inner.entries.len() >= self.max_entries && !inner.entries.contains_key(&key) {
277            self.evict_lru(&mut inner);
278        }
279
280        inner.lru_index.insert(access_order, key.clone());
281        inner.entries.insert(
282            key,
283            CacheEntry {
284                verdict: verdict.clone(),
285                inserted_at: Instant::now(),
286                generation: current_gen,
287                last_accessed: access_order,
288            },
289        );
290        self.insertions.fetch_add(1, Ordering::Relaxed);
291    }
292
293    /// Insert a verdict (backward-compatible, assumes no risk score).
294    ///
295    /// Equivalent to `insert_with_risk(action, context, verdict, false)`.
296    pub fn insert(&self, action: &Action, context: Option<&EvaluationContext>, verdict: &Verdict) {
297        self.insert_with_risk(action, context, verdict, false);
298    }
299
300    /// Invalidate all cached entries by bumping the policy generation counter.
301    ///
302    /// Existing entries remain in memory but will be treated as stale on
303    /// the next `get` call. This is O(1) — no iteration required.
304    pub fn invalidate(&self) {
305        self.policy_generation.fetch_add(1, Ordering::SeqCst);
306        self.invalidations.fetch_add(1, Ordering::Relaxed);
307    }
308
309    /// Return aggregate cache performance statistics.
310    pub fn stats(&self) -> CacheStats {
311        CacheStats {
312            hits: self.hits.load(Ordering::Relaxed),
313            misses: self.misses.load(Ordering::Relaxed),
314            evictions: self.evictions.load(Ordering::Relaxed),
315            insertions: self.insertions.load(Ordering::Relaxed),
316            invalidations: self.invalidations.load(Ordering::Relaxed),
317        }
318    }
319
320    /// Return the number of entries currently in the cache.
321    ///
322    /// Returns 0 if the lock is poisoned (fail-closed).
323    pub fn len(&self) -> usize {
324        self.inner.read().map(|c| c.entries.len()).unwrap_or(0)
325    }
326
327    /// Returns `true` if the cache contains no entries.
328    ///
329    /// Returns `true` if the lock is poisoned (fail-closed).
330    pub fn is_empty(&self) -> bool {
331        self.len() == 0
332    }
333
334    /// Determine whether the evaluation context allows caching.
335    ///
336    /// Context-dependent results (those relying on mutable session state)
337    /// must NOT be cached because the verdict may change between calls
338    /// even for the same action.
339    ///
340    /// # Arguments
341    ///
342    /// * `context` — Optional evaluation context (session-level fields).
343    /// * `has_risk_score` — Whether the request context carries a risk score
344    ///   from continuous authorization. When `true`, the verdict depends on
345    ///   the current risk score and must not be served from cache.
346    fn is_cacheable_context(context: Option<&EvaluationContext>, has_risk_score: bool) -> bool {
347        // SECURITY (R237-ENG-6): risk_score from continuous authorization can change
348        // ABAC verdicts between calls. A cached Allow for risk_score=0.1 must not be
349        // served when the next request has risk_score=0.9. Since risk_score is dynamic
350        // and lives outside EvaluationContext (on AbacEvalContext/StatelessContextBlob),
351        // we accept it as a separate flag.
352        if has_risk_score {
353            return false;
354        }
355        match context {
356            None => true,
357            Some(ctx) => {
358                // Session-dependent fields that make caching unsafe:
359                // - call_counts: changes every call
360                // - previous_actions: changes every call
361                // - call_chain: may vary per request path
362                // - timestamp: time-window policies depend on wall clock
363                // - capability_token: token-specific, may expire
364                // - session_state: changes with session lifecycle
365                // - verification_tier: may change mid-session
366                //
367                // Cacheable fields (stable within a session):
368                // - agent_id: identity doesn't change
369                // - agent_identity: attested identity doesn't change
370                // - tenant_id: tenant doesn't change
371                ctx.timestamp.is_none()
372                    && ctx.call_counts.is_empty()
373                    && ctx.previous_actions.is_empty()
374                    && ctx.call_chain.is_empty()
375                    && ctx.capability_token.is_none()
376                    && ctx.session_state.is_none()
377                    && ctx.verification_tier.is_none()
378            }
379        }
380    }
381
382    /// Build a cache key from an action and optional context.
383    ///
384    /// SECURITY (R227-ENG-1, R228-ENG-4): Tool and function names are normalized
385    /// through normalize_full() (NFKC + lowercase + homoglyph mapping) before
386    /// hashing to ensure cache key consistency with engine evaluation. Without this,
387    /// "FileRead", "fileread", and "fileread" (fullwidth) produce different cache keys,
388    /// causing cache pollution and inconsistent verdicts for the same logical tool.
389    fn build_key(action: &Action, context: Option<&EvaluationContext>) -> CacheKey {
390        CacheKey {
391            tool_hash: Self::hash_str(&crate::normalize::normalize_full(&action.tool)),
392            function_hash: Self::hash_str(&crate::normalize::normalize_full(&action.function)),
393            // SECURITY (R229-ENG-1): Normalize paths and domains before hashing so that
394            // case/Unicode variants of the same logical target share a cache entry.
395            paths_hash: Self::hash_sorted_normalized_strs(&action.target_paths),
396            domains_hash: Self::hash_sorted_normalized_strs(&action.target_domains),
397            resolved_ips_hash: Self::hash_sorted_strs(&action.resolved_ips),
398            identity_hash: Self::hash_identity(context),
399            // SECURITY (R245-ENG-2): Include parameters in cache key to prevent
400            // verdict poisoning. Without this, a cached Allow for benign parameters
401            // is served for a request with malicious parameters (same tool/paths),
402            // bypassing DLP/injection detection.
403            parameters_hash: Self::hash_parameters(&action.parameters),
404        }
405    }
406
407    /// Hash a single string using `DefaultHasher`.
408    fn hash_str(s: &str) -> u64 {
409        let mut hasher = std::collections::hash_map::DefaultHasher::new();
410        s.hash(&mut hasher);
411        hasher.finish()
412    }
413
414    /// Hash a sorted slice of strings for order-independent comparison.
415    ///
416    /// Sorts a clone of the slice so that `["a", "b"]` and `["b", "a"]`
417    /// produce the same hash. Used for resolved_ips which are already canonical.
418    fn hash_sorted_strs(strs: &[String]) -> u64 {
419        let mut hasher = std::collections::hash_map::DefaultHasher::new();
420        let mut sorted: Vec<&str> = strs.iter().map(|s| s.as_str()).collect();
421        sorted.sort_unstable();
422        sorted.len().hash(&mut hasher);
423        for s in &sorted {
424            s.hash(&mut hasher);
425        }
426        hasher.finish()
427    }
428
429    /// Hash a sorted slice of strings after normalizing each entry.
430    ///
431    /// SECURITY (R229-ENG-1): target_paths and target_domains must be normalized
432    /// before hashing to prevent cache pollution — e.g., "/TMP/FOO" and "/tmp/foo"
433    /// must produce the same cache key since the engine evaluates them identically.
434    fn hash_sorted_normalized_strs(strs: &[String]) -> u64 {
435        let mut hasher = std::collections::hash_map::DefaultHasher::new();
436        let mut normalized: Vec<String> = strs
437            .iter()
438            .map(|s| crate::normalize::normalize_full(s))
439            .collect();
440        normalized.sort_unstable();
441        normalized.len().hash(&mut hasher);
442        for s in &normalized {
443            s.hash(&mut hasher);
444        }
445        hasher.finish()
446    }
447
448    /// Hash the parameters field of an action.
449    ///
450    /// SECURITY (R245-ENG-2): Parameters must be part of the cache key because
451    /// DLP inspection, injection detection, and ABAC constraints may produce
452    /// different verdicts based on parameter content. Without this, a cached
453    /// Allow for `{"path": "/tmp/safe"}` would be served for
454    /// `{"path": "/tmp/safe", "inject": "<script>alert(1)</script>"}`.
455    fn hash_parameters(params: &serde_json::Value) -> u64 {
456        let mut hasher = std::collections::hash_map::DefaultHasher::new();
457        // Use canonical JSON serialization for consistent hashing.
458        // On serialization failure, hash a sentinel to avoid collapsing
459        // different parameters to the same hash (fail-closed).
460        match serde_json::to_string(params) {
461            Ok(json) => json.hash(&mut hasher),
462            Err(_) => 255u8.hash(&mut hasher),
463        }
464        hasher.finish()
465    }
466
467    /// Hash the identity components of an evaluation context.
468    ///
469    /// Hashes all identity-affecting fields: `agent_id`, `tenant_id`,
470    /// and the full `agent_identity` (issuer, subject, audience, claims).
471    fn hash_identity(context: Option<&EvaluationContext>) -> u64 {
472        let mut hasher = std::collections::hash_map::DefaultHasher::new();
473        match context {
474            None => {
475                0u8.hash(&mut hasher); // sentinel for no context
476            }
477            Some(ctx) => {
478                1u8.hash(&mut hasher); // sentinel for present context
479                                       // SECURITY (R226-ENG-1): Hash Option<String> directly, not unwrap_or("").
480                                       // Previously, None and Some("") hashed to the same value, causing
481                                       // cross-tenant cache collisions when one tenant has agent_id=None
482                                       // and another has agent_id=Some("").
483                ctx.agent_id.hash(&mut hasher);
484                ctx.tenant_id.hash(&mut hasher);
485                if let Some(ref identity) = ctx.agent_identity {
486                    2u8.hash(&mut hasher); // sentinel for identity present
487                    identity.issuer.hash(&mut hasher);
488                    identity.subject.hash(&mut hasher);
489                    // R230-ENG-2: Include audience and claims in cache key.
490                    // AgentIdentityMatch constraints check required_claims and
491                    // audience — different claims/audience must produce different
492                    // cache entries to prevent cross-identity cache collisions.
493                    identity.audience.len().hash(&mut hasher);
494                    for aud in &identity.audience {
495                        aud.hash(&mut hasher);
496                    }
497                    // Hash claims deterministically: sort by key
498                    let mut claim_keys: Vec<&String> = identity.claims.keys().collect();
499                    claim_keys.sort_unstable();
500                    claim_keys.len().hash(&mut hasher);
501                    for key in &claim_keys {
502                        key.hash(&mut hasher);
503                        // Hash the JSON string representation for Value
504                        if let Ok(val_str) = serde_json::to_string(&identity.claims[*key]) {
505                            val_str.hash(&mut hasher);
506                        }
507                    }
508                } else {
509                    3u8.hash(&mut hasher); // sentinel for identity absent
510                }
511            }
512        }
513        hasher.finish()
514    }
515
516    /// Evict the least-recently-used entry from the cache.
517    ///
518    /// Uses the BTreeMap LRU index for O(log n) eviction instead of
519    /// scanning all entries. The BTreeMap is ordered by access counter,
520    /// so `first_key_value()` gives us the oldest entry directly.
521    fn evict_lru(&self, inner: &mut CacheInner) {
522        // Pop the smallest access counter (oldest entry) from the index.
523        if let Some((&access_counter, _)) = inner.lru_index.iter().next() {
524            if let Some(evicted_key) = inner.lru_index.remove(&access_counter) {
525                inner.entries.remove(&evicted_key);
526                self.evictions.fetch_add(1, Ordering::Relaxed);
527            }
528        }
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use serde_json::json;
536    use std::collections::HashMap;
537    use std::thread;
538    use vellaveto_types::EvaluationContext;
539
540    /// Helper: create a simple action.
541    fn make_action(tool: &str, function: &str) -> Action {
542        Action::new(tool.to_string(), function.to_string(), json!({}))
543    }
544
545    /// Helper: create an action with target paths and domains.
546    fn make_action_with_targets(
547        tool: &str,
548        function: &str,
549        paths: Vec<&str>,
550        domains: Vec<&str>,
551    ) -> Action {
552        Action {
553            tool: tool.to_string(),
554            function: function.to_string(),
555            parameters: json!({}),
556            target_paths: paths.into_iter().map(|s| s.to_string()).collect(),
557            target_domains: domains.into_iter().map(|s| s.to_string()).collect(),
558            resolved_ips: vec![],
559        }
560    }
561
562    /// Helper: create a cacheable context (only stable identity fields).
563    fn make_cacheable_context(agent_id: &str) -> EvaluationContext {
564        EvaluationContext {
565            agent_id: Some(agent_id.to_string()),
566            tenant_id: None,
567            timestamp: None,
568            agent_identity: None,
569            call_counts: HashMap::new(),
570            previous_actions: vec![],
571            call_chain: vec![],
572            verification_tier: None,
573            capability_token: None,
574            session_state: None,
575        }
576    }
577
578    /// Helper: create a non-cacheable context (has session-dependent fields).
579    fn make_noncacheable_context() -> EvaluationContext {
580        let mut counts = HashMap::new();
581        counts.insert("bash".to_string(), 5);
582        EvaluationContext {
583            agent_id: Some("agent-1".to_string()),
584            tenant_id: None,
585            timestamp: None,
586            agent_identity: None,
587            call_counts: counts,
588            previous_actions: vec!["read_file".to_string()],
589            call_chain: vec![],
590            verification_tier: None,
591            capability_token: None,
592            session_state: None,
593        }
594    }
595
596    #[test]
597    fn test_cache_hit_and_miss() {
598        let cache = DecisionCache::new(100, Duration::from_secs(60));
599        let action = make_action("read_file", "read");
600        let verdict = Verdict::Allow;
601
602        // Miss before insert
603        assert!(cache.get(&action, None).is_none());
604        assert_eq!(cache.stats().misses, 1);
605
606        // Insert and hit
607        cache.insert(&action, None, &verdict);
608        let result = cache.get(&action, None);
609        assert!(result.is_some());
610        assert_eq!(result, Some(Verdict::Allow));
611        assert_eq!(cache.stats().hits, 1);
612        assert_eq!(cache.stats().insertions, 1);
613
614        // Different action is a miss
615        let other_action = make_action("write_file", "write");
616        assert!(cache.get(&other_action, None).is_none());
617        assert_eq!(cache.stats().misses, 2);
618    }
619
620    #[test]
621    fn test_ttl_expiry() {
622        // Use a very short TTL (minimum 1 second)
623        let cache = DecisionCache::new(100, Duration::from_secs(1));
624        let action = make_action("read_file", "read");
625        let verdict = Verdict::Allow;
626
627        cache.insert(&action, None, &verdict);
628        assert!(cache.get(&action, None).is_some());
629
630        // Wait for TTL to expire
631        thread::sleep(Duration::from_millis(1100));
632
633        // Should be a miss after TTL
634        assert!(cache.get(&action, None).is_none());
635    }
636
637    #[test]
638    fn test_invalidation_on_policy_change() {
639        let cache = DecisionCache::new(100, Duration::from_secs(60));
640        let action = make_action("read_file", "read");
641        let verdict = Verdict::Allow;
642
643        cache.insert(&action, None, &verdict);
644        assert!(cache.get(&action, None).is_some());
645
646        // Invalidate (simulates policy reload)
647        cache.invalidate();
648        assert_eq!(cache.stats().invalidations, 1);
649
650        // Previous entry is now stale
651        assert!(cache.get(&action, None).is_none());
652
653        // New insert under new generation works
654        let deny = Verdict::Deny {
655            reason: "blocked".to_string(),
656        };
657        cache.insert(&action, None, &deny);
658        let result = cache.get(&action, None);
659        assert!(matches!(result, Some(Verdict::Deny { .. })));
660    }
661
662    #[test]
663    fn test_lru_eviction() {
664        let cache = DecisionCache::new(3, Duration::from_secs(60));
665
666        // Fill cache to capacity
667        for i in 0..3 {
668            let action = make_action(&format!("tool_{i}"), "func");
669            cache.insert(&action, None, &Verdict::Allow);
670        }
671        assert_eq!(cache.len(), 3);
672        assert_eq!(cache.stats().evictions, 0);
673
674        // Insert a 4th entry — should evict LRU (tool_0)
675        let action_new = make_action("tool_new", "func");
676        cache.insert(&action_new, None, &Verdict::Allow);
677        assert_eq!(cache.len(), 3);
678        assert_eq!(cache.stats().evictions, 1);
679
680        // The new entry should be present
681        assert!(cache.get(&action_new, None).is_some());
682
683        // The evicted entry (tool_0) should be gone
684        let action_0 = make_action("tool_0", "func");
685        assert!(cache.get(&action_0, None).is_none());
686    }
687
688    #[test]
689    fn test_stats_tracking() {
690        let cache = DecisionCache::new(100, Duration::from_secs(60));
691        let action = make_action("read_file", "read");
692
693        // Initial stats are zero
694        let stats = cache.stats();
695        assert_eq!(stats.hits, 0);
696        assert_eq!(stats.misses, 0);
697        assert_eq!(stats.evictions, 0);
698        assert_eq!(stats.insertions, 0);
699        assert_eq!(stats.invalidations, 0);
700
701        // Miss
702        cache.get(&action, None);
703        assert_eq!(cache.stats().misses, 1);
704
705        // Insert
706        cache.insert(&action, None, &Verdict::Allow);
707        assert_eq!(cache.stats().insertions, 1);
708
709        // Hit
710        cache.get(&action, None);
711        assert_eq!(cache.stats().hits, 1);
712
713        // Invalidate
714        cache.invalidate();
715        assert_eq!(cache.stats().invalidations, 1);
716    }
717
718    #[test]
719    fn test_context_dependent_not_cached() {
720        let cache = DecisionCache::new(100, Duration::from_secs(60));
721        let action = make_action("bash", "execute");
722        let verdict = Verdict::Allow;
723        let ctx = make_noncacheable_context();
724
725        // Insert with non-cacheable context is a no-op
726        cache.insert(&action, Some(&ctx), &verdict);
727        assert_eq!(cache.len(), 0);
728        assert_eq!(cache.stats().insertions, 0);
729
730        // Get with non-cacheable context is always a miss
731        assert!(cache.get(&action, Some(&ctx)).is_none());
732        assert_eq!(cache.stats().misses, 1);
733    }
734
735    #[test]
736    fn test_context_dependent_timestamp_not_cached() {
737        let cache = DecisionCache::new(100, Duration::from_secs(60));
738        let action = make_action("bash", "execute");
739        let verdict = Verdict::Allow;
740
741        let ctx = EvaluationContext {
742            timestamp: Some("2026-01-01T12:00:00Z".to_string()),
743            agent_id: None,
744            tenant_id: None,
745            agent_identity: None,
746            call_counts: HashMap::new(),
747            previous_actions: vec![],
748            call_chain: vec![],
749            verification_tier: None,
750            capability_token: None,
751            session_state: None,
752        };
753
754        cache.insert(&action, Some(&ctx), &verdict);
755        assert_eq!(cache.len(), 0);
756    }
757
758    #[test]
759    fn test_context_dependent_session_state_not_cached() {
760        let cache = DecisionCache::new(100, Duration::from_secs(60));
761        let action = make_action("bash", "execute");
762        let verdict = Verdict::Allow;
763
764        let ctx = EvaluationContext {
765            session_state: Some("active".to_string()),
766            agent_id: None,
767            tenant_id: None,
768            timestamp: None,
769            agent_identity: None,
770            call_counts: HashMap::new(),
771            previous_actions: vec![],
772            call_chain: vec![],
773            verification_tier: None,
774            capability_token: None,
775        };
776
777        cache.insert(&action, Some(&ctx), &verdict);
778        assert_eq!(cache.len(), 0);
779    }
780
781    #[test]
782    fn test_cacheable_context_with_identity() {
783        let cache = DecisionCache::new(100, Duration::from_secs(60));
784        let action = make_action("read_file", "read");
785        let verdict = Verdict::Allow;
786        let ctx = make_cacheable_context("agent-42");
787
788        // Cacheable context with only agent_id should work
789        cache.insert(&action, Some(&ctx), &verdict);
790        assert_eq!(cache.len(), 1);
791
792        let result = cache.get(&action, Some(&ctx));
793        assert_eq!(result, Some(Verdict::Allow));
794    }
795
796    #[test]
797    fn test_cache_key_collision_resistance() {
798        let cache = DecisionCache::new(100, Duration::from_secs(60));
799
800        // These actions differ only in tool name
801        let action_a = make_action("read_file", "execute");
802        let action_b = make_action("write_file", "execute");
803
804        cache.insert(&action_a, None, &Verdict::Allow);
805        cache.insert(
806            &action_b,
807            None,
808            &Verdict::Deny {
809                reason: "blocked".to_string(),
810            },
811        );
812
813        assert_eq!(cache.get(&action_a, None), Some(Verdict::Allow));
814        assert!(matches!(
815            cache.get(&action_b, None),
816            Some(Verdict::Deny { .. })
817        ));
818
819        // Actions with different target paths
820        let action_c = make_action_with_targets("read", "exec", vec!["/tmp/a"], vec![]);
821        let action_d = make_action_with_targets("read", "exec", vec!["/tmp/b"], vec![]);
822
823        cache.insert(&action_c, None, &Verdict::Allow);
824        cache.insert(
825            &action_d,
826            None,
827            &Verdict::Deny {
828                reason: "path denied".to_string(),
829            },
830        );
831
832        assert_eq!(cache.get(&action_c, None), Some(Verdict::Allow));
833        assert!(matches!(
834            cache.get(&action_d, None),
835            Some(Verdict::Deny { .. })
836        ));
837
838        // Actions with different domains
839        let action_e = make_action_with_targets("http", "get", vec![], vec!["example.com"]);
840        let action_f = make_action_with_targets("http", "get", vec![], vec!["evil.com"]);
841
842        cache.insert(&action_e, None, &Verdict::Allow);
843        cache.insert(
844            &action_f,
845            None,
846            &Verdict::Deny {
847                reason: "domain denied".to_string(),
848            },
849        );
850
851        assert_eq!(cache.get(&action_e, None), Some(Verdict::Allow));
852        assert!(matches!(
853            cache.get(&action_f, None),
854            Some(Verdict::Deny { .. })
855        ));
856
857        // Different identity contexts produce different keys
858        let ctx_agent_1 = make_cacheable_context("agent-1");
859        let ctx_agent_2 = make_cacheable_context("agent-2");
860        let action_g = make_action("tool", "func");
861
862        cache.insert(&action_g, Some(&ctx_agent_1), &Verdict::Allow);
863        cache.insert(
864            &action_g,
865            Some(&ctx_agent_2),
866            &Verdict::Deny {
867                reason: "wrong agent".to_string(),
868            },
869        );
870
871        assert_eq!(
872            cache.get(&action_g, Some(&ctx_agent_1)),
873            Some(Verdict::Allow)
874        );
875        assert!(matches!(
876            cache.get(&action_g, Some(&ctx_agent_2)),
877            Some(Verdict::Deny { .. })
878        ));
879    }
880
881    #[test]
882    fn test_max_entries_bound() {
883        // Request more than MAX_CACHE_ENTRIES — should be clamped
884        let cache = DecisionCache::new(MAX_CACHE_ENTRIES + 1000, Duration::from_secs(60));
885        assert_eq!(cache.max_entries, MAX_CACHE_ENTRIES);
886
887        // Request 0 — should be clamped to 1
888        let cache_min = DecisionCache::new(0, Duration::from_secs(60));
889        assert_eq!(cache_min.max_entries, 1);
890    }
891
892    #[test]
893    fn test_ttl_bounds_clamped() {
894        // TTL below minimum is clamped
895        let cache = DecisionCache::new(100, Duration::from_secs(0));
896        assert_eq!(cache.ttl, Duration::from_secs(MIN_TTL_SECS));
897
898        // TTL above maximum is clamped
899        let cache_max = DecisionCache::new(100, Duration::from_secs(MAX_TTL_SECS + 1000));
900        assert_eq!(cache_max.ttl, Duration::from_secs(MAX_TTL_SECS));
901    }
902
903    #[test]
904    fn test_is_empty() {
905        let cache = DecisionCache::new(100, Duration::from_secs(60));
906        assert!(cache.is_empty());
907
908        let action = make_action("tool", "func");
909        cache.insert(&action, None, &Verdict::Allow);
910        assert!(!cache.is_empty());
911    }
912
913    #[test]
914    fn test_deny_verdict_cached() {
915        let cache = DecisionCache::new(100, Duration::from_secs(60));
916        let action = make_action("bash", "execute");
917        let verdict = Verdict::Deny {
918            reason: "dangerous tool".to_string(),
919        };
920
921        cache.insert(&action, None, &verdict);
922        let result = cache.get(&action, None);
923        assert!(matches!(result, Some(Verdict::Deny { ref reason }) if reason == "dangerous tool"));
924    }
925
926    #[test]
927    fn test_require_approval_verdict_cached() {
928        let cache = DecisionCache::new(100, Duration::from_secs(60));
929        let action = make_action("deploy", "production");
930        let verdict = Verdict::RequireApproval {
931            reason: "needs human review".to_string(),
932        };
933
934        cache.insert(&action, None, &verdict);
935        let result = cache.get(&action, None);
936        assert!(
937            matches!(result, Some(Verdict::RequireApproval { ref reason }) if reason == "needs human review")
938        );
939    }
940
941    #[test]
942    fn test_overwrite_existing_entry() {
943        let cache = DecisionCache::new(100, Duration::from_secs(60));
944        let action = make_action("tool", "func");
945
946        cache.insert(&action, None, &Verdict::Allow);
947        assert_eq!(cache.get(&action, None), Some(Verdict::Allow));
948
949        // Overwrite with Deny
950        let deny = Verdict::Deny {
951            reason: "now denied".to_string(),
952        };
953        cache.insert(&action, None, &deny);
954        assert!(matches!(
955            cache.get(&action, None),
956            Some(Verdict::Deny { .. })
957        ));
958
959        // Length should still be 1 (overwrite, not add)
960        assert_eq!(cache.len(), 1);
961    }
962
963    #[test]
964    fn test_path_order_independence() {
965        let cache = DecisionCache::new(100, Duration::from_secs(60));
966
967        // Same paths in different order should produce the same cache key
968        let action_a = make_action_with_targets("read", "exec", vec!["/a", "/b"], vec![]);
969        let action_b = make_action_with_targets("read", "exec", vec!["/b", "/a"], vec![]);
970
971        cache.insert(&action_a, None, &Verdict::Allow);
972        // Should be a hit because paths are sorted before hashing
973        assert_eq!(cache.get(&action_b, None), Some(Verdict::Allow));
974    }
975
976    #[test]
977    fn test_domain_order_independence() {
978        let cache = DecisionCache::new(100, Duration::from_secs(60));
979
980        let action_a = make_action_with_targets("http", "get", vec![], vec!["a.com", "b.com"]);
981        let action_b = make_action_with_targets("http", "get", vec![], vec!["b.com", "a.com"]);
982
983        cache.insert(&action_a, None, &Verdict::Allow);
984        assert_eq!(cache.get(&action_b, None), Some(Verdict::Allow));
985    }
986
987    #[test]
988    fn test_multiple_invalidations() {
989        let cache = DecisionCache::new(100, Duration::from_secs(60));
990        let action = make_action("tool", "func");
991
992        cache.insert(&action, None, &Verdict::Allow);
993        cache.invalidate();
994        cache.invalidate();
995        cache.invalidate();
996
997        assert_eq!(cache.stats().invalidations, 3);
998        assert!(cache.get(&action, None).is_none());
999
1000        // Insert after multiple invalidations still works
1001        cache.insert(&action, None, &Verdict::Allow);
1002        assert!(cache.get(&action, None).is_some());
1003    }
1004
1005    #[test]
1006    fn test_debug_does_not_leak_entries() {
1007        let cache = DecisionCache::new(100, Duration::from_secs(60));
1008        let action = make_action("secret_tool", "func");
1009        cache.insert(&action, None, &Verdict::Allow);
1010
1011        let debug_output = format!("{cache:?}");
1012        // Debug output should show metadata, not entry contents
1013        assert!(debug_output.contains("max_entries"));
1014        assert!(debug_output.contains("current_size"));
1015        assert!(!debug_output.contains("secret_tool"));
1016    }
1017
1018    /// R227-ENG-1: Cache keys are case-insensitive for tool/function names.
1019    #[test]
1020    fn test_r227_cache_key_case_insensitive() {
1021        let cache = DecisionCache::new(100, Duration::from_secs(60));
1022        let action_lower = make_action("file_read", "get_content");
1023        let action_upper = make_action("File_Read", "Get_Content");
1024        let action_mixed = make_action("FILE_READ", "GET_CONTENT");
1025
1026        cache.insert(&action_lower, None, &Verdict::Allow);
1027
1028        // All case variants should hit the same cache entry
1029        assert!(
1030            cache.get(&action_upper, None).is_some(),
1031            "Mixed-case tool name should match lowercased cache key"
1032        );
1033        assert!(
1034            cache.get(&action_mixed, None).is_some(),
1035            "All-caps tool name should match lowercased cache key"
1036        );
1037    }
1038
1039    /// R228-ENG-1: Different resolved IPs must produce different cache keys.
1040    /// This prevents DNS rebinding attacks from hitting a stale Allow verdict
1041    /// cached for a different IP resolution of the same domain.
1042    #[test]
1043    fn test_r228_resolved_ips_in_cache_key() {
1044        let cache = DecisionCache::new(100, Duration::from_secs(60));
1045
1046        let mut action_public = Action::new("tool", "fn", serde_json::json!({}));
1047        action_public.target_domains.push("attacker.com".into());
1048        action_public.resolved_ips.push("1.2.3.4".into());
1049
1050        let mut action_metadata = Action::new("tool", "fn", serde_json::json!({}));
1051        action_metadata.target_domains.push("attacker.com".into());
1052        action_metadata.resolved_ips.push("169.254.169.254".into());
1053
1054        // Cache Allow for public IP
1055        cache.insert(&action_public, None, &Verdict::Allow);
1056
1057        // Same domain but different resolved IP must be a cache miss
1058        let result = cache.get(&action_metadata, None);
1059        assert!(
1060            result.is_none(),
1061            "Different resolved IP must produce a cache miss (DNS rebinding defense)"
1062        );
1063    }
1064
1065    /// R237-ENG-6: Contexts with a risk_score must not be cached.
1066    /// risk_score from continuous authorization can change ABAC verdicts
1067    /// between calls, so a cached Allow for risk_score=0.1 must not be
1068    /// served when the next request has risk_score=0.9.
1069    #[test]
1070    fn test_r237_risk_score_prevents_caching() {
1071        let cache = DecisionCache::new(100, Duration::from_secs(60));
1072        let action = make_action("tool", "func");
1073
1074        // Insert with has_risk_score=true should be a no-op
1075        cache.insert_with_risk(&action, None, &Verdict::Allow, true);
1076        assert_eq!(cache.len(), 0, "Insert with risk_score should be a no-op");
1077        assert_eq!(cache.stats().insertions, 0);
1078
1079        // Get with has_risk_score=true should be a miss even if entry exists
1080        cache.insert(&action, None, &Verdict::Allow);
1081        assert_eq!(cache.len(), 1);
1082        assert!(
1083            cache.get_with_risk(&action, None, true).is_none(),
1084            "Get with risk_score should bypass cache"
1085        );
1086        assert!(
1087            cache.get_with_risk(&action, None, false).is_some(),
1088            "Get without risk_score should hit cache"
1089        );
1090    }
1091
1092    /// R237-ENG-6: Backward-compatible get/insert still work without risk_score.
1093    #[test]
1094    fn test_r237_backward_compat_no_risk_score() {
1095        let cache = DecisionCache::new(100, Duration::from_secs(60));
1096        let action = make_action("tool", "func");
1097
1098        cache.insert(&action, None, &Verdict::Allow);
1099        assert_eq!(cache.len(), 1);
1100        assert!(cache.get(&action, None).is_some());
1101    }
1102
1103    /// R245-ENG-2: Different parameters must produce different cache keys.
1104    /// Without this, a cached Allow for benign parameters would be served
1105    /// for a request with malicious parameters (same tool/paths), bypassing
1106    /// DLP and injection detection.
1107    #[test]
1108    fn test_r245_parameters_in_cache_key() {
1109        let cache = DecisionCache::new(100, Duration::from_secs(60));
1110
1111        let action_safe = Action::new(
1112            "read_file",
1113            "exec",
1114            serde_json::json!({"path": "/tmp/safe"}),
1115        );
1116        let action_malicious = Action::new(
1117            "read_file",
1118            "exec",
1119            serde_json::json!({"path": "/tmp/safe", "inject": "<script>alert(1)</script>"}),
1120        );
1121
1122        // Cache Allow for safe parameters
1123        cache.insert(&action_safe, None, &Verdict::Allow);
1124        assert_eq!(cache.len(), 1);
1125
1126        // Same tool/function but different parameters must be a cache miss
1127        let result = cache.get(&action_malicious, None);
1128        assert!(
1129            result.is_none(),
1130            "Different parameters must produce a cache miss (verdict poisoning defense)"
1131        );
1132    }
1133
1134    /// R245-ENG-2: Same parameters produce a cache hit.
1135    #[test]
1136    fn test_r245_same_parameters_cache_hit() {
1137        let cache = DecisionCache::new(100, Duration::from_secs(60));
1138
1139        let action1 = Action::new("tool", "fn", serde_json::json!({"key": "value"}));
1140        let action2 = Action::new("tool", "fn", serde_json::json!({"key": "value"}));
1141
1142        cache.insert(&action1, None, &Verdict::Allow);
1143        assert!(
1144            cache.get(&action2, None).is_some(),
1145            "Identical parameters must produce a cache hit"
1146        );
1147    }
1148}