openlatch-client 0.1.18

OpenLatch runtime enforcement node — the capture-and-enforce client for the AI Operations Platform
//! In-memory `ContentHashCache` — bounded LRU keyed by absolute path.
//!
//! Lost on restart; rebuilt by initial inventory + periodic rescan. This is
//! NOT trust state — it's a transient view of "what's currently on disk" so
//! a native-hook event and the FS watcher's later event for the same content
//! don't both reach the cloud. The cloud is the source of truth for trust
//! verdicts (per `.claude/rules/config-plane-monitoring.md`).

use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;

use dashmap::DashMap;

/// Composite cache key. `subpath` is `Some` for fan-out kinds (one envelope
/// per MCP server inside a single file) and `None` otherwise.
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub struct CacheKey {
    pub path: PathBuf,
    pub subpath: Option<String>,
}

impl CacheKey {
    /// The path is folded through [`dedup_key`] so the two producers that feed
    /// this cache — the native hook, which reports the path as the agent wrote
    /// it, and the FS watcher, which reports it as the OS spells it — collide
    /// on Windows instead of each minting its own entry and emitting a
    /// duplicate event for one file. On Unix this is the identity.
    ///
    /// [`CacheEntry::path`] keeps the path as observed; only the lookup key is
    /// folded.
    pub fn new(path: PathBuf, subpath: Option<String>) -> Self {
        Self {
            path: crate::path_compat::dedup_key(&path),
            subpath,
        }
    }
}

/// One cached observation. `path_hash` and `content_hash` are SHA-256
/// digests stored as raw bytes (the wire format hex-encodes them).
#[derive(Debug, Clone)]
pub struct CacheEntry {
    pub path: PathBuf,
    pub subpath: Option<String>,
    pub path_hash: [u8; 32],
    pub content_hash: [u8; 32],
    pub last_observed: Instant,
    pub kind: String,
    pub agent: String,
}

impl CacheEntry {
    pub fn key(&self) -> CacheKey {
        CacheKey::new(self.path.clone(), self.subpath.clone())
    }
}

/// Bounded LRU cache.
///
/// `DashMap` provides concurrent access for the lookup hot path; a separate
/// `VecDeque<CacheKey>` under a single `Mutex` tracks LRU ordering for
/// eviction. Recency reordering happens only on insert, so reads don't
/// contend with eviction.
pub struct ContentHashCache {
    inner: DashMap<CacheKey, CacheEntry>,
    lru: Mutex<VecDeque<CacheKey>>,
    max_entries: usize,
}

impl ContentHashCache {
    /// Construct a new cache with `max_entries` capacity. A floor of 64
    /// applies regardless of the requested value to keep tiny caps from
    /// thrashing the LRU under realistic workloads.
    pub fn new(max_entries: usize) -> Self {
        let cap = max_entries.max(64);
        Self {
            inner: DashMap::new(),
            lru: Mutex::new(VecDeque::with_capacity(cap)),
            max_entries: cap,
        }
    }

    /// Insert or update. Returns the previous `content_hash` for the entry,
    /// if any. Triggers LRU eviction when size exceeds `max_entries`.
    pub fn insert(&self, entry: CacheEntry) -> Option<[u8; 32]> {
        let key = entry.key();
        let prev = self
            .inner
            .insert(key.clone(), entry)
            .map(|e| e.content_hash);

        let mut lru = self.lock_lru();
        lru.retain(|k| k != &key);
        lru.push_back(key);
        while lru.len() > self.max_entries {
            if let Some(evicted) = lru.pop_front() {
                self.inner.remove(&evicted);
            }
        }

        prev
    }

    /// Convenience lookup for entries with no subpath (the common
    /// non-fan-out case). For fan-out kinds use `get_subpath` or
    /// `entries_for_path`.
    pub fn get(&self, path: &Path) -> Option<CacheEntry> {
        self.get_subpath(path, None)
    }

    pub fn get_subpath(&self, path: &Path, subpath: Option<&str>) -> Option<CacheEntry> {
        let key = CacheKey::new(path.to_path_buf(), subpath.map(str::to_string));
        self.inner.get(&key).map(|r| r.clone())
    }

