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