Skip to main content

lean_ctx/core/cache/
validation.rs

1use md5::{Digest, Md5};
2use std::time::SystemTime;
3
4pub fn file_mtime(path: &str) -> Option<SystemTime> {
5    std::fs::metadata(path).and_then(|m| m.modified()).ok()
6}
7
8pub fn is_cache_entry_stale(path: &str, cached_mtime: Option<SystemTime>) -> bool {
9    let current = file_mtime(path);
10    match (cached_mtime, current) {
11        // Both unavailable (e.g. WSL DrvFS): can't tell → assume fresh (conservative).
12        (None, None) => false,
13        // One side missing: metadata changed or appeared/disappeared → stale.
14        (Some(_), None) | (None, Some(_)) => true,
15        // `!=`, not `>`: a *backward* mtime (git checkout, touch -t, snapshot
16        // restore) is just as much a content change as a forward one.
17        (Some(cached), Some(current)) => current != cached,
18    }
19}
20
21/// Files larger than this are not content-hashed for stub verification; the
22/// mtime check alone decides. Keeps the stub fast-path O(small-file-read).
23const VERIFY_HASH_CAP_BYTES: u64 = 8 * 1024 * 1024;
24
25fn cache_verify_enabled() -> bool {
26    std::env::var("LEAN_CTX_CACHE_VERIFY").map_or(true, |v| v != "0")
27}
28
29/// Staleness with content verification: like [`is_cache_entry_stale`], but when
30/// the mtime claims "unchanged", additionally compares the md5 of the on-disk
31/// content against the cached hash.
32///
33/// mtime alone cannot be trusted for *correctness*: same-second writes are
34/// invisible on coarse-granularity filesystems (HFS+ 1s, FAT 2s) and mtimes can
35/// be restored by tools. Serving an `[unchanged]` stub for changed content
36/// would silently mislead the agent — the worst failure mode a context layer
37/// can have. The extra disk read costs microseconds for typical source files;
38/// the stub's token savings are unaffected. Opt out: `LEAN_CTX_CACHE_VERIFY=0`.
39///
40/// Note: entries whose stored content differs from disk by design (e.g. secret
41/// redaction) hash differently and therefore never serve stubs — conservative
42/// and correct.
43pub fn is_cache_entry_stale_verified(
44    path: &str,
45    cached_mtime: Option<SystemTime>,
46    cached_hash: &str,
47) -> bool {
48    if is_cache_entry_stale(path, cached_mtime) {
49        return true;
50    }
51    if cached_hash.is_empty() || !cache_verify_enabled() {
52        return false;
53    }
54    let Ok(meta) = std::fs::metadata(path) else {
55        // Can't stat → never serve a stub on top of it.
56        return true;
57    };
58    if meta.len() > VERIFY_HASH_CAP_BYTES {
59        return false;
60    }
61    match std::fs::read(path) {
62        // Hash the same view of the bytes that `store()` hashed (lossy UTF-8).
63        Ok(bytes) => compute_md5(&String::from_utf8_lossy(&bytes)) != cached_hash,
64        Err(_) => true,
65    }
66}
67
68pub(super) fn compute_md5(content: &str) -> String {
69    let mut hasher = Md5::new();
70    hasher.update(content.as_bytes());
71    crate::core::agent_identity::hex_encode(&hasher.finalize())
72}