    /// All cached entries that share `path`, regardless of subpath. Used by
    /// FS-watcher removal (the file is gone — every server inside it is
    /// gone too) and rescan removal detection.
    pub fn entries_for_path(&self, path: &Path) -> Vec<CacheEntry> {
        // Stored keys are folded by `CacheKey::new`; fold the needle too, or
        // this scans for a spelling the map cannot contain.
        let wanted = crate::path_compat::dedup_key(path);
        self.inner
            .iter()
            .filter(|r| r.key().path == wanted)
            .map(|r| r.clone())
            .collect()
    }

    pub fn remove(&self, path: &Path) -> Option<CacheEntry> {
        self.remove_subpath(path, None)
    }

    pub fn remove_subpath(&self, path: &Path, subpath: Option<&str>) -> Option<CacheEntry> {
        let key = CacheKey::new(path.to_path_buf(), subpath.map(str::to_string));
        self.lock_lru().retain(|k| k != &key);
        self.inner.remove(&key).map(|(_, v)| v)
    }

    /// Remove every entry under `path` (all subpaths). Returns the removed
    /// entries — callers use them to emit `removed` envelopes per server.
    pub fn remove_all_under_path(&self, path: &Path) -> Vec<CacheEntry> {
        let wanted = crate::path_compat::dedup_key(path);
        let keys: Vec<CacheKey> = self
            .inner
            .iter()
            .filter(|r| r.key().path == wanted)
            .map(|r| r.key().clone())
            .collect();
        let mut removed = Vec::with_capacity(keys.len());
        let mut lru = self.lock_lru();
        for key in keys {
            lru.retain(|k| k != &key);
            if let Some((_, v)) = self.inner.remove(&key) {
                removed.push(v);
            }
        }
        removed
    }

