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