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}