    /// Lock the LRU deque, recovering from poison. A poisoned lock means a
    /// previous holder panicked; the deque is still well-formed (we never
    /// leave it half-mutated), so we recover the inner data and continue.
    /// Silently swallowing poison would desync `lru` and `inner`.
    fn lock_lru(&self) -> std::sync::MutexGuard<'_, VecDeque<CacheKey>> {
        match self.lru.lock() {
            Ok(g) => g,
            Err(poisoned) => {
                tracing::warn!("ContentHashCache LRU mutex was poisoned — recovering");
                poisoned.into_inner()
            }
        }
    }

    pub fn len(&self) -> usize {
        self.inner.len()
    }

    pub fn is_empty(&self) -> bool {
        self.inner.is_empty()
    }

    /// Snapshot all entries into an owned Vec. Releases internal locks
    /// before returning so callers can iterate without holding them.
    pub fn snapshot(&self) -> Vec<CacheEntry> {
        self.inner.iter().map(|r| r.clone()).collect()
    }

    /// Compare-and-update. Returns `true` when the new entry's
    /// `content_hash` differs from any cached value (and inserts it),
    /// `false` when it matches (cache untouched). Used by the FS watcher
    /// to suppress duplicate events when a native hook has already
    /// recorded the same content.
    pub fn check_and_update(&self, entry: CacheEntry) -> bool {
        let key = entry.key();
        if let Some(existing) = self.inner.get(&key) {
            if existing.content_hash == entry.content_hash {
                return false;
            }
        }
        self.insert(entry);
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn entry(path: &str, content: u8) -> CacheEntry {
        CacheEntry {
            path: PathBuf::from(path),
            subpath: None,
            path_hash: [0u8; 32],
            content_hash: [content; 32],
            last_observed: Instant::now(),
            kind: "mcp".to_string(),
            agent: "claude-code".to_string(),
        }
    }

    fn subpath_entry(path: &str, subpath: &str, content: u8) -> CacheEntry {
        CacheEntry {
            path: PathBuf::from(path),
            subpath: Some(subpath.to_string()),
            path_hash: [0u8; 32],
            content_hash: [content; 32],
            last_observed: Instant::now(),
            kind: "mcp".to_string(),
            agent: "claude-code".to_string(),
        }
    }

    #[test]
    fn insert_and_get_round_trip() {
        let cache = ContentHashCache::new(64);
        cache.insert(entry("/tmp/a", 1));
        let got = cache.get(Path::new("/tmp/a")).unwrap();
        assert_eq!(got.content_hash, [1u8; 32]);
    }

    #[test]
    fn insert_returns_previous_hash_on_replace() {
        let cache = ContentHashCache::new(64);
        assert!(cache.insert(entry("/tmp/a", 1)).is_none());
        let prev = cache.insert(entry("/tmp/a", 2)).unwrap();
        assert_eq!(prev, [1u8; 32]);
    }

    #[test]
    fn remove_clears_both_inner_and_lru() {
        let cache = ContentHashCache::new(64);
        cache.insert(entry("/tmp/a", 1));
        let removed = cache.remove(Path::new("/tmp/a")).unwrap();
        assert_eq!(removed.content_hash, [1u8; 32]);
        assert!(cache.get(Path::new("/tmp/a")).is_none());
        assert!(cache.is_empty());
    }

    #[test]
    fn check_and_update_returns_false_on_identical_hash() {
        let cache = ContentHashCache::new(64);
        assert!(cache.check_and_update(entry("/tmp/a", 1)));
        assert!(!cache.check_and_update(entry("/tmp/a", 1)));
    }

    #[test]
    fn check_and_update_returns_true_on_changed_hash() {
        let cache = ContentHashCache::new(64);
        cache.check_and_update(entry("/tmp/a", 1));
        assert!(cache.check_and_update(entry("/tmp/a", 2)));
        assert_eq!(
            cache.get(Path::new("/tmp/a")).unwrap().content_hash,
            [2u8; 32]
        );
    }

    #[test]
    fn lru_evicts_oldest_when_over_capacity() {
        // Floor of 64 means we need at least 65 inserts to see eviction.
        let cache = ContentHashCache::new(0);
        assert_eq!(cache.max_entries, 64);
        for i in 0..65u8 {
            cache.insert(entry(&format!("/tmp/{i}"), i));
        }
        assert_eq!(cache.len(), 64);
        // The oldest path (/tmp/0) should have been evicted.
        assert!(cache.get(Path::new("/tmp/0")).is_none());
        assert!(cache.get(Path::new("/tmp/64")).is_some());
    }

    #[test]
    fn touching_an_entry_updates_recency() {
        let cache = ContentHashCache::new(64);
        for i in 0..64u8 {
            cache.insert(entry(&format!("/tmp/{i}"), i));
        }
        // Re-insert /tmp/0 — moves it to the back of the LRU.
        cache.insert(entry("/tmp/0", 99));
        // Now insert one more — /tmp/1 (next-oldest) should be evicted, not /tmp/0.
        cache.insert(entry("/tmp/new", 1));
        assert!(cache.get(Path::new("/tmp/0")).is_some());
        assert!(cache.get(Path::new("/tmp/1")).is_none());
        assert!(cache.get(Path::new("/tmp/new")).is_some());
    }

    #[test]
    fn snapshot_returns_all_entries() {
        let cache = ContentHashCache::new(64);
        cache.insert(entry("/tmp/a", 1));
        cache.insert(entry("/tmp/b", 2));
        let snap = cache.snapshot();
        assert_eq!(snap.len(), 2);
    }

    #[test]
    fn same_path_different_subpath_coexist() {
        let cache = ContentHashCache::new(64);
        cache.insert(subpath_entry("/tmp/a", "ctx7", 1));
        cache.insert(subpath_entry("/tmp/a", "github", 2));
        let entries = cache.entries_for_path(Path::new("/tmp/a"));
        assert_eq!(entries.len(), 2);
        assert_eq!(
            cache
                .get_subpath(Path::new("/tmp/a"), Some("ctx7"))
                .unwrap()
                .content_hash,
            [1u8; 32]
        );
        assert_eq!(
            cache
                .get_subpath(Path::new("/tmp/a"), Some("github"))
                .unwrap()
                .content_hash,
            [2u8; 32]
        );
    }

    #[test]
    fn remove_all_under_path_clears_every_subpath() {
        let cache = ContentHashCache::new(64);
        cache.insert(subpath_entry("/tmp/a", "ctx7", 1));
        cache.insert(subpath_entry("/tmp/a", "github", 2));
        cache.insert(entry("/tmp/other", 9));
        let removed = cache.remove_all_under_path(Path::new("/tmp/a"));
        assert_eq!(removed.len(), 2);
        assert!(cache.entries_for_path(Path::new("/tmp/a")).is_empty());
        assert!(cache.get(Path::new("/tmp/other")).is_some());
    }
}