Skip to main content

coven_core/blob/
cache.rs

1//! The device-local cache for **Remote** blobs: bytes on disk, keyed by the exact
2//! locator hash,
3//! with the folder the file lives in as the only retention truth.
4//!
5//! The cache holds copies of Remote blobs only — re-fetchable from the cloud,
6//! evictable to a size budget, kept-or-dropped per pin. A **Local** blob is not in
7//! the cache: a user-provided Local blob is the user's own file at a path (an
8//! external ref); a host-provided Local blob is in the local store (see
9//! [`local_files`](super::local_files)). So `CacheEager`/`CacheLazy`/pin/budget all
10//! describe a blob only while it is Remote. See the [blob concept tree](crate::blob)
11//! for where the cache sits in the whole storage model.
12//!
13//! There is no cache table. A cached Remote blob is in **exactly one** of two
14//! folders under the store dir, or in neither. Both are segmented by the blob's
15//! namespace, so each namespace's cache evicts against its own budget without
16//! touching another's:
17//!
18//! - `storage/pinned/<namespace>/{ab}/{cd}/<locator-hash>` — kept, budget-exempt. A Remote
19//!   blob's cache copy the user pinned for offline (kept from eviction).
20//! - `storage/cache/<namespace>/{ab}/{cd}/<locator-hash>` — opportunistic, evictable. A blob
21//!   fetched on read (`CacheLazy`) or eagerly on pull (`CacheEager`).
22//! - neither — not cached. No file; fetched from the cloud on the next read.
23//!
24//! Presence is the file on disk; kept-ness is which folder. Nothing the two
25//! `readdir`s can't answer, so no metadata sidecar to keep in sync with the disk.
26//! Reads verify both plaintext size and content hash against the exact row-bound
27//! locator before trusting cached bytes. A corrupt occupied path fails loudly and
28//! is never replaced. Pin/unpin stage a verified copy, publish it without replacing
29//! an occupied destination, then remove the source.
30//!
31//! Both reads **dispatch on coven's own authoritative state** — they never probe
32//! every store and take the first hit. The discriminator is the **locality root**
33//! plus the blob's intrinsic **provenance**, not "is there a local file here." Coven
34//! resolves the blob's backing row (found in the table its `namespace` declares) up
35//! to its gated root or remote root (see
36//! [`Gates::root_kept_of`](crate::sync::gate::Gates::root_kept_of)), then dispatches:
37//!
38//! - **Remote** with an exact locator ⇒ the bytes live in the cloud fronted by
39//!   the device cache. The first legitimate probe runs per-device cache
40//!   materialization — which no shared state records — checking `pinned/` then
41//!   `cache/`, then fetching the exact cloud object.
42//! - **PendingRemote** ⇒ the row's audience is remote but its exact cloud object is
43//!   not published yet. Provenance selects the verified upload source: the external
44//!   file for a user-provided blob or the local store for a host-provided blob.
45//! - **Local** ⇒ the bytes are on-device; provenance picks the copy. A
46//!   **user-provided** blob is the user's own external file (`local_blob_refs`), read
47//!   straight from its path and validated by size + content hash — its ref MUST exist
48//!   ([`BlobCacheError::NoExternalRef`] otherwise). A **host-provided** blob is in the
49//!   **local store** ([`local_files`](super::local_files)), its only copy — a miss is
50//!   fail-loud corruption ([`BlobCacheError::NoLocalCopy`]). Neither falls through to
51//!   the cloud: a Local blob has no cloud copy.
52//!
53//! [`read_blob`] returns the entire blob (a cloud miss fetches + decrypts it and
54//! populates `cache/`); [`open_blob_stream`] serves a plaintext byte range for a host
55//! streaming or seeking (a cloud miss stages and verifies the whole plaintext, then
56//! returns the requested range without populating the cache).
57//!
58//! The cache has a **per-namespace** size budget the host sets per device (see
59//! [`Database::set_cache_budget`]), so a small namespace (`covers`) is never wiped by
60//! pressure from a big one (`release_files`). A namespace's budget counts **only**
61//! the files under `cache/<namespace>/` — `pinned/` is structurally exempt, and
62//! `storage/local` (the local store) is never walked at all. After every populate
63//! into a namespace ([`read_blob`]'s miss-write and [`write_blob`]),
64//! [`evict_to_budget`] sums that namespace's `cache/<namespace>/` files and, if their
65//! total exceeds its budget, deletes the oldest by modification time until the total
66//! is back under it — touching only that namespace's subtree. Modification time is
67//! the recency proxy — there is no `last_accessed` column, the same folder-truth
68//! trade-off the whole cache makes; pinning retains the Remote blobs the user chose
69//! to keep local. With a namespace's budget unset eviction is off for it and its
70//! cache grows without bound. Tests can reset all of `cache/` in one sweep; a pinned
71//! blob (in `pinned/`) survives because it lives in the other folder.
72
73use crate::blob::{Provenance, RowBlobAuthority, RowBlobRef};
74use crate::database::{Database, DbError};
75use crate::store_dir::{PathTokenError, StoreDir};
76use crate::sync::storage::{StorageError, SyncStorage};
77
78/// Closed cloud access for one exact Remote blob. Store code resolves the
79/// authority; the cache only reads bytes with the supplied protection.
80pub(crate) struct RemoteBlobAccess<'a> {
81    storage: &'a dyn SyncStorage,
82    protection: crate::sync::storage::BlobSpoolProtection,
83}
84
85impl<'a> RemoteBlobAccess<'a> {
86    pub(crate) fn new(
87        storage: &'a dyn SyncStorage,
88        protection: crate::sync::storage::BlobSpoolProtection,
89    ) -> Self {
90        Self {
91            storage,
92            protection,
93        }
94    }
95}
96
97/// Prefix for the `protocol_state` keys holding each namespace's device-local cache-size
98/// budget in bytes (a single decimal value per namespace, not per-blob accounting).
99/// The key for one namespace is [`cache_budget_state_key`]. A namespace with no such
100/// key has no budget ⇒ eviction off for it ⇒ that namespace's cache grows unbounded.
101/// Read/written through [`Database::get_cache_budget`] /
102/// [`Database::set_cache_budget`].
103pub const CACHE_BUDGET_STATE_KEY_PREFIX: &str = "cache_budget:";
104
105/// The `protocol_state` key holding `namespace`'s cache-size budget. Namespaces are safe
106/// path tokens (no `:`), so the `cache_budget:` prefix never collides with one.
107pub fn cache_budget_state_key(namespace: &str) -> String {
108    format!("{CACHE_BUDGET_STATE_KEY_PREFIX}{namespace}")
109}
110
111/// Why a blob-cache operation failed.
112#[derive(Debug)]
113pub enum BlobCacheError {
114    /// A blob `id`/`namespace`/`cloud_path` that can't form a safe path — bad data
115    /// that could escape the store dir or can't be partitioned. The blob is
116    /// refused before any path is built (the same gate the pull runs).
117    Path(PathTokenError),
118    /// A cloud read failed: the blob isn't in the cloud, or the backend errored
119    /// (surfaced from [`SyncStorage::get_blob`]).
120    Storage(StorageError),
121    /// A Remote blob's bytes were needed from the cloud but no cloud home is
122    /// connected, so there is no storage to fetch them from. A home-less store
123    /// holds only Local blobs (external refs + the local store), which serve
124    /// straight off disk and never reach the cloud-miss path; reaching here means
125    /// a Remote blob was read with no provider connected — a real fault, surfaced
126    /// rather than masked.
127    NoCloudHome,
128    /// A local-disk failure: a cache write, a folder move, or a test cache reset.
129    /// Carries a human-readable cause.
130    Io(String),
131    /// A blob-metadata query failed — resolving the blob's locality, looking up its
132    /// external ref, or reading its cache budget or expected size. A database read
133    /// the blob path depends on, distinct from a disk I/O failure.
134    Metadata(DbError),
135    /// Building the sync storage from config failed — missing credentials or cloud
136    /// configuration — when a Remote blob needed it. A configuration fault, not a
137    /// disk I/O error.
138    StorageSetup(String),
139    /// The old-value and new-value walks of one changeset disagreed on row count.
140    /// They are two views of the same changeset; a mismatch means cleanup cannot
141    /// pair updated rows with their previous blob ids.
142    ChangesetWalkMismatch { old_count: usize, new_count: usize },
143    /// A registered external blob ref (a user-provided Local blob's user-owned
144    /// file) points at a file that is no longer there — the user moved, renamed, or
145    /// deleted it. Terminal: an external blob has no cloud copy to fall back to, so
146    /// this never re-fetches. The host surfaces a "files missing / moved" state
147    /// whose actions are relocate (pick the new folder, re-register) or re-import.
148    ExternalMissing {
149        id: String,
150        path: std::path::PathBuf,
151        /// The underlying read failure — a missing file or a real I/O error,
152        /// preserved rather than collapsed so the host sees why the read failed.
153        source: String,
154    },
155    /// A registered external blob's file is present but its length no longer matches
156    /// the registered `size` — the user truncated it or replaced it with a
157    /// different-length file. Terminal like [`Self::ExternalMissing`]: a mismatch
158    /// means this is not the exact file coven registered.
159    ExternalSizeMismatch {
160        id: String,
161        path: std::path::PathBuf,
162    },
163    /// A **Local** blob (its gated locality root's gate is off) has no copy in the
164    /// local store. A Local blob has no cloud copy, so there is nothing to fall back
165    /// to: the state is broken, not a cache miss. Surfaced loud rather than silently
166    /// fetching from the cloud — a make_local rollback leftover, an interrupted
167    /// materialize, or a lost local file would otherwise be papered over. The host
168    /// re-materializes or repairs.
169    NoLocalCopy { namespace: String, id: String },
170    /// A blob could not be resolved to a locality: its namespace declares no
171    /// blob-bearing table, or that table has no row with the id, or the row reaches no
172    /// gated root or remote root — so the source of Local-vs-Remote truth can't be
173    /// read. In a consistent store every readable blob has a locality root, so this
174    /// is a real fault — surfaced rather than guessing a source by probing.
175    LocalityUnresolved { id: String },
176    /// The gate resolved a blob to **Local + user-provided**, but no external-ref row
177    /// is registered for it. A user-provided Local blob's bytes live only at the user's
178    /// path, tracked by that ref; its absence is corruption (a lost or never-written
179    /// ref), not a cache miss to fall through — surfaced loud so the host repairs or
180    /// re-imports.
181    NoExternalRef { id: String },
182    /// A Remote blob fetched whole from the cloud came back with a plaintext length
183    /// that disagrees with the row's declared size. The bytes are not what the row
184    /// describes, so they are refused before caching or returning: caching them would
185    /// fail the read's own length check on every later read, warning and refetching
186    /// the same wrong object forever. Terminal like the length checks the cache-hit
187    /// and file-download paths run.
188    CloudSizeMismatch {
189        namespace: String,
190        id: String,
191        expected: u64,
192        actual: u64,
193    },
194    /// A Remote blob fetched from the cloud decrypted to plaintext whose content
195    /// hash disagrees with the author-signed hash on the blob's row. The bytes are
196    /// not the ones the row's author pinned — a tampered object, a rolled-back
197    /// prior version, or a same-size object planted under a different uploader's
198    /// prefix — so they are refused before caching or returning. The row's hash is
199    /// the authority; a mismatch is tamper, never a cache miss to refetch.
200    CloudHashMismatch {
201        namespace: String,
202        id: String,
203        expected: String,
204        actual: String,
205    },
206    /// A blob's row carries no content hash, so a whole-blob download cannot be
207    /// verified against the author's signed value. The hash is a required column,
208    /// so an absent one is bad data (a row that predates the field, or a NULL where
209    /// a hash must be), surfaced rather than serving unverified bytes.
210    MissingContentHash { namespace: String, id: String },
211    /// An authoritative local plaintext file exists at the exact path but its
212    /// bytes differ from the row's signed size/hash.
213    LocalIntegrity {
214        path: std::path::PathBuf,
215        expected_size: u64,
216        actual_size: u64,
217        expected_hash: crate::sync::store_commit::ObjectHash,
218        actual_hash: crate::sync::store_commit::ObjectHash,
219    },
220}
221
222impl std::fmt::Display for BlobCacheError {
223    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224        match self {
225            BlobCacheError::Path(e) => write!(f, "blob path error: {e}"),
226            BlobCacheError::Storage(e) => write!(f, "blob cache storage error: {e}"),
227            BlobCacheError::NoCloudHome => {
228                write!(f, "no cloud home connected to read a Remote blob")
229            }
230            BlobCacheError::Io(e) => write!(f, "blob cache I/O error: {e}"),
231            BlobCacheError::Metadata(e) => write!(f, "blob metadata error: {e}"),
232            BlobCacheError::StorageSetup(e) => write!(f, "sync storage setup failed: {e}"),
233            BlobCacheError::ChangesetWalkMismatch {
234                old_count,
235                new_count,
236            } => write!(
237                f,
238                "changeset old/new walks disagree: {old_count} old rows, {new_count} new rows"
239            ),
240            BlobCacheError::ExternalMissing { id, path, source } => write!(
241                f,
242                "external blob {id} could not be read at {}: {source}",
243                path.display()
244            ),
245            BlobCacheError::ExternalSizeMismatch { id, path } => write!(
246                f,
247                "external blob {id} at {} no longer matches its registered size",
248                path.display()
249            ),
250            BlobCacheError::NoLocalCopy { namespace, id } => write!(
251                f,
252                "local blob {namespace}/{id} is gated Local but absent from the local store"
253            ),
254            BlobCacheError::LocalityUnresolved { id } => write!(
255                f,
256                "cannot resolve locality for blob {id}: no locality root determines where it lives"
257            ),
258            BlobCacheError::NoExternalRef { id } => write!(
259                f,
260                "user-provided Local blob {id} has no registered external ref"
261            ),
262            BlobCacheError::CloudSizeMismatch {
263                namespace,
264                id,
265                expected,
266                actual,
267            } => write!(
268                f,
269                "cloud blob {namespace}/{id} is {actual} bytes, expected {expected}"
270            ),
271            BlobCacheError::CloudHashMismatch {
272                namespace,
273                id,
274                expected,
275                actual,
276            } => write!(
277                f,
278                "cloud blob {namespace}/{id} content hash is {actual}, expected {expected}"
279            ),
280            BlobCacheError::MissingContentHash { namespace, id } => write!(
281                f,
282                "blob {namespace}/{id} has no content hash to verify its bytes against"
283            ),
284            BlobCacheError::LocalIntegrity {
285                path,
286                expected_size,
287                actual_size,
288                expected_hash,
289                actual_hash,
290            } => write!(
291                f,
292                "local blob {} has size/hash {actual_size}/{actual_hash}, expected {expected_size}/{expected_hash}",
293                path.display()
294            ),
295        }
296    }
297}
298
299impl std::error::Error for BlobCacheError {}
300
301impl From<PathTokenError> for BlobCacheError {
302    fn from(e: PathTokenError) -> Self {
303        BlobCacheError::Path(e)
304    }
305}
306
307impl From<StorageError> for BlobCacheError {
308    fn from(e: StorageError) -> Self {
309        BlobCacheError::Storage(e)
310    }
311}
312
313impl From<crate::blob::local_files::LocalBlobError> for BlobCacheError {
314    fn from(e: crate::blob::local_files::LocalBlobError) -> Self {
315        use crate::blob::local_files::LocalBlobError;
316        match e {
317            LocalBlobError::Path(p) => BlobCacheError::Path(p),
318            LocalBlobError::Io(s) => BlobCacheError::Io(s),
319        }
320    }
321}
322
323/// Write a Remote blob's plaintext into the evictable cache under a synthetic exact
324/// locator hash, so a later test read serves it locally without a cloud round-trip and
325/// a pin can promote it to `pinned/`.
326///
327/// Used when a blob becomes Remote and coven already has its plaintext in hand —
328/// the inline push moving a just-uploaded host-provided blob's local-store copy
329/// into the cache — so the cache is populated on write rather than fetch-on-read.
330///
331/// After the bytes land, [`evict_to_budget`] runs so a write that pushes the blob's
332/// namespace cache over that namespace's budget evicts its oldest files back under
333/// budget (a no-op when the namespace has no budget set). The just-written file is
334/// passed as `protect`, so it is excluded from eviction — this write can never drop
335/// the very bytes it produced. An eviction failure is returned to the caller instead
336/// of reporting a budgeted write as complete while the cache could not be trimmed.
337#[cfg(test)]
338pub(crate) async fn write_blob(
339    db: &Database,
340    store_dir: &StoreDir,
341    namespace: &str,
342    locator_hash: crate::sync::store_commit::ObjectHash,
343    bytes: &[u8],
344) -> Result<(), BlobCacheError> {
345    let dest = store_dir.cache_blob_path(namespace, locator_hash)?;
346    crate::local_blob::write_atomic(&dest, bytes)
347        .await
348        .map_err(BlobCacheError::Io)?;
349    // The write into `cache/<namespace>/` may have pushed that namespace over its
350    // budget; evict its oldest files back under it, never the file just written
351    // (passed as `protect`). A no-op when the namespace has no budget set.
352    evict_to_budget(db, store_dir, namespace, Some(&dest)).await?;
353    Ok(())
354}
355
356/// Copy a Remote blob's plaintext source file into the evictable cache without
357/// holding the whole blob in memory. Uses the same cache placement and eviction
358/// contract as the byte-slice cache writer used by tests.
359pub(crate) async fn write_blob_from_file(
360    db: &Database,
361    store_dir: &StoreDir,
362    namespace: &str,
363    locator_hash: crate::sync::store_commit::ObjectHash,
364    plaintext_size: u64,
365    plaintext_hash: crate::sync::store_commit::ObjectHash,
366    src_path: &std::path::Path,
367) -> Result<(), BlobCacheError> {
368    let dest = store_dir.cache_blob_path(namespace, locator_hash)?;
369    let staged = crate::local_blob::stage_atomic_destination(&dest)
370        .await
371        .map_err(BlobCacheError::Io)?;
372    crate::local_blob::copy_atomic(src_path, staged.path())
373        .await
374        .map_err(BlobCacheError::Io)?;
375    verify_local_file_facts(staged.path(), plaintext_size, plaintext_hash).await?;
376    publish_exact_file(staged, plaintext_size, plaintext_hash).await?;
377    evict_to_budget(db, store_dir, namespace, Some(&dest)).await?;
378    Ok(())
379}
380
381/// Write a Remote blob's plaintext straight into the KEPT cache folder
382/// (`storage/pinned/<locator-hash>`), so a just-uploaded blob the user pinned for offline is
383/// kept local and budget-exempt with no later cloud round-trip. The kept sibling of
384/// [`write_blob`] (which writes into the evictable locator-keyed cache).
385///
386/// Called by the upload drain after a successful upload whose entry is
387/// `retain_pinned`: the same plaintext the drain already read to seal is written
388/// here, so the pin is populate-on-write rather than fetch-on-read. The bytes are
389/// the plaintext (what the cache stores and serves), not the sealed ciphertext in
390/// the cloud.
391///
392/// Unlike [`write_blob`] there is NO post-write eviction: `pinned/` is structurally
393/// exempt from the size budget (the sweep never walks it), so a kept populate can
394/// neither push the evictable cache over budget nor be trimmed. Later reads verify
395/// the file's exact size and hash before trusting the pinned bytes.
396pub(crate) async fn populate_pinned(
397    store_dir: &StoreDir,
398    stored: &crate::blob::locator::StoredBlobRef,
399    src_path: &std::path::Path,
400) -> Result<(), BlobCacheError> {
401    let locator = stored.locator();
402    populate_pinned_from_file(
403        store_dir,
404        locator.namespace(),
405        locator.locator_hash(),
406        locator.plaintext_size(),
407        locator.plaintext_hash(),
408        src_path,
409    )
410    .await
411}
412
413pub(crate) async fn populate_pinned_from_file(
414    store_dir: &StoreDir,
415    namespace: &str,
416    locator_hash: crate::sync::store_commit::ObjectHash,
417    plaintext_size: u64,
418    plaintext_hash: crate::sync::store_commit::ObjectHash,
419    src_path: &std::path::Path,
420) -> Result<(), BlobCacheError> {
421    let dest = store_dir.pinned_blob_path(namespace, locator_hash)?;
422    let staged = crate::local_blob::stage_atomic_destination(&dest)
423        .await
424        .map_err(BlobCacheError::Io)?;
425    crate::local_blob::copy_atomic(src_path, staged.path())
426        .await
427        .map_err(BlobCacheError::Io)?;
428    verify_local_file_facts(staged.path(), plaintext_size, plaintext_hash).await?;
429    publish_exact_file(staged, plaintext_size, plaintext_hash).await
430}
431
432/// Read a Remote blob's plaintext from the cache only — the locator-keyed pinned
433/// path then the locator-keyed evictable path, in order — returning `None` when it
434/// is in neither folder. No cloud
435/// fetch and no local-store check: just the two cache folders. A failure to even
436/// check existence (broken filesystem) is surfaced, never collapsed into `None`. Two
437/// callers share this probe:
438///
439/// - [`read_remote_whole`]: the Remote read's cache-hit check — a hit serves the file,
440///   a `None` means fetch from the cloud and populate the cache.
441/// - the inline push's crash-recovery read of a host-provided blob whose local-store
442///   copy a prior cycle already moved into the cache. The push's primary read is from
443///   the local store ([`local_files::read`](super::local_files::read)); this is the
444///   fallback, and a `None` from both tells the push the blob is not ready, so it
445///   aborts rather than publishing a row whose blob never reached the cloud.
446pub async fn read_staged(
447    store_dir: &StoreDir,
448    reference: &RowBlobRef,
449) -> Result<Option<Vec<u8>>, BlobCacheError> {
450    read_cached_exact(store_dir, reference).await
451}
452
453/// Drop one exact Remote blob cache copy from both locator-keyed folders, part of
454/// apply-side cleanup when an incoming changeset deletes
455/// a blob-bearing row (a gate retract or a genuine delete). A peer drops only its
456/// own cache copy here — it never writes a cloud tombstone, which belongs to the
457/// deleting / make-Local owner. The `pinned/` copy is budget-exempt, so without
458/// this it would leak forever once the row is gone. (The local store is dropped
459/// separately by the apply-side caller — a peer holds the blob in the cache, not
460/// the local store, but the caller drops both wherever the bytes are.)
461///
462/// An absent file in either folder is the expected case (a blob is in at most one
463/// folder, or neither), not an error. Every other I/O failure is surfaced.
464pub async fn drop_cached_blob(
465    db: &Database,
466    store_dir: &StoreDir,
467    reference: &RowBlobRef,
468) -> Result<(), BlobCacheError> {
469    validate_row_reference(db, reference).await?;
470    drop_cached_stored_blob(store_dir, remote_stored_ref(reference)?).await
471}
472
473pub(crate) async fn drop_cached_stored_blob(
474    store_dir: &StoreDir,
475    stored: &crate::blob::locator::StoredBlobRef,
476) -> Result<(), BlobCacheError> {
477    let locator = stored.locator();
478    drop_cached_locator(store_dir, locator.namespace(), locator.locator_hash()).await
479}
480
481/// Drop one exact locator's cache and pinned copies without touching the
482/// logical-id-keyed local source. The source and cache represent different
483/// ownership states and can be live independently when logical IDs are reused.
484pub(crate) async fn drop_cached_locator(
485    store_dir: &StoreDir,
486    namespace: &str,
487    locator_hash: crate::sync::store_commit::ObjectHash,
488) -> Result<(), BlobCacheError> {
489    let pinned = store_dir.pinned_blob_path(namespace, locator_hash)?;
490    let cache = store_dir.cache_blob_path(namespace, locator_hash)?;
491    for path in [pinned, cache] {
492        // An absent file in either folder is the expected case (`remove_file`
493        // reports it as `Ok(false)`, not an error); every real I/O failure surfaces.
494        crate::local_blob::remove_file(&path)
495            .await
496            .map_err(BlobCacheError::Io)?;
497    }
498    Ok(())
499}
500
501/// Whether a Remote blob's cache copy is currently pinned — present in
502/// `storage/pinned/<namespace>/<locator-hash>`. The pin truth is the folder a blob's file
503/// lives in, not a table (see the module docs), so this is a single existence
504/// check on the kept folder: a blob in `cache/` or in neither folder is not
505/// pinned. A failure to even check existence (broken filesystem) is surfaced,
506/// never collapsed into "not pinned".
507pub async fn is_pinned(
508    db: &Database,
509    store_dir: &StoreDir,
510    reference: &RowBlobRef,
511) -> Result<bool, BlobCacheError> {
512    validate_row_reference(db, reference).await?;
513    let (pinned, _) = remote_cache_paths(store_dir, reference)?;
514    match crate::local_blob::exists(&pinned).await {
515        Ok(true) => {
516            verify_exact_local_file(&pinned, reference).await?;
517            Ok(true)
518        }
519        Ok(false) => Ok(false),
520        Err(error) => Err(BlobCacheError::Io(error)),
521    }
522}
523
524/// Read a blob's whole contents, dispatching on coven's authoritative state — the
525/// blob's locality root, then its intrinsic provenance — rather than probing every
526/// store and taking the first hit.
527///
528/// [`resolve_source`] reads the row authority first: **Remote** with an exact
529/// locator ⇒ the bytes live in the cloud fronted by the device cache, so the first
530/// legitimate probe checks the exact locator's pinned path then cache path for a per-device cache
531/// copy and serves a hit. A miss resolves the blob's scope to its encryption key,
532/// downloads + decrypts it via [`SyncStorage::get_blob`], writes the
533/// whole blob to its locator-keyed cache path (evictable — a fetch-on-read populates the evictable
534/// cache, never the kept folder), and returns the bytes it just fetched. Later cache
535/// hits verify the file size and hash against the row before trusting it. The read reports
536/// success only after the post-populate [`evict_to_budget`] sweep succeeds.
537///
538/// **Local** or **PendingRemote** ⇒ the bytes are on-device, and provenance picks which copy:
539/// a **user-provided** blob is the user's own external file (`local_blob_refs` row),
540/// read straight from its path and validated by size + content hash — its ref MUST exist
541/// ([`BlobCacheError::NoExternalRef`] if not: a Local user-provided blob without its
542/// ref is corruption, not a fall-through), and a vanished/short file is
543/// [`BlobCacheError::ExternalMissing`] / [`BlobCacheError::ExternalSizeMismatch`]. A
544/// **host-provided** blob is in the **local store** (`storage/local/<namespace>/<id>`,
545/// see [`local_files`](super::local_files)), its only copy — a miss is
546/// [`BlobCacheError::NoLocalCopy`], fail-loud corruption, never a cloud fetch.
547pub(crate) async fn read_blob(
548    db: &Database,
549    store_dir: &StoreDir,
550    remote: Option<RemoteBlobAccess<'_>>,
551    reference: &RowBlobRef,
552) -> Result<Vec<u8>, BlobCacheError> {
553    validate_row_reference(db, reference).await?;
554    let blob = reference.blob();
555    let bytes = match resolve_source(reference)? {
556        // Remote: the bytes live in the cloud fronted by the device cache.
557        BlobSource::Cache => read_remote_whole(db, store_dir, remote, reference).await,
558        // Local + user-provided: the user's own external file. Its ref must be present
559        // — gate-resolved Local + UserProvided with no ref is corruption, not a miss.
560        BlobSource::External => {
561            let ext = lookup_external_ref(db, reference).await?.ok_or_else(|| {
562                BlobCacheError::NoExternalRef {
563                    id: blob.id.clone(),
564                }
565            })?;
566            read_external_file(reference, ext, ExactRead::Whole).await
567        }
568        // Local + host-provided: the local store is the ONLY copy (a Local blob has no
569        // cloud copy). A miss is fail-loud corruption, not a cache miss to refetch.
570        BlobSource::LocalStore => {
571            let path = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
572            match crate::local_blob::exists(&path).await {
573                Ok(true) => {}
574                Ok(false) => {
575                    return Err(BlobCacheError::NoLocalCopy {
576                        namespace: blob.namespace.clone(),
577                        id: blob.id.clone(),
578                    });
579                }
580                Err(error) => return Err(BlobCacheError::Io(error)),
581            }
582            read_exact_local_file(&path, reference, ExactRead::Whole).await
583        }
584    }?;
585    validate_row_reference(db, reference).await?;
586    Ok(bytes)
587}
588
589/// Serve a Remote blob whole. The one legitimate probe — per-device cache
590/// materialization, a filesystem fact no shared state holds — checks the exact
591/// locator's pinned path then cache path and serves a hit, otherwise reading the
592/// exact cloud object. Split from [`read_blob`] so the whole-blob Remote path reads
593/// as one branch of the authority dispatch.
594async fn read_remote_whole(
595    db: &Database,
596    store_dir: &StoreDir,
597    remote: Option<RemoteBlobAccess<'_>>,
598    reference: &RowBlobRef,
599) -> Result<Vec<u8>, BlobCacheError> {
600    let blob = reference.blob();
601    // A cache hit (`pinned/` or `cache/`) serves the file straight off disk — the same
602    // pinned→cache probe [`read_staged`] runs. An existence-check failure there is
603    // surfaced, not collapsed into a miss: re-downloading over a present file would be
604    // wasteful and could mask a real fault.
605    if let Some(bytes) = read_cached_exact(store_dir, reference).await? {
606        return Ok(bytes);
607    }
608
609    // Miss: fetch from the cloud and populate the evictable cache. A home-less
610    // store reaches here only when a Remote blob is read with no provider
611    // connected — there is no storage to fetch it from, so surface that fault.
612    let (_, cache) = remote_cache_paths(store_dir, reference)?;
613    let remote = remote.ok_or(BlobCacheError::NoCloudHome)?;
614    let stored = remote_stored_ref(reference)?;
615    let staged = remote
616        .storage
617        .stage_verified_blob_plaintext(stored, remote.protection, &cache)
618        .await?;
619    let bytes = crate::local_blob::read(staged.path())
620        .await
621        .map_err(BlobCacheError::Io)?;
622    validate_row_reference(db, reference).await?;
623    publish_materialization(staged, reference).await?;
624    // The populate may have pushed `cache/` over budget; evict the oldest files
625    // back under it, never the file just written (passed as `protect`) — so this
626    // read's own sweep can't drop the bytes it just fetched, which it returns below.
627    // A no-op when no budget is set.
628    //
629    evict_to_budget(db, store_dir, &blob.namespace, Some(&cache)).await?;
630    Ok(bytes)
631}
632
633/// Serve `len` plaintext bytes of a blob starting at `offset`, for a host
634/// streaming or seeking it (playback) without loading the whole file. The ranged
635/// sibling of [`read_blob`]: same arguments plus `(source_size, offset, len)`,
636/// returning the plaintext slice.
637///
638/// `source_size` is the blob's plaintext length — the host knows it (the row that
639/// owns the blob carries it) and every serving path needs it to bound the range.
640/// The range is validated once here, against
641/// `source_size`, so a request behaves identically whether it is served from the
642/// local file or the cloud: `len == 0` is an empty result, and an `offset + len`
643/// past `source_size` (or an overflow) is an error, never a short read — the same
644/// contract [`crate::sync::cloud_storage::BlobRangeReader::read`] enforces.
645///
646/// The serving paths mirror [`read_blob`], dispatched the same way ([`resolve_source`]
647/// reads the gate, then provenance): **Remote** serves the range off a cache hit
648/// (the exact locator's pinned or cache path) — the whole-plaintext local file read at `offset`,
649/// no decryption, no cloud. A miss stages and verifies the whole cloud plaintext,
650/// returns the requested range, and **never writes a cache file**; only the
651/// whole-file read populates, and later cache hits must match the
652/// declared blob length before [`read_blob`] serves them. **Local +
653/// user-provided** reads the range off the user's external file (ref required —
654/// [`NoExternalRef`] otherwise; a vanished/short file is [`ExternalMissing`]).
655/// **Local + host-provided** reads the range off the local store ([`NoLocalCopy`] on
656/// a miss, never a cloud fetch).
657///
658/// As in [`read_blob`], a failure to even check a file's existence is surfaced,
659/// never collapsed into a miss (which would re-fetch over a present file and could
660/// mask a real fault).
661///
662/// [`NoExternalRef`]: BlobCacheError::NoExternalRef
663/// [`ExternalMissing`]: BlobCacheError::ExternalMissing
664/// [`NoLocalCopy`]: BlobCacheError::NoLocalCopy
665pub(crate) async fn open_blob_stream(
666    db: &Database,
667    store_dir: &StoreDir,
668    remote: Option<RemoteBlobAccess<'_>>,
669    reference: &RowBlobRef,
670    offset: u64,
671    len: u64,
672) -> Result<Vec<u8>, BlobCacheError> {
673    validate_row_reference(db, reference).await?;
674    let blob = reference.blob();
675    let source_size = reference.plaintext_size();
676    // The range contract, applied once for all serving paths. A zero-length read
677    // is empty without touching disk or cloud; an out-of-range read is an error
678    // before any path runs, so the local-file path can't silently short-read.
679    if len == 0 {
680        return Ok(Vec::new());
681    }
682    let end = offset.checked_add(len).ok_or_else(|| {
683        BlobCacheError::Io(format!(
684            "blob range overflow for {}: offset={offset}, len={len}",
685            blob.id
686        ))
687    })?;
688    if end > source_size {
689        return Err(BlobCacheError::Io(format!(
690            "blob range {offset}..{end} for {} exceeds blob size {source_size}",
691            blob.id
692        )));
693    }
694
695    let bytes = match resolve_source(reference)? {
696        // Remote: the cache copy, else a verified cloud stage (populating nothing).
697        BlobSource::Cache => read_remote_range(db, store_dir, remote, reference, offset, len).await,
698        // Local + user-provided: range-read the user's external file. The window was
699        // validated against `source_size`, and `read_range` reads exactly `len`
700        // (failing loud on a short file). The ref must be present — no fallback.
701        BlobSource::External => {
702            let ext = lookup_external_ref(db, reference).await?.ok_or_else(|| {
703                BlobCacheError::NoExternalRef {
704                    id: blob.id.clone(),
705                }
706            })?;
707            read_external_file(reference, ext, ExactRead::Range { offset, len }).await
708        }
709        // Local + host-provided: range-read the local store, coven's only copy. A miss
710        // is fail-loud corruption, never a cloud fetch.
711        BlobSource::LocalStore => {
712            let path = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
713            match crate::local_blob::exists(&path).await {
714                Ok(true) => {}
715                Ok(false) => {
716                    return Err(BlobCacheError::NoLocalCopy {
717                        namespace: blob.namespace.clone(),
718                        id: blob.id.clone(),
719                    });
720                }
721                Err(error) => return Err(BlobCacheError::Io(error)),
722            }
723            read_exact_local_file(&path, reference, ExactRead::Range { offset, len }).await
724        }
725    }?;
726    validate_row_reference(db, reference).await?;
727    Ok(bytes)
728}
729
730/// Serve a Remote blob's plaintext range. A hit at either exact locator cache path
731/// reads the slice off the whole-plaintext local file. A miss stages and verifies
732/// the whole cloud plaintext, returns its requested range, and writes NO cache file. Split from
733/// [`open_blob_stream`] so the Remote path reads as one branch of the locality
734/// dispatch; the range was already validated by the caller against `source_size`.
735async fn read_remote_range(
736    db: &Database,
737    store_dir: &StoreDir,
738    remote: Option<RemoteBlobAccess<'_>>,
739    reference: &RowBlobRef,
740    offset: u64,
741    len: u64,
742) -> Result<Vec<u8>, BlobCacheError> {
743    // A hit in either folder serves the slice from the local plaintext file. The
744    // file must match the whole blob length, so the validated range is in bounds
745    // and `read_range` reads exactly `len` bytes. An existence-check failure is
746    // surfaced, not read as a miss.
747    if let Some(hit) = cached_blob_path(store_dir, reference).await? {
748        return read_exact_local_file(hit.path(), reference, ExactRead::Range { offset, len })
749            .await;
750    }
751
752    // Miss: serve the range from the cloud (range read + decrypt over the resolved
753    // scope) WITHOUT writing a cache file. Only `read_blob` populates the cache. A
754    // home-less store has no storage to range-read a Remote blob from; surface it.
755    let remote = remote.ok_or(BlobCacheError::NoCloudHome)?;
756    let stored = remote_stored_ref(reference)?;
757    let (_, destination) = remote_cache_paths(store_dir, reference)?;
758    let staged = remote
759        .storage
760        .stage_verified_blob_plaintext(stored, remote.protection, &destination)
761        .await?;
762    let bytes = crate::local_blob::read_range(staged.path(), offset, len)
763        .await
764        .map_err(BlobCacheError::Io)?;
765    validate_row_reference(db, reference).await?;
766    Ok(bytes)
767}
768
769/// Stage a Remote blob's whole verified plaintext beside `dest` without making
770/// `dest` visible. Uses an exact cache copy when present (`pinned/` or `cache/`),
771/// otherwise streams the exact cloud object. The returned stage is not visible at
772/// the destination; its caller chooses how to publish it.
773pub(crate) async fn stage_remote_blob_plaintext(
774    db: &Database,
775    store_dir: &StoreDir,
776    remote: Option<RemoteBlobAccess<'_>>,
777    reference: &RowBlobRef,
778    dest: &std::path::Path,
779) -> Result<crate::local_blob::AtomicStagedFile, BlobCacheError> {
780    validate_row_reference(db, reference).await?;
781    if let Some(hit) = cached_blob_path(store_dir, reference).await? {
782        let staged = stage_exact_local_copy(hit.path(), dest, reference).await?;
783        validate_row_reference(db, reference).await?;
784        return Ok(staged);
785    }
786
787    let remote = remote.ok_or(BlobCacheError::NoCloudHome)?;
788    let stored = remote_stored_ref(reference)?;
789    let staged = remote
790        .storage
791        .stage_verified_blob_plaintext(stored, remote.protection, dest)
792        .await?;
793    validate_row_reference(db, reference).await?;
794    Ok(staged)
795}
796
797/// Ensure the exact current row blob plaintext is durable on this device.
798/// Remote blobs publish to their locator-keyed evictable cache path. Local and
799/// pending-remote blobs already have an authoritative local source, which this
800/// operation exact-verifies without creating a remote cache entry.
801pub(crate) async fn materialize_row_blob(
802    db: &Database,
803    store_dir: &StoreDir,
804    remote: Option<RemoteBlobAccess<'_>>,
805    reference: &RowBlobRef,
806) -> Result<(), BlobCacheError> {
807    validate_row_reference(db, reference).await?;
808    match resolve_source(reference)? {
809        BlobSource::Cache => {
810            let (_, destination) = remote_cache_paths(store_dir, reference)?;
811            if cached_blob_path_with_facts(store_dir, reference)
812                .await?
813                .is_some()
814            {
815                validate_row_reference(db, reference).await?;
816                return Ok(());
817            }
818            let staged =
819                stage_remote_blob_plaintext(db, store_dir, remote, reference, &destination).await?;
820            verify_exact_local_file(staged.path(), reference).await?;
821            validate_row_reference(db, reference).await?;
822            publish_materialization(staged, reference).await
823        }
824        BlobSource::External => {
825            let external = lookup_external_ref(db, reference).await?.ok_or_else(|| {
826                BlobCacheError::NoExternalRef {
827                    id: reference.blob().id.clone(),
828                }
829            })?;
830            verify_external_file(reference, &external).await?;
831            validate_row_reference(db, reference).await
832        }
833        BlobSource::LocalStore => {
834            let blob = reference.blob();
835            let path = store_dir.local_blob_path(&blob.namespace, &blob.id)?;
836            match crate::local_blob::exists(&path).await {
837                Ok(true) => verify_exact_local_file(&path, reference).await?,
838                Ok(false) => {
839                    return Err(BlobCacheError::NoLocalCopy {
840                        namespace: blob.namespace.clone(),
841                        id: blob.id.clone(),
842                    });
843                }
844                Err(error) => return Err(BlobCacheError::Io(error)),
845            }
846            validate_row_reference(db, reference).await
847        }
848    }
849}
850
851async fn publish_materialization(
852    staged: crate::local_blob::AtomicStagedFile,
853    reference: &RowBlobRef,
854) -> Result<(), BlobCacheError> {
855    publish_exact_file(
856        staged,
857        reference.plaintext_size(),
858        reference.plaintext_hash(),
859    )
860    .await
861}
862
863async fn publish_exact_file(
864    staged: crate::local_blob::AtomicStagedFile,
865    expected_size: u64,
866    expected_hash: crate::sync::store_commit::ObjectHash,
867) -> Result<(), BlobCacheError> {
868    match staged.commit_new().await {
869        Ok(()) => Ok(()),
870        Err(crate::local_blob::CommitNewFileError::DestinationExists(path)) => {
871            verify_local_file_facts(&path, expected_size, expected_hash).await
872        }
873        Err(error) => Err(BlobCacheError::Io(error.to_string())),
874    }
875}
876
877/// Materialize a Remote blob's whole plaintext into a coven-owned destination
878/// without replacing an occupied path. An occupied exact file is idempotent; an
879/// occupied file with different size or bytes fails loudly.
880pub(crate) async fn materialize_remote_blob_to_file(
881    db: &Database,
882    store_dir: &StoreDir,
883    remote: Option<RemoteBlobAccess<'_>>,
884    reference: &RowBlobRef,
885    dest: &std::path::Path,
886) -> Result<u64, BlobCacheError> {
887    let staged = stage_remote_blob_plaintext(db, store_dir, remote, reference, dest).await?;
888    verify_exact_local_file(staged.path(), reference).await?;
889    validate_row_reference(db, reference).await?;
890    publish_materialization(staged, reference).await?;
891    Ok(reference.plaintext_size())
892}
893
894/// Ensure a blob is local AND protected: present at its locator-keyed pinned path, exempt
895/// from the evictable cache. A pin POPULATES — if the blob isn't cached it is
896/// fetched first — so it is not a flag flip. Idempotent.
897///
898/// Three cases per blob: already in `pinned/` (nothing to do); in `cache/` (stage
899/// and publish it into `pinned/`, so a read-populated or eagerly-pulled blob is promoted with no
900/// cloud fetch); in neither (fetch from the cloud and write straight to `pinned/`).
901/// `&[RowBlobRef]` rather than ids because every operation must use and revalidate
902/// the exact row version and locator.
903/// Pin one Remote blob into its locator-keyed pinned path: a no-op if already pinned, a verified move
904/// from the evictable cache if staged there, else a cloud fetch straight into
905/// `pinned/`. [`pin`] dispatches this per blob, up to its concurrency limit.
906pub(crate) async fn pin_one(
907    db: &Database,
908    store_dir: &StoreDir,
909    remote: Option<RemoteBlobAccess<'_>>,
910    reference: &RowBlobRef,
911) -> Result<(), BlobCacheError> {
912    validate_row_reference(db, reference).await?;
913    let (pinned, _) = remote_cache_paths(store_dir, reference)?;
914
915    // Already protected — idempotent no-op. A failure to even check existence
916    // (broken filesystem) is surfaced, not collapsed into "absent": fetching and
917    // overwriting a present pinned blob would be wasteful and could mask a real
918    // fault, the same posture `read_blob` takes on its hit check.
919    match crate::local_blob::exists(&pinned).await {
920        Ok(true) => {
921            verify_exact_local_file(&pinned, reference).await?;
922            return Ok(());
923        }
924        Ok(false) => {}
925        Err(e) => return Err(BlobCacheError::Io(e)),
926    }
927
928    // Staged or read-populated in the evictable cache — promote it from a staged
929    // verified copy (no cloud fetch). An `exists`
930    // failure here is surfaced too, never read as "not cached" (which would
931    // re-fetch over a present file).
932    match cached_blob_path_with_facts(store_dir, reference).await? {
933        Some(CachedBlobPath::Pinned(_)) => return Ok(()),
934        Some(CachedBlobPath::Cache(path)) => {
935            return move_exact_cache_file(db, &path, &pinned, reference).await;
936        }
937        None => {}
938    }
939
940    // In neither folder — fetch from the cloud straight into `pinned/`. A
941    // home-less store has no storage to fetch a Remote blob from; surface it.
942    materialize_remote_blob_to_file(db, store_dir, remote, reference, &pinned).await?;
943    Ok(())
944}
945
946/// Drop a Remote blob's pin: move its locator-keyed pinned file to the evictable path so
947/// the cache copy stays (still readable) but is now evictable. Not a delete.
948///
949/// A pin keeps a specific Remote blob's cache copy from eviction; unpin reverses it
950/// regardless of the blob's [`CacheFill`] — a `CacheEager` blob lands in the
951/// evictable cache on pull (it is not auto-pinned), so unpinning one that was never
952/// pinned is simply a no-op (it is already as-evictable-as-it-gets).
953pub async fn unpin(
954    db: &Database,
955    store_dir: &StoreDir,
956    blobs: &[RowBlobRef],
957) -> Result<(), BlobCacheError> {
958    for reference in blobs {
959        validate_row_reference(db, reference).await?;
960        let (pinned, cache) = remote_cache_paths(store_dir, reference)?;
961
962        // Move it into the evictable cache if it is currently pinned. If it isn't in
963        // `pinned/` (already in `cache/`, or remote), there is nothing to demote —
964        // the blob is already as-evictable-as-it-gets, so this is a no-op. A failure
965        // to even check existence is surfaced, never collapsed into "absent": unpin
966        // must not report success over a broken-filesystem check.
967        match crate::local_blob::exists(&pinned).await {
968            Ok(true) => move_exact_cache_file(db, &pinned, &cache, reference).await?,
969            Ok(false) => {}
970            Err(e) => return Err(BlobCacheError::Io(e)),
971        }
972    }
973    Ok(())
974}
975
976/// Drop everything in the evictable cache: delete all of `storage/cache/`, leaving
977/// `storage/pinned/` untouched. A whole-directory sweep, not a per-blob size-budget
978/// eviction — every unpinned blob goes, and a pinned blob (in `pinned/`) survives
979/// because it lives in the other folder.
980///
981/// An absent `cache/` is the only failure that is not an error: it means nothing has
982/// been cached yet, so there is nothing to clear. Every other I/O failure is
983/// returned — a swept directory must actually be gone, never reported clear over a
984/// failed delete.
985#[cfg(test)]
986pub async fn clear_cache(store_dir: &StoreDir) -> Result<(), BlobCacheError> {
987    let cache_dir = store_dir.cache_dir();
988    match crate::local_blob::remove_dir_all(&cache_dir).await {
989        Ok(true) => Ok(()),
990        // No cache dir yet — nothing has been cached, so it is already clear.
991        Ok(false) => {
992            tracing::debug!(
993                "clear_cache: no cache dir at {}, nothing to clear",
994                cache_dir.display()
995            );
996            Ok(())
997        }
998        Err(e) => Err(BlobCacheError::Io(e)),
999    }
1000}
1001
1002/// Evict the oldest files from `namespace`'s cache subtree
1003/// (`storage/cache/<namespace>/`) until its total size is back within that
1004/// namespace's [`Database::get_cache_budget`] budget. The cache layer's per-namespace
1005/// size enforcement, run synchronously after every populate into that namespace
1006/// ([`read_blob`]'s miss-write, [`write_blob`]).
1007///
1008/// Each namespace evicts independently against its own budget, walking **only** its
1009/// own subtree: evicting `release_files` (big) never touches `covers` (a small
1010/// reserved slice). The budget counts **only** the files under
1011/// `cache/<namespace>/` — `pinned/` is never walked (nor is the local store under
1012/// `storage/local/`), so a pinned blob is structurally exempt and can never be
1013/// evicted. With no budget set for this namespace this is a no-op: that namespace's
1014/// cache is unlimited until the host opts it into a budget.
1015///
1016/// Recency is the file's modification time. There is no `last_accessed` column —
1017/// the same folder-truth trade-off the whole cache makes — so the oldest-written
1018/// file is evicted first; pinning, not access tracking, is how a blob is kept.
1019///
1020/// `protect` is the file a just-finished populate wrote (the trigger passes its
1021/// locator-keyed cache path; a bare sweep passes `None`): it is **excluded from the
1022/// candidates outright**, never deleted, so the populate that triggered this sweep
1023/// can't evict the very bytes it just produced. Its size still counts toward the
1024/// total it must fit under, so if that one file alone exceeds the budget the cache
1025/// is left holding exactly it and over budget by that much — the caller still gets
1026/// its bytes, and the next populate's sweep is unaffected. This makes survival
1027/// structural rather than reliant on mtime granularity (two writes within one
1028/// filesystem mtime tick would otherwise be unordered).
1029///
1030/// If every evictable candidate is deleted and the total is still over budget — the
1031/// protected in-use file alone exceeds this namespace's budget — this returns
1032/// `Ok(())` (the file being served can't be evicted), but logs that the cache stays
1033/// over budget because a single in-use blob is larger than the whole budget. It is
1034/// surfaced, not silently reported as if the budget were met.
1035///
1036/// A file that has vanished by the time it is deleted (a concurrent sweep or test
1037/// cache reset already removed it) is the one legitimate skip — logged at debug,
1038/// its now-absent bytes dropped from the running total. Every other stat or delete
1039/// failure is surfaced, never swallowed: a cache that can't be measured or trimmed
1040/// must fail loudly, not silently drift over budget.
1041pub async fn evict_to_budget(
1042    db: &Database,
1043    store_dir: &StoreDir,
1044    namespace: &str,
1045    protect: Option<&std::path::Path>,
1046) -> Result<(), BlobCacheError> {
1047    let budget = match db
1048        .get_cache_budget(namespace)
1049        .await
1050        .map_err(BlobCacheError::Metadata)?
1051    {
1052        Some(budget) => budget,
1053        // This namespace has no budget set — its cache is unlimited, so there is
1054        // nothing to enforce. Another namespace's budget never reaches here.
1055        None => return Ok(()),
1056    };
1057
1058    let mut entries = crate::local_blob::walk_files(&store_dir.cache_namespace_dir(namespace)?)
1059        .await
1060        .map_err(BlobCacheError::Io)?;
1061    // The protected file's bytes count toward the total it must fit under, but it is
1062    // never a deletion candidate — drop it from the list, not the sum.
1063    let mut total: u64 = entries.iter().map(|(_, _, size)| size).sum();
1064    if let Some(protect) = protect {
1065        entries.retain(|(path, _, _)| path.as_path() != protect);
1066    }
1067    if total <= budget {
1068        return Ok(());
1069    }
1070
1071    // Oldest modification time first: that file is evicted first. The recency key is
1072    // milliseconds since the Unix epoch (file mtime), so the smallest sorts first. A
1073    // stable sort is fine — files with the same recency are interchangeable for the
1074    // budget, and the just-written file (the one survival depends on) is already
1075    // excluded above.
1076    entries.sort_by_key(|(_, recency, _)| *recency);
1077
1078    // Each `size` here was part of the `total` sum above, so subtracting it as its
1079    // file is evicted can't underflow as long as that invariant holds. `checked_sub`
1080    // rather than `saturating_sub`: flooring at 0 would mask a genuine accounting
1081    // miscount (a `size` not actually in the sum), so a violation panics loudly
1082    // instead of silently mis-measuring the cache.
1083    for (path, _recency, size) in entries {
1084        if total <= budget {
1085            break;
1086        }
1087        let subtract = |total: u64| {
1088            total.checked_sub(size).unwrap_or_else(|| {
1089                panic!(
1090                    "evict accounting underflow at {}: size {size} > running total {total} \
1091                     (invariant: every cache file's size was summed into the total)",
1092                    path.display()
1093                )
1094            })
1095        };
1096        match crate::local_blob::remove_file(&path).await {
1097            Ok(true) => total = subtract(total),
1098            // The file is already gone (a concurrent sweep or test reset). Its bytes
1099            // are no longer on disk, so drop them from the total and move on — the
1100            // one legitimate skip, not a masked failure.
1101            Ok(false) => {
1102                tracing::debug!("evict: {} already gone, skipping", path.display());
1103                total = subtract(total);
1104            }
1105            Err(e) => return Err(BlobCacheError::Io(e)),
1106        }
1107    }
1108
1109    // Every evictable candidate is gone and the cache is still over budget: the
1110    // protected in-use file alone exceeds this namespace's budget. We can't evict the
1111    // file being served, so return Ok — but surface that the budget is unmet rather
1112    // than reporting success silently.
1113    if total > budget {
1114        tracing::warn!(
1115            "evict: cache stays {} bytes over budget ({total} > {budget}) — a single in-use blob exceeds the whole cache budget",
1116            total - budget
1117        );
1118    }
1119    Ok(())
1120}
1121
1122/// The single store a blob's bytes live in, resolved from coven's authoritative
1123/// state — the blob's locality root, then the blob's intrinsic provenance — the
1124/// [`read_blob`] / [`open_blob_stream`] dispatch key. Neither component is stored on
1125/// the [`BlobRef`]: gated locality is mutable shared state (a make_remote/make_local
1126/// flips it), remote-root locality is declared by the table, and provenance, though
1127/// intrinsic, is read from the row's declaration, not trusted off the address.
1128enum BlobSource {
1129    /// Remote (gate on, or remote root): the cloud, fronted by the device's evictable
1130    /// cache (`pinned/` or `cache/`, else fetched).
1131    Cache,
1132    /// Local (gate off) + user-provided: the user's own external file
1133    /// (`local_blob_refs`).
1134    External,
1135    /// Local (gate off) + host-provided: coven's local store
1136    /// (`storage/local/<namespace>/<id>`).
1137    LocalStore,
1138}
1139
1140/// Resolve the single store a blob's bytes live in from coven's own authoritative
1141/// state — never a probe. Reads the **locality root** first: the carrying row is found in the
1142/// table the blob's `namespace` declares ([`BlobDecls::row_for_blob_in_namespace`] —
1143/// the namespace is the blob's address, so an id colliding across namespaces still
1144/// reads the right table), then walked up to its gated root or remote root
1145/// ([`Gates::root_kept_of`]) using the database's open-time schema models — the same
1146/// row→root→gate resolution the make_remote drain runs ([`crate::blob::upload`]).
1147/// Gate on, or a remote root, ⇒ [`BlobSource::Cache`] (Remote); gate off ⇒ provenance
1148/// picks the Local copy: user-provided ⇒ [`BlobSource::External`], host-provided ⇒
1149/// [`BlobSource::LocalStore`]. A blob whose namespace declares no table, or whose row
1150/// reaches no locality root, has no determinable source —
1151/// [`BlobCacheError::LocalityUnresolved`], surfaced rather than guessed.
1152fn resolve_source(reference: &RowBlobRef) -> Result<BlobSource, BlobCacheError> {
1153    match reference.authority() {
1154        RowBlobAuthority::Remote(_) => Ok(BlobSource::Cache),
1155        RowBlobAuthority::Local | RowBlobAuthority::PendingRemote(_) => {
1156            Ok(match reference.blob().provenance {
1157                Provenance::UserProvided => BlobSource::External,
1158                Provenance::HostProvided => BlobSource::LocalStore,
1159            })
1160        }
1161    }
1162}
1163
1164async fn validate_row_reference(
1165    db: &Database,
1166    reference: &RowBlobRef,
1167) -> Result<(), BlobCacheError> {
1168    db.validate_row_blob_ref(reference)
1169        .await
1170        .map_err(BlobCacheError::Metadata)
1171}
1172
1173fn remote_stored_ref(
1174    reference: &RowBlobRef,
1175) -> Result<&crate::blob::locator::StoredBlobRef, BlobCacheError> {
1176    reference
1177        .stored()
1178        .ok_or_else(|| BlobCacheError::LocalityUnresolved {
1179            id: reference.blob().id.clone(),
1180        })
1181}
1182
1183fn remote_cache_paths(
1184    store_dir: &StoreDir,
1185    reference: &RowBlobRef,
1186) -> Result<(std::path::PathBuf, std::path::PathBuf), BlobCacheError> {
1187    let stored = remote_stored_ref(reference)?;
1188    let namespace = stored.locator().namespace();
1189    let locator_hash = stored.locator().locator_hash();
1190    Ok((
1191        store_dir.pinned_blob_path(namespace, locator_hash)?,
1192        store_dir.cache_blob_path(namespace, locator_hash)?,
1193    ))
1194}
1195
1196async fn verify_exact_local_file(
1197    path: &std::path::Path,
1198    reference: &RowBlobRef,
1199) -> Result<(), BlobCacheError> {
1200    verify_local_file_facts(path, reference.plaintext_size(), reference.plaintext_hash()).await
1201}
1202
1203async fn verify_local_file_facts(
1204    path: &std::path::Path,
1205    expected_size: u64,
1206    expected_hash: crate::sync::store_commit::ObjectHash,
1207) -> Result<(), BlobCacheError> {
1208    let (actual_size, actual_hash) = crate::local_blob::exact_file_facts(path)
1209        .await
1210        .map_err(BlobCacheError::Io)?;
1211    verify_local_file_identity_values(path, expected_size, expected_hash, actual_size, actual_hash)
1212}
1213
1214async fn read_exact_local_file(
1215    path: &std::path::Path,
1216    reference: &RowBlobRef,
1217    read: ExactRead,
1218) -> Result<Vec<u8>, BlobCacheError> {
1219    let (bytes, actual_size, actual_hash) = match read {
1220        ExactRead::Whole => crate::local_blob::read_with_facts(path).await,
1221        ExactRead::Range { offset, len } => {
1222            crate::local_blob::read_range_with_facts(path, offset, len).await
1223        }
1224    }
1225    .map_err(BlobCacheError::Io)?;
1226    verify_local_file_identity(path, reference, actual_size, actual_hash)?;
1227    Ok(bytes)
1228}
1229
1230fn verify_local_file_identity(
1231    path: &std::path::Path,
1232    reference: &RowBlobRef,
1233    actual_size: u64,
1234    actual_hash: crate::sync::store_commit::ObjectHash,
1235) -> Result<(), BlobCacheError> {
1236    verify_local_file_identity_values(
1237        path,
1238        reference.plaintext_size(),
1239        reference.plaintext_hash(),
1240        actual_size,
1241        actual_hash,
1242    )
1243}
1244
1245fn verify_local_file_identity_values(
1246    path: &std::path::Path,
1247    expected_size: u64,
1248    expected_hash: crate::sync::store_commit::ObjectHash,
1249    actual_size: u64,
1250    actual_hash: crate::sync::store_commit::ObjectHash,
1251) -> Result<(), BlobCacheError> {
1252    if actual_size != expected_size || actual_hash != expected_hash {
1253        return Err(BlobCacheError::LocalIntegrity {
1254            path: path.to_path_buf(),
1255            expected_size,
1256            actual_size,
1257            expected_hash,
1258            actual_hash,
1259        });
1260    }
1261    Ok(())
1262}
1263
1264/// Look up the external file ref for `id`, mapping the DB error into the cache's
1265/// error type. Used by the Local + user-provided dispatch arm of [`read_blob`] /
1266/// [`open_blob_stream`]: a `None` there is [`BlobCacheError::NoExternalRef`] (the gate
1267/// said Local + user-provided, so the ref must exist), not a fall-through.
1268async fn lookup_external_ref(
1269    db: &Database,
1270    reference: &RowBlobRef,
1271) -> Result<Option<crate::db::ExternalBlob>, BlobCacheError> {
1272    db.external_blob_for_row(reference)
1273        .await
1274        .map_err(BlobCacheError::Metadata)
1275}
1276
1277/// The cache folder path that currently holds a Remote blob's plaintext, checking
1278/// `pinned/` then `cache/`. A failure to check either path is surfaced, never
1279/// collapsed into a miss.
1280enum CachedBlobPath {
1281    Pinned(std::path::PathBuf),
1282    Cache(std::path::PathBuf),
1283}
1284
1285impl CachedBlobPath {
1286    fn path(&self) -> &std::path::Path {
1287        match self {
1288            CachedBlobPath::Pinned(path) | CachedBlobPath::Cache(path) => path,
1289        }
1290    }
1291}
1292
1293async fn cached_blob_path_with_facts(
1294    store_dir: &StoreDir,
1295    reference: &RowBlobRef,
1296) -> Result<Option<CachedBlobPath>, BlobCacheError> {
1297    let hit = cached_blob_path(store_dir, reference).await?;
1298    if let Some(hit) = &hit {
1299        verify_exact_local_file(hit.path(), reference).await?;
1300    }
1301    Ok(hit)
1302}
1303
1304async fn cached_blob_path(
1305    store_dir: &StoreDir,
1306    reference: &RowBlobRef,
1307) -> Result<Option<CachedBlobPath>, BlobCacheError> {
1308    let (pinned, cache) = remote_cache_paths(store_dir, reference)?;
1309    for hit in [CachedBlobPath::Pinned(pinned), CachedBlobPath::Cache(cache)] {
1310        match crate::local_blob::exists(hit.path()).await {
1311            Ok(true) => return Ok(Some(hit)),
1312            Ok(false) => {}
1313            Err(error) => return Err(BlobCacheError::Io(error)),
1314        }
1315    }
1316    Ok(None)
1317}
1318
1319async fn read_cached_exact(
1320    store_dir: &StoreDir,
1321    reference: &RowBlobRef,
1322) -> Result<Option<Vec<u8>>, BlobCacheError> {
1323    let Some(hit) = cached_blob_path(store_dir, reference).await? else {
1324        return Ok(None);
1325    };
1326    read_exact_local_file(hit.path(), reference, ExactRead::Whole)
1327        .await
1328        .map(Some)
1329}
1330
1331/// A whole-blob vs ranged read of an external (user-provided Local) file. The two
1332/// reads share the validate-on-read; only the local read primitive differs.
1333#[derive(Clone, Copy)]
1334enum ExactRead {
1335    Whole,
1336    Range { offset: u64, len: u64 },
1337}
1338
1339/// Read a user-provided Local blob from its registered external file `ext`. The
1340/// external file is the only copy — no fallback. A failed read surfaces its
1341/// underlying cause as [`BlobCacheError::ExternalMissing`] (the error is preserved,
1342/// not collapsed); for a whole read, a length that no longer matches the registered
1343/// `size` is [`BlobCacheError::ExternalSizeMismatch`].
1344async fn read_external_file(
1345    reference: &RowBlobRef,
1346    ext: crate::db::ExternalBlob,
1347    op: ExactRead,
1348) -> Result<Vec<u8>, BlobCacheError> {
1349    let id = &reference.blob().id;
1350    let read = match op {
1351        ExactRead::Whole => crate::local_blob::read_with_facts(&ext.path).await,
1352        ExactRead::Range { offset, len } => {
1353            crate::local_blob::read_range_with_facts(&ext.path, offset, len).await
1354        }
1355    };
1356    let (bytes, actual_size, actual_hash) =
1357        read.map_err(|source| BlobCacheError::ExternalMissing {
1358            id: id.to_string(),
1359            path: ext.path.clone(),
1360            source,
1361        })?;
1362    if actual_size != ext.size || actual_size != reference.plaintext_size() {
1363        return Err(BlobCacheError::ExternalSizeMismatch {
1364            id: id.clone(),
1365            path: ext.path,
1366        });
1367    }
1368    verify_local_file_identity(&ext.path, reference, actual_size, actual_hash)?;
1369    Ok(bytes)
1370}
1371
1372async fn verify_external_file(
1373    reference: &RowBlobRef,
1374    ext: &crate::db::ExternalBlob,
1375) -> Result<(), BlobCacheError> {
1376    let id = &reference.blob().id;
1377    let (actual_size, actual_hash) = crate::local_blob::exact_file_facts(&ext.path)
1378        .await
1379        .map_err(|source| BlobCacheError::ExternalMissing {
1380            id: id.clone(),
1381            path: ext.path.clone(),
1382            source,
1383        })?;
1384    if actual_size != ext.size || actual_size != reference.plaintext_size() {
1385        return Err(BlobCacheError::ExternalSizeMismatch {
1386            id: id.clone(),
1387            path: ext.path.clone(),
1388        });
1389    }
1390    let expected_hash = reference.plaintext_hash();
1391    if actual_hash != expected_hash {
1392        return Err(BlobCacheError::LocalIntegrity {
1393            path: ext.path.clone(),
1394            expected_size: reference.plaintext_size(),
1395            actual_size,
1396            expected_hash,
1397            actual_hash,
1398        });
1399    }
1400    Ok(())
1401}
1402
1403/// Move one exact locator file between cache folders. The source is copied into an
1404/// operation-owned stage and exact-verified before no-replace publication, so an
1405/// eviction cannot invalidate the bytes being published. The row reference is
1406/// revalidated immediately before publication.
1407async fn move_exact_cache_file(
1408    db: &Database,
1409    from: &std::path::Path,
1410    to: &std::path::Path,
1411    reference: &RowBlobRef,
1412) -> Result<(), BlobCacheError> {
1413    let staged = stage_exact_local_copy(from, to, reference).await?;
1414    validate_row_reference(db, reference).await?;
1415    publish_materialization(staged, reference).await?;
1416    crate::local_blob::remove_file(from)
1417        .await
1418        .map_err(BlobCacheError::Io)?;
1419    crate::local_blob::sync_parent_dir(from)
1420        .await
1421        .map_err(BlobCacheError::Io)
1422}
1423
1424async fn stage_exact_local_copy(
1425    from: &std::path::Path,
1426    to: &std::path::Path,
1427    reference: &RowBlobRef,
1428) -> Result<crate::local_blob::AtomicStagedFile, BlobCacheError> {
1429    let staged = crate::local_blob::stage_atomic_destination(to)
1430        .await
1431        .map_err(BlobCacheError::Io)?;
1432    let (actual_size, actual_hash) = crate::local_blob::copy_atomic_with_facts(from, staged.path())
1433        .await
1434        .map_err(BlobCacheError::Io)?;
1435    verify_local_file_identity(from, reference, actual_size, actual_hash)?;
1436    Ok(staged)
1437}