Skip to main content

verdant_runtime/
store.rs

1//! Content-addressed payload store. The cache key is the blake3 hash of
2//! the canonical *input* bytes for a tool call (not the output); the value
3//! is the exact output payload bytes that the MCP tool fed back to the
4//! model on its original execution. The store is append-only for M1; M2
5//! will add eviction.
6//!
7//! Layout on disk:
8//!
9//! ```text
10//! <root>/
11//!   ab/                       # first two hex chars of the key
12//!     ab12cd...ef.payload     # raw bytes
13//!     ab12cd...ef.meta.json   # invalidation metadata (raw_hash, size, kind)
14//! ```
15//!
16//! Keys are 64-char lowercase hex strings; we shard by the first two chars
17//! so a single project does not produce a directory with 100k+ entries.
18
19use serde::{Deserialize, Serialize};
20use std::fs;
21use std::io::{self, Read as _, Write as _};
22use std::path::{Path, PathBuf};
23
24#[derive(Debug, thiserror::Error)]
25pub enum StoreError {
26    #[error("io: {0}")]
27    Io(#[from] io::Error),
28    #[error("malformed key: {0}")]
29    BadKey(String),
30    #[error("metadata decode failed: {0}")]
31    Meta(#[from] serde_json::Error),
32    #[error(
33        "integrity check failed for key {key}: stored payload hash {actual} != expected {expected}"
34    )]
35    Integrity {
36        key: String,
37        expected: String,
38        actual: String,
39    },
40}
41
42/// Lowercase hex blake3 digest. Wrapping in a newtype so we can't confuse
43/// a payload-hash with the cache key (which hashes inputs, not outputs).
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct Key(pub String);
46
47impl Key {
48    pub fn from_bytes(bytes: &[u8]) -> Self {
49        Key(blake3::hash(bytes).to_hex().to_string())
50    }
51
52    pub fn as_str(&self) -> &str {
53        &self.0
54    }
55
56    fn validate(&self) -> Result<(), StoreError> {
57        if self.0.len() != 64 || !self.0.chars().all(|c| c.is_ascii_hexdigit()) {
58            return Err(StoreError::BadKey(self.0.clone()));
59        }
60        Ok(())
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct PayloadMeta {
66    /// blake3 of the raw payload bytes; recorded so reads can detect a
67    /// torn write or external file corruption rather than silently
68    /// returning bad bytes.
69    pub payload_hash: String,
70    /// Length of the payload bytes.
71    pub bytes: u64,
72    /// Tool kind tag, free-form ("read", "bash", etc.) — used for
73    /// telemetry and for tool-specific revalidation logic that the
74    /// runtime layer applies on green hits.
75    pub tool_kind: String,
76    /// File dependencies of this entry. Each entry pairs a path with the
77    /// blake3 of that file at the time the cache entry was written; on
78    /// every green-hit lookup the file is re-blake3'd and the entry is
79    /// invalidated on mismatch. Stored on disk so a fresh process can
80    /// restore the cache state without depending on an in-memory
81    /// registry that does not survive restart.
82    #[serde(default)]
83    pub file_roots: Vec<FileRootSerde>,
84    /// Upstream cache-key dependencies. For LlmCall entries this is the
85    /// set of tool-call cache keys whose results appeared in the
86    /// prompt's `tool_result` blocks; when one of those tool entries is
87    /// invalidated by a file edit, every LlmCall whose upstream set
88    /// contains that key is invalidated too. Tool-call entries
89    /// typically leave this empty (their dependencies are encoded in
90    /// `file_roots`); future M3+ extensions may use it for nested
91    /// composite nodes.
92    #[serde(default)]
93    pub upstream_keys: Vec<String>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct FileRootSerde {
98    pub path: String,
99    pub expected_hash: String,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Payload {
104    pub bytes: Vec<u8>,
105    pub meta: PayloadMeta,
106}
107
108/// Storage backend trait. Concrete impls: `FileStore` (local
109/// content-addressed disk), and (M4 step 7+) `RemoteStore` over HTTP.
110/// `LiveCache` owns one `Box<dyn Store>` and routes every
111/// content-addressed read/write through this trait so the same cache
112/// state machine works against either backend without conditional
113/// compilation in the runtime layer.
114pub trait Store: Send + Sync {
115    fn persist_with_upstreams(
116        &self,
117        key: &Key,
118        bytes: &[u8],
119        tool_kind: &str,
120        file_roots: Vec<FileRootSerde>,
121        upstream_keys: Vec<String>,
122    ) -> Result<(), StoreError>;
123
124    fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError>;
125
126    fn remove(&self, key: &Key) -> Result<(), StoreError>;
127
128    fn total_bytes(&self) -> Result<u64, StoreError>;
129
130    fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError>;
131
132    fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError>;
133
134    fn contains(&self, key: &Key) -> bool;
135
136    fn persist(
137        &self,
138        key: &Key,
139        bytes: &[u8],
140        tool_kind: &str,
141        file_roots: Vec<FileRootSerde>,
142    ) -> Result<(), StoreError> {
143        self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
144    }
145}
146
147#[derive(Debug)]
148pub struct FileStore {
149    root: PathBuf,
150}
151
152impl FileStore {
153    pub fn open(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
154        let root = root.into();
155        fs::create_dir_all(&root)?;
156        Ok(Self { root })
157    }
158
159    pub fn root(&self) -> &Path {
160        &self.root
161    }
162
163    fn shard_dir(&self, key: &Key) -> PathBuf {
164        self.root.join(&key.0[..2])
165    }
166
167    fn payload_path(&self, key: &Key) -> PathBuf {
168        self.shard_dir(key).join(format!("{}.payload", key.0))
169    }
170
171    fn meta_path(&self, key: &Key) -> PathBuf {
172        self.shard_dir(key).join(format!("{}.meta.json", key.0))
173    }
174
175    /// Write a payload. Uses tempfile + rename so a crash mid-write leaves
176    /// the store in a consistent state (either the entry is fully there
177    /// or it is not), which keeps `lookup` from ever observing torn data.
178    pub fn persist(
179        &self,
180        key: &Key,
181        bytes: &[u8],
182        tool_kind: &str,
183        file_roots: Vec<FileRootSerde>,
184    ) -> Result<(), StoreError> {
185        self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
186    }
187
188    /// Persist a payload that depends on previously-cached upstream
189    /// entries. The LlmCall path uses this to record which tool-call
190    /// keys it consumed, so downstream invalidation can walk the
191    /// dependency edge and drop dependent entries when a tool key is
192    /// marked dirty.
193    pub fn persist_with_upstreams(
194        &self,
195        key: &Key,
196        bytes: &[u8],
197        tool_kind: &str,
198        file_roots: Vec<FileRootSerde>,
199        upstream_keys: Vec<String>,
200    ) -> Result<(), StoreError> {
201        key.validate()?;
202        fs::create_dir_all(self.shard_dir(key))?;
203
204        let payload_hash = blake3::hash(bytes).to_hex().to_string();
205        let meta = PayloadMeta {
206            payload_hash,
207            bytes: bytes.len() as u64,
208            tool_kind: tool_kind.to_string(),
209            file_roots,
210            upstream_keys,
211        };
212
213        write_atomic(&self.payload_path(key), bytes)?;
214        let meta_bytes = serde_json::to_vec(&meta)?;
215        write_atomic(&self.meta_path(key), &meta_bytes)?;
216        Ok(())
217    }
218
219    /// Delete the payload + meta for a key. Used by `LiveCache` when an
220    /// upstream invalidation makes a cached entry definitely-stale; the
221    /// caller has already removed the registry entry, and this drops
222    /// the bytes from disk so a future rehydration does not resurrect
223    /// the entry.
224    pub fn remove(&self, key: &Key) -> Result<(), StoreError> {
225        key.validate()?;
226        let pp = self.payload_path(key);
227        let mp = self.meta_path(key);
228        if pp.exists() {
229            fs::remove_file(&pp)?;
230        }
231        if mp.exists() {
232            fs::remove_file(&mp)?;
233        }
234        Ok(())
235    }
236
237    /// Total bytes occupied by the store, summed across every payload
238    /// and meta file under the root. Used by `evict_to_cap` and by
239    /// operator-facing stats; cheap on small stores, linear-walk on
240    /// large ones (we accept the cost because eviction is a periodic
241    /// operation, not a hot path).
242    pub fn total_bytes(&self) -> Result<u64, StoreError> {
243        let mut total: u64 = 0;
244        let entries = match fs::read_dir(&self.root) {
245            Ok(e) => e,
246            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
247            Err(e) => return Err(e.into()),
248        };
249        for shard in entries.flatten() {
250            let shard_path = shard.path();
251            if !shard_path.is_dir() {
252                continue;
253            }
254            for entry in fs::read_dir(&shard_path)?.flatten() {
255                #[allow(clippy::collapsible_if)]
256                if let Ok(md) = entry.metadata() {
257                    if md.is_file() {
258                        total = total.saturating_add(md.len());
259                    }
260                }
261            }
262        }
263        Ok(total)
264    }
265
266    /// Evict oldest entries until `total_bytes() <= cap_bytes`. Order
267    /// is by payload-file `mtime` ascending so the least-recently-
268    /// modified entry is removed first; on filesystems where reads
269    /// update atime but not mtime this is a true write-order eviction
270    /// (entries that have never been re-persisted go first), which is
271    /// the right default for an append-only cache because a hot key
272    /// gets re-persisted on every backend fall-through and a cold key
273    /// does not. Returns the number of entries dropped.
274    ///
275    /// The function intentionally does not touch the in-memory
276    /// `LiveCache` registry; the caller is expected to either call
277    /// this at process startup (before `LiveCache::new` rehydrates)
278    /// or to recreate the cache afterwards.
279    pub fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError> {
280        let mut current = self.total_bytes()?;
281        if current <= cap_bytes {
282            return Ok(0);
283        }
284        let entries = self.entry_stats()?;
285
286        let mut dropped = 0usize;
287        for (_, key, size) in entries {
288            if current <= cap_bytes {
289                break;
290            }
291            if self.remove(&key).is_ok() {
292                current = current.saturating_sub(size);
293                dropped += 1;
294            }
295        }
296        Ok(dropped)
297    }
298
299    /// Per-entry disk stats as `(payload mtime, key, payload+meta bytes)`,
300    /// sorted oldest-first with the store key as a deterministic tiebreaker
301    /// (filesystem mtime granularity collapses many same-session writes onto
302    /// identical timestamps, so without it the ordering at a cap boundary
303    /// would vary across runs). A meta.json whose sibling payload is absent
304    /// (the half-written state a crash between the two atomic renames leaves)
305    /// is included with its own size so callers count and reclaim it rather
306    /// than over-evicting healthy entries.
307    pub fn entry_stats(&self) -> Result<Vec<(std::time::SystemTime, Key, u64)>, StoreError> {
308        let mut entries: Vec<(std::time::SystemTime, Key, u64)> = Vec::new();
309        let dir = match fs::read_dir(&self.root) {
310            Ok(e) => e,
311            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(entries),
312            Err(e) => return Err(e.into()),
313        };
314        for shard in dir.flatten() {
315            let shard_path = shard.path();
316            if !shard_path.is_dir() {
317                continue;
318            }
319            for entry in fs::read_dir(&shard_path)?.flatten() {
320                let p = entry.path();
321                let name = match p.file_name().and_then(|n| n.to_str()) {
322                    Some(s) => s.to_string(),
323                    None => continue,
324                };
325                if let Some(stem) = name.strip_suffix(".payload") {
326                    let key = Key(stem.to_string());
327                    if key.validate().is_err() {
328                        continue;
329                    }
330                    let md = entry.metadata()?;
331                    let payload_len = md.len();
332                    let meta_len = fs::metadata(self.meta_path(&key))
333                        .map(|m| m.len())
334                        .unwrap_or(0);
335                    let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
336                    entries.push((mtime, key, payload_len + meta_len));
337                } else if let Some(stem) = name.strip_suffix(".meta.json") {
338                    let key = Key(stem.to_string());
339                    if key.validate().is_err() || self.payload_path(&key).exists() {
340                        continue;
341                    }
342                    let md = entry.metadata()?;
343                    let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
344                    entries.push((mtime, key, md.len()));
345                }
346            }
347        }
348        entries.sort_by(|(ta, ka, _), (tb, kb, _)| ta.cmp(tb).then_with(|| ka.0.cmp(&kb.0)));
349        Ok(entries)
350    }
351
352    /// Iterate every (key, meta) pair in the store. Used by `LiveCache`
353    /// to rehydrate its in-memory registry on startup so cross-process
354    /// cache hits work — without this, a fresh MCP server would see an
355    /// empty registry and miss every lookup until it re-populated each
356    /// entry from scratch.
357    pub fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError> {
358        let mut out = Vec::new();
359        let entries = match fs::read_dir(&self.root) {
360            Ok(e) => e,
361            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
362            Err(e) => return Err(e.into()),
363        };
364        for shard in entries.flatten() {
365            let shard_path = shard.path();
366            if !shard_path.is_dir() {
367                continue;
368            }
369            for entry in fs::read_dir(&shard_path)?.flatten() {
370                let p = entry.path();
371                let name = match p.file_name().and_then(|n| n.to_str()) {
372                    Some(s) if s.ends_with(".meta.json") => s.to_string(),
373                    _ => continue,
374                };
375                let key_hex = name.trim_end_matches(".meta.json").to_string();
376                let key = Key(key_hex);
377                if key.validate().is_err() {
378                    continue;
379                }
380                let meta: PayloadMeta = match fs::read(&p)
381                    .ok()
382                    .and_then(|b| serde_json::from_slice(&b).ok())
383                {
384                    Some(m) => m,
385                    None => continue,
386                };
387                out.push((key, meta));
388            }
389        }
390        Ok(out)
391    }
392
393    /// Look up a payload. Returns `None` if absent. Returns
394    /// `StoreError::Integrity` if the payload bytes on disk do not match
395    /// the recorded hash, which indicates corruption (torn write that
396    /// somehow survived, on-disk tamper, hardware fault) and must not
397    /// silently return wrong bytes to the model.
398    pub fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError> {
399        key.validate()?;
400        let pp = self.payload_path(key);
401        let mp = self.meta_path(key);
402        // A NotFound on either file is a benign race: eviction or
403        // another process removed one sibling between the two reads
404        // (or between this check and the read). Treat it as a cache
405        // miss rather than surfacing a hard io error to the caller.
406        let mut bytes = Vec::new();
407        match fs::File::open(&pp).and_then(|mut f| f.read_to_end(&mut bytes)) {
408            Ok(_) => {}
409            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
410            Err(e) => return Err(e.into()),
411        }
412        let meta_bytes = match fs::read(&mp) {
413            Ok(b) => b,
414            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
415            Err(e) => return Err(e.into()),
416        };
417        let meta: PayloadMeta = serde_json::from_slice(&meta_bytes)?;
418
419        let actual = blake3::hash(&bytes).to_hex().to_string();
420        if actual != meta.payload_hash {
421            return Err(StoreError::Integrity {
422                key: key.0.clone(),
423                expected: meta.payload_hash.clone(),
424                actual,
425            });
426        }
427        Ok(Some(Payload { bytes, meta }))
428    }
429
430    /// True if the key has a complete entry (both payload and meta).
431    /// Useful for tests and stats; the integrity check still fires on
432    /// `lookup`, so this is an existence-only check.
433    pub fn contains(&self, key: &Key) -> bool {
434        key.validate().is_ok() && self.payload_path(key).exists() && self.meta_path(key).exists()
435    }
436}
437
438impl Store for FileStore {
439    fn persist_with_upstreams(
440        &self,
441        key: &Key,
442        bytes: &[u8],
443        tool_kind: &str,
444        file_roots: Vec<FileRootSerde>,
445        upstream_keys: Vec<String>,
446    ) -> Result<(), StoreError> {
447        FileStore::persist_with_upstreams(self, key, bytes, tool_kind, file_roots, upstream_keys)
448    }
449
450    fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError> {
451        FileStore::lookup(self, key)
452    }
453
454    fn remove(&self, key: &Key) -> Result<(), StoreError> {
455        FileStore::remove(self, key)
456    }
457
458    fn total_bytes(&self) -> Result<u64, StoreError> {
459        FileStore::total_bytes(self)
460    }
461
462    fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError> {
463        FileStore::evict_to_cap(self, cap_bytes)
464    }
465
466    fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError> {
467        FileStore::iter_meta(self)
468    }
469
470    fn contains(&self, key: &Key) -> bool {
471        FileStore::contains(self, key)
472    }
473}
474
475fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
476    // Write to a sibling temp file in the same directory so the rename is
477    // atomic on POSIX (same filesystem). Without this, a crash between
478    // open() and the final write could leave a half-written payload that
479    // a subsequent lookup would return as if intact.
480    let parent = target
481        .parent()
482        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no parent"))?;
483    let mut guard = TempGuard::create_in(parent)?;
484    guard.file.write_all(bytes)?;
485    guard.file.flush()?;
486    guard.persist(target)
487}
488
489// Minimal in-tree temp-file guard. Holds a file handle plus its path; the
490// guard removes the file on drop so a failed write does not leak. Calling
491// `persist` consumes the guard, disarms the cleanup, and renames atomically
492// to the target. Without disarming we'd hit a race where the cleanup
493// removes the temp file before rename observes it, which is what the first
494// version of this helper was doing.
495struct TempGuard {
496    path: PathBuf,
497    file: fs::File,
498    armed: bool,
499}
500
501impl TempGuard {
502    fn create_in(dir: &Path) -> io::Result<Self> {
503        use std::sync::atomic::{AtomicU64, Ordering};
504        static COUNTER: AtomicU64 = AtomicU64::new(0);
505        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
506        let pid = std::process::id();
507        let path = dir.join(format!(".verdant-tmp-{pid}-{n}"));
508        let file = fs::OpenOptions::new()
509            .write(true)
510            .create_new(true)
511            .open(&path)?;
512        Ok(Self {
513            path,
514            file,
515            armed: true,
516        })
517    }
518
519    fn persist(mut self, target: &Path) -> io::Result<()> {
520        self.armed = false;
521        fs::rename(&self.path, target)
522    }
523}
524
525impl Drop for TempGuard {
526    fn drop(&mut self) {
527        if self.armed {
528            let _ = fs::remove_file(&self.path);
529        }
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use tempfile::TempDir;
537
538    fn store() -> (TempDir, FileStore) {
539        let dir = TempDir::new().unwrap();
540        let s = FileStore::open(dir.path().to_path_buf()).unwrap();
541        (dir, s)
542    }
543
544    #[test]
545    fn persist_then_lookup_roundtrip() {
546        let (_d, s) = store();
547        let k = Key::from_bytes(b"input-1");
548        s.persist(&k, b"hello world", "read", vec![]).unwrap();
549        let p = s.lookup(&k).unwrap().expect("must exist");
550        assert_eq!(p.bytes, b"hello world");
551        assert_eq!(p.meta.tool_kind, "read");
552        assert_eq!(p.meta.bytes, 11);
553    }
554
555    #[test]
556    fn lookup_missing_returns_none() {
557        let (_d, s) = store();
558        let k = Key::from_bytes(b"never-written");
559        assert!(s.lookup(&k).unwrap().is_none());
560    }
561
562    #[test]
563    fn integrity_violation_detected() {
564        let (_d, s) = store();
565        let k = Key::from_bytes(b"input-2");
566        s.persist(&k, b"trusted", "read", vec![]).unwrap();
567        // Corrupt the payload on disk under the store's feet — a real
568        // failure would be hardware-induced or external tamper, but a
569        // direct overwrite is the cheapest reproducible test.
570        let pp = s.root.join(&k.0[..2]).join(format!("{}.payload", k.0));
571        fs::write(&pp, b"tampered").unwrap();
572        let err = s.lookup(&k).expect_err("integrity must fire");
573        assert!(matches!(err, StoreError::Integrity { .. }));
574    }
575
576    #[test]
577    fn partial_write_only_meta_returns_none() {
578        // Simulates a case where meta landed but payload did not (or
579        // vice-versa); since we use atomic rename per file but the *pair*
580        // is not jointly atomic, a crash between the two renames can
581        // leave one orphan. lookup must treat that as cache miss, not
582        // partial data.
583        let (_d, s) = store();
584        let k = Key::from_bytes(b"input-3");
585        // Manually drop only a meta file.
586        fs::create_dir_all(s.root.join(&k.0[..2])).unwrap();
587        let mp = s.root.join(&k.0[..2]).join(format!("{}.meta.json", k.0));
588        fs::write(
589            &mp,
590            serde_json::to_vec(&PayloadMeta {
591                payload_hash: blake3::hash(b"orphan").to_hex().to_string(),
592                bytes: 6,
593                tool_kind: "read".into(),
594                file_roots: vec![],
595                upstream_keys: vec![],
596            })
597            .unwrap(),
598        )
599        .unwrap();
600        assert!(s.lookup(&k).unwrap().is_none());
601    }
602
603    #[test]
604    fn lookup_orphan_missing_payload_returns_none_not_err() {
605        // A crash between the two atomic renames (or eviction removing
606        // one sibling) leaves a meta with no payload. lookup must treat
607        // this benign race as a miss, not surface an io error.
608        let (_d, s) = store();
609        let k = Key::from_bytes(b"orphan-meta");
610        s.persist(&k, b"payload bytes", "read", vec![]).unwrap();
611        fs::remove_file(s.payload_path(&k)).unwrap();
612        assert!(
613            matches!(s.lookup(&k), Ok(None)),
614            "payload-missing/meta-present must be Ok(None)"
615        );
616    }
617
618    #[test]
619    fn lookup_orphan_missing_meta_returns_none_not_err() {
620        let (_d, s) = store();
621        let k = Key::from_bytes(b"orphan-payload");
622        s.persist(&k, b"payload bytes", "read", vec![]).unwrap();
623        fs::remove_file(s.meta_path(&k)).unwrap();
624        assert!(
625            matches!(s.lookup(&k), Ok(None)),
626            "meta-missing/payload-present must be Ok(None)"
627        );
628    }
629
630    #[test]
631    fn evict_reclaims_meta_only_orphans() {
632        // A meta.json whose sibling payload is absent still occupies
633        // disk. evict_to_cap must count and remove it, not over-evict
634        // healthy entries to compensate.
635        let (_d, s) = store();
636        let healthy = Key::from_bytes(b"healthy");
637        s.persist(&healthy, &[b'h'; 4096], "read", vec![]).unwrap();
638
639        let orphan = Key::from_bytes(b"orphan-entry");
640        fs::create_dir_all(s.shard_dir(&orphan)).unwrap();
641        let orphan_meta = serde_json::to_vec(&PayloadMeta {
642            payload_hash: blake3::hash(b"gone").to_hex().to_string(),
643            bytes: 4,
644            tool_kind: "read".into(),
645            file_roots: vec![],
646            upstream_keys: vec![],
647        })
648        .unwrap();
649        fs::write(s.meta_path(&orphan), &orphan_meta).unwrap();
650
651        let dropped = s.evict_to_cap(0).unwrap();
652        assert!(dropped >= 2, "both healthy entry and orphan must drop");
653        assert!(
654            !s.meta_path(&orphan).exists(),
655            "meta-only orphan must be removed"
656        );
657        assert_eq!(s.total_bytes().unwrap(), 0);
658    }
659
660    #[test]
661    fn evict_order_is_deterministic_for_equal_mtimes() {
662        // Filesystem mtime granularity collapses same-session writes to
663        // identical timestamps. With every entry sharing one mtime the
664        // primary sort key is a constant, so the eviction victim is
665        // fully determined by the secondary key-order tiebreaker: the
666        // lexicographically-smallest store keys must be the ones
667        // dropped, independent of read_dir enumeration order.
668        let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
669        let dir = TempDir::new().unwrap();
670        let s = FileStore::open(dir.path().to_path_buf()).unwrap();
671        let mut keys: Vec<Key> = (0..8)
672            .map(|i| Key::from_bytes(format!("dk{i}").as_bytes()))
673            .collect();
674        for k in &keys {
675            s.persist(k, &[b'x'; 4096], "read", vec![]).unwrap();
676            fs::File::options()
677                .write(true)
678                .open(s.payload_path(k))
679                .unwrap()
680                .set_modified(fixed)
681                .unwrap();
682        }
683        let before = s.total_bytes().unwrap();
684        let dropped = s.evict_to_cap(before / 2).unwrap();
685        assert!(dropped > 0, "eviction must drop at least one entry");
686
687        let evicted: std::collections::HashSet<String> = keys
688            .iter()
689            .filter(|k| s.lookup(k).unwrap().is_none())
690            .map(|k| k.0.clone())
691            .collect();
692        keys.sort_by(|a, b| a.0.cmp(&b.0));
693        let expected: std::collections::HashSet<String> = keys
694            .iter()
695            .take(evicted.len())
696            .map(|k| k.0.clone())
697            .collect();
698        assert_eq!(
699            evicted, expected,
700            "with equal mtimes the lowest store keys must be the deterministic victims"
701        );
702    }
703
704    #[test]
705    fn total_bytes_sums_payloads_and_meta() {
706        let (_d, s) = store();
707        assert_eq!(s.total_bytes().unwrap(), 0, "fresh store is zero bytes");
708        let k = Key::from_bytes(b"size-test");
709        s.persist(&k, &[b'x'; 1024], "read", vec![]).unwrap();
710        let bytes = s.total_bytes().unwrap();
711        assert!(bytes >= 1024, "payload alone is ≥1024, got {bytes}");
712    }
713
714    #[test]
715    fn evict_to_cap_drops_oldest_first() {
716        let (_d, s) = store();
717        // Persist four entries with distinct mtimes (sleep briefly so
718        // the filesystem mtime resolution doesn't collapse them).
719        let keys: Vec<Key> = (0..4)
720            .map(|i| Key::from_bytes(format!("k{i}").as_bytes()))
721            .collect();
722        for (i, k) in keys.iter().enumerate() {
723            s.persist(k, &[b'A' + i as u8; 4096], "read", vec![])
724                .unwrap();
725            std::thread::sleep(std::time::Duration::from_millis(20));
726        }
727        let before = s.total_bytes().unwrap();
728        assert!(before >= 4 * 4096);
729
730        // Cap to roughly two entries worth.
731        let cap = before / 2;
732        let dropped = s.evict_to_cap(cap).unwrap();
733        assert!(dropped >= 1, "should drop at least one entry");
734        let after = s.total_bytes().unwrap();
735        assert!(
736            after <= cap,
737            "after eviction must fit cap; got {after}/{cap}"
738        );
739
740        // The oldest key (k0) must be gone; the newest (k3) must
741        // still be present.
742        assert!(s.lookup(&keys[0]).unwrap().is_none(), "oldest must evict");
743        assert!(s.lookup(&keys[3]).unwrap().is_some(), "newest must survive");
744    }
745
746    #[test]
747    fn evict_below_cap_is_noop() {
748        let (_d, s) = store();
749        let k = Key::from_bytes(b"small");
750        s.persist(&k, b"tiny", "read", vec![]).unwrap();
751        let dropped = s.evict_to_cap(u64::MAX).unwrap();
752        assert_eq!(dropped, 0);
753        assert!(s.lookup(&k).unwrap().is_some());
754    }
755
756    #[test]
757    fn malformed_key_rejected() {
758        let (_d, s) = store();
759        let bad = Key("not-hex".to_string());
760        assert!(s.persist(&bad, b"x", "read", vec![]).is_err());
761        assert!(s.lookup(&bad).is_err());
762    }
763
764    #[test]
765    fn shard_dirs_distribute_keys() {
766        let (_d, s) = store();
767        for i in 0..16u8 {
768            let k = Key::from_bytes(&[i, i, i]);
769            s.persist(&k, &[i], "read", vec![]).unwrap();
770        }
771        // Count distinct two-char shard directories. With 16 random
772        // blake3 hashes the chance of every key collapsing into one
773        // shard is astronomically small; we assert that we have at
774        // least four distinct shards.
775        let mut shards = std::collections::HashSet::new();
776        for entry in fs::read_dir(s.root()).unwrap() {
777            let e = entry.unwrap();
778            if e.path().is_dir() {
779                shards.insert(e.file_name().to_string_lossy().to_string());
780            }
781        }
782        assert!(shards.len() >= 4, "shards = {shards:?}");
783    }
784
785    #[test]
786    fn contains_only_when_complete() {
787        let (_d, s) = store();
788        let k = Key::from_bytes(b"x");
789        assert!(!s.contains(&k));
790        s.persist(&k, b"y", "read", vec![]).unwrap();
791        assert!(s.contains(&k));
792    }
793}