Skip to main content

coven/
handle.rs

1//! The data handle: one object a host constructs once that owns coven's
2//! pieces and exposes the whole data interface as methods.
3//!
4//! coven owns the store's data — SQL rows and blobs, on disk first, cloud
5//! optional. A host (a desktop/mobile app) talks to coven through this one
6//! handle and never assembles coven's internals by hand or hands them back to
7//! coven on every call. The handle holds the [`Database`], the [`StoreDir`],
8//! the keys, and — once a cloud provider is connected — the [`SyncManager`]; the
9//! caller passes only descriptors (a [`BlobRef`], SQL, a config) and coven does
10//! its own plumbing.
11//!
12//! The stack runs on Tokio with a [`SyncManager`] and is `Send + Sync`
13//! throughout.
14//!
15//! ## What it owns
16//!
17//! - **Rows** — the [`Database`] (coven already owns the connection). The host
18//!   runs its app SQL through [`sql`](CovenHandle::sql) and row+blob batches
19//!   through [`write`](CovenHandle::write).
20//! - **Blobs** — the [`StoreDir`] the blob engine reads/writes, plus the
21//!   credentials to build a read [`SyncStorage`] on a cloud miss. Read, ranged
22//!   read, store, register external, pin/unpin, the locality transitions, and the
23//!   upload drain are methods here.
24//! - **Sync** — built lazily by [`connect_sync`](CovenHandle::connect_sync) when a
25//!   cloud provider is connected. A store with no cloud home never builds a
26//!   [`SyncManager`] and only ever holds Local blobs.
27
28use std::collections::HashMap;
29use std::path::PathBuf;
30use std::sync::{Arc, RwLock};
31
32use tokio::sync::watch;
33use tracing::{debug, error, info};
34
35use crate::blob::cache::BlobCacheError;
36use crate::blob::transition::{MakeLocalError, MakeRemoteError};
37use crate::blob::upload::DrainOutcome;
38use crate::blob::{BlobRef, BlobTransitionObserver, RowBlobRef};
39use crate::clock::ClockRef;
40use crate::config::Config;
41use crate::coven::StoreOpenGuard;
42use crate::database::{Database, DbError};
43use crate::encryption::{EncryptionService, MasterKeyring, SealError};
44use crate::keys::{
45    DeviceIdentityCustody, IdentityError, KeyError, MasterKeyCustody, MasterKeyError, StoreKeys,
46};
47#[cfg(any(test, feature = "test-utils"))]
48use crate::storage::cloud::CloudHome;
49use crate::store_dir::StoreDir;
50#[cfg(any(test, feature = "test-utils"))]
51use crate::sync::cloud_storage::CloudCipher;
52use crate::sync::cloud_storage::{BlobPathScheme, CloudSyncStorage};
53use crate::sync::membership::MemberRole;
54use crate::sync::storage::{StorageError, SyncStorage};
55use crate::sync::sync_loop::SyncLoopStatus;
56use crate::sync::sync_manager::MemberInfo;
57use crate::sync::sync_manager::{ConfigProvider, SyncError, SyncManager};
58
59/// A Remote blob read needs sync storage; if building it from config fails
60/// (missing credentials or cloud configuration) the read surfaces that as a
61/// configuration fault, not a disk I/O error. `BlobCacheError` lives in
62/// `coven-core` and cannot name `coven`'s `StorageSetupError`, so the typed error
63/// is rendered to its message at this crate boundary.
64impl From<crate::storage::cloud::setup::StorageSetupError> for BlobCacheError {
65    fn from(e: crate::storage::cloud::setup::StorageSetupError) -> Self {
66        BlobCacheError::StorageSetup(e.to_string())
67    }
68}
69
70/// The cipher a store's app-data sealing runs under, resolved from `custody`.
71///
72/// A store whose custody unlocks `None` has no key to seal under or open with,
73/// which is [`SealError::Locked`] — the same discipline the sync engine's cipher
74/// resolution keeps, where an opaque home with no established key refuses to
75/// start rather than inventing one.
76///
77/// Shared by [`CovenHandle`] and [`CovenReadHandle`](crate::CovenReadHandle) so
78/// both resolve the identical keyring the identical way; a payload one seals, the
79/// other opens.
80pub(crate) fn app_data_cipher(
81    custody: &dyn MasterKeyCustody,
82) -> Result<EncryptionService, SealError> {
83    let keyring = custody.unlock()?.ok_or(SealError::Locked)?;
84    Ok(EncryptionService::from(keyring))
85}
86
87pub(crate) fn routing_encryption_from_custody(
88    custody: &dyn MasterKeyCustody,
89) -> Result<EncryptionService, DbError> {
90    let keyring = custody
91        .unlock()
92        .map_err(|error| DbError::Message(format!("unlock Store key for row routing: {error}")))?
93        .ok_or_else(|| {
94            DbError::Message("Merge scoped write requires an established Store key".to_string())
95        })?;
96    Ok(EncryptionService::from(keyring))
97}
98
99/// The handle over one coven store.
100///
101/// Open it once with [`Coven::builder`](crate::Coven::builder), then call methods. Cheap to
102/// [`clone`](Clone) — every field is shared (an `Arc`, a `Clone` handle, or a
103/// reference-counted lock), so a clone drives the same database, sync manager,
104/// and storage as the original.
105///
106/// # Using the handle
107///
108/// The host builds the handle once at startup and then only calls methods on it
109/// — it never assembles coven's internals by hand or hands them back to coven on
110/// every call. Rows go through the connection coven owns; blobs go through the
111/// handle's read/store methods; sync is optional.
112///
113/// ```no_run
114/// # use coven::{CovenHandle, RowBlobRef};
115/// # async fn use_store(handle: &CovenHandle, cover: &RowBlobRef)
116/// #     -> Result<(), Box<dyn std::error::Error>> {
117/// // Rows: run app SQL on the connection coven owns.
118/// let note_count = handle
119///     .sql(|sql| {
120///         sql.query_row("SELECT count(*) FROM notes", [], |row| row.get(0))
121///             .map_err(coven::CovenError::from)
122///     })
123///     .await?;
124/// let note_count: i64 = note_count.value;
125///
126/// // Blobs: read an exact row version. coven resolves locality — the user's own
127/// // file, its local store, the cache, or a cloud fetch — and returns plaintext.
128/// let bytes: Vec<u8> = handle.read_blob(cover).await?;
129///
130/// // Sync is optional. Connect a provider, then drive it; a store with no
131/// // cloud home never calls these and stays fully usable on-device.
132/// handle.connect_sync().await?;
133/// handle.sync_now();
134/// # let _ = note_count;
135/// # Ok(())
136/// # }
137/// ```
138#[derive(Clone)]
139pub struct CovenHandle {
140    db: Database,
141
142    /// A read-only companion connection on the same WAL database, opened at
143    /// [`open`](crate::CovenBuilder::open) after the writer's migrations completed.
144    /// Backs [`sql_read`](Self::sql_read): a pure read runs here on its own
145    /// connection thread, concurrent with the writer's thread rather than queued
146    /// behind it, and attaches no changeset session. `Database` is `Clone` (clones
147    /// share one connection thread), so every [`CovenHandle`] clone shares this one
148    /// reader — many readers coexist with the single writer under WAL, each seeing
149    /// the last committed state.
150    read_db: Database,
151    stamper: crate::sync::hlc::UpdatedAtStamper,
152    store_dir: StoreDir,
153
154    /// Supplies the host's current config on demand. coven reads it fresh each
155    /// call so a host with reactive config sees changes without rebuilding the
156    /// handle. The same provider the [`SyncManager`] reads from.
157    config_provider: ConfigProvider,
158    key_service: StoreKeys,
159
160    /// The store's master-key custody, resolved once at
161    /// [`open`](crate::CovenBuilder::open) from the builder's
162    /// [`KeyCustody`](crate::KeyCustody) selection. Every master-key read and
163    /// write in the handle and the sync engine goes through this — coven never
164    /// touches a crypto type directly.
165    key_custody: Arc<dyn MasterKeyCustody>,
166
167    /// This store's device-identity custody, resolved once at
168    /// [`open`](crate::CovenBuilder::open) from the builder's
169    /// [`IdentityCustody`](crate::IdentityCustody) selection. Every read of
170    /// this store's signing identity in the handle and the sync engine goes
171    /// through this.
172    identity_custody: Arc<dyn DeviceIdentityCustody>,
173    clock: ClockRef,
174    cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
175
176    /// Host bookkeeping for blob transitions (upload progress, materialize
177    /// progress, completion). Passed to the [`SyncManager`] and to the upload
178    /// drain. `None` for a host that doesn't surface transition progress.
179    observer: Option<Arc<dyn BlobTransitionObserver>>,
180
181    /// Holds the store-directory lock for this handle and every clone,
182    /// and is cloned into each [`SyncManager`] so a running sync loop keeps the
183    /// lock alive until its own thread exits — the lock's lifetime tracks the
184    /// last writer, not the host's drop timing.
185    open_guard: Arc<StoreOpenGuard>,
186
187    /// Built lazily by [`connect_sync`](Self::connect_sync) when a provider is
188    /// connected; `None` for a home-less, all-Local store. Shared behind a lock
189    /// so a connect/disconnect mutates it in place without rebuilding the handle.
190    sync: Arc<RwLock<Option<Arc<SyncManager>>>>,
191
192    /// Serializes async lifecycle replacement so concurrent connects/restarts
193    /// cannot each start a loop and race to install the survivor.
194    sync_lifecycle: Arc<tokio::sync::Mutex<()>>,
195
196    /// The current sync-status value this handle owns. Every [`SyncManager`] it builds
197    /// clones this sender into its sync loop, so a
198    /// [`subscribe_sync_status`](Self::subscribe_sync_status) receiver keeps
199    /// receiving across a reconnect — which drops the old manager and loop and
200    /// builds new ones, but reuses this same channel. A subscription created
201    /// before any provider is connected is valid and starts receiving once a loop
202    /// runs.
203    sync_status_tx: tokio::sync::watch::Sender<SyncLoopStatus>,
204}
205
206impl CovenHandle {
207    /// Build the handle over an already-open [`Database`] and the store's
208    /// directory. Does no I/O and builds no sync manager — a home-less store is
209    /// fully usable (rows + Local blobs) without one. Call
210    /// [`connect_sync`](Self::connect_sync) when a cloud provider is connected.
211    ///
212    /// `config_provider` is read fresh on every call that needs the current
213    /// config (the cloud-home selection, the blob-path scheme), so the host can
214    /// reconnect a provider without rebuilding the handle. `observer` carries the
215    /// host's transition bookkeeping; pass `None` if it surfaces none.
216    #[allow(clippy::too_many_arguments)]
217    pub(crate) fn new(
218        db: Database,
219        read_db: Database,
220        stamper: crate::sync::hlc::UpdatedAtStamper,
221        store_dir: StoreDir,
222        config_provider: ConfigProvider,
223        key_service: StoreKeys,
224        key_custody: Arc<dyn MasterKeyCustody>,
225        identity_custody: Arc<dyn DeviceIdentityCustody>,
226        clock: ClockRef,
227        cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
228        observer: Option<Arc<dyn BlobTransitionObserver>>,
229        open_guard: Arc<StoreOpenGuard>,
230    ) -> Self {
231        Self {
232            db,
233            read_db,
234            stamper,
235            store_dir,
236            config_provider,
237            key_service,
238            key_custody,
239            identity_custody,
240            clock,
241            cloudkit_ops,
242            observer,
243            open_guard,
244            sync: Arc::new(RwLock::new(None)),
245            sync_lifecycle: Arc::new(tokio::sync::Mutex::new(())),
246            sync_status_tx: tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
247        }
248    }
249
250    fn config(&self) -> Config {
251        (self.config_provider)()
252    }
253
254    // =========================================================================
255    // Rows
256    // =========================================================================
257
258    /// The owned [`Database`]. Public row access goes through
259    /// [`CovenHandle::sql`] and [`CovenHandle::write`]; coven internals use this
260    /// to reach row-level helpers.
261    pub(crate) fn db(&self) -> &Database {
262        &self.db
263    }
264
265    /// The read-only companion [`Database`] backing [`sql_read`](Self::sql_read).
266    /// A pure read runs against this connection, concurrent with the writer.
267    pub(crate) fn read_db(&self) -> &Database {
268        &self.read_db
269    }
270
271    pub(crate) fn stamper(&self) -> crate::sync::hlc::UpdatedAtStamper {
272        self.stamper.clone()
273    }
274
275    pub(crate) fn store_dir(&self) -> StoreDir {
276        self.store_dir.clone()
277    }
278
279    pub(crate) fn routing_encryption(&self) -> Result<EncryptionService, DbError> {
280        routing_encryption_from_custody(self.key_custody.as_ref())
281    }
282
283    // =========================================================================
284    // Sync lifecycle
285    // =========================================================================
286
287    /// The connected [`SyncManager`], or `None` for a home-less store or one
288    /// whose provider has not been connected yet. The host reaches sync-engine
289    /// operations not surfaced as handle methods (membership, invite/remove,
290    /// status) through this.
291    pub(crate) fn sync_manager(&self) -> Option<Arc<SyncManager>> {
292        self.sync.read().unwrap().clone()
293    }
294
295    /// Subscribe to the sync loop's [`SyncLoopStatus`] stream. The channel is
296    /// owned by this handle, not the loop, so the receiver keeps working across a
297    /// reconnect and may be created before any provider is connected (it starts
298    /// receiving once a loop runs). Infallible for that reason — there is no loop
299    /// state to check.
300    ///
301    /// The receiver immediately contains the current value. Intermediate values
302    /// may be coalesced; `Synchronized.row_changes` is a refresh hint rather than a
303    /// complete change stream.
304    pub fn subscribe_sync_status(&self) -> tokio::sync::watch::Receiver<SyncLoopStatus> {
305        self.sync_status_tx.subscribe()
306    }
307
308    /// Writes that have shared rows and have not reached a published position.
309    pub async fn pending_writes(&self) -> Result<Vec<coven_core::PendingWrite>, crate::CovenError> {
310        self.db
311            .pending_writes()
312            .await
313            .map_err(crate::CovenError::from)
314    }
315
316    /// Writes stopped by a semantic publication fault and awaiting an explicit
317    /// retry or discard decision.
318    pub async fn blocked_writes(&self) -> Result<Vec<coven_core::PendingWrite>, crate::CovenError> {
319        self.db
320            .blocked_writes()
321            .await
322            .map_err(crate::CovenError::from)
323    }
324
325    /// Requeue one blocked write for full production validation. Serial writes
326    /// requeue their whole ordered branch. A connected sync loop is woken after
327    /// the durable transition.
328    pub async fn retry_blocked_write(
329        &self,
330        write_id: &coven_core::WriteId,
331    ) -> Result<Vec<coven_core::WriteId>, crate::CovenError> {
332        let retried = self
333            .db
334            .retry_blocked_write(write_id)
335            .await
336            .map_err(crate::CovenError::from)?;
337        self.sync_now();
338        Ok(retried)
339    }
340
341    /// Atomically discard a blocked write and reverse every later unpublished
342    /// shared write whose working-row state depends on it.
343    pub async fn discard_blocked_write(
344        &self,
345        write_id: &coven_core::WriteId,
346    ) -> Result<Vec<coven_core::WriteId>, crate::CovenError> {
347        let merge_cleanup_pending = self
348            .db
349            .merge_candidate_cleanup_pending(write_id)
350            .await
351            .map_err(crate::CovenError::from)?;
352        let merge_abandonment_required = if merge_cleanup_pending {
353            false
354        } else {
355            self.db
356                .merge_candidate_abandonment_required(write_id)
357                .await
358                .map_err(crate::CovenError::from)?
359        };
360        if merge_cleanup_pending || merge_abandonment_required {
361            let abandonment = self
362                .sync_manager()
363                .ok_or_else(|| {
364                    crate::CovenError::CandidateResolution("sync is not connected".to_string())
365                })?
366                .abandon_merge_candidate(write_id.clone())
367                .await
368                .map_err(|error| crate::CovenError::CandidateResolution(error.to_string()))?;
369            match abandonment {
370                coven_core::sync::store_outbound::MergeCandidateAbandonment::NotRequired => {
371                    return Err(crate::CovenError::CandidateResolution(
372                        "blocked Merge candidate has no abandonment authority".to_string(),
373                    ));
374                }
375                coven_core::sync::store_outbound::MergeCandidateAbandonment::Abandoned => {}
376                coven_core::sync::store_outbound::MergeCandidateAbandonment::CandidateActivated => {
377                    return Err(crate::CovenError::CandidateResolution(
378                        "Merge candidate activated before abandonment and cannot be discarded"
379                            .to_string(),
380                    ));
381                }
382            }
383        }
384        self.db
385            .discard_blocked_write(write_id)
386            .await
387            .map_err(crate::CovenError::from)
388    }
389
390    pub async fn pending_branches(
391        &self,
392    ) -> Result<Option<coven_core::PendingBranch>, crate::CovenError> {
393        self.db
394            .pending_branches()
395            .await
396            .map_err(crate::CovenError::from)
397    }
398
399    /// Read the current durable status of one write.
400    pub async fn write_status(
401        &self,
402        write_id: &coven_core::WriteId,
403    ) -> Result<coven_core::WriteStatus, crate::CovenError> {
404        self.db
405            .write_status(write_id)
406            .await
407            .map_err(crate::CovenError::from)
408    }
409
410    /// Subscribe to one write's current durable status. The initial value is
411    /// reconstructed from SQLite before the receiver is returned.
412    pub async fn subscribe_write_status(
413        &self,
414        write_id: &coven_core::WriteId,
415    ) -> Result<tokio::sync::watch::Receiver<coven_core::WriteStatus>, crate::CovenError> {
416        self.db
417            .subscribe_write_status(write_id)
418            .await
419            .map_err(crate::CovenError::from)
420    }
421
422    /// Build the [`SyncManager`] for a connected cloud provider, start its sync
423    /// loop, and install it. Returns the started manager, or an error if the cloud
424    /// home fails to build — in which case nothing is installed, so the handle
425    /// never holds a manager that reports success with nothing started.
426    ///
427    /// The at-rest cipher is resolved from the handle's custody per start: an
428    /// opaque home unlocks the master keyring (failing with
429    /// [`SyncError::MasterKeyNotEstablished`] if none is established), a
430    /// browsable one never consults custody. Reconnecting a provider rebuilds
431    /// the manager — the [`Database`] keeps the seeded register clock across
432    /// the rebuild, so only the cloud home + loop are replaced.
433    pub async fn connect_sync(&self) -> Result<(), SyncError> {
434        self.build_and_install_sync(self.cloudkit_ops.clone(), |manager| async move {
435            manager.start_sync().await
436        })
437        .await?;
438        info!("coven handle: sync manager connected");
439        Ok(())
440    }
441
442    pub async fn connect_sync_with_cloudkit(
443        &self,
444        cloudkit_ops: Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>,
445    ) -> Result<(), SyncError> {
446        self.build_and_install_sync(Some(cloudkit_ops), |manager| async move {
447            manager.start_sync().await
448        })
449        .await?;
450        info!("coven handle: sync manager connected with CloudKit driver");
451        Ok(())
452    }
453
454    /// Build a [`SyncManager`], start its loop via `start`, and install it — the
455    /// shared construct-and-install both [`connect_sync`](Self::connect_sync) and
456    /// the test-only
457    /// [`connect_sync_with_test_home`](Self::connect_sync_with_test_home) run.
458    ///
459    /// Start before installing: a failed start (the cloud home fails to build, or a
460    /// test home's bootstrap fails) returns its error with nothing installed, so the
461    /// handle is left home-less rather than holding a manager whose loop never
462    /// started.
463    async fn build_and_install_sync<F, Fut>(
464        &self,
465        cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
466        start: F,
467    ) -> Result<Arc<SyncManager>, SyncError>
468    where
469        F: FnOnce(Arc<SyncManager>) -> Fut,
470        Fut: std::future::Future<Output = Result<(), SyncError>>,
471    {
472        let _lifecycle = self.sync_lifecycle.lock().await;
473        let previous = self.sync.write().unwrap().take();
474        if let Some(manager) = previous {
475            manager.stop_sync()?;
476        }
477
478        let manager = Arc::new(SyncManager::new(
479            self.config_provider.clone(),
480            self.key_service.clone(),
481            self.key_custody.clone(),
482            self.identity_custody.clone(),
483            self.db.clone(),
484            self.clock.clone(),
485            cloudkit_ops,
486            self.observer.clone(),
487            self.open_guard.clone(),
488            self.sync_status_tx.clone(),
489        ));
490        Box::pin(start(manager.clone())).await?;
491        *self.sync.write().unwrap() = Some(manager.clone());
492        Ok(manager)
493    }
494
495    /// Test-only: connect a started [`SyncManager`] over an injected [`CloudHome`]
496    /// instead of one built from [`Config`], so a host's integration tests drive
497    /// the real make-Remote / make-Local / upload-drain and read paths over a mock
498    /// cloud with no live provider.
499    ///
500    /// The test counterpart of [`connect_sync`](Self::connect_sync): it stands the
501    /// manager over `home`/`cipher` through
502    /// [`SyncManager::start_sync_with_home`], starts the loop, and installs it with
503    /// the same start-before-install discipline — a failed connect leaves the
504    /// handle home-less rather than holding a manager whose loop never started.
505    /// The injected `cipher` is the at-rest protection directly — the manager's
506    /// custody is never consulted on this path.
507    ///
508    /// The read path needs no separate hook: [`blob_storage`](Self::blob_storage)
509    /// serves reads from the connected loop's own [`CloudSyncStorage`], which here
510    /// wraps the injected `home`, so [`read_blob`](Self::read_blob) /
511    /// [`pin`](Self::pin) resolve a Remote miss against the same test home the
512    /// drain writes to.
513    #[cfg(any(test, feature = "test-utils"))]
514    pub async fn connect_sync_with_test_home(
515        &self,
516        home: Arc<dyn CloudHome>,
517        cipher: CloudCipher,
518    ) -> Result<(), SyncError> {
519        self.build_and_install_sync(self.cloudkit_ops.clone(), move |manager| async move {
520            manager.start_sync_with_home(home, cipher).await
521        })
522        .await?;
523        info!("coven handle: sync manager connected over an injected test cloud home");
524        Ok(())
525    }
526
527    #[cfg(any(test, feature = "test-utils"))]
528    pub async fn connect_sync_with_test_home_and_coordination(
529        &self,
530        home: Arc<dyn CloudHome>,
531        coordination: Arc<dyn crate::storage::cloud::CloudHeadStorage>,
532        cipher: CloudCipher,
533    ) -> Result<(), SyncError> {
534        self.build_and_install_sync(self.cloudkit_ops.clone(), move |manager| async move {
535            manager
536                .start_sync_with_home_and_coordination(home, coordination, cipher)
537                .await
538        })
539        .await?;
540        info!("coven handle: sync manager connected over an injected coordinated test home");
541        Ok(())
542    }
543
544    /// Test-only: connect over an injected [`CloudHome`] while resolving the
545    /// at-rest cipher from custody the way production
546    /// [`connect_sync`](Self::connect_sync) does, instead of taking an explicit
547    /// cipher like [`connect_sync_with_test_home`](Self::connect_sync_with_test_home).
548    ///
549    /// Where that method injects the cipher and never touches custody, this drives
550    /// [`SyncManager::start_sync_with_test_home_custody`], which unlocks the master
551    /// keyring through the store's custody exactly as `start_sync` would — so a
552    /// test can establish a key, connect over a mock home, and prove the traffic
553    /// is sealed under that key. An opaque home with no key established fails
554    /// [`SyncError::MasterKeyNotEstablished`] before the loop starts.
555    #[cfg(any(test, feature = "test-utils"))]
556    pub async fn connect_sync_with_test_home_custody(
557        &self,
558        home: Arc<dyn CloudHome>,
559    ) -> Result<(), SyncError> {
560        self.build_and_install_sync(self.cloudkit_ops.clone(), move |manager| async move {
561            manager.start_sync_with_test_home_custody(home).await
562        })
563        .await?;
564        info!(
565            "coven handle: sync manager connected over an injected test cloud home with custody-resolved cipher"
566        );
567        Ok(())
568    }
569
570    /// Start (or restart) the sync loop of the installed [`SyncManager`]. A no-op
571    /// when no provider is connected — a home-less store has nothing to start.
572    /// Errors if the installed manager's cloud home fails to build.
573    pub async fn start_sync(&self) -> Result<(), SyncError> {
574        let _lifecycle = self.sync_lifecycle.lock().await;
575        match self.sync_manager() {
576            Some(manager) => manager.start_sync().await,
577            None => {
578                debug!("start_sync: no provider connected; nothing to start");
579                Ok(())
580            }
581        }
582    }
583
584    /// Stop the sync loop after the in-flight cycle, keeping the installed
585    /// manager so [`start_sync`](Self::start_sync) can resume it. A no-op when no
586    /// provider is connected.
587    ///
588    /// The material a running loop resolved from custody (the master keyring,
589    /// the device signing identity) is cached only inside that loop for as
590    /// long as it runs — nowhere else in the handle — and this is where it is
591    /// purged. A subsequent [`start_sync`](Self::start_sync)/
592    /// [`connect_sync`](Self::connect_sync) re-resolves fresh from whatever
593    /// custody now serves, so a host's lock flow that stops sync as part of
594    /// locking, then later reconnects, never resumes on stale material.
595    pub fn stop_sync(&self) {
596        match self.sync_manager() {
597            Some(manager) => {
598                if let Err(stop_error) = manager.stop_sync() {
599                    error!("stop_sync failed: {stop_error}");
600                }
601            }
602            None => debug!("stop_sync: no provider connected; nothing to stop"),
603        }
604    }
605
606    /// Disconnect the provider entirely: stop the loop and drop the installed
607    /// [`SyncManager`]. The store becomes home-less until the next
608    /// [`connect_sync`](Self::connect_sync).
609    ///
610    /// Carries the same purge as [`stop_sync`](Self::stop_sync) (dropping the
611    /// manager cannot leave more behind than stopping its loop already
612    /// cleared) and additionally drops the manager itself, so nothing about
613    /// the previous connection — including which custody it resolved
614    /// material from — survives into the next connect.
615    pub fn disconnect_sync(&self) {
616        if let Some(manager) = self.sync_manager() {
617            if let Err(stop_error) = manager.stop_sync() {
618                error!("disconnect_sync failed to stop sync: {stop_error}");
619            }
620        }
621        *self.sync.write().unwrap() = None;
622        info!("coven handle: sync manager disconnected");
623    }
624
625    /// Wake the sync loop to run a cycle now rather than at the next idle tick. A
626    /// no-op when no provider is connected.
627    pub fn sync_now(&self) {
628        match self.sync_manager() {
629            Some(manager) => manager.trigger_sync(),
630            None => debug!("sync_now: no provider connected; sync wake ignored"),
631        }
632    }
633
634    /// Whether the sync loop is running. `false` for a home-less store.
635    pub fn is_syncing(&self) -> bool {
636        self.sync_manager()
637            .is_some_and(|manager| manager.is_sync_ready())
638    }
639
640    /// Whether a [`SyncManager`] is installed — a provider is connected. Distinct
641    /// from [`is_syncing`](Self::is_syncing), which additionally requires the loop
642    /// to be running: this is the predicate a host uses for "has a cloud home"
643    /// without the loop-ready condition.
644    pub fn is_connected(&self) -> bool {
645        self.sync_manager().is_some()
646    }
647
648    // =========================================================================
649    // Master-key lifecycle
650    // =========================================================================
651
652    /// Generate this store's master key and establish it under the handle's
653    /// custody. Errors with [`MasterKeyError::AlreadyEstablished`] if custody
654    /// already unlocks one — coven never generates over an existing key, so a
655    /// corrupt (present-but-unreadable) entry is never silently overwritten
656    /// either, since custody's `unlock` surfaces that as `Err`, not `None`.
657    /// The only place coven ever generates a master key. Returns its
658    /// fingerprint for the host to record in its own config.
659    pub fn initialize_master_key(&self) -> Result<String, MasterKeyError> {
660        if self.key_custody.unlock()?.is_some() {
661            return Err(MasterKeyError::AlreadyEstablished);
662        }
663        let keyring = MasterKeyring::generate();
664        self.key_custody.persist(&keyring)?;
665        Ok(keyring.fingerprint())
666    }
667
668    /// Import a serialized master keyring a host already holds and establish it
669    /// under the handle's custody, replacing whatever custody already holds.
670    /// Returns its fingerprint for the host to record in its own config.
671    pub fn import_master_key(&self, serialized: &str) -> Result<String, MasterKeyError> {
672        let keyring = MasterKeyring::from_serialized(serialized)?;
673        self.key_custody.persist(&keyring)?;
674        Ok(keyring.fingerprint())
675    }
676
677    /// Remove the master key from custody — a host's lock/sign-out flow. `Ok`
678    /// whether or not one was established.
679    pub fn forget_master_key(&self) -> Result<(), KeyError> {
680        self.key_custody.forget()
681    }
682
683    /// The established master key's fingerprint, or `None` if custody has
684    /// never had one established (or is locked, for a policy where that's
685    /// representable).
686    pub fn master_key_fingerprint(&self) -> Result<Option<String>, KeyError> {
687        Ok(self.key_custody.unlock()?.map(|k| k.fingerprint()))
688    }
689
690    // =========================================================================
691    // Identity lifecycle
692    // =========================================================================
693
694    /// Generate this store's signing identity and establish it under the
695    /// handle's identity custody. Errors with
696    /// [`IdentityError::AlreadyEstablished`] if custody already unlocks one —
697    /// coven never generates over an existing identity. The counterpart of
698    /// [`initialize_master_key`](Self::initialize_master_key) for a store a
699    /// host is creating fresh (not joining or restoring, which each establish
700    /// their own identity as part of what they do). Returns the established
701    /// public key, hex-encoded.
702    pub fn initialize_identity(&self) -> Result<String, IdentityError> {
703        if self.identity_custody.unlock()?.is_some() {
704            return Err(IdentityError::AlreadyEstablished);
705        }
706        let keypair = crate::keys::UserKeypair::generate();
707        self.identity_custody.persist(&keypair)?;
708        Ok(crate::keys::public_key_hex(&keypair))
709    }
710
711    // =========================================================================
712    // Host secrets
713    // =========================================================================
714
715    /// Set a host's own store-scoped secret — an API token, a service
716    /// credential — under the same platform keyring, and the same access
717    /// policy, as coven's own key material. `name` identifies the secret
718    /// within the store; coven owns the account rendering and the entry's
719    /// protection class. [`KeyError::InvalidSecretName`] if `name` collides
720    /// with one of coven's own reserved slot names, is empty, or contains
721    /// `:`.
722    pub fn set_host_secret(&self, name: &str, value: &str) -> Result<(), KeyError> {
723        self.key_service.set_host_secret(name, value)
724    }
725
726    /// Read a host secret set by [`set_host_secret`](Self::set_host_secret),
727    /// `None` if never set. A present-but-empty entry is corrupt, not
728    /// absent — the same discipline coven's own key reads apply.
729    pub fn host_secret(&self, name: &str) -> Result<Option<String>, KeyError> {
730        self.key_service.get_host_secret(name)
731    }
732
733    /// Remove a host secret. `Ok` whether or not one was set.
734    pub fn delete_host_secret(&self, name: &str) -> Result<(), KeyError> {
735        self.key_service.delete_host_secret(name)
736    }
737
738    // =========================================================================
739    // App-data sealing
740    // =========================================================================
741
742    /// Seal `plaintext` under the store's current master-key generation, for a
743    /// host to store in its own rows — a password entry's payload, an API token.
744    /// coven's at-rest encryption is cloud-side; the local database is plaintext
745    /// SQLite, so a host with a secret to keep in a row seals it here first.
746    ///
747    /// The output records the generation it was sealed under, so it stays
748    /// openable after any number of key rotations. `aad` binds the ciphertext to
749    /// its context — the owning row's primary key, say — and
750    /// [`open_app_data`](Self::open_app_data) with a different `aad` fails, so a
751    /// payload moved to another row does not silently open there.
752    ///
753    /// [`SealError::Locked`] if the store has no established master key, the same
754    /// gate [`connect_sync`](Self::connect_sync) applies before it seals cloud
755    /// traffic.
756    pub fn seal_app_data(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
757        Ok(app_data_cipher(self.key_custody.as_ref())?.seal_app_data(plaintext, aad))
758    }
759
760    /// Open a payload [`seal_app_data`](Self::seal_app_data) produced, under
761    /// whichever generation it names — a rotated keyring still opens everything
762    /// it sealed before rotating.
763    ///
764    /// [`SealError::Locked`] if the store is locked; a wrong `aad`, a tampered
765    /// payload, an unreadable version, or a generation this store's keyring lacks
766    /// each surface their own typed error.
767    pub fn open_app_data(&self, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
768        app_data_cipher(self.key_custody.as_ref())?.open_app_data(sealed, aad)
769    }
770
771    // =========================================================================
772    // Blobs
773    // =========================================================================
774
775    /// The read [`SyncStorage`] for coven's locality-aware read, or `None` for a
776    /// home-less store: `Some(home)` when a provider is connected, `None` when
777    /// none is. coven reaches storage only on a cloud miss — a Remote blob not yet
778    /// cached. A Local blob (the only kind a home-less store has) is served from
779    /// its external ref or the local store without ever touching storage, so a
780    /// home-less read passes `None` and the cache layer surfaces
781    /// [`BlobCacheError::NoCloudHome`] only if a Remote blob ever reaches the miss
782    /// path — a real fault, not masked.
783    ///
784    /// A provider that IS configured but whose storage fails to build (missing
785    /// credentials, a bad cipher) surfaces that error rather than reporting
786    /// home-less.
787    ///
788    /// When a [`SyncManager`] is connected and its loop is running, the read
789    /// reuses that loop's own [`CloudSyncStorage`] rather than rebuilding one from
790    /// config — so a read and the loop's writes share the exact home + cipher (and
791    /// a key rotation the loop applies in place is seen here on the next read), and
792    /// a test home injected via
793    /// [`connect_sync_with_test_home`](Self::connect_sync_with_test_home) is served
794    /// from with no separate hook. A manager connected but not yet running its loop
795    /// still wraps the manager's stored home; only a home-less store builds from
796    /// config when a provider is configured.
797    async fn blob_storage(
798        &self,
799    ) -> Result<Option<Arc<dyn SyncStorage>>, crate::storage::cloud::setup::StorageSetupError> {
800        if let Some(manager) = self.sync_manager() {
801            if let Some(loop_handle) = manager.sync_loop_handle() {
802                let storage: Arc<dyn SyncStorage> = loop_handle.storage().clone();
803                return Ok(Some(storage));
804            }
805            if let Some(home) = manager.cloud_home() {
806                let config = self.config();
807                let storage = crate::storage::cloud::setup::create_sync_storage_with_home(
808                    &config,
809                    self.key_custody.as_ref(),
810                    self.identity_custody.as_ref(),
811                    home,
812                    None,
813                )?;
814                return Ok(Some(Arc::new(storage)));
815            }
816        }
817        let config = self.config();
818        if config.cloud_home.provider.is_none() {
819            return Ok(None);
820        }
821        let storage = crate::storage::cloud::setup::create_sync_storage_with_cloudkit(
822            &config,
823            &self.key_service,
824            self.key_custody.as_ref(),
825            self.identity_custody.as_ref(),
826            None,
827            self.clock.clone(),
828            self.cloudkit_ops.clone(),
829        )
830        .await?;
831        Ok(Some(Arc::new(storage)))
832    }
833
834    /// Capture the exact current blob-bearing row version. Blob operations use
835    /// this row-bound value so a later row replacement cannot redirect a read.
836    pub async fn row_blob_ref(&self, table: &str, row_id: &str) -> Result<RowBlobRef, DbError> {
837        self.db.row_blob_ref(table, row_id).await
838    }
839
840    /// Read a blob's whole plaintext through coven's locality-aware read: served
841    /// from the user's file (Local user-provided), coven's local store (Local
842    /// host-provided), the pinned/evictable cache on a Remote hit, or fetched
843    /// from the cloud (into the cache) on a Remote miss. The host passes the
844    /// [`RowBlobRef`] captured from [`row_blob_ref`](Self::row_blob_ref); coven
845    /// holds the database, directory, and storage.
846    pub async fn read_blob(&self, blob: &RowBlobRef) -> Result<Vec<u8>, BlobCacheError> {
847        let storage = self.blob_storage().await?;
848        crate::blob::cache::read_blob(&self.db, &self.store_dir, storage.as_deref(), blob).await
849    }
850
851    /// Serve `len` plaintext bytes of an exact row blob starting at `offset`, for
852    /// streaming or seeking without loading the whole file. The [`RowBlobRef`]
853    /// carries the plaintext length used to bound the range. The ranged sibling
854    /// of [`read_blob`](Self::read_blob).
855    pub async fn open_blob_stream(
856        &self,
857        blob: &RowBlobRef,
858        offset: u64,
859        len: u64,
860    ) -> Result<Vec<u8>, BlobCacheError> {
861        let storage = self.blob_storage().await?;
862        crate::blob::cache::open_blob_stream(
863            &self.db,
864            &self.store_dir,
865            storage.as_deref(),
866            blob,
867            offset,
868            len,
869        )
870        .await
871    }
872
873    /// Pin a Remote blob set for offline: coven fetches each into the protected
874    /// cache (`storage/pinned/`) — from the evictable cache if already there, else
875    /// the cloud — exempt from the size budget. Idempotent.
876    pub async fn pin(&self, blobs: &[RowBlobRef]) -> Result<(), BlobCacheError> {
877        let storage = self.blob_storage().await?;
878        crate::blob::cache::pin(&self.db, &self.store_dir, storage.as_deref(), blobs).await
879    }
880
881    /// Unpin a Remote blob set: coven moves each from `storage/pinned/` to the
882    /// evictable `storage/cache/` (still readable, now droppable). No cloud read.
883    pub async fn unpin(&self, blobs: &[RowBlobRef]) -> Result<(), BlobCacheError> {
884        crate::blob::cache::unpin(&self.store_dir, blobs).await
885    }
886
887    /// The cloud object key a blob's bytes live at, derived under the connected
888    /// home's path scheme (`Hashed` → `{namespace}/{ab}/{cd}/{id}`, `Plain` →
889    /// `{namespace}/{cloud_path}`). coven owns this derivation — the host passes a
890    /// [`BlobRef`] and never reconstructs the cloud layout. The host enqueues a
891    /// blob's cloud removal under this key (its delete drains to a tombstone; see
892    /// [`crate::blob::delete`]), and a test asserts an upload's key matches the
893    /// read key with it. A `Plain` home whose `cloud_path` is absent, or does not
894    /// name the blob it carries, is a surfaced error — see
895    /// [`CloudSyncStorage::blob_key`].
896    pub fn blob_cloud_key(&self, blob: &BlobRef) -> Result<String, StorageError> {
897        let active_loop = self
898            .sync_manager()
899            .and_then(|manager| manager.sync_loop_handle());
900        let (scheme, uploader) = match active_loop {
901            Some(sync_loop) => (
902                sync_loop.blob_path_scheme(),
903                Some(sync_loop.self_uploader()),
904            ),
905            None => {
906                let scheme = BlobPathScheme::for_storage(self.config().cloud_home.storage);
907                let uploader = crate::keys::identity_public_key(self.identity_custody.as_ref())
908                    .map_err(|e| StorageError::Storage(format!("read this store's identity: {e}")))?
909                    .map(hex::encode);
910                (scheme, uploader)
911            }
912        };
913        CloudSyncStorage::blob_key(
914            scheme,
915            &blob.namespace,
916            uploader.as_deref(),
917            &blob.id,
918            blob.cloud_path.as_deref(),
919        )
920    }
921
922    /// Whether every blob in `blobs` is pinned for offline — present in coven's
923    /// kept cache folder (`storage/pinned/`). The host answers "is this release
924    /// kept offline" through this instead of stat-ing coven's cache layout itself.
925    /// An empty set is vacuously pinned. A blob not pinned (in the evictable cache
926    /// or absent) makes the whole set unpinned; an existence-check failure is
927    /// surfaced, never read as "not pinned".
928    pub async fn is_pinned(&self, blobs: &[BlobRef]) -> Result<bool, BlobCacheError> {
929        for blob in blobs {
930            if !crate::blob::cache::is_pinned(&self.store_dir, &blob.namespace, &blob.id).await? {
931                return Ok(false);
932            }
933        }
934        Ok(true)
935    }
936
937    /// Remove one Remote blob's re-fetchable on-device cache copies from both
938    /// `storage/pinned/` and `storage/cache/`. This never touches the local store,
939    /// whose bytes may be the only usable copy owned by an unpublished write.
940    /// It does not delete the cloud blob or its carrying row; a later read can
941    /// fetch the bytes again.
942    pub async fn evict_blob(&self, blob: &BlobRef) -> Result<(), BlobCacheError> {
943        crate::blob::cache::drop_cached_blob(&self.store_dir, &blob.namespace, &blob.id).await
944    }
945
946    /// Make `(root_table, root_id)` Remote (Local → Remote): enqueue an upload per
947    /// user-provided blob from its external file and record the make_remote
948    /// intent, then return. The drain uploads each and flips the gate true on the
949    /// last; the gate flip re-emits the subtree and the cycle's inline push
950    /// uploads host-provided blobs. `pin` keeps the uploaded blobs in the cache as
951    /// pinned offline copies. Errors with [`MakeRemoteError::SyncNotReady`] when no
952    /// provider is connected.
953    pub async fn make_remote(
954        &self,
955        root_table: &str,
956        root_id: &str,
957        pin: bool,
958    ) -> Result<(), MakeRemoteError> {
959        match self.sync_manager() {
960            Some(manager) => manager.make_remote(root_table, root_id, pin).await,
961            None => Err(MakeRemoteError::SyncNotReady),
962        }
963    }
964
965    /// Cancel an in-flight make_remote of `(root_table, root_id)`: clear its intent
966    /// and pending uploads and tombstone any blob already in the cloud. The gate
967    /// never flips, so the root stays Local. Errors with
968    /// [`MakeRemoteError::SyncNotReady`] when no provider is connected.
969    pub async fn cancel_make_remote(
970        &self,
971        root_table: &str,
972        root_id: &str,
973    ) -> Result<(), MakeRemoteError> {
974        match self.sync_manager() {
975            Some(manager) => manager.cancel_make_remote(root_table, root_id).await,
976            None => Err(MakeRemoteError::SyncNotReady),
977        }
978    }
979
980    /// Make `(root_table, root_id)` Local (Remote → Local): bring each blob back to
981    /// a local file durability-first — a user-provided blob to the path named in
982    /// `dest` (blob id → destination path), a host-provided blob to coven's local
983    /// store (no dest) — then flip the gate false, register the external refs, and
984    /// enqueue the cloud deletes in one atomic commit. `cancel` aborts before the
985    /// commit (the root stays Remote). Errors with [`MakeLocalError::SyncNotReady`]
986    /// when no provider is connected.
987    pub async fn make_local(
988        &self,
989        root_table: &str,
990        root_id: &str,
991        dest: &HashMap<String, PathBuf>,
992        cancel: &watch::Receiver<bool>,
993    ) -> Result<(), MakeLocalError> {
994        let manager = self.sync_manager().ok_or(MakeLocalError::SyncNotReady)?;
995        let routing_encryption = (self.db.write_policy() == crate::WritePolicy::MergeConcurrent
996            && self.db.gates().has_scoped_graph())
997        .then(|| self.routing_encryption())
998        .transpose()?;
999        manager
1000            .make_local(root_table, root_id, dest, cancel, routing_encryption)
1001            .await
1002    }
1003
1004    /// Drain pending blob uploads now: read each local file, seal it under its
1005    /// scope, write it to the cloud, and keep a `retain_pinned` entry's plaintext
1006    /// in the protected cache. Returns the [`DrainOutcome`].
1007    ///
1008    /// The sync loop drains each cycle; this drives a drain directly off the
1009    /// connected home, against coven's own register clock and the handle's
1010    /// observer. Errors when no provider is connected (there is no cloud to write
1011    /// to).
1012    pub async fn drain_uploads(&self) -> Result<DrainOutcome, SyncError> {
1013        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1014        let sync_loop = manager
1015            .sync_loop_handle()
1016            .ok_or(SyncError::LoopNotRunning)?;
1017        sync_loop
1018            .drain_uploads()
1019            .await
1020            .map_err(SyncError::BlobUpload)
1021    }
1022
1023    pub async fn get_cache_budget(&self, namespace: &str) -> Result<Option<u64>, crate::DbError> {
1024        self.db.get_cache_budget(namespace).await
1025    }
1026
1027    pub async fn set_cache_budget(
1028        &self,
1029        namespace: &str,
1030        max_bytes: u64,
1031    ) -> Result<(), crate::DbError> {
1032        self.db.set_cache_budget(namespace, max_bytes).await
1033    }
1034
1035    pub fn get_user_pubkey(&self) -> Result<Option<String>, SyncError> {
1036        crate::keys::identity_public_key(self.identity_custody.as_ref())
1037            .map(|opt| opt.map(hex::encode))
1038            .map_err(SyncError::from)
1039    }
1040
1041    /// Generate a restore code, seeded with the store's current membership-head
1042    /// floor read from the cloud. Requires a connected provider: unlike the old,
1043    /// storage-free version of this call, minting a trustworthy floor is a
1044    /// network read, not a pure function of local config and keyring state — a
1045    /// restore code minted without one would carry no protection against a
1046    /// storage provider replaying an older, otherwise validly signed membership
1047    /// state to the device that redeems it.
1048    pub async fn generate_restore_code(&self) -> Result<String, SyncError> {
1049        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1050        manager.generate_restore_code().await
1051    }
1052
1053    pub async fn get_members(&self) -> Result<Vec<MemberInfo>, SyncError> {
1054        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1055        manager.get_members().await
1056    }
1057
1058    pub async fn begin_device_join(
1059        &self,
1060        member_pubkey: &str,
1061        provider_administrator: crate::ProviderAdminGrantId,
1062    ) -> Result<crate::DeviceJoinOffer, SyncError> {
1063        let storage = self.device_join_storage()?;
1064        let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1065        let authorization = self.device_join_authorization(&storage).await?;
1066        Ok(crate::sync::device_join::begin_device_join(
1067            &self.db,
1068            storage.as_ref(),
1069            &authorization,
1070            &signer,
1071            member_pubkey,
1072            provider_administrator,
1073        )
1074        .await?)
1075    }
1076
1077    pub async fn authorize_device_provider_access(
1078        &self,
1079        request: crate::DeviceProviderAccessRequest,
1080        access_administrator: Option<&dyn crate::DeviceProviderAccessAdministrator>,
1081    ) -> Result<crate::DeviceProviderAdmissionApproval, SyncError> {
1082        let storage = self.device_join_storage()?;
1083        let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1084        let authorization = self.device_join_authorization(&storage).await?;
1085        let exact = self.device_join_exact_storage()?;
1086        let coordination = self.device_join_coordination(&storage)?;
1087        Ok(crate::sync::device_join::authorize_device_provider_access(
1088            &self.db,
1089            storage.as_ref(),
1090            coordination,
1091            Some(exact.as_ref()),
1092            access_administrator,
1093            &authorization,
1094            &signer,
1095            request,
1096        )
1097        .await?)
1098    }
1099
1100    pub async fn accept_device_registration_request(
1101        &self,
1102        request: crate::DeviceRegistrationRequest,
1103    ) -> Result<crate::ProvisionalDeviceBootstrap, SyncError> {
1104        let storage = self.device_join_storage()?;
1105        let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1106        let authorization = self.device_join_authorization(&storage).await?;
1107        let coordination = self.device_join_coordination(&storage)?;
1108        Ok(
1109            crate::sync::device_join::accept_device_registration_request(
1110                &self.db,
1111                storage.as_ref(),
1112                coordination,
1113                &authorization,
1114                &signer,
1115                request,
1116            )
1117            .await?,
1118        )
1119    }
1120
1121    pub async fn publish_device_provider_challenge(
1122        &self,
1123        bootstrap: crate::ProvisionalDeviceBootstrap,
1124    ) -> Result<crate::ProviderReadyDeviceBootstrap, SyncError> {
1125        let storage = self.device_join_storage()?;
1126        let exact = self.device_join_exact_storage()?;
1127        Ok(crate::sync::device_join::publish_device_provider_challenge(
1128            &self.db,
1129            storage.as_ref(),
1130            Some(exact.as_ref()),
1131            bootstrap,
1132        )
1133        .await?)
1134    }
1135
1136    pub async fn complete_device_provider_admission(
1137        &self,
1138        readiness: crate::DeviceJoinReadiness,
1139    ) -> Result<crate::DeviceProviderAdmissionCompletion, SyncError> {
1140        let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1141        let exact = self.device_join_exact_storage()?;
1142        Ok(
1143            crate::sync::device_join::complete_device_provider_admission(
1144                &self.db,
1145                Some(exact.as_ref()),
1146                &signer,
1147                readiness,
1148            )
1149            .await?,
1150        )
1151    }
1152
1153    pub async fn finalize_device_join(
1154        &self,
1155        completion: crate::DeviceProviderAdmissionCompletion,
1156    ) -> Result<crate::DeviceJoinActivation, SyncError> {
1157        let storage = self.device_join_storage()?;
1158        let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1159        let authorization = self.device_join_authorization(&storage).await?;
1160        let coordination = self.device_join_coordination(&storage)?;
1161        Ok(crate::sync::device_join::finalize_device_join(
1162            &self.db,
1163            storage.as_ref(),
1164            coordination,
1165            &authorization,
1166            &signer,
1167            completion,
1168        )
1169        .await?)
1170    }
1171
1172    pub async fn cancel_device_join(
1173        &self,
1174        attempt: crate::DeviceJoinAttemptRef,
1175    ) -> Result<crate::DeviceJoinCancellation, SyncError> {
1176        let storage = self.device_join_storage()?;
1177        let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1178        let authorization = self.device_join_authorization(&storage).await?;
1179        let coordination = self.device_join_coordination(&storage)?;
1180        Ok(crate::sync::device_join::cancel_device_join(
1181            &self.db,
1182            storage.as_ref(),
1183            coordination,
1184            &authorization,
1185            &signer,
1186            attempt,
1187        )
1188        .await?)
1189    }
1190
1191    pub async fn device_join_status(
1192        &self,
1193        attempt_id: crate::DeviceJoinAttemptId,
1194        role: crate::DeviceJoinRole,
1195    ) -> Result<Option<crate::DeviceJoinStatus>, SyncError> {
1196        Ok(
1197            crate::sync::device_join::load_store_device_join_status(&self.db, attempt_id, role)
1198                .await?,
1199        )
1200    }
1201
1202    fn device_join_storage(&self) -> Result<Arc<CloudSyncStorage>, SyncError> {
1203        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1204        let loop_handle = manager
1205            .sync_loop_handle()
1206            .ok_or(SyncError::LoopNotRunning)?;
1207        Ok(loop_handle.storage().clone())
1208    }
1209
1210    fn device_join_exact_storage(
1211        &self,
1212    ) -> Result<Arc<dyn crate::storage::cloud::ExactSlotStorage>, SyncError> {
1213        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1214        let home = manager.cloud_home().ok_or(SyncError::NotConfigured)?;
1215        home.exact_slot_storage()
1216            .ok_or_else(|| SyncError::Protocol("provider has no exact-slot adapter".to_string()))
1217    }
1218
1219    fn device_join_coordination<'a>(
1220        &self,
1221        storage: &'a CloudSyncStorage,
1222    ) -> Result<Option<&'a dyn crate::sync::storage::CoordinationStorage>, SyncError> {
1223        match self.db.write_policy() {
1224            crate::WritePolicy::MergeConcurrent => Ok(None),
1225            crate::WritePolicy::Serial => storage
1226                .serial_coordination()
1227                .map(Some)
1228                .map_err(|error| SyncError::Protocol(error.to_string())),
1229        }
1230    }
1231
1232    async fn device_join_authorization(
1233        &self,
1234        storage: &CloudSyncStorage,
1235    ) -> Result<crate::DeviceJoinAuthorization, SyncError> {
1236        let coordination = self.device_join_coordination(storage)?;
1237        Ok(
1238            crate::sync::device_join::load_current_device_join_authorization(
1239                &self.db,
1240                storage,
1241                coordination,
1242            )
1243            .await?,
1244        )
1245    }
1246
1247    pub async fn invite_member(
1248        &self,
1249        public_key_hex: &str,
1250        invitee_email: Option<&str>,
1251        role: MemberRole,
1252    ) -> Result<String, SyncError> {
1253        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1254        manager
1255            .invite_member(public_key_hex, invitee_email, role)
1256            .await
1257    }
1258
1259    pub async fn remove_member(&self, public_key_hex: &str) -> Result<String, SyncError> {
1260        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1261        manager.remove_member(public_key_hex).await
1262    }
1263
1264    /// Create and activate a circle whose founder is this Store identity.
1265    /// Returns only after the signed roster, metadata, access set, control,
1266    /// Store commit, activation head, and local materialization are durable.
1267    pub async fn create_circle(&self, name: &str) -> Result<crate::CircleId, SyncError> {
1268        let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1269        manager.create_circle(name).await
1270    }
1271
1272    /// Return circles with a locally verified active access record.
1273    pub async fn get_circles(&self) -> Result<Vec<crate::CircleInfo>, SyncError> {
1274        let identity = crate::keys::require_identity(self.identity_custody.as_ref())?;
1275        self.db
1276            .get_circles(&crate::keys::public_key_hex(&identity))
1277            .await
1278            .map_err(SyncError::from)
1279    }
1280
1281    /// Return durable circle commands that have not activated.
1282    pub async fn get_circle_operations(
1283        &self,
1284    ) -> Result<Vec<crate::CircleOperationInfo>, SyncError> {
1285        self.db
1286            .get_circle_operations()
1287            .await
1288            .map_err(SyncError::from)
1289    }
1290
1291    /// Discard a blocked circle command. Repeating the discard is a no-op;
1292    /// pending commands must finish or become blocked before they can be discarded.
1293    pub async fn discard_circle_operation(
1294        &self,
1295        circle_id: crate::CircleId,
1296    ) -> Result<(), SyncError> {
1297        self.db
1298            .discard_blocked_circle_operation(circle_id)
1299            .await
1300            .map_err(SyncError::from)
1301    }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306    use super::*;
1307
1308    use crate::blob::{CacheFill, Provenance};
1309    use crate::clock::SystemClock;
1310    use crate::config::{CloudProvider, Config, HomeStorage};
1311    use crate::encryption::EncryptionService;
1312    use crate::keys::{test_keyring, StoreKeys};
1313    use crate::storage::cloud::cloudkit::{
1314        CloudKitAcceptedShareRecord, CloudKitAtomicCreateBatch, CloudKitOps,
1315        CloudKitProviderIdentity, CloudKitRecordCreate, CloudKitRecordVersion, CloudKitScope,
1316        CloudKitShare,
1317    };
1318    use crate::storage::cloud::test_utils::InMemoryCloudHome;
1319    use crate::storage::cloud::CloudHomeError;
1320    use crate::sync::cloud_storage::CloudCipher;
1321    use crate::sync::sync_manager::{ConfigProvider, SyncError};
1322    use crate::sync::test_helpers::{
1323        open_test_db_with_blob, plant_blob_row, read_test_db, temp_store_dir, TestStore,
1324    };
1325    use std::collections::HashMap;
1326    use std::sync::atomic::{AtomicUsize, Ordering};
1327    use std::sync::Mutex;
1328    use std::time::Duration;
1329
1330    type TestCloudKitCoordinate = (CloudKitScope, String);
1331    type TestCloudKitObject = (Vec<u8>, u64);
1332
1333    struct TestCloudKitOps {
1334        store: Mutex<HashMap<TestCloudKitCoordinate, TestCloudKitObject>>,
1335        shares: Mutex<HashMap<String, CloudKitShare>>,
1336        batches: Mutex<HashMap<String, Vec<CloudKitRecordCreate>>>,
1337        next_batch: AtomicUsize,
1338    }
1339
1340    /// A ready-to-use custody for tests that build a [`CovenHandle`] directly
1341    /// (bypassing the builder) and never exercise master-key lifecycle
1342    /// methods — the blob/storage/status tests in this module. Seeded
1343    /// in-memory so it needs no keyring registration.
1344    fn test_key_custody() -> Arc<dyn crate::keys::MasterKeyCustody> {
1345        crate::custody::KeyCustody::InMemory(crate::encryption::MasterKeyring::generate()).resolve(
1346            "unused-store-id",
1347            &crate::store_dir::StoreDir::new("unused-store-dir"),
1348        )
1349    }
1350
1351    /// A ready-to-use identity custody for the same tests, seeded in-memory
1352    /// so it needs no keyring registration — the identity sibling of
1353    /// [`test_key_custody`].
1354    fn test_identity_custody() -> Arc<dyn DeviceIdentityCustody> {
1355        crate::identity_custody::IdentityCustody::InMemory(crate::keys::UserKeypair::generate())
1356            .resolve(
1357                "unused-store-id",
1358                &crate::store_dir::StoreDir::new("unused-store-dir"),
1359            )
1360    }
1361
1362    fn host_blob_test_db(namespace: &str) -> Database {
1363        open_test_db_with_blob(
1364            crate::sync::session::BlobDecl::new(
1365                namespace,
1366                Provenance::HostProvided,
1367                CacheFill::CacheLazy,
1368            )
1369            .with_cloud_path_column("cloud_path"),
1370        )
1371    }
1372
1373    struct PausedUploadDrain {
1374        paused: std::sync::atomic::AtomicBool,
1375        reached: tokio::sync::Notify,
1376    }
1377
1378    impl PausedUploadDrain {
1379        fn new() -> Self {
1380            Self {
1381                paused: std::sync::atomic::AtomicBool::new(true),
1382                reached: tokio::sync::Notify::new(),
1383            }
1384        }
1385
1386        fn resume(&self) {
1387            self.paused
1388                .store(false, std::sync::atomic::Ordering::SeqCst);
1389        }
1390    }
1391
1392    #[async_trait::async_trait]
1393    impl crate::blob::BlobTransitionObserver for PausedUploadDrain {
1394        async fn on_blob_upload_started(&self, _blob_id: &str) {}
1395
1396        async fn on_blob_uploaded(&self, _blob_id: &str) {}
1397
1398        async fn on_blob_upload_failed(&self, _blob_id: &str, _error: &str) {}
1399
1400        fn should_skip_uploads(&self) -> bool {
1401            let paused = self.paused.load(std::sync::atomic::Ordering::SeqCst);
1402            if paused {
1403                self.reached.notify_one();
1404            }
1405            paused
1406        }
1407    }
1408
1409    async fn queue_host_blob(
1410        handle: &CovenHandle,
1411        id: &str,
1412        cloud_path: &str,
1413        bytes: &[u8],
1414        remote: bool,
1415    ) -> coven_core::WriteId {
1416        let note_id = format!("note-{id}");
1417        let id = id.to_string();
1418        let cloud_path = cloud_path.to_string();
1419        let bytes = bytes.to_vec();
1420        let size = bytes.len() as i64;
1421        let hash = crate::blob::content_hash(&bytes);
1422        let write = handle
1423            .write(
1424                {
1425                    let id = id.clone();
1426                    let bytes = bytes.clone();
1427                    move |batch| {
1428                        batch.put_blob("images", id, bytes);
1429                        Ok(())
1430                    }
1431                },
1432                {
1433                    let id = id.clone();
1434                    move |sql| {
1435                        let stamp = sql.stamp();
1436                        sql.execute(
1437                            "INSERT INTO notes \
1438                             (id, title, shared, _updated_at, created_at) \
1439                             VALUES (?1, 'blob owner', ?2, ?3, '2026-01-01')",
1440                            rusqlite::params![note_id, remote as i64, stamp],
1441                        )?;
1442                        sql.execute(
1443                            "INSERT INTO note_photos \
1444                             (id, note_id, kind, size, hash, _updated_at, created_at, cloud_path) \
1445                             VALUES (?1, ?2, 'cover', ?3, ?4, ?5, '2026-01-01', ?6)",
1446                            rusqlite::params![id, note_id, size, hash, stamp, cloud_path],
1447                        )?;
1448                        Ok(())
1449                    }
1450                },
1451            )
1452            .await
1453            .expect("queue host blob write");
1454        write.write_id
1455    }
1456
1457    async fn wait_for_host_blob_publication(
1458        handle: &CovenHandle,
1459        id: &str,
1460        write_id: &coven_core::WriteId,
1461    ) -> RowBlobRef {
1462        let mut status = handle
1463            .subscribe_write_status(write_id)
1464            .await
1465            .expect("subscribe to host blob publication");
1466        handle.sync_now();
1467        tokio::time::timeout(Duration::from_secs(20), async {
1468            loop {
1469                let current = status.borrow().clone();
1470                match current {
1471                    coven_core::WriteStatus::Published(_) => break,
1472                    coven_core::WriteStatus::Pending | coven_core::WriteStatus::Publishing => {
1473                        status
1474                            .changed()
1475                            .await
1476                            .expect("write status channel remains open")
1477                    }
1478                    other => panic!("host blob write did not publish: {other:?}"),
1479                }
1480            }
1481        })
1482        .await
1483        .expect("host blob publishes");
1484        handle
1485            .row_blob_ref("note_photos", id)
1486            .await
1487            .expect("capture published host blob row")
1488    }
1489
1490    async fn publish_host_blob(
1491        handle: &CovenHandle,
1492        id: &str,
1493        cloud_path: &str,
1494        bytes: &[u8],
1495    ) -> RowBlobRef {
1496        let write_id = queue_host_blob(handle, id, cloud_path, bytes, true).await;
1497        wait_for_host_blob_publication(handle, id, &write_id).await
1498    }
1499
1500    #[tokio::test]
1501    async fn read_blob_with_unbuildable_storage_is_a_typed_setup_error_not_io() {
1502        let (_tmp, store_dir) = temp_store_dir();
1503        let db = host_blob_test_db("images");
1504        let mut config = Config::with_defaults(
1505            "lib-setup-error".to_string(),
1506            "device".to_string(),
1507            store_dir.clone(),
1508            "Test".to_string(),
1509        );
1510        // A provider is selected but its bucket is unset, so the read path cannot
1511        // build sync storage. That is a configuration fault the user must fix — it
1512        // must reach the caller as StorageSetup, not be mislabeled as a disk I/O
1513        // error the way the old catch-all Io variant did.
1514        config.cloud_home.provider = Some(CloudProvider::S3);
1515        let config_provider: ConfigProvider = Arc::new(move || config.clone());
1516        let handle = CovenHandle::new(
1517            db.clone(),
1518            // `read_db`: these tests never call `sql_read`, and the test db is
1519            // `:memory:` (unique per connection, no shareable read-only companion),
1520            // so the writer clone stands in.
1521            db.clone(),
1522            db.stamper(),
1523            store_dir.clone(),
1524            config_provider,
1525            StoreKeys::new("lib-setup-error".to_string()),
1526            test_key_custody(),
1527            test_identity_custody(),
1528            Arc::new(SystemClock),
1529            None,
1530            None,
1531            StoreOpenGuard::acquire_for_test(&store_dir),
1532        );
1533
1534        plant_blob_row(&db, "anyblob0", false, b"typed setup error").await;
1535        let blob = db
1536            .row_blob_ref("note_photos", "anyblob0")
1537            .await
1538            .expect("capture local blob row");
1539        let err = handle
1540            .read_blob(&blob)
1541            .await
1542            .expect_err("no sync storage can be built from the broken config");
1543        assert!(
1544            matches!(err, BlobCacheError::StorageSetup(_)),
1545            "got {err:?}"
1546        );
1547    }
1548
1549    fn test_handle(store_id: &str, store_dir: StoreDir, db: Database) -> CovenHandle {
1550        test_handle_with_custody(store_id, store_dir, db, test_key_custody())
1551    }
1552
1553    fn test_handle_with_custody(
1554        store_id: &str,
1555        store_dir: StoreDir,
1556        db: Database,
1557        key_custody: Arc<dyn crate::keys::MasterKeyCustody>,
1558    ) -> CovenHandle {
1559        let config = Config::with_defaults(
1560            store_id.to_string(),
1561            "test-device".to_string(),
1562            store_dir.clone(),
1563            "Test Store".to_string(),
1564        );
1565        let config_provider: ConfigProvider = Arc::new(move || config.clone());
1566        CovenHandle::new(
1567            db.clone(),
1568            // `read_db`: these tests never call `sql_read`, and the test db is
1569            // `:memory:` (unique per connection, no shareable read-only companion),
1570            // so the writer clone stands in.
1571            db.clone(),
1572            db.stamper(),
1573            store_dir.clone(),
1574            config_provider,
1575            StoreKeys::new(store_id.to_string()),
1576            key_custody,
1577            test_identity_custody(),
1578            Arc::new(SystemClock),
1579            None,
1580            None,
1581            StoreOpenGuard::acquire_for_test(&store_dir),
1582        )
1583    }
1584
1585    impl TestCloudKitOps {
1586        fn new() -> Self {
1587            Self {
1588                store: Mutex::new(HashMap::new()),
1589                shares: Mutex::new(HashMap::new()),
1590                batches: Mutex::new(HashMap::new()),
1591                next_batch: AtomicUsize::new(0),
1592            }
1593        }
1594    }
1595
1596    impl CloudKitOps for TestCloudKitOps {
1597        fn provider_identity(
1598            &self,
1599            scope: &CloudKitScope,
1600        ) -> Result<CloudKitProviderIdentity, CloudHomeError> {
1601            let (owner_name, zone_name) = match scope {
1602                CloudKitScope::Private => ("test-owner", "test-zone"),
1603                CloudKitScope::Shared {
1604                    owner_name,
1605                    zone_name,
1606                } => (owner_name.as_str(), zone_name.as_str()),
1607            };
1608            Ok(CloudKitProviderIdentity {
1609                container_id: "iCloud.test.coven".to_string(),
1610                environment: crate::CloudKitEnvironment::Development,
1611                owner_name: owner_name.to_string(),
1612                zone_name: zone_name.to_string(),
1613                current_user_record_name: "test-user".to_string(),
1614            })
1615        }
1616
1617        fn accepted_read_write_share(
1618            &self,
1619            _scope: &CloudKitScope,
1620        ) -> Result<CloudKitAcceptedShareRecord, CloudHomeError> {
1621            Err(CloudHomeError::NotFound(
1622                "accepted CloudKit share".to_string(),
1623            ))
1624        }
1625
1626        fn write_record(
1627            &self,
1628            scope: &CloudKitScope,
1629            key: &str,
1630            data: Vec<u8>,
1631        ) -> Result<(), CloudHomeError> {
1632            let mut store = self.store.lock().unwrap();
1633            let coordinate = (scope.clone(), key.to_string());
1634            let version = store.get(&coordinate).map_or(1, |(_, version)| version + 1);
1635            store.insert(coordinate, (data, version));
1636            Ok(())
1637        }
1638
1639        fn read_record(&self, scope: &CloudKitScope, key: &str) -> Result<Vec<u8>, CloudHomeError> {
1640            self.store
1641                .lock()
1642                .unwrap()
1643                .get(&(scope.clone(), key.to_string()))
1644                .map(|(bytes, _)| bytes.clone())
1645                .ok_or_else(|| CloudHomeError::NotFound(key.to_string()))
1646        }
1647
1648        fn list_records(
1649            &self,
1650            scope: &CloudKitScope,
1651            prefix: &str,
1652        ) -> Result<Vec<String>, CloudHomeError> {
1653            Ok(self
1654                .store
1655                .lock()
1656                .unwrap()
1657                .keys()
1658                .filter(|(stored_scope, key)| stored_scope == scope && key.starts_with(prefix))
1659                .map(|(_, key)| key.clone())
1660                .collect())
1661        }
1662
1663        fn delete_record(&self, scope: &CloudKitScope, key: &str) -> Result<(), CloudHomeError> {
1664            self.store
1665                .lock()
1666                .unwrap()
1667                .remove(&(scope.clone(), key.to_string()));
1668            Ok(())
1669        }
1670
1671        fn record_exists(&self, scope: &CloudKitScope, key: &str) -> Result<bool, CloudHomeError> {
1672            Ok(self
1673                .store
1674                .lock()
1675                .unwrap()
1676                .contains_key(&(scope.clone(), key.to_string())))
1677        }
1678
1679        fn read_versioned_record(
1680            &self,
1681            scope: &CloudKitScope,
1682            key: &str,
1683        ) -> Result<crate::storage::cloud::CloudVersionedHead, CloudHomeError> {
1684            let store = self.store.lock().unwrap();
1685            let (bytes, version) = store
1686                .get(&(scope.clone(), key.to_string()))
1687                .ok_or_else(|| CloudHomeError::NotFound(key.to_string()))?;
1688            Ok(crate::storage::cloud::CloudVersionedHead {
1689                bytes: bytes.clone(),
1690                version: crate::storage::cloud::CloudHeadVersion::from_provider(
1691                    version.to_string(),
1692                )?,
1693            })
1694        }
1695
1696        fn create_record(
1697            &self,
1698            scope: &CloudKitScope,
1699            key: &str,
1700            data: Vec<u8>,
1701        ) -> Result<
1702            crate::storage::cloud::CloudVersionedHead,
1703            crate::storage::cloud::CloudHeadCreateError,
1704        > {
1705            let mut store = self.store.lock().unwrap();
1706            let coordinate = (scope.clone(), key.to_string());
1707            if store.contains_key(&coordinate) {
1708                return Err(crate::storage::cloud::CloudHeadCreateError::AlreadyExists);
1709            }
1710            store.insert(coordinate, (data.clone(), 1));
1711            Ok(crate::storage::cloud::CloudVersionedHead {
1712                bytes: data,
1713                version: crate::storage::cloud::CloudHeadVersion::from_provider("1".to_string())?,
1714            })
1715        }
1716
1717        fn begin_atomic_create(
1718            &self,
1719            _scope: &CloudKitScope,
1720        ) -> Result<CloudKitAtomicCreateBatch, CloudHomeError> {
1721            let batch = CloudKitAtomicCreateBatch::from_provider(format!(
1722                "handle-batch-{}",
1723                self.next_batch.fetch_add(1, Ordering::SeqCst)
1724            ))?;
1725            self.batches
1726                .lock()
1727                .unwrap()
1728                .insert(batch.as_provider().to_string(), Vec::new());
1729            Ok(batch)
1730        }
1731
1732        fn stage_atomic_create_record(
1733            &self,
1734            _scope: &CloudKitScope,
1735            batch: &CloudKitAtomicCreateBatch,
1736            create: CloudKitRecordCreate,
1737        ) -> Result<(), CloudHomeError> {
1738            self.batches
1739                .lock()
1740                .unwrap()
1741                .get_mut(batch.as_provider())
1742                .ok_or_else(|| CloudHomeError::NotFound(batch.as_provider().to_string()))?
1743                .push(create);
1744            Ok(())
1745        }
1746
1747        fn commit_atomic_create(
1748            &self,
1749            scope: &CloudKitScope,
1750            batch: &CloudKitAtomicCreateBatch,
1751        ) -> Result<Vec<CloudKitRecordVersion>, CloudHomeError> {
1752            let mut batches = self.batches.lock().unwrap();
1753            let creates = batches
1754                .get(batch.as_provider())
1755                .ok_or_else(|| CloudHomeError::NotFound(batch.as_provider().to_string()))?;
1756            let mut store = self.store.lock().unwrap();
1757            for create in creates {
1758                if store.contains_key(&(scope.clone(), create.key.clone())) {
1759                    return Err(CloudHomeError::AlreadyExists(create.key.clone()));
1760                }
1761            }
1762            let creates = batches
1763                .remove(batch.as_provider())
1764                .expect("validated handle CloudKit batch disappeared");
1765            let mut created = Vec::with_capacity(creates.len());
1766            for create in creates {
1767                store.insert((scope.clone(), create.key.clone()), (create.data, 1));
1768                created.push(CloudKitRecordVersion {
1769                    key: create.key,
1770                    version: crate::storage::cloud::CloudHeadVersion::from_provider(
1771                        "1".to_string(),
1772                    )?,
1773                });
1774            }
1775            Ok(created)
1776        }
1777
1778        fn discard_atomic_create(
1779            &self,
1780            _scope: &CloudKitScope,
1781            batch: &CloudKitAtomicCreateBatch,
1782        ) -> Result<(), CloudHomeError> {
1783            self.batches.lock().unwrap().remove(batch.as_provider());
1784            Ok(())
1785        }
1786
1787        fn replace_record(
1788            &self,
1789            scope: &CloudKitScope,
1790            key: &str,
1791            expected: &crate::storage::cloud::CloudHeadVersion,
1792            data: Vec<u8>,
1793        ) -> Result<
1794            crate::storage::cloud::CloudVersionedHead,
1795            crate::storage::cloud::CloudHeadReplaceError,
1796        > {
1797            let mut store = self.store.lock().unwrap();
1798            let coordinate = (scope.clone(), key.to_string());
1799            let Some((_, current)) = store.get(&coordinate) else {
1800                return Err(
1801                    crate::storage::cloud::CloudHomeError::NotFound(key.to_string()).into(),
1802                );
1803            };
1804            if expected.as_provider() != current.to_string() {
1805                return Err(crate::storage::cloud::CloudHeadReplaceError::VersionMismatch);
1806            }
1807            let version = current + 1;
1808            store.insert(coordinate, (data.clone(), version));
1809            Ok(crate::storage::cloud::CloudVersionedHead {
1810                bytes: data,
1811                version: crate::storage::cloud::CloudHeadVersion::from_provider(
1812                    version.to_string(),
1813                )?,
1814            })
1815        }
1816
1817        fn delete_record_versions(
1818            &self,
1819            scope: &CloudKitScope,
1820            exact_records: &[CloudKitRecordVersion],
1821        ) -> Result<(), CloudHomeError> {
1822            let mut store = self.store.lock().unwrap();
1823            for record in exact_records {
1824                let coordinate = (scope.clone(), record.key.clone());
1825                let (_, version) = store
1826                    .get(&coordinate)
1827                    .ok_or_else(|| CloudHomeError::NotFound(record.key.clone()))?;
1828                if version.to_string() != record.version.as_provider() {
1829                    return Err(CloudHomeError::Transport(format!(
1830                        "handle CloudKit record {:?} changed before exact deletion",
1831                        record.key
1832                    )));
1833                }
1834            }
1835            for record in exact_records {
1836                store.remove(&(scope.clone(), record.key.clone()));
1837            }
1838            Ok(())
1839        }
1840
1841        fn grant_share(&self, member_pubkey: &str) -> Result<CloudKitShare, CloudHomeError> {
1842            let share = CloudKitShare {
1843                share_url: format!("coven-test-share-{member_pubkey}"),
1844                owner_name: "owner".to_string(),
1845                zone_name: "zone".to_string(),
1846            };
1847            self.shares
1848                .lock()
1849                .unwrap()
1850                .insert(member_pubkey.to_string(), share.clone());
1851            Ok(share)
1852        }
1853
1854        fn share_for_member(
1855            &self,
1856            member_pubkey: &str,
1857        ) -> Result<Option<CloudKitShare>, CloudHomeError> {
1858            Ok(self.shares.lock().unwrap().get(member_pubkey).cloned())
1859        }
1860
1861        fn revoke_share(&self, member_pubkey: &str) -> Result<(), CloudHomeError> {
1862            self.shares.lock().unwrap().remove(member_pubkey);
1863            Ok(())
1864        }
1865
1866        fn accept_share(&self, _share_url: &str) -> Result<CloudKitShare, CloudHomeError> {
1867            Ok(CloudKitShare {
1868                share_url: "coven-test-share".to_string(),
1869                owner_name: "owner".to_string(),
1870                zone_name: "zone".to_string(),
1871            })
1872        }
1873    }
1874
1875    /// `connect_sync_with_test_home` stands a real `SyncManager` over an injected
1876    /// `InMemoryCloudHome`. A host write creates a pending exact Store row/blob;
1877    /// the public drain uploads its prepared blob object, the next cycle publishes
1878    /// the row with its exact locator, and `read_blob` uses that row-bound locator
1879    /// to read the same object through the handle.
1880    #[tokio::test]
1881    async fn test_home_drives_drain_and_read_through_the_handle() {
1882        let local = tokio::task::LocalSet::new();
1883        local
1884            .run_until(async {
1885                tokio::task::spawn_local(run_test_home_drives_drain_and_read_through_the_handle())
1886                    .await
1887                    .expect("test-home handle task");
1888            })
1889            .await;
1890    }
1891
1892    async fn run_test_home_drives_drain_and_read_through_the_handle() {
1893        test_keyring::install();
1894
1895        let (_tmp, store_dir) = temp_store_dir();
1896        // `note_photos` carries a blob in the `images` namespace so the read path can
1897        // resolve a planted row up to its gated `notes` root (the gate that decides
1898        // Local vs Remote).
1899        let db = host_blob_test_db("images");
1900
1901        // Pre-create the exact Store in the same home the handle will connect to,
1902        // with the same signing identity and cipher.
1903        let mut config = Config::with_defaults(
1904            "lib-test".to_string(),
1905            "test-device".to_string(),
1906            store_dir.clone(),
1907            "Test Store".to_string(),
1908        );
1909        config.cloud_home.storage = HomeStorage::Opaque;
1910        let config_provider: ConfigProvider = {
1911            let config = config.clone();
1912            Arc::new(move || config.clone())
1913        };
1914        let upload_pause = Arc::new(PausedUploadDrain::new());
1915        let signer = crate::keys::UserKeypair::generate();
1916        let store = TestStore::create(&db, "lib-test", signer.clone())
1917            .await
1918            .expect("create exact test Store");
1919        let identity_custody = crate::identity_custody::IdentityCustody::InMemory(signer)
1920            .resolve("lib-test", &store_dir);
1921
1922        let stamper = db.stamper();
1923        let handle = CovenHandle::new(
1924            db.clone(),
1925            // `read_db`: this test never calls `sql_read`, and the test db is
1926            // `:memory:` (no shareable read-only companion), so the writer clone
1927            // stands in.
1928            db.clone(),
1929            stamper,
1930            store_dir.clone(),
1931            config_provider,
1932            StoreKeys::new("lib-test".to_string()),
1933            test_key_custody(),
1934            identity_custody,
1935            Arc::new(SystemClock),
1936            None,
1937            Some(upload_pause.clone()),
1938            StoreOpenGuard::acquire_for_test(&store_dir),
1939        );
1940
1941        // Inject the mock home; the host hands over only the home + cipher.
1942        let home = store.home.clone();
1943        handle
1944            .connect_sync_with_test_home(
1945                home.clone(),
1946                CloudCipher::Encrypted(EncryptionService::from_key([42; 32])),
1947            )
1948            .await
1949            .expect("connect over the injected test home");
1950
1951        // The loop prepares the exact blob upload from the pending Store write,
1952        // then the observer pauses before it can drain the queue itself.
1953        let plaintext = b"cover-art-bytes-for-the-test-home".to_vec();
1954        queue_host_blob(&handle, "cover-1", "cover-cover-1.jpg", &plaintext, false).await;
1955        handle
1956            .make_remote("notes", "note-cover-1", false)
1957            .await
1958            .expect("queue the exact row/blob transition");
1959        tokio::time::timeout(Duration::from_secs(20), upload_pause.reached.notified())
1960            .await
1961            .expect("the loop reaches the paused upload drain");
1962        let local = handle
1963            .row_blob_ref("note_photos", "cover-1")
1964            .await
1965            .expect("capture Local row while upload is paused");
1966        assert!(
1967            matches!(local.authority(), crate::blob::RowBlobAuthority::Local),
1968            "the row stays Local until the exact upload completes",
1969        );
1970        assert!(local.stored().is_none());
1971
1972        upload_pause.resume();
1973        let outcome = handle
1974            .drain_uploads()
1975            .await
1976            .expect("drain the prepared exact blob through the public handle");
1977        assert_eq!(outcome.uploaded, 1);
1978        assert!(outcome.yielded_for_publish);
1979        assert!(outcome.failures.failures().is_empty());
1980
1981        let blob = handle
1982            .row_blob_ref("note_photos", "cover-1")
1983            .await
1984            .expect("capture Remote row after exact upload");
1985        let object = blob
1986            .stored()
1987            .expect("published blob has exact storage")
1988            .object();
1989        let exact = home
1990            .clone()
1991            .exact_slot_storage()
1992            .expect("test home supports exact object slots");
1993        let at_rest = exact
1994            .read_at(object.slot())
1995            .await
1996            .expect("the exact blob object exists");
1997        assert!(
1998            !at_rest.is_empty(),
1999            "the exact blob object contains its sealed payload",
2000        );
2001
2002        // The published `RowBlobRef` carries the exact remote object and authority;
2003        // the read resolves it through the same connected home.
2004        let read = handle
2005            .read_blob(&blob)
2006            .await
2007            .expect("read through the handle");
2008        assert_eq!(
2009            read, plaintext,
2010            "read_blob fetched the blob's plaintext from the injected test home",
2011        );
2012    }
2013
2014    #[tokio::test]
2015    async fn connected_manager_reuses_cloud_home_for_loop_storage() {
2016        test_keyring::install();
2017
2018        let (_tmp, store_dir) = temp_store_dir();
2019        let db = host_blob_test_db("images");
2020
2021        let mut config = Config::with_defaults(
2022            "lib-cloudkit-home-reuse".to_string(),
2023            "test-device".to_string(),
2024            store_dir.clone(),
2025            "Test Store".to_string(),
2026        );
2027        config.cloud_home.provider = Some(CloudProvider::CloudKit);
2028        config.cloud_home.storage = HomeStorage::Browsable;
2029        let config_provider: ConfigProvider = {
2030            let config = config.clone();
2031            Arc::new(move || config.clone())
2032        };
2033
2034        let handle = CovenHandle::new(
2035            db.clone(),
2036            // `read_db`: these tests never call `sql_read`, and the test db is
2037            // `:memory:` (unique per connection, no shareable read-only companion),
2038            // so the writer clone stands in.
2039            db.clone(),
2040            db.stamper(),
2041            store_dir.clone(),
2042            config_provider,
2043            StoreKeys::new("lib-cloudkit-home-reuse".to_string()),
2044            test_key_custody(),
2045            test_identity_custody(),
2046            Arc::new(SystemClock),
2047            Some(Arc::new(TestCloudKitOps::new())),
2048            None,
2049            StoreOpenGuard::acquire_for_test(&store_dir),
2050        );
2051
2052        handle
2053            .connect_sync()
2054            .await
2055            .expect("connect sync over the test CloudKit driver");
2056
2057        let manager = handle
2058            .sync_manager()
2059            .expect("connect_sync installs a manager");
2060        let stored_home = manager.cloud_home().expect("manager stores cloud home");
2061        let loop_handle = manager
2062            .sync_loop_handle()
2063            .expect("connect_sync starts the sync loop");
2064
2065        assert!(
2066            std::ptr::addr_eq(stored_home.as_ref(), loop_handle.storage().cloud_home()),
2067            "the sync loop storage must wrap the same cloud home stored on the manager",
2068        );
2069    }
2070
2071    /// A read-only handle holds no sync loop, so every cloud-miss read builds
2072    /// storage fresh from config via the `cipher: None` path. The writer publishes
2073    /// a host-provided row and exact encrypted blob through the normal Store path;
2074    /// publication releases its local staging bytes, forcing the reader to use the
2075    /// row's exact cloud locator and resolve the same cipher through custody.
2076    #[tokio::test]
2077    async fn read_only_handle_resolves_an_encrypted_cipher_through_custody() {
2078        let local = tokio::task::LocalSet::new();
2079        local
2080            .run_until(async {
2081                tokio::task::spawn_local(
2082                    run_read_only_handle_resolves_an_encrypted_cipher_through_custody(),
2083                )
2084                .await
2085                .expect("encrypted read-only handle task");
2086            })
2087            .await;
2088    }
2089
2090    async fn run_read_only_handle_resolves_an_encrypted_cipher_through_custody() {
2091        test_keyring::install();
2092
2093        let store_id = "ro-encrypted-custody-test";
2094        let (_tmp, store_dir) = temp_store_dir();
2095        let db = host_blob_test_db("images");
2096
2097        let mut config = Config::with_defaults(
2098            store_id.to_string(),
2099            "test-device".to_string(),
2100            store_dir.clone(),
2101            "Test Store".to_string(),
2102        );
2103        config.cloud_home.provider = Some(CloudProvider::CloudKit);
2104        config.cloud_home.storage = HomeStorage::Opaque;
2105
2106        let custody = crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir);
2107        custody
2108            .persist(&crate::encryption::MasterKeyring::generate())
2109            .expect("establish a master key");
2110
2111        // Exact opaque blob locators bind their uploader registration, so establish
2112        // the writer's signing identity before connecting storage.
2113        let identity_custody =
2114            crate::identity_custody::IdentityCustody::Keyring.resolve(store_id, &store_dir);
2115        identity_custody
2116            .persist(&crate::keys::UserKeypair::generate())
2117            .expect("establish this store's signing identity");
2118
2119        let ops = Arc::new(TestCloudKitOps::new());
2120        let key_service = StoreKeys::new(store_id.to_string());
2121        let config_provider: ConfigProvider = {
2122            let config = config.clone();
2123            Arc::new(move || config.clone())
2124        };
2125        let writer = CovenHandle::new(
2126            db.clone(),
2127            db.clone(),
2128            db.stamper(),
2129            store_dir.clone(),
2130            config_provider,
2131            key_service.clone(),
2132            custody.clone(),
2133            identity_custody.clone(),
2134            Arc::new(SystemClock),
2135            Some(ops.clone()),
2136            None,
2137            StoreOpenGuard::acquire_for_test(&store_dir),
2138        );
2139        writer
2140            .connect_sync_with_cloudkit(ops.clone())
2141            .await
2142            .expect("connect encrypted CloudKit writer");
2143        let plaintext = b"encrypted-cloud-blob-for-the-read-only-handle".to_vec();
2144        let blob = publish_host_blob(&writer, "cover-1", "cover-cover-1.jpg", &plaintext).await;
2145
2146        let config_provider: ConfigProvider = {
2147            let config = config.clone();
2148            Arc::new(move || config.clone())
2149        };
2150        let reader = crate::read_handle::CovenReadHandle::new(
2151            db,
2152            store_dir,
2153            config_provider,
2154            key_service,
2155            custody,
2156            identity_custody,
2157            Arc::new(SystemClock),
2158            Some(ops),
2159        );
2160
2161        let read = reader
2162            .read_blob(&blob)
2163            .await
2164            .expect("the read-only handle resolves the same cipher through custody");
2165        assert_eq!(
2166            read, plaintext,
2167            "the blob decrypts back to its original plaintext",
2168        );
2169    }
2170
2171    #[tokio::test]
2172    async fn sync_not_configured_is_typed() {
2173        let (_tmp, store_dir) = temp_store_dir();
2174        let db = read_test_db("images");
2175        let handle = test_handle("lib-no-sync", store_dir, db);
2176
2177        let result = handle.get_members().await;
2178
2179        assert!(matches!(result, Err(SyncError::NotConfigured)));
2180    }
2181
2182    /// `initialize_master_key` is the only place coven ever generates a
2183    /// master key, and it refuses to run again once one is established —
2184    /// coven never generates over an existing key.
2185    #[tokio::test]
2186    async fn initialize_master_key_refuses_a_second_call() {
2187        test_keyring::install();
2188        let (_tmp, store_dir) = temp_store_dir();
2189        let db = read_test_db("images");
2190        let custody =
2191            crate::custody::KeyCustody::Keyring.resolve("lib-init-master-key-twice", &store_dir);
2192        let handle = test_handle_with_custody("lib-init-master-key-twice", store_dir, db, custody);
2193
2194        let fingerprint = handle
2195            .initialize_master_key()
2196            .expect("the first call establishes a master key");
2197        assert!(!fingerprint.is_empty());
2198        assert_eq!(
2199            handle.master_key_fingerprint().unwrap(),
2200            Some(fingerprint),
2201            "master_key_fingerprint reflects what initialize_master_key just established",
2202        );
2203
2204        let error = handle
2205            .initialize_master_key()
2206            .expect_err("a second call must refuse rather than generate over an existing key");
2207        assert!(matches!(
2208            error,
2209            crate::keys::MasterKeyError::AlreadyEstablished
2210        ));
2211    }
2212
2213    /// The end-to-end proof that `initialize_master_key` establishes the key
2214    /// that actually seals cloud traffic. A keyring-custody store initializes a
2215    /// master key, connects over an injected opaque `InMemoryCloudHome` through
2216    /// the custody-resolving connect path — no cipher is injected; the manager
2217    /// unlocks the key exactly as production `start_sync` does — then enqueues
2218    /// and drains a blob. The bytes at rest in the home are ciphertext, never
2219    /// the plaintext (the assertion a browsable/plaintext home would fail),
2220    /// while `read_blob` decrypts them back. Only the established key sealing the
2221    /// upload makes both hold.
2222    #[tokio::test]
2223    async fn initialize_master_key_seals_cloud_traffic_the_custody_path_reads_back() {
2224        let local = tokio::task::LocalSet::new();
2225        local
2226            .run_until(async {
2227                tokio::task::spawn_local(Box::pin(
2228                    run_initialize_master_key_seals_cloud_traffic_the_custody_path_reads_back(),
2229                ))
2230                .await
2231                .expect("master-key cloud traffic test task");
2232            })
2233            .await;
2234    }
2235
2236    async fn run_initialize_master_key_seals_cloud_traffic_the_custody_path_reads_back() {
2237        test_keyring::install();
2238
2239        let (_tmp, store_dir) = temp_store_dir();
2240        let db = host_blob_test_db("images");
2241        let store_id = "lib-init-master-key-seals-traffic";
2242
2243        // Opaque storage: the master key established below seals every object at
2244        // rest. A configured provider is unnecessary — the injected test home is
2245        // the enablement.
2246        let mut config = Config::with_defaults(
2247            store_id.to_string(),
2248            "test-device".to_string(),
2249            store_dir.clone(),
2250            "Test Store".to_string(),
2251        );
2252        config.cloud_home.storage = HomeStorage::Opaque;
2253        let config_provider: ConfigProvider = {
2254            let config = config.clone();
2255            Arc::new(move || config.clone())
2256        };
2257
2258        let custody = crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir);
2259        let identity_custody =
2260            crate::identity_custody::IdentityCustody::Keyring.resolve(store_id, &store_dir);
2261        let handle = CovenHandle::new(
2262            db.clone(),
2263            db.clone(),
2264            db.stamper(),
2265            store_dir.clone(),
2266            config_provider,
2267            StoreKeys::new(store_id.to_string()),
2268            custody,
2269            identity_custody,
2270            Arc::new(SystemClock),
2271            None,
2272            None,
2273            StoreOpenGuard::acquire_for_test(&store_dir),
2274        );
2275
2276        handle
2277            .initialize_master_key()
2278            .expect("establish the master key before connecting");
2279        handle
2280            .initialize_identity()
2281            .expect("establish this store's identity before connecting");
2282
2283        // Connect over the injected home through the custody path: the manager
2284        // resolves the cipher from the just-established key, never an injected
2285        // one. An opaque home with no key would fail here with
2286        // `MasterKeyNotEstablished`.
2287        let home = Arc::new(InMemoryCloudHome::new());
2288        handle
2289            .connect_sync_with_test_home_custody(home.clone())
2290            .await
2291            .expect("connect over the injected opaque home, resolving the cipher from custody");
2292
2293        // Publish a host-provided row and exact blob under the opaque home. The
2294        // resulting row reference carries its uploader authority and stored slot.
2295        let plaintext = b"cover-art-sealed-under-the-established-master-key".to_vec();
2296        let blob = publish_host_blob(&handle, "cover-1", "cover-cover-1.jpg", &plaintext).await;
2297        let cloud_key = blob
2298            .stored()
2299            .expect("published blob has exact storage")
2300            .object()
2301            .slot()
2302            .logical_key();
2303
2304        // At rest the object is ciphertext: the stored bytes are not the
2305        // plaintext, and no object in the home holds the plaintext verbatim.
2306        let at_rest = home.get(cloud_key).expect("the blob landed in the home");
2307        assert_ne!(
2308            at_rest, plaintext,
2309            "the master key sealed the upload — the bytes at rest are not the plaintext",
2310        );
2311        assert!(
2312            home.keys()
2313                .iter()
2314                .all(|k| home.get(k).as_deref() != Some(plaintext.as_slice())),
2315            "no object in the home holds the plaintext",
2316        );
2317
2318        // Read back through the row's activated exact locator and the same
2319        // custody-resolved cipher.
2320        let read = handle
2321            .read_blob(&blob)
2322            .await
2323            .expect("read through the handle");
2324        assert_eq!(
2325            read, plaintext,
2326            "read_blob decrypts the sealed blob back to its original plaintext",
2327        );
2328    }
2329
2330    #[tokio::test]
2331    async fn import_master_key_rejects_raw_hex() {
2332        let (_tmp, store_dir) = temp_store_dir();
2333        let db = read_test_db("images");
2334        let handle = test_handle("lib-import-master-key", store_dir, db);
2335
2336        let raw_hex = hex::encode([0x22u8; 32]);
2337        assert!(handle.import_master_key(&raw_hex).is_err());
2338    }
2339
2340    #[tokio::test]
2341    async fn import_master_key_accepts_the_current_serialized_keyring() {
2342        let (_tmp, store_dir) = temp_store_dir();
2343        let db = read_test_db("images");
2344        let handle = test_handle("lib-import-master-key", store_dir, db);
2345
2346        let keyring = crate::encryption::MasterKeyring::generate();
2347        let imported_fingerprint = handle
2348            .import_master_key(&keyring.to_serialized())
2349            .expect("import the serialized keyring");
2350        assert_eq!(imported_fingerprint, keyring.fingerprint());
2351        assert_eq!(
2352            handle.master_key_fingerprint().unwrap(),
2353            Some(imported_fingerprint),
2354        );
2355    }
2356
2357    /// `forget_master_key` removes an established key, and is `Ok` whether or
2358    /// not one was established — a host's lock/sign-out flow.
2359    #[tokio::test]
2360    async fn forget_master_key_clears_an_established_key_and_is_idempotent() {
2361        let (_tmp, store_dir) = temp_store_dir();
2362        let db = read_test_db("images");
2363        let handle = test_handle("lib-forget-master-key", store_dir, db);
2364
2365        assert!(handle.master_key_fingerprint().unwrap().is_some());
2366        handle
2367            .forget_master_key()
2368            .expect("forget an established key");
2369        assert!(handle.master_key_fingerprint().unwrap().is_none());
2370        handle
2371            .forget_master_key()
2372            .expect("forgetting an already-absent key is not an error");
2373    }
2374
2375    // =========================================================================
2376    // Identity lifecycle
2377    // =========================================================================
2378
2379    /// A handle over a real (keyring-backed) identity custody, for tests that
2380    /// need to prove something about a store's *own* keyring account rather
2381    /// than the shared in-memory `test_identity_custody`.
2382    fn test_handle_with_real_identity(
2383        store_id: &str,
2384        store_dir: StoreDir,
2385        db: Database,
2386    ) -> CovenHandle {
2387        let config = Config::with_defaults(
2388            store_id.to_string(),
2389            "test-device".to_string(),
2390            store_dir.clone(),
2391            "Test Store".to_string(),
2392        );
2393        let config_provider: ConfigProvider = Arc::new(move || config.clone());
2394        CovenHandle::new(
2395            db.clone(),
2396            db.clone(),
2397            db.stamper(),
2398            store_dir.clone(),
2399            config_provider,
2400            StoreKeys::new(store_id.to_string()),
2401            test_key_custody(),
2402            crate::identity_custody::IdentityCustody::Keyring.resolve(store_id, &store_dir),
2403            Arc::new(SystemClock),
2404            None,
2405            None,
2406            StoreOpenGuard::acquire_for_test(&store_dir),
2407        )
2408    }
2409
2410    /// `initialize_identity` is the only place coven ever generates a
2411    /// store's signing identity, and it refuses to run again once one is
2412    /// established — coven never generates over an existing identity. The
2413    /// identity sibling of `initialize_master_key_refuses_a_second_call`.
2414    #[tokio::test]
2415    async fn initialize_identity_refuses_a_second_call() {
2416        test_keyring::install();
2417        let (_tmp, store_dir) = temp_store_dir();
2418        let db = read_test_db("images");
2419        let handle = test_handle_with_real_identity("lib-init-identity-twice", store_dir, db);
2420
2421        let pubkey = handle
2422            .initialize_identity()
2423            .expect("the first call establishes an identity");
2424        assert!(!pubkey.is_empty());
2425        assert_eq!(
2426            handle.get_user_pubkey().unwrap(),
2427            Some(pubkey),
2428            "get_user_pubkey reflects what initialize_identity just established",
2429        );
2430
2431        let error = handle
2432            .initialize_identity()
2433            .expect_err("a second call must refuse rather than generate over an existing identity");
2434        assert!(matches!(
2435            error,
2436            crate::keys::IdentityError::AlreadyEstablished
2437        ));
2438    }
2439
2440    /// Creating two stores on one device establishes two different
2441    /// identities — each store's `initialize_identity` generates its own
2442    /// keypair, under its own keyring account, independent of the other.
2443    #[tokio::test]
2444    async fn creating_two_stores_yields_two_different_identities() {
2445        test_keyring::install();
2446        let (_tmp_a, store_dir_a) = temp_store_dir();
2447        let (_tmp_b, store_dir_b) = temp_store_dir();
2448        let handle_a = test_handle_with_real_identity(
2449            "lib-two-stores-identity-a",
2450            store_dir_a,
2451            read_test_db("images"),
2452        );
2453        let handle_b = test_handle_with_real_identity(
2454            "lib-two-stores-identity-b",
2455            store_dir_b,
2456            read_test_db("images"),
2457        );
2458
2459        let pubkey_a = handle_a
2460            .initialize_identity()
2461            .expect("establish store a's identity");
2462        let pubkey_b = handle_b
2463            .initialize_identity()
2464            .expect("establish store b's identity");
2465
2466        assert_ne!(
2467            pubkey_a, pubkey_b,
2468            "two stores on one device must not share an identity",
2469        );
2470        assert_eq!(handle_a.get_user_pubkey().unwrap(), Some(pubkey_a));
2471        assert_eq!(handle_b.get_user_pubkey().unwrap(), Some(pubkey_b));
2472    }
2473
2474    // =========================================================================
2475    // Host secrets
2476    // =========================================================================
2477
2478    /// The host-facing round trip: `set_host_secret` / `host_secret` /
2479    /// `delete_host_secret` through the handle, with an absent secret
2480    /// reading `None` both before it's ever set and after it's deleted.
2481    #[tokio::test]
2482    async fn host_secret_round_trips_through_the_handle() {
2483        test_keyring::install();
2484        let (_tmp, store_dir) = temp_store_dir();
2485        let db = read_test_db("images");
2486        let handle = test_handle("lib-host-secret-round-trip", store_dir, db);
2487
2488        assert_eq!(
2489            handle.host_secret("discogs_api_key").expect("get"),
2490            None,
2491            "an unset host secret reads as absent",
2492        );
2493
2494        handle
2495            .set_host_secret("discogs_api_key", "the-discogs-key")
2496            .expect("set");
2497        assert_eq!(
2498            handle.host_secret("discogs_api_key").expect("get"),
2499            Some("the-discogs-key".to_string()),
2500        );
2501
2502        handle
2503            .delete_host_secret("discogs_api_key")
2504            .expect("delete");
2505        assert_eq!(
2506            handle
2507                .host_secret("discogs_api_key")
2508                .expect("get after delete"),
2509            None,
2510        );
2511    }
2512
2513    // =========================================================================
2514    // App-data sealing
2515    // =========================================================================
2516
2517    /// The host-facing round trip over a keyring-custody store: what the handle
2518    /// seals under the store's established master key, the same handle opens —
2519    /// and a payload presented with a different `aad` than it was bound to does
2520    /// not open, so a value lifted into another row stays shut.
2521    #[tokio::test]
2522    async fn seal_and_open_app_data_round_trip_through_the_handle() {
2523        test_keyring::install();
2524        let (_tmp, store_dir) = temp_store_dir();
2525        let db = read_test_db("images");
2526        let store_id = "lib-app-data-round-trip";
2527        let handle = test_handle_with_custody(
2528            store_id,
2529            store_dir.clone(),
2530            db,
2531            crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2532        );
2533        handle
2534            .initialize_master_key()
2535            .expect("establish the store's master key");
2536
2537        let sealed = handle
2538            .seal_app_data(b"entry-secret", b"row-42")
2539            .expect("seal under the established key");
2540        assert_ne!(
2541            sealed, b"entry-secret",
2542            "the sealed payload is not the plaintext",
2543        );
2544
2545        assert_eq!(
2546            handle.open_app_data(&sealed, b"row-42").unwrap(),
2547            b"entry-secret",
2548            "the handle opens what it sealed",
2549        );
2550
2551        let error = handle
2552            .open_app_data(&sealed, b"row-99")
2553            .expect_err("a different aad must not open the payload");
2554        assert!(matches!(error, SealError::Crypto(_)), "{error:?}");
2555    }
2556
2557    /// A read-only handle over the same store opens what the writer sealed: it
2558    /// resolves the same master keyring through its own custody (the same
2559    /// `store_id` keyring account), so a secondary reader — a File Provider
2560    /// extension, a second process — reads the host's sealed rows.
2561    #[tokio::test]
2562    async fn open_app_data_round_trips_through_the_read_handle() {
2563        test_keyring::install();
2564        let (_tmp, store_dir) = temp_store_dir();
2565        let db = read_test_db("images");
2566        let store_id = "lib-app-data-read-handle";
2567
2568        let writer = test_handle_with_custody(
2569            store_id,
2570            store_dir.clone(),
2571            db.clone(),
2572            crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2573        );
2574        writer
2575            .initialize_master_key()
2576            .expect("establish the store's master key");
2577        let sealed = writer
2578            .seal_app_data(b"read-me-back", b"ctx")
2579            .expect("seal through the write handle");
2580
2581        let config_provider: ConfigProvider = {
2582            let config = Config::with_defaults(
2583                store_id.to_string(),
2584                "test-device".to_string(),
2585                store_dir.clone(),
2586                "Test Store".to_string(),
2587            );
2588            Arc::new(move || config.clone())
2589        };
2590        let reader = crate::read_handle::CovenReadHandle::new(
2591            db,
2592            store_dir.clone(),
2593            config_provider,
2594            StoreKeys::new(store_id.to_string()),
2595            crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2596            test_identity_custody(),
2597            Arc::new(SystemClock),
2598            None,
2599        );
2600
2601        assert_eq!(
2602            reader.open_app_data(&sealed, b"ctx").unwrap(),
2603            b"read-me-back",
2604            "the read handle opens what the write handle sealed",
2605        );
2606    }
2607
2608    /// A store whose custody holds no master key has nothing to seal under and
2609    /// nothing to open with. Both directions refuse with `Locked` rather than
2610    /// inventing a key — the app-data counterpart of the sync engine's
2611    /// `MasterKeyNotEstablished` gate. Here the store is genuinely never
2612    /// initialized: a real keyring custody whose account holds no key.
2613    #[tokio::test]
2614    async fn app_data_is_locked_when_no_master_key_is_established() {
2615        test_keyring::install();
2616        let (_tmp, store_dir) = temp_store_dir();
2617        let db = read_test_db("images");
2618        let store_id = "lib-app-data-locked";
2619        let handle = test_handle_with_custody(
2620            store_id,
2621            store_dir.clone(),
2622            db,
2623            crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2624        );
2625        assert!(
2626            handle.master_key_fingerprint().unwrap().is_none(),
2627            "the store starts with no established master key",
2628        );
2629
2630        let seal_error = handle
2631            .seal_app_data(b"nothing to seal under", b"ctx")
2632            .expect_err("sealing a locked store must refuse");
2633        assert!(matches!(seal_error, SealError::Locked), "{seal_error:?}");
2634
2635        let open_error = handle
2636            .open_app_data(b"nothing to open with", b"ctx")
2637            .expect_err("opening on a locked store must refuse");
2638        assert!(matches!(open_error, SealError::Locked), "{open_error:?}");
2639    }
2640
2641    #[tokio::test]
2642    async fn plaintext_membership_operations_are_typed() {
2643        let local = tokio::task::LocalSet::new();
2644        local
2645            .run_until(async {
2646                tokio::task::spawn_local(run_plaintext_membership_operations_are_typed())
2647                    .await
2648                    .expect("plaintext membership test task");
2649            })
2650            .await;
2651    }
2652
2653    async fn run_plaintext_membership_operations_are_typed() {
2654        await_test_orchestration(tokio::spawn(async {
2655            test_keyring::install();
2656
2657            let (_tmp, store_dir) = temp_store_dir();
2658            let db = read_test_db("images");
2659            let handle = test_handle("lib-plaintext-membership", store_dir, db);
2660            handle
2661                .connect_sync_with_test_home(
2662                    Arc::new(InMemoryCloudHome::new()),
2663                    CloudCipher::Plaintext,
2664                )
2665                .await
2666                .expect("connect plaintext home");
2667
2668            let public_key_hex = hex::encode(crate::keys::UserKeypair::generate().public_key());
2669            let invite = handle
2670                .invite_member(&public_key_hex, None, MemberRole::Member)
2671                .await;
2672            let remove = handle.remove_member(&public_key_hex).await;
2673            let circle = handle.create_circle("Household").await;
2674
2675            assert!(matches!(invite, Err(SyncError::NotEncryptedHome)));
2676            assert!(matches!(remove, Err(SyncError::NotEncryptedHome)));
2677            assert!(matches!(
2678                circle,
2679                Err(SyncError::Circle(
2680                    crate::sync::circle_ops::CircleOperationError::BrowsableStorage
2681                ))
2682            ));
2683        }))
2684        .await;
2685    }
2686
2687    async fn await_test_orchestration(task: tokio::task::JoinHandle<()>) {
2688        task.await.expect("test orchestration task completes");
2689    }
2690
2691    #[tokio::test]
2692    async fn create_circle_returns_after_merge_activation_is_materialized() {
2693        await_test_orchestration(tokio::spawn(async {
2694            test_keyring::install();
2695
2696            let (_tmp, store_dir) = temp_store_dir();
2697            let db = read_test_db("images");
2698            let keyring = crate::encryption::MasterKeyring::generate();
2699            let custody = crate::custody::KeyCustody::InMemory(keyring.clone())
2700                .resolve("lib-create-circle-merge", &store_dir);
2701            let handle =
2702                test_handle_with_custody("lib-create-circle-merge", store_dir, db.clone(), custody);
2703            handle
2704                .connect_sync_with_test_home(
2705                    Arc::new(InMemoryCloudHome::new()),
2706                    CloudCipher::Encrypted(EncryptionService::from(keyring)),
2707                )
2708                .await
2709                .expect("connect encrypted Merge home");
2710
2711            let circle_id = handle
2712                .create_circle("Household")
2713                .await
2714                .expect("create and activate circle");
2715
2716            assert_eq!(
2717                handle.get_circles().await.expect("read active circles"),
2718                vec![crate::CircleInfo {
2719                    id: circle_id,
2720                    name: "Household".to_string(),
2721                    role: crate::CircleRole::Owner,
2722                }]
2723            );
2724            assert!(handle
2725                .get_circle_operations()
2726                .await
2727                .expect("read completed circle operations")
2728                .is_empty());
2729
2730            let circle = circle_id.to_string();
2731            db.call(move |conn| {
2732                let activated: i64 = conn.query_row(
2733                    "SELECT COUNT(*) FROM circle_control_activations WHERE circle_id = ?1",
2734                    [&circle],
2735                    |row| row.get(0),
2736                )?;
2737                let active_access: i64 = conn.query_row(
2738                    "SELECT COUNT(*) FROM circle_access_cache
2739                 WHERE circle_id = ?1 AND disposition = 'active'",
2740                    [&circle],
2741                    |row| row.get(0),
2742                )?;
2743                let pending: i64 = conn.query_row(
2744                    "SELECT COUNT(*) FROM circle_operations WHERE circle_id = ?1",
2745                    [&circle],
2746                    |row| row.get(0),
2747                )?;
2748                assert_eq!((activated, active_access, pending), (1, 1, 0));
2749                Ok::<_, crate::DbError>(())
2750            })
2751            .await
2752            .expect("read activated circle state");
2753        }))
2754        .await;
2755    }
2756
2757    #[tokio::test]
2758    async fn create_circle_returns_after_serial_activation_is_materialized() {
2759        await_test_orchestration(tokio::spawn(async {
2760            test_keyring::install();
2761
2762            let (_tmp, store_dir) = temp_store_dir();
2763            let db = crate::sync::test_helpers::open_serial_test_db();
2764            let keyring = crate::encryption::MasterKeyring::generate();
2765            let custody = crate::custody::KeyCustody::InMemory(keyring.clone())
2766                .resolve("lib-create-circle-serial", &store_dir);
2767            let handle = test_handle_with_custody(
2768                "lib-create-circle-serial",
2769                store_dir,
2770                db.clone(),
2771                custody,
2772            );
2773            let home = Arc::new(InMemoryCloudHome::new());
2774            handle
2775                .connect_sync_with_test_home_and_coordination(
2776                    home.clone(),
2777                    home,
2778                    CloudCipher::Encrypted(EncryptionService::from(keyring)),
2779                )
2780                .await
2781                .expect("connect encrypted Serial home");
2782
2783            let circle_id = handle
2784                .create_circle("Household")
2785                .await
2786                .expect("create and activate Serial circle");
2787
2788            let circle = circle_id.to_string();
2789            db.call(move |conn| {
2790                let activated: i64 = conn.query_row(
2791                    "SELECT COUNT(*) FROM circle_control_activations WHERE circle_id = ?1",
2792                    [&circle],
2793                    |row| row.get(0),
2794                )?;
2795                let pending: i64 = conn.query_row(
2796                    "SELECT COUNT(*) FROM circle_operations WHERE circle_id = ?1",
2797                    [&circle],
2798                    |row| row.get(0),
2799                )?;
2800                let serial_positions: i64 = conn.query_row(
2801                    "SELECT COUNT(*) FROM materialized_commits WHERE device_id = 'serial'",
2802                    [],
2803                    |row| row.get(0),
2804                )?;
2805                assert_eq!((activated, pending), (1, 0));
2806                assert!(serial_positions >= 1);
2807                Ok::<_, crate::DbError>(())
2808            })
2809            .await
2810            .expect("read activated Serial circle state");
2811        }))
2812        .await;
2813    }
2814
2815    #[tokio::test]
2816    async fn reconnect_sync_stops_the_previous_loop() {
2817        let local = tokio::task::LocalSet::new();
2818        local
2819            .run_until(async {
2820                tokio::task::spawn_local(run_reconnect_sync_stops_the_previous_loop())
2821                    .await
2822                    .expect("sync reconnect test task");
2823            })
2824            .await;
2825    }
2826
2827    async fn run_reconnect_sync_stops_the_previous_loop() {
2828        test_keyring::install();
2829
2830        let (_tmp, store_dir) = temp_store_dir();
2831        let db = read_test_db("images");
2832        let config = Config::with_defaults(
2833            "lib-reconnect-loop".to_string(),
2834            "test-device".to_string(),
2835            store_dir.clone(),
2836            "Test Store".to_string(),
2837        );
2838        let config_provider: ConfigProvider = {
2839            let config = config.clone();
2840            Arc::new(move || config.clone())
2841        };
2842        let handle = CovenHandle::new(
2843            db.clone(),
2844            // `read_db`: these tests never call `sql_read`, and the test db is
2845            // `:memory:` (unique per connection, no shareable read-only companion),
2846            // so the writer clone stands in.
2847            db.clone(),
2848            db.stamper(),
2849            store_dir.clone(),
2850            config_provider,
2851            StoreKeys::new("lib-reconnect-loop".to_string()),
2852            test_key_custody(),
2853            test_identity_custody(),
2854            Arc::new(SystemClock),
2855            None,
2856            None,
2857            StoreOpenGuard::acquire_for_test(&store_dir),
2858        );
2859
2860        let home = Arc::new(InMemoryCloudHome::new());
2861        handle
2862            .connect_sync_with_test_home(home.clone(), CloudCipher::Plaintext)
2863            .await
2864            .expect("first connect over injected home");
2865        let first_loop = handle
2866            .sync_manager()
2867            .expect("first manager installed")
2868            .sync_loop_handle()
2869            .expect("first loop installed");
2870        assert!(first_loop.is_running(), "first loop starts running");
2871
2872        handle
2873            .connect_sync_with_test_home(home, CloudCipher::Plaintext)
2874            .await
2875            .expect("second connect over injected home");
2876        let replacement_loop = handle
2877            .sync_manager()
2878            .expect("replacement manager installed")
2879            .sync_loop_handle()
2880            .expect("replacement loop installed");
2881
2882        assert!(
2883            !first_loop.is_running(),
2884            "reconnect must stop the old loop before installing a replacement",
2885        );
2886        assert!(
2887            replacement_loop.is_running(),
2888            "reconnect leaves the replacement loop running",
2889        );
2890    }
2891
2892    #[tokio::test]
2893    async fn stopped_installed_loop_blocks_blob_transitions() {
2894        let local = tokio::task::LocalSet::new();
2895        local
2896            .run_until(async {
2897                tokio::task::spawn_local(run_stopped_installed_loop_blocks_blob_transitions())
2898                    .await
2899                    .expect("stopped-loop readiness test task");
2900            })
2901            .await;
2902    }
2903
2904    async fn run_stopped_installed_loop_blocks_blob_transitions() {
2905        test_keyring::install();
2906
2907        let (_tmp, store_dir) = temp_store_dir();
2908        let db = read_test_db("images");
2909        let config = Config::with_defaults(
2910            "lib-stopped-loop-readiness".to_string(),
2911            "test-device".to_string(),
2912            store_dir.clone(),
2913            "Test Store".to_string(),
2914        );
2915        let config_provider: ConfigProvider = {
2916            let config = config.clone();
2917            Arc::new(move || config.clone())
2918        };
2919        let handle = CovenHandle::new(
2920            db.clone(),
2921            // `read_db`: these tests never call `sql_read`, and the test db is
2922            // `:memory:` (unique per connection, no shareable read-only companion),
2923            // so the writer clone stands in.
2924            db.clone(),
2925            db.stamper(),
2926            store_dir.clone(),
2927            config_provider,
2928            StoreKeys::new("lib-stopped-loop-readiness".to_string()),
2929            test_key_custody(),
2930            test_identity_custody(),
2931            Arc::new(SystemClock),
2932            None,
2933            None,
2934            StoreOpenGuard::acquire_for_test(&store_dir),
2935        );
2936
2937        handle
2938            .connect_sync_with_test_home(Arc::new(InMemoryCloudHome::new()), CloudCipher::Plaintext)
2939            .await
2940            .expect("connect over injected home");
2941        let manager = handle.sync_manager().expect("manager installed");
2942        let loop_handle = manager.sync_loop_handle().expect("loop installed");
2943
2944        loop_handle.stop().expect("stop installed loop");
2945
2946        let make_remote = manager.make_remote("notes", "note-1", false).await;
2947        assert!(matches!(make_remote, Err(MakeRemoteError::SyncNotReady)));
2948
2949        let (_cancel_tx, cancel_rx) = watch::channel(false);
2950        let make_local = manager
2951            .make_local("notes", "note-1", &HashMap::new(), &cancel_rx, None)
2952            .await;
2953        assert!(matches!(make_local, Err(MakeLocalError::SyncNotReady)));
2954    }
2955
2956    #[tokio::test]
2957    async fn encrypted_session_keeps_its_binding_after_config_changes() {
2958        let local = tokio::task::LocalSet::new();
2959        local
2960            .run_until(async {
2961                tokio::task::spawn_local(
2962                    run_encrypted_session_keeps_its_binding_after_config_changes(),
2963                )
2964                .await
2965                .expect("encrypted-session binding test task");
2966            })
2967            .await;
2968    }
2969
2970    async fn run_encrypted_session_keeps_its_binding_after_config_changes() {
2971        test_keyring::install();
2972
2973        let (tmp, store_dir) = temp_store_dir();
2974        let db = host_blob_test_db("images");
2975
2976        let config = Config::with_defaults(
2977            "lib-test".to_string(),
2978            "test-device".to_string(),
2979            store_dir.clone(),
2980            "Test Store".to_string(),
2981        );
2982        let live_config = Arc::new(RwLock::new(config));
2983        let config_provider: ConfigProvider = {
2984            let live_config = live_config.clone();
2985            Arc::new(move || {
2986                live_config
2987                    .read()
2988                    .expect("test config lock is not poisoned")
2989                    .clone()
2990            })
2991        };
2992
2993        let handle = CovenHandle::new(
2994            db.clone(),
2995            // `read_db`: these tests never call `sql_read`, and the test db is
2996            // `:memory:` (unique per connection, no shareable read-only companion),
2997            // so the writer clone stands in.
2998            db.clone(),
2999            db.stamper(),
3000            store_dir.clone(),
3001            config_provider,
3002            StoreKeys::new("lib-test".to_string()),
3003            test_key_custody(),
3004            test_identity_custody(),
3005            Arc::new(SystemClock),
3006            None,
3007            None,
3008            StoreOpenGuard::acquire_for_test(&store_dir),
3009        );
3010
3011        let home = Arc::new(InMemoryCloudHome::new());
3012        handle
3013            .connect_sync_with_test_home(
3014                home.clone(),
3015                CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
3016            )
3017            .await
3018            .expect("connect encrypted injected home");
3019        let manager = handle.sync_manager().expect("sync manager installed");
3020        let loop_handle = manager.sync_loop_handle().expect("sync loop installed");
3021
3022        {
3023            let mut next_config = live_config
3024                .write()
3025                .expect("test config lock is not poisoned");
3026            next_config.store_id = "next-lib".to_string();
3027            next_config.store_dir = StoreDir::new(tmp.path().join("next-store"));
3028            next_config.cloud_home.storage = HomeStorage::Browsable;
3029        }
3030
3031        assert_eq!(loop_handle.config().store_id, "lib-test");
3032        assert_eq!(loop_handle.store_dir(), &store_dir);
3033        assert!(matches!(
3034            loop_handle.blob_path_scheme(),
3035            BlobPathScheme::Hashed
3036        ));
3037
3038        let rotated = EncryptionService::from_key([7u8; 32])
3039            .with_appended_generation(2, [8u8; 32])
3040            .expect("append generation");
3041        loop_handle
3042            .adopt_key_rotation_for_test(rotated)
3043            .expect("adopt encrypted generation");
3044        assert_eq!(
3045            loop_handle
3046                .current_encryption()
3047                .expect("session remains encrypted")
3048                .current_generation(),
3049            2,
3050        );
3051
3052        let plaintext = b"encrypted-drain-bytes-after-key-rotation".to_vec();
3053        let blob = publish_host_blob(&handle, "plain-cover", "plain-cover", &plaintext).await;
3054        let cloud_key = blob
3055            .stored()
3056            .expect("published blob has exact storage")
3057            .object()
3058            .slot()
3059            .logical_key();
3060        let stored = home.get(cloud_key).expect("uploaded cloud object");
3061        assert_ne!(
3062            stored.as_slice(),
3063            plaintext.as_slice(),
3064            "an encrypted session must never upload plaintext cloud bytes",
3065        );
3066
3067        let aad_context = |store_id: &str| {
3068            let mut context = Vec::new();
3069            context.extend_from_slice(&(store_id.len() as u64).to_le_bytes());
3070            context.extend_from_slice(store_id.as_bytes());
3071            context.extend_from_slice(&(cloud_key.len() as u64).to_le_bytes());
3072            context.extend_from_slice(cloud_key.as_bytes());
3073            context
3074        };
3075        let cipher = CloudCipher::Encrypted(
3076            loop_handle
3077                .current_encryption()
3078                .expect("session remains encrypted"),
3079        );
3080        assert_eq!(
3081            cipher
3082                .open(stored.clone(), &aad_context("lib-test"))
3083                .expect("open with the installed session binding"),
3084            plaintext,
3085        );
3086        assert!(
3087            cipher.open(stored, &aad_context("next-lib")).is_err(),
3088            "a later config must not change the installed session's store binding",
3089        );
3090    }
3091
3092    fn status_test_handle(store_id: &str) -> (tempfile::TempDir, CovenHandle) {
3093        let (tmp, store_dir) = temp_store_dir();
3094        let db = read_test_db("images");
3095        let config = Config::with_defaults(
3096            store_id.to_string(),
3097            "test-device".to_string(),
3098            store_dir.clone(),
3099            "Test Store".to_string(),
3100        );
3101        let config_provider: ConfigProvider = {
3102            let config = config.clone();
3103            Arc::new(move || config.clone())
3104        };
3105        let handle = CovenHandle::new(
3106            db.clone(),
3107            // `read_db`: these tests never call `sql_read`, and the test db is
3108            // `:memory:` (unique per connection, no shareable read-only companion),
3109            // so the writer clone stands in.
3110            db.clone(),
3111            db.stamper(),
3112            store_dir.clone(),
3113            config_provider,
3114            StoreKeys::new(store_id.to_string()),
3115            test_key_custody(),
3116            test_identity_custody(),
3117            Arc::new(SystemClock),
3118            None,
3119            None,
3120            StoreOpenGuard::acquire_for_test(&store_dir),
3121        );
3122        (tmp, handle)
3123    }
3124
3125    /// The current state starts offline, moves through storage checking and
3126    /// publication, then reports synchronization.
3127    #[tokio::test]
3128    async fn subscribed_host_sees_offline_checking_publishing_then_synchronized() {
3129        let local = tokio::task::LocalSet::new();
3130        local
3131            .run_until(async {
3132                tokio::task::spawn_local(
3133                    run_subscribed_host_sees_offline_checking_publishing_then_synchronized(),
3134                )
3135                .await
3136                .expect("sync status sequence test task");
3137            })
3138            .await;
3139    }
3140
3141    async fn run_subscribed_host_sees_offline_checking_publishing_then_synchronized() {
3142        test_keyring::install();
3143
3144        let (_tmp, handle) = status_test_handle("lib-status-syncing");
3145        let mut rx = handle.subscribe_sync_status();
3146        assert_eq!(format!("{:?}", *rx.borrow()), "Offline");
3147
3148        let home = InMemoryCloudHome::new();
3149        let (probe_reached, release_probe) = home.pause_next_probe();
3150        handle
3151            .connect_sync_with_test_home(Arc::new(home.clone()), CloudCipher::Plaintext)
3152            .await
3153            .expect("connect over injected home");
3154
3155        tokio::time::timeout(Duration::from_secs(20), probe_reached.notified())
3156            .await
3157            .expect("the reachability probe reaches its test pause");
3158        assert_eq!(format!("{:?}", *rx.borrow()), "CheckingStorage");
3159
3160        let (publication_reached, release_publication) = home.pause_after_exact_create_call(1);
3161        release_probe.notify_one();
3162        tokio::time::timeout(Duration::from_secs(20), publication_reached.notified())
3163            .await
3164            .expect("publication reaches its test pause");
3165        let publishing = rx.borrow().clone();
3166        assert_eq!(format!("{publishing:?}"), "Publishing");
3167
3168        release_publication.notify_one();
3169        tokio::time::timeout(Duration::from_secs(20), async {
3170            loop {
3171                if matches!(&*rx.borrow(), SyncLoopStatus::Synchronized(_)) {
3172                    break;
3173                }
3174                rx.changed().await.expect("the status channel remains open");
3175            }
3176        })
3177        .await
3178        .expect("a synchronized status arrives within the timeout");
3179        let done = rx.borrow().clone();
3180        assert!(
3181            format!("{done:?}").starts_with("Synchronized("),
3182            "a successful cycle ends synchronized, got {done:?}",
3183        );
3184    }
3185
3186    #[tokio::test]
3187    async fn transport_failure_after_reachability_probe_returns_to_offline() {
3188        let local = tokio::task::LocalSet::new();
3189        local
3190            .run_until(async {
3191                tokio::task::spawn_local(
3192                    run_transport_failure_after_reachability_probe_returns_to_offline(),
3193                )
3194                .await
3195                .expect("transport failure status test task");
3196            })
3197            .await;
3198    }
3199
3200    async fn run_transport_failure_after_reachability_probe_returns_to_offline() {
3201        test_keyring::install();
3202
3203        let (_tmp, handle) = status_test_handle("lib-status-cycle-transport");
3204        let mut rx = handle.subscribe_sync_status();
3205        let home = InMemoryCloudHome::new();
3206        let (probe_reached, release_probe) = home.pause_next_probe();
3207        handle
3208            .connect_sync_with_test_home(Arc::new(home.clone()), CloudCipher::Plaintext)
3209            .await
3210            .expect("connect over injected home");
3211
3212        tokio::time::timeout(Duration::from_secs(20), probe_reached.notified())
3213            .await
3214            .expect("the reachability probe reaches the provider");
3215        assert_eq!(format!("{:?}", *rx.borrow()), "CheckingStorage");
3216        home.arm_write_failures();
3217        release_probe.notify_one();
3218
3219        tokio::time::timeout(Duration::from_secs(20), async {
3220            loop {
3221                rx.changed().await.expect("the status channel remains open");
3222                match rx.borrow().clone() {
3223                    SyncLoopStatus::CheckingStorage | SyncLoopStatus::Publishing => {}
3224                    SyncLoopStatus::Offline => break,
3225                    status => {
3226                        panic!("a provider transport failure must end offline, got {status:?}")
3227                    }
3228                }
3229            }
3230        })
3231        .await
3232        .expect("the failed cycle publishes a terminal status");
3233    }
3234
3235    /// A subscription created before any provider is connected keeps receiving
3236    /// across a reconnect — the channel is owned by the handle, not the loop that
3237    /// a reconnect replaces. Under a per-loop channel the receiver would observe
3238    /// `Closed` after the reconnect dropped the first loop's sender.
3239    #[tokio::test]
3240    async fn subscription_survives_a_reconnect() {
3241        let local = tokio::task::LocalSet::new();
3242        local
3243            .run_until(async {
3244                tokio::task::spawn_local(run_subscription_survives_a_reconnect())
3245                    .await
3246                    .expect("status subscription reconnect test task");
3247            })
3248            .await;
3249    }
3250
3251    async fn run_subscription_survives_a_reconnect() {
3252        test_keyring::install();
3253
3254        let (_tmp, handle) = status_test_handle("lib-status-reconnect");
3255
3256        // Subscribe before any provider is connected — valid because the channel
3257        // is handle-owned.
3258        let mut rx = handle.subscribe_sync_status();
3259        let home = Arc::new(InMemoryCloudHome::new());
3260
3261        handle
3262            .connect_sync_with_test_home(home.clone(), CloudCipher::Plaintext)
3263            .await
3264            .expect("first connect");
3265        // Reconnect immediately: this drops the first loop and starts a second one
3266        // over the same store home before the first loop's startup delay elapses.
3267        handle
3268            .connect_sync_with_test_home(home, CloudCipher::Plaintext)
3269            .await
3270            .expect("reconnect");
3271
3272        tokio::time::timeout(Duration::from_secs(20), rx.changed())
3273            .await
3274            .expect("a status arrives from the post-reconnect loop")
3275            .expect("a reconnect does not close the handle-owned status channel");
3276        let status = rx.borrow().clone();
3277        assert!(
3278            matches!(
3279                status,
3280                SyncLoopStatus::CheckingStorage | SyncLoopStatus::Publishing
3281            ),
3282            "the received status is a cycle start marker, got {status:?}",
3283        );
3284    }
3285
3286    /// `stop_sync` keeps the installed manager (so `start_sync` can resume
3287    /// it); `disconnect_sync` drops it outright. The resolved cipher and the
3288    /// device keypair `SyncManager`/`CloudSyncStorage` hold live only inside
3289    /// that manager (see its doc) — nothing else in the handle references
3290    /// them — so once `sync_manager()` is `None`, nothing a connection
3291    /// resolved survives past the call. A later `connect_sync` builds a new
3292    /// manager that re-resolves fresh from custody
3293    /// (`resolve_cipher_never_caches_reflects_whatever_custody_now_serves` in
3294    /// `sync_manager.rs` pins that re-resolution).
3295    #[tokio::test]
3296    async fn disconnect_sync_drops_the_installed_manager_not_just_the_loop() {
3297        let local = tokio::task::LocalSet::new();
3298        local
3299            .run_until(async {
3300                tokio::task::spawn_local(
3301                    run_disconnect_sync_drops_the_installed_manager_not_just_the_loop(),
3302                )
3303                .await
3304                .expect("disconnect-manager test task");
3305            })
3306            .await;
3307    }
3308
3309    async fn run_disconnect_sync_drops_the_installed_manager_not_just_the_loop() {
3310        test_keyring::install();
3311
3312        let (_tmp, store_dir) = temp_store_dir();
3313        let db = read_test_db("images");
3314        let handle = test_handle("lib-disconnect-drops-manager", store_dir, db);
3315
3316        handle
3317            .connect_sync_with_test_home(Arc::new(InMemoryCloudHome::new()), CloudCipher::Plaintext)
3318            .await
3319            .expect("connect over injected home");
3320        assert!(
3321            handle.sync_manager().is_some(),
3322            "connect installs a manager"
3323        );
3324
3325        handle.stop_sync();
3326        assert!(
3327            handle.sync_manager().is_some(),
3328            "stop_sync keeps the manager installed so start_sync can resume it",
3329        );
3330
3331        handle.disconnect_sync();
3332        assert!(
3333            handle.sync_manager().is_none(),
3334            "disconnect_sync drops the installed manager entirely — nothing it \
3335             cached survives past this call",
3336        );
3337    }
3338}