Skip to main content

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}