agentguard_core/decision/
cache.rs

1//! LRU decision cache with TTL and policy-version invalidation.
2
3use crate::decision::canonical::{canonical_json, write_canonical_value};
4use crate::request::AgentRequest;
5use crate::ttl::Clock;
6use cedar_policy::Entities;
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::sync::atomic::{AtomicU64, Ordering};
10use std::sync::Arc;
11use std::time::Duration;
12
13/// Cache key derived from a request (and the current policy version).
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
15pub struct CacheKey(pub [u8; 32]);
16
17impl CacheKey {
18    /// Derive a key from an agent request without additional entities.
19    /// Includes `policy_version` so a policy reload invalidates all entries.
20    ///
21    /// Streams each field's canonical JSON through the hasher, avoiding the
22    /// intermediate `String` allocations the previous implementation made.
23    /// For high-throughput PDPs (thousands of decisions/sec) this matters.
24    ///
25    /// # Examples
26    /// ```
27    /// use agentguard_core::{AgentRequestBuilder, Principal, AgentAction, Resource, AgentContext};
28    /// use agentguard_core::decision::cache::CacheKey;
29    /// let req = AgentRequestBuilder::new(Principal::user("alice"))
30    ///     .action(AgentAction::tool("send_email"))
31    ///     .resource(Resource::new("Mailbox", "alice@acme"))
32    ///     .context(AgentContext::new())
33    ///     .build()
34    ///     .unwrap();
35    /// let _k = CacheKey::for_request(&req, 0);
36    /// ```
37    pub fn for_request(req: &AgentRequest, policy_version: u64) -> Self {
38        Self::for_request_with_entities(req, &Entities::empty(), policy_version)
39    }
40
41    /// Derive a key from an agent request and the complete Cedar entity set.
42    /// Entity attributes and parent relationships can change a decision even
43    /// when the visible request is identical, so they are part of the cache
44    /// identity and are canonicalized in deterministic order.
45    pub fn for_request_with_entities(
46        req: &AgentRequest,
47        entities: &Entities,
48        policy_version: u64,
49    ) -> Self {
50        let mut hasher = Sha256::new();
51        // Reusable buffer for canonical-JSON serialization. Capacity
52        // 256 is a heuristic: the principal/action/resource/context
53        // JSON blobs fit comfortably for typical requests; a deeper
54        // path or many attributes will grow the Vec automatically.
55        let mut buf: Vec<u8> = Vec::with_capacity(256);
56
57        // Hash a length-prefixed JSON encoding of each component so
58        // boundaries between fields can't be ambiguous.
59        // Format: 4-byte big-endian length || canonical JSON.
60        // Reusing `buf` across fields avoids 3-4 small Vec allocations
61        // per cache-miss (the prior implementation allocated a fresh
62        // Vec for every field).
63        //
64        // Security: every field is mandatory. If serialization fails
65        // for any reason (today it's infallible for Principal /
66        // Action / Resource / Context, but the serde_json::Value API
67        // is fallible), we PANIC rather than produce a key that omits
68        // the field — a key collision between two distinct requests
69        // would leak Allow decisions across security boundaries.
70        let mut hash_value = |h: &mut Sha256, value: &serde_json::Value| {
71            buf.clear();
72            write_canonical_value(&mut buf, value).expect("canonical write to Vec is infallible");
73            let len = (buf.len() as u32).to_be_bytes();
74            sha2::Digest::update(h, len);
75            sha2::Digest::update(h, &buf);
76        };
77
78        let principal = serde_json::to_value(&req.principal)
79            .expect("Principal is always serializable; see principal.rs round-trip tests");
80        hash_value(&mut hasher, &principal);
81        let action = serde_json::to_value(&req.action)
82            .expect("AgentAction is always serializable; see action.rs round-trip tests");
83        hash_value(&mut hasher, &action);
84        let resource = serde_json::to_value(&req.resource)
85            .expect("Resource is always serializable; see resource.rs round-trip tests");
86        hash_value(&mut hasher, &resource);
87        // Context is already canonical; hash it directly.
88        let ctx_bytes = canonical_json(&req.context)
89            .expect("AgentContext is always serializable; see context.rs round-trip tests");
90        let ctx_len = (ctx_bytes.len() as u32).to_be_bytes();
91        sha2::Digest::update(&mut hasher, ctx_len);
92        sha2::Digest::update(&mut hasher, &ctx_bytes);
93        let mut entity_values: Vec<Vec<u8>> = entities
94            .iter()
95            .map(|entity| {
96                let value = entity
97                    .to_json_value()
98                    .expect("Cedar entities must be serializable for cache keys");
99                canonical_json(&value).expect("Cedar entity JSON must be canonicalizable")
100            })
101            .collect();
102        entity_values.sort();
103        for entity in entity_values {
104            let len = (entity.len() as u32).to_be_bytes();
105            sha2::Digest::update(&mut hasher, len);
106            sha2::Digest::update(&mut hasher, entity);
107        }
108        if let Some(t) = &req.trace {
109            buf.clear();
110            let s = t.to_string();
111            let len = (s.len() as u32).to_be_bytes();
112            sha2::Digest::update(&mut hasher, len);
113            sha2::Digest::update(&mut hasher, s.as_bytes());
114        }
115        sha2::Digest::update(&mut hasher, policy_version.to_be_bytes());
116
117        let hash: [u8; 32] = hasher.finalize().into();
118        Self(hash)
119    }
120
121    pub fn as_hex(&self) -> String {
122        hex::encode(self.0)
123    }
124}
125
126/// A cached decision record.
127///
128/// The `effect` string is `"allow"` or `"deny"`. `cached_at_policy_version`
129/// is the policy version that was active when this entry was inserted;
130/// `get` ignores entries whose version doesn't match the current
131/// `policy_version`, achieving invalidation on policy reload.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct CachedDecision {
134    pub effect: String,
135    pub policies: Vec<String>,
136    pub reasons: Vec<String>,
137    pub cached_at_policy_version: u64,
138}
139
140impl CachedDecision {
141    /// Construct an `Allow` decision with the current policy version.
142    pub fn allow() -> Self {
143        Self {
144            effect: "allow".into(),
145            policies: vec![],
146            reasons: vec![],
147            cached_at_policy_version: 0,
148        }
149    }
150
151    /// Construct a `Deny` decision with the current policy version.
152    pub fn deny() -> Self {
153        Self {
154            effect: "deny".into(),
155            policies: vec![],
156            reasons: vec![],
157            cached_at_policy_version: 0,
158        }
159    }
160}
161
162/// Configuration for [`DecisionCache`].
163#[derive(Debug, Clone)]
164pub struct CacheConfig {
165    /// Maximum number of entries.
166    pub capacity: usize,
167    /// TTL for "allow" decisions.
168    pub allow_ttl: Duration,
169    /// TTL for "deny" decisions. Conservative (shorter) because deny flips are
170    /// security-sensitive.
171    pub deny_ttl: Duration,
172    /// Whether to cache deny decisions at all.
173    pub cache_denies: bool,
174}
175
176impl Default for CacheConfig {
177    fn default() -> Self {
178        Self {
179            capacity: 10_000,
180            allow_ttl: Duration::from_secs(60),
181            deny_ttl: Duration::from_secs(5),
182            cache_denies: true,
183        }
184    }
185}
186
187/// LRU + TTL decision cache.
188///
189/// Backed by a simple LRU map. Thread-safe via [`parking_lot::RwLock`]:
190/// reads (the common case on a hot PDP) take a shared lock and run
191/// concurrently; only `put()` (cache fill) takes the exclusive lock.
192///
193/// Note: a cache miss can still trigger a "thundering herd" where many
194/// concurrent readers all see `None` for a popular key and all run the
195/// underlying authorization. A single-flight pattern (per-key `OnceCell`)
196/// would close this, but is left for a future enhancement.
197pub struct DecisionCache {
198    config: CacheConfig,
199    clock: Arc<dyn Clock>,
200    policy_version: AtomicU64,
201    inner: parking_lot::RwLock<lru::LruCache<CacheKey, (CachedDecision, std::time::Instant)>>,
202    hits: AtomicU64,
203    misses: AtomicU64,
204    evictions: AtomicU64,
205}
206
207impl DecisionCache {
208    /// Construct a cache after validating its public configuration.
209    ///
210    /// Use this at application boundaries so invalid capacities produce a
211    /// recoverable configuration error instead of a panic.
212    pub fn try_new(
213        config: CacheConfig,
214        clock: Arc<dyn Clock>,
215    ) -> std::result::Result<Self, String> {
216        let capacity = std::num::NonZeroUsize::new(config.capacity)
217            .ok_or_else(|| "cache capacity must be greater than zero".to_owned())?;
218        if std::time::Instant::now()
219            .checked_add(config.allow_ttl)
220            .is_none()
221        {
222            return Err("allow cache TTL exceeds the monotonic clock range".to_owned());
223        }
224        if std::time::Instant::now()
225            .checked_add(config.deny_ttl)
226            .is_none()
227        {
228            return Err("deny cache TTL exceeds the monotonic clock range".to_owned());
229        }
230        Ok(Self {
231            config,
232            clock,
233            policy_version: AtomicU64::new(0),
234            inner: parking_lot::RwLock::new(lru::LruCache::new(capacity)),
235            hits: AtomicU64::new(0),
236            misses: AtomicU64::new(0),
237            evictions: AtomicU64::new(0),
238        })
239    }
240
241    /// Construct a cache with an already validated configuration.
242    ///
243    /// # Panics
244    /// Panics when the capacity is zero or a TTL exceeds the monotonic clock
245    /// range. Use [`Self::try_new`] when values come from application
246    /// configuration or another fallible input.
247    pub fn new(config: CacheConfig, clock: Arc<dyn Clock>) -> Self {
248        Self::try_new(config, clock).expect("DecisionCache configuration must be valid")
249    }
250
251    /// A disabled cache (every call is a miss).
252    pub fn disabled(clock: Arc<dyn Clock>) -> Self {
253        let c = CacheConfig {
254            capacity: 1,
255            ..CacheConfig::default()
256        };
257        let cap = std::num::NonZeroUsize::new(c.capacity).unwrap();
258        Self {
259            config: c,
260            clock,
261            policy_version: AtomicU64::new(0),
262            inner: parking_lot::RwLock::new(lru::LruCache::new(cap)),
263            hits: AtomicU64::new(0),
264            misses: AtomicU64::new(0),
265            evictions: AtomicU64::new(0),
266        }
267    }
268
269    pub fn policy_version(&self) -> u64 {
270        self.policy_version.load(Ordering::Relaxed)
271    }
272
273    /// Bump the policy version. All cache entries are invalidated on the
274    /// next `get()` because the stored `cached_at_policy_version` no longer
275    /// matches.
276    pub fn invalidate_all(&self) {
277        self.policy_version.fetch_add(1, Ordering::Relaxed);
278    }
279
280    /// Convenience: set the policy version to a specific value.
281    pub fn set_policy_version(&self, v: u64) {
282        self.policy_version.store(v, Ordering::Relaxed);
283    }
284
285    pub fn get(&self, key: &CacheKey) -> Option<CachedDecision> {
286        let now = self.clock.now();
287        let policy_version = self.policy_version();
288        // Read path takes a SHARED lock. Many concurrent readers can
289        // coexist. Stale / expired entries are NOT evicted under the
290        // read lock — instead, a separate [`sweep_stale`] call (run
291        // from the policy watcher task) drains them so concurrent
292        // readers do not serialize on the writer after a policy
293        // reload.
294        let guard = self.inner.read();
295        if let Some((cached, expires_at)) = guard.peek(key).cloned() {
296            if cached.cached_at_policy_version != policy_version {
297                self.misses.fetch_add(1, Ordering::Relaxed);
298                return None;
299            }
300            if now >= expires_at {
301                self.misses.fetch_add(1, Ordering::Relaxed);
302                return None;
303            }
304            // Touch for LRU recency (requires mut access; skip here
305            // because we hold a shared lock — readers don't update
306            // recency, which means hot reads can be evicted before
307            // cold reads. Acceptable trade-off for the throughput
308            // gain: the cache capacity is sized for working set, and
309            // eviction is "approximately LRU" which is good enough for
310            // a PDP).
311            self.hits.fetch_add(1, Ordering::Relaxed);
312            return Some(cached);
313        }
314        self.misses.fetch_add(1, Ordering::Relaxed);
315        None
316    }
317
318    /// Drain stale (policy-version mismatch) and expired entries
319    /// from the cache. Cheap when nothing is stale: O(1) under the
320    /// fast path. Designed to be called from a background task or
321    /// after [`Self::invalidate_all`].
322    ///
323    /// Returns the number of entries dropped. Used by tests to assert
324    /// the eviction worked.
325    pub fn sweep_stale(&self) -> usize {
326        let now = self.clock.now();
327        let policy_version = self.policy_version();
328        let mut guard = self.inner.write();
329        let before = guard.len();
330        let stale: Vec<CacheKey> = guard
331            .iter()
332            .filter_map(|(k, (cached, expires_at))| {
333                if cached.cached_at_policy_version != policy_version || now >= *expires_at {
334                    Some(k.clone())
335                } else {
336                    None
337                }
338            })
339            .collect();
340        for k in &stale {
341            guard.pop(k);
342        }
343        before.saturating_sub(guard.len())
344    }
345
346    pub fn put(&self, key: CacheKey, decision: CachedDecision) {
347        let now = self.clock.now();
348        let ttl = match decision.effect.as_str() {
349            "allow" => self.config.allow_ttl,
350            "deny" if self.config.cache_denies => self.config.deny_ttl,
351            _ => return,
352        };
353        // Fill takes an EXCLUSIVE lock. This is rare relative to reads.
354        let mut guard = self.inner.write();
355        let prev = guard.push(key.clone(), (decision, now + ttl));
356        // `lru::LruCache::push` returns `Some((old_k, old_v))` when the
357        // key already existed (the old value is returned on refresh)
358        // OR when a new key caused an LRU eviction (the evicted key
359        // is returned). Distinguish by comparing the returned key
360        // against the one we just pushed: an eviction has a different
361        // key, a refresh returns the same one.
362        if let Some((evicted_key, _)) = prev {
363            if evicted_key != key {
364                self.evictions.fetch_add(1, Ordering::Relaxed);
365            }
366        }
367    }
368
369    pub fn stats(&self) -> CacheStats {
370        CacheStats {
371            hits: self.hits.load(Ordering::Relaxed),
372            misses: self.misses.load(Ordering::Relaxed),
373            evictions: self.evictions.load(Ordering::Relaxed),
374            size: self.inner.read().len(),
375            policy_version: self.policy_version(),
376        }
377    }
378
379    /// Build a `CacheConfig` from the `AGENTGUARD_CACHE_*` environment
380    /// variables.
381    ///
382    /// Environment knobs (humantime where applicable):
383    ///
384    /// - `AGENTGUARD_CACHE_TTL` — sets `allow_ttl`. Does NOT touch
385    ///   `deny_ttl` (use `AGENTGUARD_DENY_CACHE_TTL` for that).
386    /// - `AGENTGUARD_DENY_CACHE_TTL` — sets `deny_ttl` independently.
387    ///   Falls back to the default when unset.
388    /// - `AGENTGUARD_CACHE_CAPACITY` — sets the LRU capacity (positive
389    ///   integer).
390    ///
391    /// Falls back to defaults when unset or unparseable.
392    #[deprecated(note = "use try_config_from_env to reject malformed settings")]
393    pub fn config_from_env() -> CacheConfig {
394        Self::config_from_overrides(
395            std::env::var("AGENTGUARD_CACHE_TTL").ok(),
396            std::env::var("AGENTGUARD_DENY_CACHE_TTL").ok(),
397            std::env::var("AGENTGUARD_CACHE_CAPACITY").ok(),
398        )
399    }
400
401    /// Parse cache settings from the process environment, rejecting malformed
402    /// values rather than silently changing the requested operating limits.
403    pub fn try_config_from_env() -> std::result::Result<CacheConfig, String> {
404        fn read(name: &str) -> std::result::Result<Option<String>, String> {
405            match std::env::var(name) {
406                Ok(value) => Ok(Some(value)),
407                Err(std::env::VarError::NotPresent) => Ok(None),
408                Err(std::env::VarError::NotUnicode(_)) => {
409                    Err(format!("{name} must be valid Unicode"))
410                }
411            }
412        }
413        Self::try_config_from_overrides(
414            read("AGENTGUARD_CACHE_TTL")?.as_deref(),
415            read("AGENTGUARD_DENY_CACHE_TTL")?.as_deref(),
416            read("AGENTGUARD_CACHE_CAPACITY")?.as_deref(),
417        )
418    }
419
420    /// Parse optional environment-style cache overrides strictly.
421    pub fn try_config_from_overrides(
422        cache_ttl: Option<&str>,
423        deny_ttl: Option<&str>,
424        capacity: Option<&str>,
425    ) -> std::result::Result<CacheConfig, String> {
426        let mut cfg = CacheConfig::default();
427        if let Some(ttl) = cache_ttl {
428            cfg.allow_ttl = crate::ttl::parse_duration(ttl)
429                .map_err(|error| format!("AGENTGUARD_CACHE_TTL: {error}"))?;
430        }
431        if let Some(ttl) = deny_ttl {
432            cfg.deny_ttl = crate::ttl::parse_duration(ttl)
433                .map_err(|error| format!("AGENTGUARD_DENY_CACHE_TTL: {error}"))?;
434        }
435        if let Some(capacity) = capacity {
436            cfg.capacity = capacity
437                .parse::<usize>()
438                .ok()
439                .filter(|capacity| *capacity > 0)
440                .ok_or_else(|| {
441                    "AGENTGUARD_CACHE_CAPACITY must be a positive integer when set".to_owned()
442                })?;
443        }
444        Ok(cfg)
445    }
446
447    /// Same as [`Self::config_from_env`] but accepts explicit
448    /// overrides. Used by tests so they don't have to mutate
449    /// `std::env` from `unsafe` blocks (the crate forbids unsafe).
450    pub fn config_from_overrides(
451        cache_ttl: Option<String>,
452        deny_ttl: Option<String>,
453        capacity: Option<String>,
454    ) -> CacheConfig {
455        let mut cfg = CacheConfig::default();
456        if let Some(ttl) = cache_ttl {
457            match crate::ttl::parse_duration(&ttl) {
458                Ok(d) => cfg.allow_ttl = d,
459                Err(_) => tracing::warn!(
460                    ttl = %ttl,
461                    "AGENTGUARD_CACHE_TTL is not a valid duration; using default"
462                ),
463            }
464        }
465        if let Some(ttl) = deny_ttl {
466            match crate::ttl::parse_duration(&ttl) {
467                Ok(d) => cfg.deny_ttl = d,
468                Err(_) => tracing::warn!(
469                    ttl = %ttl,
470                    "AGENTGUARD_DENY_CACHE_TTL is not a valid duration; using default"
471                ),
472            }
473        }
474        if let Some(cap) = capacity {
475            if let Ok(n) = cap.parse::<usize>() {
476                if n > 0 {
477                    cfg.capacity = n;
478                }
479            }
480        }
481        cfg
482    }
483}
484
485#[derive(Debug, Clone, Copy)]
486pub struct CacheStats {
487    pub hits: u64,
488    pub misses: u64,
489    pub evictions: u64,
490    pub size: usize,
491    pub policy_version: u64,
492}
493
494impl CacheStats {
495    pub fn hit_rate(&self) -> f64 {
496        let total = self.hits + self.misses;
497        if total == 0 {
498            0.0
499        } else {
500            self.hits as f64 / total as f64
501        }
502    }
503}
504
505/// Test helper: convert a request + policy_version into a cache key.
506#[cfg(test)]
507fn cache_key_for(req: &AgentRequest, policy_version: u64) -> CacheKey {
508    CacheKey::for_request(req, policy_version)
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::request::AgentRequestBuilder;
515    use crate::ttl::MockClock;
516    use crate::{AgentAction, AgentContext, Principal, Resource};
517    use std::sync::Arc;
518
519    fn req() -> AgentRequest {
520        AgentRequestBuilder::new(Principal::user("alice"))
521            .action(AgentAction::tool("send_email"))
522            .resource(Resource::new("Mailbox", "alice@acme"))
523            .context(AgentContext::new().with_arg("to", "[email protected]"))
524            .build()
525            .unwrap()
526    }
527
528    fn req_with_marker(marker: u64) -> AgentRequest {
529        let principal_id = format!("alice-{marker}");
530        let resource_id = format!("alice-{marker}@acme");
531        AgentRequestBuilder::new(Principal::user(principal_id))
532            .action(AgentAction::tool("send_email"))
533            .resource(Resource::new("Mailbox", resource_id))
534            .context(AgentContext::new().with_arg("to", "[email protected]"))
535            .build()
536            .unwrap()
537    }
538
539    #[test]
540    fn try_new_rejects_zero_capacity_without_panicking() {
541        let clock: Arc<dyn Clock> = Arc::new(MockClock::new());
542        let config = CacheConfig {
543            capacity: 0,
544            ..CacheConfig::default()
545        };
546        let error = DecisionCache::try_new(config, clock)
547            .err()
548            .expect("zero capacity should return a configuration error");
549        assert_eq!(error, "cache capacity must be greater than zero");
550    }
551
552    #[test]
553    fn try_new_rejects_ttl_outside_monotonic_clock_range() {
554        let clock: Arc<dyn Clock> = Arc::new(MockClock::new());
555        let config = CacheConfig {
556            allow_ttl: Duration::MAX,
557            ..CacheConfig::default()
558        };
559        let error = DecisionCache::try_new(config, clock)
560            .err()
561            .expect("unrepresentable TTL should return a configuration error");
562        assert_eq!(error, "allow cache TTL exceeds the monotonic clock range");
563    }
564
565    #[test]
566    fn cache_miss_then_hit() {
567        let clock = Arc::new(MockClock::new());
568        let cache = DecisionCache::new(CacheConfig::default(), clock.clone());
569        let key = CacheKey::for_request(&req(), 0);
570        assert!(cache.get(&key).is_none());
571        cache.put(key.clone(), CachedDecision::allow());
572        let got = cache.get(&key).unwrap();
573        assert_eq!(got.effect, "allow");
574        assert_eq!(cache.stats().hits, 1);
575        assert_eq!(cache.stats().misses, 1);
576    }
577
578    #[test]
579    fn entity_attributes_change_cache_identity() {
580        let entity = cedar_policy::Entity::from_json_str(
581            r#"{"uid":{"type":"User","id":"alice"},"attrs":{"admin":true},"parents":[]}"#,
582            None,
583        )
584        .unwrap();
585        let entities = cedar_policy::Entities::from_entities([entity], None).unwrap();
586        assert_ne!(
587            CacheKey::for_request(&req(), 0),
588            CacheKey::for_request_with_entities(&req(), &entities, 0)
589        );
590    }
591
592    #[test]
593    #[allow(clippy::field_reassign_with_default)]
594    fn expire_after_ttl() {
595        let clock = Arc::new(MockClock::new());
596        let mut cfg = CacheConfig::default();
597        cfg.allow_ttl = Duration::from_secs(5);
598        let cache = DecisionCache::new(cfg, clock.clone());
599        let key = CacheKey::for_request(&req(), 0);
600        cache.put(key.clone(), CachedDecision::allow());
601        clock.advance_unix(Duration::from_secs(10));
602        assert!(cache.get(&key).is_none(), "should have expired");
603    }
604
605    #[test]
606    fn invalidate_on_policy_version_bump() {
607        let clock = Arc::new(MockClock::new());
608        let cache = DecisionCache::new(CacheConfig::default(), clock.clone());
609        let key = CacheKey::for_request(&req(), 0);
610        cache.put(key.clone(), CachedDecision::allow());
611        assert!(cache.get(&key).is_some());
612        cache.invalidate_all();
613        assert!(cache.get(&key).is_none(), "policy bump should invalidate");
614    }
615
616    #[test]
617    fn deny_cached_when_enabled() {
618        let clock = Arc::new(MockClock::new());
619        let cache = DecisionCache::new(CacheConfig::default(), clock.clone());
620        let key = CacheKey::for_request(&req(), 0);
621        cache.put(key.clone(), CachedDecision::deny());
622        assert_eq!(cache.get(&key).unwrap().effect, "deny");
623    }
624
625    #[test]
626    #[allow(clippy::field_reassign_with_default)]
627    fn deny_not_cached_when_disabled() {
628        let clock = Arc::new(MockClock::new());
629        let mut cfg = CacheConfig::default();
630        cfg.cache_denies = false;
631        let cache = DecisionCache::new(cfg, clock.clone());
632        let key = CacheKey::for_request(&req(), 0);
633        cache.put(key.clone(), CachedDecision::deny());
634        assert!(cache.get(&key).is_none(), "denies not cached");
635    }
636
637    #[test]
638    fn hit_rate_calculation() {
639        let s = CacheStats {
640            hits: 7,
641            misses: 3,
642            evictions: 0,
643            size: 5,
644            policy_version: 0,
645        };
646        assert!((s.hit_rate() - 0.7).abs() < 1e-9);
647    }
648
649    #[test]
650    fn concurrent_reads_dont_block_writers() {
651        use std::sync::Arc;
652        use std::sync::Barrier;
653        use std::thread;
654
655        let clock = Arc::new(MockClock::new());
656        let cache = Arc::new(DecisionCache::new(CacheConfig::default(), clock));
657        let key = cache_key_for(&req(), 0);
658
659        // Seed the cache so reads hit.
660        cache.put(key.clone(), CachedDecision::allow());
661
662        // ponytail: deterministic Barrier replaces the previous
663        // thread::yield_now() (which was timing-sensitive). Each
664        // reader runs a small, bounded number of gets so progress
665        // is guaranteed on any scheduler.
666        const N_READERS: usize = 4;
667        const LOOPS_PER_READER: usize = 50;
668        let ready = Arc::new(Barrier::new(N_READERS + 1));
669        let mut handles = Vec::new();
670        for _ in 0..N_READERS {
671            let cache = Arc::clone(&cache);
672            let key = key.clone();
673            let ready = Arc::clone(&ready);
674            handles.push(thread::spawn(move || {
675                ready.wait();
676                for _ in 0..LOOPS_PER_READER {
677                    let _ = cache.get(&key);
678                }
679            }));
680        }
681
682        // Wait for all readers to be in their loop before writing.
683        ready.wait();
684        for v in 1..=5 {
685            cache.set_policy_version(v);
686            cache.put(key.clone(), CachedDecision::allow());
687        }
688        for h in handles {
689            h.join().unwrap();
690        }
691        // The test's purpose is to confirm no deadlock. h.join().unwrap()
692        // returning for all 4 readers proves the RwLock works correctly
693        // under concurrent reader/writer traffic. We intentionally
694        // don't assert on hits/misses counts — those are racy and
695        // a small number of policy_version bumps can race every
696        // reader to a miss.
697    }
698
699    #[test]
700    fn eviction_counter_increments() {
701        let clock = Arc::new(MockClock::new());
702        let cfg = CacheConfig {
703            capacity: 2,
704            ..CacheConfig::default()
705        };
706        let cache = DecisionCache::new(cfg, clock.clone());
707
708        for i in 0..4u64 {
709            let key = CacheKey::for_request(&req_with_marker(i), i);
710            cache.put(key, CachedDecision::allow());
711        }
712
713        assert!(
714            cache.stats().evictions >= 2,
715            "evictions counter should reflect LRU churn, got {}",
716            cache.stats().evictions
717        );
718    }
719
720    #[test]
721    fn concurrent_eviction_throughput() {
722        // Stress test for the read-then-evict contention path: many
723        // readers simultaneously observe stale entries after a
724        // policy reload. With the old implementation they serialized
725        // on the writer; the new implementation defers eviction to
726        // sweep_stale and lets readers return None without taking a
727        // write lock.
728        use std::sync::Barrier;
729        let clock = Arc::new(MockClock::new());
730        let cache = Arc::new(DecisionCache::new(CacheConfig::default(), clock.clone()));
731
732        let key = CacheKey::for_request(&req(), 0);
733        cache.put(key.clone(), CachedDecision::allow());
734        cache.invalidate_all();
735
736        let barrier = Arc::new(Barrier::new(16));
737        let mut handles = Vec::new();
738        for _ in 0..16 {
739            let cache = cache.clone();
740            let key = key.clone();
741            let barrier = barrier.clone();
742            handles.push(std::thread::spawn(move || {
743                barrier.wait();
744                for _ in 0..1000 {
745                    assert!(cache.get(&key).is_none());
746                }
747            }));
748        }
749        for h in handles {
750            h.join().unwrap();
751        }
752        // Drain any stragglers via the new background sweep.
753        let drained = cache.sweep_stale();
754        assert!(
755            drained >= 1,
756            "sweep_stale must have evicted the stale entry, got {drained}"
757        );
758    }
759
760    #[test]
761    fn config_from_overrides_sets_both_independently() {
762        // AGENTGUARD_CACHE_TTL should set allow_ttl only;
763        // AGENTGUARD_DENY_CACHE_TTL sets deny_ttl independently.
764        // Neither silently caps the other.
765        let cfg = DecisionCache::config_from_overrides(
766            Some("600s".to_string()),
767            Some("2s".to_string()),
768            None,
769        );
770        assert_eq!(cfg.allow_ttl, Duration::from_secs(600));
771        assert_eq!(cfg.deny_ttl, Duration::from_secs(2));
772    }
773
774    #[test]
775    fn strict_config_parser_rejects_invalid_ttls_and_capacity() {
776        assert!(
777            DecisionCache::try_config_from_overrides(Some("soon"), None, None)
778                .unwrap_err()
779                .contains("AGENTGUARD_CACHE_TTL")
780        );
781        assert!(
782            DecisionCache::try_config_from_overrides(None, Some("soon"), None)
783                .unwrap_err()
784                .contains("AGENTGUARD_DENY_CACHE_TTL")
785        );
786        for capacity in ["0", "-1", "many"] {
787            assert!(DecisionCache::try_config_from_overrides(None, None, Some(capacity)).is_err());
788        }
789    }
790
791    #[test]
792    fn strict_config_parser_applies_explicit_overrides() {
793        let cfg =
794            DecisionCache::try_config_from_overrides(Some("600s"), Some("2s"), Some("42")).unwrap();
795        assert_eq!(cfg.allow_ttl, Duration::from_secs(600));
796        assert_eq!(cfg.deny_ttl, Duration::from_secs(2));
797        assert_eq!(cfg.capacity, 42);
798    }
799}