coven_protocol/blob.rs
1//! The blob engine: coven's single owner of a blob's whole durability lifecycle.
2//!
3//! coven syncs opaque encrypted blobs referenced by DB rows. By default it owns
4//! the cloud layout (the content-addressed `{namespace}/{ab}/{cd}/{id}`) and
5//! encryption; the host decides which rows carry blobs, where their plaintext
6//! lives locally, and how each is scoped for encryption. A home configured for
7//! the unobfuscated blob-path scheme instead stores each blob at the consumer's
8//! readable [`BlobRef::cloud_path`] so the bucket is browsable.
9//!
10//! # The coven concept tree
11//!
12//! A blob has two **declared** properties — [`Provenance`] (its Local story) and
13//! [`CacheFill`] (its Remote story) — and one **state**, locality, flipped by the
14//! transitions. The cache is a *mechanism* that serves Remote blobs; it is not a
15//! kind of blob.
16//!
17//! ```text
18//! A blob the host declares with:
19//!
20//! provenance — its LOCAL story: where the bytes live when Local, and the
21//! Remote→Local path requirement
22//! ├─ user-provided the user's file at a path; coven references it.
23//! │ Remote→Local writes the bytes back to a user file → NEEDS A PATH.
24//! └─ host-provided bae hands coven the data; coven keeps it in its local store.
25//! Remote→Local restores it to the local store → no path.
26//!
27//! cache fill — its REMOTE story: how a device gets the bytes when the release is
28//! Remote. A cache-mechanism setting; applies to ANY blob, regardless of
29//! provenance, once it is Remote.
30//! ├─ CacheEager fetched into the cache on pull, with the SQL row (covers)
31//! └─ CacheLazy fetched into the cache on first read (audio — big, fetch what you play)
32//!
33//! and a current state:
34//!
35//! locality
36//! ├─ Local bytes on-device — the user's path (user-provided) or coven's local store (host-provided)
37//! └─ Remote bytes in the cloud; each device's local copy is a CACHE copy, filled
38//! per `cache fill`, kept-or-evicted per `pin`
39//!
40//! namespace (bucket) the blob's category — release_files · covers · artist_images
41//!
42//! transitions
43//! ├─ Local → Remote upload the bytes; now cache-distributed to every device per cache fill
44//! └─ Remote → Local bring the bytes back to a local file — path required iff user-provided
45//!
46//! cache budget per-NAMESPACE size limit; each namespace evicts independently, so
47//! evicting release_files (big) never touches covers (small reserved slice)
48//! pin keep one specific Remote blob's cache copy from eviction (e.g. a
49//! release the user pinned for offline)
50//! ```
51//!
52//! ## The cache vs local files
53//!
54//! The cache holds local copies of **Remote** blobs (filled per `cache fill`,
55//! evicted per budget unless pinned). It is **segmented by namespace**: each
56//! namespace has its own configurable cache budget and evicts independently, so
57//! evicting `release_files` (big) never touches `covers` (a small reserved slice). A
58//! `CacheEager` cover that falls out of its namespace budget shows a placeholder
59//! until the next read re-fetches it — covers are not pinned. A **Local** blob is not
60//! in the cache: a user-provided Local blob is the user's file at its path (an
61//! external ref); a host-provided Local blob is in coven's local store, whose
62//! paths and file operations are owned by [`coven_foundation::store_dir::StoreDir`]. The cache is the mechanism for *remoteness* — so
63//! `CacheEager`/`CacheLazy`/pin/budget describe a blob only while it is Remote, never
64//! while it is Local.
65//!
66//! # The engine's halves
67//!
68//! This module is the engine; its halves move a blob through its lifecycle:
69//!
70//! - `blob::cache` — the device-local cache for **Remote** blobs: bytes on disk keyed
71//! by exact locator hash, with the folder a file lives in as the only retention truth
72//! (`storage/pinned/` protected, `storage/cache/` evictable). Reads — one-shot
73//! whole, which checks the plaintext against the row's hash because it reads
74//! every byte anyway, or an opened stream whose ranges each cost their own
75//! bytes: a positioned read of a local file, or the sealed chunks covering the
76//! range fetched from the cloud object and opened — plus pin/unpin, clear, and
77//! budget eviction.
78//! - [`coven_foundation::store_dir::StoreDir`] — coven's own copy of a **host-provided Local**
79//! blob, in `storage/local/<namespace>/<id>`. Never evicted; the budget sweep
80//! never walks it.
81//! - `blob::upload` — the cloud-write half: drain the durable upload queue, sealing
82//! each blob under its scope and writing it to the cloud with coalesced progress,
83//! so a local-only blob becomes uploaded. The sync cycle calls the drain
84//! each round before it pushes.
85//! - `blob::delete` — the cloud-delete half: turn a queued deletion into a signed
86//! cloud tombstone, hold the blob for a convergence grace so a lagging peer
87//! isn't stranded, then GC the blob once the grace has passed. The sync cycle
88//! drains tombstones and runs the GC each round after it pulls.
89//!
90//! The types below ([`BlobRef`], [`BlobScope`], [`Provenance`],
91//! [`CacheFill`], [`BlobTransitionObserver`]) are the vocabulary both halves and
92//! the host speak. Which rows carry blobs is not a runtime callback but a per-table
93//! declaration ([`crate::synced_schema::BlobDecl`]) coven resolves into a
94//! the database's `BlobDecls` each cycle to derive the blob set itself.
95//!
96//! coven also owns the two locality transitions (`blob::transition`): `make_remote`
97//! (Local → Remote: upload the bytes, then flip the gate) and `make_local`
98//! (Remote → Local: bring each blob back to a local file, then retract). The
99//! The upload drain advances the durable make-Remote intent after every exact
100//! object lands. The Store publication activates the resulting gate change;
101//! hosts observe both handoffs through the durable cloud-outbox query.
102
103pub mod locator;
104
105#[cfg(test)]
106mod row_ref_tests;
107
108use sha2::{Digest, Sha256};
109
110/// The content hash a blob-bearing row carries: the lowercase-hex SHA-256 of the
111/// blob's plaintext bytes, computed at import and stored in the row's blob columns
112/// alongside the declared size. The row is carried in a signed changeset (and in a
113/// signed snapshot), so this hash is signed by the row's author — that is what
114/// makes it authoritative: on download coven hashes the decrypted plaintext and
115/// requires equality with the row's hash, so the bytes are pinned by the author,
116/// not by the cloud key they happened to arrive under. A host computes this over a
117/// blob's plaintext at import and writes it into the row's declared hash column,
118/// the same way it writes the plaintext length into the size column.
119pub fn content_hash(plaintext: &[u8]) -> String {
120 hex::encode(Sha256::digest(plaintext))
121}
122
123/// An incremental SHA-256 over a blob's plaintext, so the streaming download path
124/// verifies a blob's content hash without holding the whole plaintext in memory:
125/// feed each decrypted chunk to [`update`](Self::update), call
126/// [`finish`](Self::finish), and compare the returned digest with the row's hash
127/// before committing the bytes to the cache. The hex-encoded digest matches
128/// [`content_hash`] over the same bytes.
129pub struct ContentHasher(Sha256);
130
131impl ContentHasher {
132 pub fn new() -> Self {
133 ContentHasher(Sha256::new())
134 }
135
136 /// Fold the next plaintext chunk into the running digest.
137 pub fn update(&mut self, chunk: &[u8]) {
138 self.0.update(chunk);
139 }
140
141 /// The lowercase-hex digest of everything fed so far.
142 pub fn finish(self) -> String {
143 hex::encode(self.0.finalize())
144 }
145}
146
147impl Default for ContentHasher {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153/// How many blob transfers coven runs at once in each of its two transfer loops:
154/// the upload drain and the pin/download loop. An
155/// open-time blob-engine tunable the host sets on the builder,
156/// carried on the `Database` alongside the other open-time
157/// blob config and read back by each loop, which holds `&Database`.
158///
159/// Each bound is a [`NonZeroUsize`], so a zero — which would leave a loop admitting
160/// nothing and never completing — is unrepresentable rather than clamped or rejected
161/// at open. `one_at_a_time()` (both `1`) is the default: transfers run one at a
162/// time in queue order.
163///
164/// [`NonZeroUsize`]: std::num::NonZeroUsize
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub struct TransferLimits {
167 /// Maximum concurrent blob uploads in one upload-drain pass.
168 pub uploads: std::num::NonZeroUsize,
169 /// Maximum concurrent blob downloads (fetches) in one pin call.
170 pub downloads: std::num::NonZeroUsize,
171}
172
173impl TransferLimits {
174 /// One at a time in each loop.
175 pub fn one_at_a_time() -> Self {
176 Self {
177 uploads: std::num::NonZeroUsize::MIN,
178 downloads: std::num::NonZeroUsize::MIN,
179 }
180 }
181}
182
183// The cache's own tests: real `Database` + `TestStore` over a temp store
184// dir, asserting hits/misses, the pinned/cache folder split, and pin/unpin/clear.
185// These drive a real temp directory on the filesystem. See `blob::cache`.
186// The upload drain's tests: real `Database` (the `cloud_outbox` queue) driven
187// against `InMemoryCloudHome`/`FailingCloudHome`, asserting record-and-continue,
188// per-entry backoff, scope-resolved sealing, and the observer callbacks. See
189// `blob::upload`.
190// The coven-owned make-Remote / make-Local transition tests: multi-device
191// make_remote + make_local through the real cycle, cancel both directions, the
192// drain's completion flip, durable cancellation, crash-idempotency at each commit
193// boundary, and a round-trip. Uses a `watch` cancel signal and retained test devices,
194// See `blob::transition`.
195// The local-files store's tests: store/read round-trip, a host-provided Local blob
196// surviving a budget sweep (the sweep never walks `local/`), and drop. These
197// drive a real temp directory through `StoreDir`.
198// The delete half's tests: tombstone signing, the drain that writes tombstones,
199// the graced GC that reclaims exact immutable objects, and the delete-outbox row
200// shape. Driven against `InMemoryCloudHome` and
201// `TestStore`. See `blob::delete`.
202
203/// Which key encrypts a blob, as a host names it on a [`BlobRef`].
204///
205/// The host names *what* a blob is scoped to — the whole store or a derived
206/// per-scope key — never the raw key bytes. Storage and encryption consume this
207/// same type; there is no key material in it to leak.
208#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
209pub enum BlobScope {
210 /// The store master key — every member reads it.
211 Master,
212 /// A per-scope key derived from the master key (e.g. one key per item).
213 Derived(String),
214}
215
216/// A blob's **Local story**: where its bytes live while the blob is Local, and
217/// whether bringing it back from Remote needs a destination path. Orthogonal to
218/// [`CacheFill`] (the Remote story) — a blob declares both.
219///
220/// The cache never enters into this: a Local blob is not a cache copy. Provenance
221/// decides which of the two Local homes holds it, and what `make_local` does to
222/// restore it.
223#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
224pub enum Provenance {
225 /// The user's own file at a path; coven references it but does not own it
226 /// (tracked as an external ref — see `local_blob_refs`). `make_local` writes
227 /// the bytes back to a user file, so it **needs a destination path**.
228 UserProvided,
229 /// The host hands coven the data; coven keeps its own copy in the local store
230 /// (`storage/local/<namespace>/<id>`, owned by [`coven_foundation::store_dir::StoreDir`]). `make_local`
231 /// restores it to the local store, so it needs **no path**.
232 HostProvided,
233}
234
235/// A blob's **Remote story**: how a device gets the bytes once the blob is Remote.
236/// A cache-mechanism setting — it describes a blob only while Remote — that applies
237/// to ANY blob regardless of [`Provenance`]. Orthogonal to provenance; a blob
238/// declares both.
239///
240/// Both classes are declared per blob and are global (every device reads the same
241/// class from the blob's [`BlobRef`]); the difference is what a device does with
242/// the blob on pull. The distinction has to be a declared property and not a
243/// per-device choice: device B, deciding during its own pull whether to fetch a
244/// blob, can only read the blob's declared class — it cannot see what device A
245/// chose locally.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
247pub enum CacheFill {
248 /// Fetched into the cache on pull, right away, on every device — part of
249 /// "having the store" (e.g. cover art, so the grid renders from local bytes
250 /// without a fetch). The cache copy is evictable + re-fetchable, not pinned.
251 CacheEager,
252 /// Not fetched on pull: a pulling device skips it and fetches it into the cache
253 /// on first read — e.g. audio, which is big and streams on demand.
254 CacheLazy,
255}
256
257/// A blob's **replacement story**: whether the row carrying it may ever be repointed at
258/// a different blob. Orthogonal to [`Provenance`] and [`CacheFill`]; a blob declares all
259/// three.
260///
261/// It exists because a cloud object must never be rewritten with different bytes. The
262/// pull verifies an object against its row's content hash and a position advances only over
263/// a fully-realized changeset, so a key whose content can change leaves a device that
264/// pulls an older changeset unable to satisfy it — wedged there for good, not merely
265/// missing a blob. Two declarations reach that guarantee by different routes, and coven
266/// enforces whichever one the blob declares:
267///
268/// - [`Replaceable`](Self::Replaceable) — the row may be repointed, so the *key* must
269/// move with the blob: a readable `cloud_path` has to name its blob
270/// (`cloud_path_names_blob`), and a replacement then writes a new
271/// object beside the one it replaces instead of over it.
272/// - [`WriteOnce`](Self::WriteOnce) — the row is never repointed, so the object at its
273/// key is written once and there is nothing to protect it from. Its path is free to be
274/// a stable, fully readable name. coven refuses the repointing.
275///
276/// `Replaceable` is the default: its guarantee is the airtight one (the key itself
277/// carries the blob id, so no path can ever be reused), while `WriteOnce` is a weaker
278/// contract a consumer opts into knowingly — see its docs.
279#[derive(Debug, Clone, Copy, PartialEq, Eq)]
280pub enum BlobReplacement {
281 /// The row may be repointed at a new blob id — replacing a cover, swapping an
282 /// attachment. Requires a readable `cloud_path` that names its blob, so that the
283 /// replacement's fresh blob id yields a fresh key.
284 Replaceable,
285 /// The row is never repointed: the blob it names when it is inserted is the blob it
286 /// names for life. Repointing one is refused.
287 ///
288 /// This buys a stable, fully readable cloud path — `Live at Leeds/01 Sonata.flac`
289 /// rather than `01 Sonata-0ef7a1c9.flac` — for content that is written once and never
290 /// rewritten: an imported file, whose bytes are what they are.
291 ///
292 /// **What coven enforces, and what it does not.** coven refuses to repoint the row,
293 /// which is the reuse it can see. It cannot see a consumer *deleting* a row and
294 /// inserting a different blob at the same `cloud_path` — the deleted row is gone, and
295 /// coven keeps no history of the paths it has used. Declaring `WriteOnce` is therefore
296 /// also a promise that the path is never reused by a different blob. Derive it from
297 /// data that never repeats and it holds by construction: a path carrying a freshly
298 /// minted id for the thing being imported can never be handed out twice.
299 WriteOnce,
300}
301
302/// Whether the readable `cloud_path` a consumer supplied names the blob `blob_id` — what
303/// coven requires of a [`Replaceable`](BlobReplacement::Replaceable) blob's key on a
304/// browsable home, and what a hashed key gets for free by carrying the id itself.
305///
306/// The path's file name (its last `/`-segment), with any extension stripped, must be the
307/// blob id or end with `-{blob_id}`:
308///
309/// ```text
310/// covers/Live at Leeds/cover-0ef7a1c9.jpg ✓ stem `cover-0ef7a1c9` ends with -0ef7a1c9
311/// covers/Live at Leeds/0ef7a1c9.jpg ✓ stem is the blob id
312/// covers/Live at Leeds/cover.jpg ✗ names no blob
313/// ```
314///
315/// A blob id names one immutable byte-string and is minted fresh for every stored blob, so
316/// a path carrying it moves whenever the bytes do — which is what leaves a replaced blob's
317/// object standing at its own key instead of overwritten.
318///
319/// The `-` delimiter is what makes this a near-injective mapping where a bare substring
320/// test would not be: without it, blob `1` would satisfy blob `11`'s path and the two could
321/// be keyed at one object. Two ids can still collide if one is a `-`-suffix of the other
322/// AND the consumer builds paths that land on the same file name — which ids drawn from any
323/// of the usual generators do not do.
324pub fn cloud_path_names_blob(cloud_path: &str, blob_id: &str) -> bool {
325 let file_name = cloud_path.rsplit('/').next().unwrap_or(cloud_path);
326 let stem = file_name
327 .rsplit_once('.')
328 .map_or(file_name, |(stem, _extension)| stem);
329 stem == blob_id
330 || stem
331 .strip_suffix(blob_id)
332 .is_some_and(|prefix| prefix.ends_with('-'))
333}
334
335/// A blob a row references: its cloud identity, encryption scope, and the two
336/// declared properties ([`provenance`](BlobRef::provenance) +
337/// [`fill`](BlobRef::fill)). coven derives it from the row's declared columns
338/// ([`crate::synced_schema::BlobDecl`]) via the database's `BlobDecls`. Where its bytes
339/// live depends on its locality and provenance: a user-provided Local blob is the
340/// user's file at its path; a host-provided Local blob is in coven's local store
341/// (`storage/local/<namespace>/<id>`); a Remote blob's device-local copy is a cache
342/// copy (`storage/pinned/<namespace>/<locator-hash>` /
343/// `storage/cache/<namespace>/<locator-hash>`, built
344/// from the validated namespace + exact locator hash — see `blob::cache`).
345#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
346pub struct BlobRef {
347 /// Cloud namespace, e.g. `"images"`. Becomes `{namespace}/{ab}/{cd}/{id}`
348 /// under the hashed scheme, or `{namespace}/{cloud_path}` under the plain one.
349 pub namespace: String,
350 /// Blob id (typically the id of the blob-bearing row).
351 pub id: String,
352 /// Encryption scope for this blob.
353 pub scope: BlobScope,
354 /// The consumer's readable cloud-relative path for this blob, e.g.
355 /// `"Artist - Album/cover.jpg"`. Used as the object key under `namespace` when
356 /// the home's storage `BlobPathScheme` is `Plain`;
357 /// ignored when `Hashed`. `None` is only valid for a `Hashed` home — a `Plain`
358 /// home with no `cloud_path` is a surfaced error, never a silent fallback.
359 pub cloud_path: Option<String>,
360 /// The blob's **Local story**: where its bytes live while Local, and whether
361 /// `make_local` needs a destination path. See [`Provenance`].
362 pub provenance: Provenance,
363 /// The blob's **Remote story**: whether a pulling device fetches it into the
364 /// cache right away ([`CacheFill::CacheEager`]) or on first read
365 /// ([`CacheFill::CacheLazy`]). See [`CacheFill`].
366 pub fill: CacheFill,
367}
368
369/// One exact blob-bearing row version. A reference becomes stale when the live
370/// row stamp or any declared blob value changes.
371#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
372#[serde(deny_unknown_fields)]
373pub struct RowBlobRef {
374 table: String,
375 row_id: String,
376 row_stamp: String,
377 column: String,
378 blob: BlobRef,
379 plaintext_size: u64,
380 plaintext_hash: crate::store_commit::ObjectHash,
381 authority: RowBlobAuthority,
382 stored: Option<locator::StoredBlobRef>,
383}
384
385/// The authority state that determines where one row version's blob lives.
386/// A remote-audience blob remains `PendingRemote` while its verified plaintext
387/// is local and no cloud object has been created; `Remote` carries the exact
388/// package authority needed to open its committed object.
389#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
390#[serde(rename_all = "snake_case", deny_unknown_fields)]
391pub enum RowBlobAuthority {
392 Local,
393 PendingRemote(locator::RemoteAudience),
394 Remote(crate::audience_package::PackageAudience),
395}
396
397pub enum BlobOpeningAuthority<'a> {
398 Store,
399 Circle {
400 circle_id: crate::circle::CircleId,
401 control: &'a crate::circle::CircleControlCoord,
402 key_fingerprint: coven_keys::encryption::KeyFingerprint,
403 },
404}
405
406#[derive(Debug, thiserror::Error)]
407pub enum BlobOpeningAuthorityError {
408 #[error("blob {id} has no exact remote authority")]
409 LocalityUnresolved { id: String },
410 #[error(
411 "Circle {circle_id} blob locator audience or key differs from its exact activated authority"
412 )]
413 CircleAuthorityMismatch { circle_id: crate::circle::CircleId },
414}
415
416impl<'de> serde::Deserialize<'de> for RowBlobRef {
417 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
418 where
419 D: serde::Deserializer<'de>,
420 {
421 #[derive(serde::Deserialize)]
422 #[serde(deny_unknown_fields)]
423 struct Fields {
424 table: String,
425 row_id: String,
426 row_stamp: String,
427 column: String,
428 blob: BlobRef,
429 plaintext_size: u64,
430 plaintext_hash: crate::store_commit::ObjectHash,
431 authority: RowBlobAuthority,
432 stored: Option<locator::StoredBlobRef>,
433 }
434
435 let fields = Fields::deserialize(deserializer)?;
436 Self::new(
437 fields.table,
438 fields.row_id,
439 fields.row_stamp,
440 fields.column,
441 fields.blob,
442 fields.plaintext_size,
443 fields.plaintext_hash,
444 fields.authority,
445 fields.stored,
446 )
447 .map_err(serde::de::Error::custom)
448 }
449}
450
451impl RowBlobAuthority {
452 pub fn audience(&self) -> crate::circle::Audience {
453 match self {
454 Self::Local => crate::circle::Audience::Local,
455 Self::PendingRemote(locator::RemoteAudience::Store) => crate::circle::Audience::Store,
456 Self::PendingRemote(locator::RemoteAudience::Circle(circle_id)) => {
457 crate::circle::Audience::Circle(*circle_id)
458 }
459 Self::Remote(crate::audience_package::PackageAudience::Store) => {
460 crate::circle::Audience::Store
461 }
462 Self::Remote(crate::audience_package::PackageAudience::Circle {
463 circle_id, ..
464 }) => crate::circle::Audience::Circle(*circle_id),
465 }
466 }
467
468 pub fn opening_authority<'a>(
469 &'a self,
470 stored: &locator::StoredBlobRef,
471 ) -> Result<BlobOpeningAuthority<'a>, BlobOpeningAuthorityError> {
472 match self {
473 Self::Local | Self::PendingRemote(_) => {
474 Err(BlobOpeningAuthorityError::LocalityUnresolved {
475 id: stored.locator().blob_id().to_string(),
476 })
477 }
478 Self::Remote(crate::audience_package::PackageAudience::Store) => {
479 Ok(BlobOpeningAuthority::Store)
480 }
481 Self::Remote(crate::audience_package::PackageAudience::Circle {
482 circle_id,
483 control,
484 key_fingerprint,
485 }) => {
486 if stored.locator().audience() != locator::RemoteAudience::Circle(*circle_id)
487 || stored.locator().key_fingerprint() != Some(*key_fingerprint)
488 {
489 return Err(BlobOpeningAuthorityError::CircleAuthorityMismatch {
490 circle_id: *circle_id,
491 });
492 }
493 Ok(BlobOpeningAuthority::Circle {
494 circle_id: *circle_id,
495 control,
496 key_fingerprint: *key_fingerprint,
497 })
498 }
499 }
500 }
501}
502
503/// Whether `locator` describes exactly the row version it was minted for.
504///
505/// A stored blob's locator carries the namespace, id, plaintext size and hash,
506/// and encryption scope of the row it was sealed from. Anything that hands a
507/// `StoredBlobRef` and a row to each other checks this before trusting the pair.
508///
509/// [`RowBlobRef::new`] enforces the same facts and more, one field at a time so
510/// it can name which one diverged; this is the yes-or-no form for callers that
511/// answer a mismatch their own way.
512/// Whether `locator` is the blob this row already has at the provider, for the
513/// audience it is being published to.
514///
515/// This is deliberately **not** equality with a freshly minted locator. A
516/// locator carries the fingerprint of the key that sealed its bytes, and that
517/// key is whichever generation the keyring sealed under at upload time. Minting
518/// one today and demanding the stored one match it says "any key rotation
519/// re-identifies every blob in the Store" — which would ask the publisher to
520/// re-upload bytes it has, under a name nothing has ever written, and for a
521/// user-provided blob there is no local file left to re-upload from.
522///
523/// What actually identifies an already-uploaded blob is its content and where
524/// it is readable from: namespace, id, plaintext size and hash, key scope, and
525/// audience. An audience move is a genuine re-seal and is still refused here.
526pub fn locator_is_this_rows_upload(
527 locator: &locator::BlobLocator,
528 blob: &BlobRef,
529 plaintext_size: u64,
530 plaintext_hash: crate::store_commit::ObjectHash,
531 audience: &locator::RemoteAudience,
532) -> bool {
533 locator_describes_row(locator, blob, plaintext_size, plaintext_hash)
534 && &locator.audience() == audience
535}
536
537pub fn locator_describes_row(
538 locator: &locator::BlobLocator,
539 blob: &BlobRef,
540 plaintext_size: u64,
541 plaintext_hash: crate::store_commit::ObjectHash,
542) -> bool {
543 locator.namespace() == blob.namespace
544 && locator.blob_id() == blob.id
545 && locator.plaintext_size() == plaintext_size
546 && locator.plaintext_hash() == plaintext_hash
547 && locator.scope().is_none_or(|scope| scope == &blob.scope)
548}
549
550impl RowBlobRef {
551 #[allow(clippy::too_many_arguments)]
552 pub fn new(
553 table: String,
554 row_id: String,
555 row_stamp: String,
556 column: String,
557 blob: BlobRef,
558 plaintext_size: u64,
559 plaintext_hash: crate::store_commit::ObjectHash,
560 authority: RowBlobAuthority,
561 stored: Option<locator::StoredBlobRef>,
562 ) -> Result<Self, RowBlobRefError> {
563 let remote = match &authority {
564 RowBlobAuthority::Local => None,
565 RowBlobAuthority::PendingRemote(audience) => Some(audience.clone()),
566 RowBlobAuthority::Remote(package) => Some(package.remote_audience()),
567 };
568 match (&authority, remote.as_ref(), stored.as_ref()) {
569 (RowBlobAuthority::Local, None, None)
570 | (RowBlobAuthority::PendingRemote(_), Some(_), None) => {}
571 (RowBlobAuthority::Remote(_), Some(expected), Some(stored))
572 if &stored.locator().audience() == expected => {}
573 (RowBlobAuthority::Local, None, Some(_)) => {
574 return Err(RowBlobRefError::LocalHasLocator);
575 }
576 (RowBlobAuthority::PendingRemote(_), Some(_), Some(_)) => {
577 return Err(RowBlobRefError::PendingHasLocator);
578 }
579 (RowBlobAuthority::Remote(_), Some(_), None) => {
580 return Err(RowBlobRefError::RemoteMissingLocator);
581 }
582 (RowBlobAuthority::Remote(_), Some(expected), Some(stored)) => {
583 return Err(RowBlobRefError::AudienceMismatch {
584 row: expected.clone(),
585 locator: stored.locator().audience(),
586 });
587 }
588 _ => unreachable!("authority determines whether a remote audience exists"),
589 }
590 if let Some(stored) = &stored {
591 let locator = stored.locator();
592 if locator.namespace() != blob.namespace {
593 return Err(RowBlobRefError::NamespaceMismatch {
594 row: blob.namespace.clone(),
595 locator: locator.namespace().to_string(),
596 });
597 }
598 if locator.blob_id() != blob.id {
599 return Err(RowBlobRefError::IdMismatch {
600 row: blob.id.clone(),
601 locator: locator.blob_id().to_string(),
602 });
603 }
604 if locator.plaintext_size() != plaintext_size
605 || locator.plaintext_hash() != plaintext_hash
606 {
607 return Err(RowBlobRefError::PlaintextMismatch);
608 }
609 match locator {
610 locator::BlobLocator::Opaque {
611 scope,
612 key_fingerprint,
613 ..
614 } => {
615 if scope != &blob.scope {
616 return Err(RowBlobRefError::ScopeMismatch);
617 }
618 if let RowBlobAuthority::Remote(
619 crate::audience_package::PackageAudience::Circle {
620 key_fingerprint: expected,
621 ..
622 },
623 ) = &authority
624 {
625 if key_fingerprint != expected {
626 return Err(RowBlobRefError::CircleKeyMismatch);
627 }
628 }
629 }
630 locator::BlobLocator::Browsable { cloud_path, .. } => {
631 if blob.cloud_path.as_deref() != Some(cloud_path) {
632 return Err(RowBlobRefError::CloudPathMismatch);
633 }
634 }
635 }
636 }
637 Ok(Self {
638 table,
639 row_id,
640 row_stamp,
641 column,
642 blob,
643 plaintext_size,
644 plaintext_hash,
645 authority,
646 stored,
647 })
648 }
649
650 pub fn table(&self) -> &str {
651 &self.table
652 }
653
654 pub fn row_id(&self) -> &str {
655 &self.row_id
656 }
657
658 pub fn row_stamp(&self) -> &str {
659 &self.row_stamp
660 }
661
662 pub fn column(&self) -> &str {
663 &self.column
664 }
665
666 pub fn blob(&self) -> &BlobRef {
667 &self.blob
668 }
669
670 pub fn plaintext_size(&self) -> u64 {
671 self.plaintext_size
672 }
673
674 pub fn plaintext_hash(&self) -> crate::store_commit::ObjectHash {
675 self.plaintext_hash
676 }
677
678 pub fn authority(&self) -> &RowBlobAuthority {
679 &self.authority
680 }
681
682 pub fn audience(&self) -> crate::circle::Audience {
683 self.authority.audience()
684 }
685
686 pub fn stored(&self) -> Option<&locator::StoredBlobRef> {
687 self.stored.as_ref()
688 }
689}
690
691#[derive(Debug, thiserror::Error)]
692pub enum RowBlobRefError {
693 #[error("Local row blob carries a remote locator")]
694 LocalHasLocator,
695 #[error("pending remote row blob carries a cloud locator")]
696 PendingHasLocator,
697 #[error("remote row blob has no exact locator")]
698 RemoteMissingLocator,
699 #[error("row audience {row:?} differs from locator audience {locator:?}")]
700 AudienceMismatch {
701 row: locator::RemoteAudience,
702 locator: locator::RemoteAudience,
703 },
704 #[error("row blob namespace {row:?} differs from locator namespace {locator:?}")]
705 NamespaceMismatch { row: String, locator: String },
706 #[error("row blob id {row:?} differs from locator id {locator:?}")]
707 IdMismatch { row: String, locator: String },
708 #[error("row blob plaintext size or hash differs from its exact locator")]
709 PlaintextMismatch,
710 #[error("row blob encryption scope differs from its exact locator")]
711 ScopeMismatch,
712 #[error("row blob Circle key differs from its exact locator")]
713 CircleKeyMismatch,
714 #[error("row blob cloud path differs from its exact locator")]
715 CloudPathMismatch,
716}
717
718/// Notified about coven's blob transitions, for host-specific bookkeeping and UI:
719/// per-blob upload progress while a make_remote uploads, per-blob materialize
720/// progress while a make_local copies files back, and the synchronous
721/// make-local completion the host turns into its own UI event.
722///
723/// The host no longer drives the transition — coven owns flipping the gate and
724/// deciding when a cycle publishes — so this observer only *reports*. The upload
725/// callbacks fire as the drain works: preparation starts while the plaintext is
726/// verified and sealed into its durable spool, `on_blob_upload_started` fires
727/// only when that prepared spool is handed to the provider,
728/// `on_blob_upload_progress` fires zero or more times as encrypted bytes reach
729/// the cloud (backends that can't report sub-file progress call it once at the end
730/// with `bytes_done == bytes_total`), `on_blob_uploaded` on success (notification
731/// only — the durable queue records Created and the Store publication later
732/// activates the root),
733/// and `on_blob_upload_failed` when an attempt fails and its entry stays queued.
734///
735/// A make-remote's root state is durable and belongs in
736/// `CloudOutboxLiveQuery`, not an observer callback that can be lost across a
737/// restart. `on_root_made_local` reports the synchronous opposite direction;
738/// `on_blob_materialize_progress` moves its per-file progress bar.
739///
740/// The upload-pause methods let the host suspend the upload pipeline without
741/// touching the queue or discarding an open provider upload. The drain checks
742/// the absolute state before admitting work, stops polling active preparation,
743/// and stops active provider request bodies from yielding bytes while paused;
744/// resume continues those same operations.
745///
746#[async_trait::async_trait]
747pub trait BlobTransitionObserver: Send + Sync {
748 /// The plaintext source is being verified and sealed into its durable
749 /// upload spool. Fires only for a Pending journal; a restart-resumed
750 /// Prepared journal proceeds directly to upload.
751 async fn on_blob_preparation_started(&self, upload: &RowBlobRef) {
752 let _ = upload;
753 }
754
755 /// `bytes_done` of `bytes_total` plaintext source bytes have been consumed
756 /// by preparation. Values are cumulative and monotonic.
757 async fn on_blob_preparation_progress(
758 &self,
759 upload: &RowBlobRef,
760 bytes_done: u64,
761 bytes_total: u64,
762 ) {
763 let _ = (upload, bytes_done, bytes_total);
764 }
765
766 /// The durable spool is prepared and its provider upload is starting now.
767 async fn on_blob_upload_started(&self, upload: &RowBlobRef);
768
769 /// `bytes_done` of `bytes_total` encrypted bytes have reached the cloud for
770 /// this in-flight blob. `bytes_done` is cumulative and monotonic within one
771 /// upload attempt. The default is a no-op so observers that don't surface
772 /// sub-file progress don't need a stub.
773 async fn on_blob_upload_progress(
774 &self,
775 upload: &RowBlobRef,
776 bytes_done: u64,
777 bytes_total: u64,
778 ) {
779 let _ = (upload, bytes_done, bytes_total);
780 }
781
782 /// The blob was uploaded to the cloud successfully — notification only.
783 /// coven owns the durable Created handoff and the Store publication that
784 /// completes the make_remote.
785 async fn on_blob_uploaded(&self, upload: &RowBlobRef);
786
787 /// An upload attempt failed; the entry remains queued for retry.
788 async fn on_blob_upload_failed(&self, upload: &RowBlobRef, error: &str);
789
790 /// Whether upload work is currently paused. The drain checks this before
791 /// admission and while provider work is active. The default is `false` so
792 /// existing implementations don't need a stub.
793 fn should_skip_uploads(&self) -> bool {
794 false
795 }
796
797 /// Complete when the absolute upload-pause state becomes paused. The
798 /// default never completes because the default state never pauses.
799 async fn wait_until_uploads_paused(&self) {
800 std::future::pending::<()>().await;
801 }
802
803 /// Complete when the absolute upload-pause state becomes running. An
804 /// observer that can return `true` from [`Self::should_skip_uploads`] must
805 /// override this so a suspended transfer can resume.
806 async fn wait_until_uploads_resumed(&self) {
807 std::future::pending::<()>().await;
808 }
809
810 /// coven completed a make_local of `(root_table, root_id)`: every blob is back
811 /// to a local file (a user file for user-provided, the local store for
812 /// host-provided), the gate is flipped false (the subtree retracts from peers),
813 /// and the cloud blobs are queued for tombstoning. The default is a no-op.
814 async fn on_root_made_local(&self, root_table: &str, root_id: &str) {
815 let _ = (root_table, root_id);
816 }
817
818 /// `done` of `total` of a make_local's blobs have been materialized back to a
819 /// local file, so the host can move a per-file progress bar. The default is a
820 /// no-op.
821 async fn on_blob_materialize_progress(
822 &self,
823 root_table: &str,
824 root_id: &str,
825 blob_id: &str,
826 done: u64,
827 total: u64,
828 ) {
829 let _ = (root_table, root_id, blob_id, done, total);
830 }
831}
832
833#[cfg(test)]
834mod cloud_path_tests {
835 use super::cloud_path_names_blob;
836
837 /// A replaceable blob's readable path must name the blob standing at it, so that
838 /// repointing the row moves its cloud key instead of overwriting the object it
839 /// replaced. A path naming no blob — the natural-looking `cover.jpg` — is what makes a
840 /// replacement rewrite the object its predecessor holds.
841 #[test]
842 fn a_cloud_path_names_the_blob_whose_id_ends_its_file_name() {
843 assert!(cloud_path_names_blob(
844 "Live at Leeds/cover-0ef7a1c9.jpg",
845 "0ef7a1c9"
846 ));
847 assert!(cloud_path_names_blob(
848 "Live at Leeds/0ef7a1c9.jpg",
849 "0ef7a1c9"
850 ));
851 assert!(
852 cloud_path_names_blob("0ef7a1c9", "0ef7a1c9"),
853 "no directory and no extension: the whole path is the blob id",
854 );
855
856 assert!(
857 !cloud_path_names_blob("Live at Leeds/cover.jpg", "0ef7a1c9"),
858 "names no blob — the next cover would take this same name",
859 );
860 assert!(
861 !cloud_path_names_blob("0ef7a1c9/cover.jpg", "0ef7a1c9"),
862 "the id must name the OBJECT, not a directory above it — two blobs under one \
863 directory would still collide on the file",
864 );
865 assert!(
866 !cloud_path_names_blob("Live at Leeds/cover-0ef7a1c9-thumb.jpg", "0ef7a1c9"),
867 "the id must END the file name's stem, not sit inside it",
868 );
869 }
870
871 /// The `-` delimiter is what makes the path→blob mapping unambiguous. A bare substring
872 /// test would let one blob satisfy another's path, and the two would be keyed at one
873 /// cloud object — the exact collision the rule exists to prevent.
874 #[test]
875 fn one_blob_id_cannot_satisfy_another_s_path_by_being_a_tail_of_it() {
876 assert!(cloud_path_names_blob("cover-10ef7a1c9.jpg", "10ef7a1c9"));
877 assert!(
878 !cloud_path_names_blob("cover-10ef7a1c9.jpg", "0ef7a1c9"),
879 "blob 0ef7a1c9 must not claim blob 10ef7a1c9's object",
880 );
881 }
882}
883
884/// The default convergence window a host gets if it configures none: how long a
885/// deleted blob is kept after its tombstone is written, before a GC pass
886/// reclaims it. The host overrides it on the coven builder; the writer's
887/// tombstone collection evaluates whatever grace it is handed against the
888/// tombstone's `deleted_at`.
889///
890/// A device offline for less than the grace is never stranded by a deletion —
891/// when it reconnects it pulls the removal of the row that referenced the blob,
892/// and the blob is still present until then. The window is human-scale (days,
893/// not the sub-second commit window the snapshot sweep's grace covers) because
894/// the device it protects is a person's offline laptop or phone, not a
895/// concurrent writer mid-publish.
896pub const BLOB_TOMBSTONE_GRACE: chrono::Duration = chrono::Duration::days(7);
897
898#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
899#[serde(rename_all = "snake_case")]
900pub enum DeferredLocalBlobDisposition {
901 Drop,
902 Cache,
903 Pin,
904}
905
906impl DeferredLocalBlobDisposition {
907 pub fn as_db(self) -> &'static str {
908 match self {
909 Self::Drop => "drop",
910 Self::Cache => "cache",
911 Self::Pin => "pin",
912 }
913 }
914
915 pub fn from_db(raw: &str) -> Result<Self, DeferredLocalBlobDispositionError> {
916 match raw {
917 "drop" => Ok(Self::Drop),
918 "cache" => Ok(Self::Cache),
919 "pin" => Ok(Self::Pin),
920 other => Err(DeferredLocalBlobDispositionError {
921 value: other.to_string(),
922 }),
923 }
924 }
925}
926
927#[derive(Debug, thiserror::Error)]
928#[error("unknown disposition in published blob drop intent: {value}")]
929pub struct DeferredLocalBlobDispositionError {
930 value: String,
931}
932
933#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
934#[serde(deny_unknown_fields)]
935pub struct DeferredLocalBlobDrop {
936 pub namespace: String,
937 pub id: String,
938 pub size: u64,
939 pub plaintext_hash: crate::store_commit::ObjectHash,
940 pub locator_hash: crate::store_commit::ObjectHash,
941 pub disposition: DeferredLocalBlobDisposition,
942}