Skip to main content

coven_core/blob/
cache.rs

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