Skip to main content

coven/sync/
sync_manager.rs

1//! High-level sync manager: lifecycle, membership, status.
2//!
3//! Owns the sync lifecycle — cloud home + sync loop — and starts/stops it when
4//! a provider is connected/disconnected, no app restart required. The host
5//! supplies the config snapshot, keys, encryption, database, clock, and blob
6//! handling; coven drives the rest.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, RwLock};
11
12use tokio::sync::watch;
13use tracing::{error, info};
14
15use crate::blob::transition::{self, MakeLocalError, MakeRemoteError};
16use crate::blob::BlobTransitionObserver;
17use crate::clock::ClockRef;
18use crate::config::{Config, HomeStorage};
19use crate::coven::StoreOpenGuard;
20use crate::database::{Database, DbError};
21use crate::encryption::EncryptionService;
22use crate::keys::{DeviceIdentityCustody, KeyError, MasterKeyCustody, StoreKeys};
23use crate::storage::cloud::setup::{SetupError, StorageSetupError};
24use crate::storage::cloud::{CloudHome, CloudHomeError};
25#[cfg(any(test, feature = "test-utils"))]
26use crate::sync::cloud_storage::BlobPathScheme;
27use crate::sync::cloud_storage::CloudCipher;
28#[cfg(any(test, feature = "test-utils"))]
29use crate::sync::cloud_storage::CloudSyncStorage;
30use crate::sync::cycle::{InitSyncError, SyncComponents};
31/// `MemberInfo` lives next to `MemberRole` in the membership module; coven's
32/// public path reaches it through here (re-exported from `lib.rs`).
33pub(crate) use crate::sync::membership::MemberInfo;
34use crate::sync::membership::MemberRole;
35use crate::sync::storage::SyncStorage;
36use crate::sync::store::{Store, StoreDatabase};
37use crate::sync::sync_loop::{SyncLoopError, SyncLoopHandle, SyncLoopStatus};
38
39/// Supplies the host's current config for building the next connection. Starting
40/// a loop captures one snapshot; commands on that running loop use its immutable
41/// store identity, representation, provider settings, and directory even if the
42/// host's next config changes meanwhile.
43pub(crate) type ConfigProvider = Arc<dyn Fn() -> Config + Send + Sync>;
44
45#[derive(Debug, thiserror::Error)]
46pub enum SyncError {
47    #[error("sync is not configured")]
48    NotConfigured,
49    #[error("sync loop is not running")]
50    LoopNotRunning,
51    #[error("sharing requires an encrypted cloud home")]
52    NotEncryptedHome,
53    #[error("no master key is established for this opaque store (locked, or never initialized)")]
54    MasterKeyNotEstablished,
55    #[error("failed to build cloud home: {0}")]
56    CloudHome(#[from] CloudHomeError),
57    #[error("failed to create sync storage: {0}")]
58    StorageSetup(StorageSetupError),
59    #[error("key error: {0}")]
60    Key(#[from] KeyError),
61    #[error("sync initialization error: {0}")]
62    Init(#[from] InitSyncError),
63    #[error("Store protocol state: {0}")]
64    Protocol(String),
65    #[error("Store operation: {0}")]
66    Store(#[from] crate::sync::store::StoreError),
67    #[error("{0}")]
68    Setup(#[from] SetupError),
69    #[error("membership error: {0}")]
70    Membership(Box<crate::sync::store::MembershipOpsError>),
71    #[error("circle operation: {0}")]
72    Circle(#[from] crate::sync::store::CircleOperationError),
73    #[error("device join: {0}")]
74    DeviceJoin(#[from] crate::DeviceJoinError),
75    #[error("{0}")]
76    Database(#[from] DbError),
77    #[error("blob upload drain failed: {0}")]
78    BlobUpload(DbError),
79    #[error("sync loop error: {0}")]
80    Loop(SyncLoopError),
81}
82
83impl From<crate::sync::store::MembershipOpsError> for SyncError {
84    fn from(error: crate::sync::store::MembershipOpsError) -> Self {
85        Self::Membership(Box::new(error))
86    }
87}
88
89/// High-level sync manager.
90///
91/// Holds the store's master-key custody. The at-rest cipher is resolved from
92/// it per [`start_sync`](Self::start_sync) call for an opaque home; a
93/// store with scoped rows also loads generation 1 for stable row routing,
94/// independent of the home's storage representation.
95pub(crate) struct SyncManager {
96    config_provider: ConfigProvider,
97    key_service: StoreKeys,
98    custody: Arc<dyn MasterKeyCustody>,
99    identity_custody: Arc<dyn DeviceIdentityCustody>,
100    database: StoreDatabase,
101    clock: ClockRef,
102    cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
103    observer: Option<Arc<dyn BlobTransitionObserver>>,
104
105    /// The store-directory lock, cloned into every sync loop this manager
106    /// starts so the loop's thread keeps it alive for the whole of its final
107    /// cycle. The lock releases when the last writer — a running loop, else the
108    /// handle — is gone, never while a detached loop thread is still writing.
109    open_guard: Arc<StoreOpenGuard>,
110
111    /// The current status value the [`CovenHandle`](crate::CovenHandle) owns, cloned
112    /// into every sync loop this manager starts so a subscription outlives the
113    /// loop restarts a reconnect performs.
114    status_tx: tokio::sync::watch::Sender<SyncLoopStatus>,
115
116    // Mutable sync state — updated when providers are connected/disconnected
117    sync_loop_handle: RwLock<Option<Arc<SyncLoopHandle>>>,
118    cloud_home: RwLock<Option<Arc<dyn CloudHome>>>,
119
120    /// Serializes the membership operations that mint or rotate the store key —
121    /// invite (wraps the key to a new member) and remove (mints a fresh key and
122    /// re-wraps it to everyone remaining). Each clones the live cipher at entry
123    /// and builds a new keyring on top of it; without this, two rapid ops on one
124    /// device would both clone the same base generation and prepare competing
125    /// membership authorities. Held for the whole operation so the second waits
126    /// and builds on the first's committed state.
127    member_ops_lock: tokio::sync::Mutex<()>,
128}
129
130impl SyncManager {
131    async fn storage_for_command(
132        &self,
133        config: &Config,
134        active_loop: Option<&Arc<SyncLoopHandle>>,
135    ) -> Result<Arc<crate::sync::cloud_storage::CloudSyncStorage>, SyncError> {
136        if let Some(active_loop) = active_loop {
137            return Ok(active_loop.storage().clone());
138        }
139        let storage = match self.cloud_home() {
140            Some(home) => crate::storage::cloud::setup::create_sync_storage_with_home(
141                config,
142                self.custody.as_ref(),
143                self.identity_custody.as_ref(),
144                home,
145                None,
146            ),
147            None => {
148                crate::storage::cloud::setup::create_sync_storage_with_cloudkit(
149                    config,
150                    &self.key_service,
151                    self.custody.as_ref(),
152                    self.identity_custody.as_ref(),
153                    None,
154                    self.clock.clone(),
155                    self.cloudkit_ops.clone(),
156                )
157                .await
158            }
159        }
160        .map_err(SyncError::StorageSetup)?;
161        Ok(Arc::new(storage))
162    }
163
164    /// Build the manager off the owned [`Database`]. Session initialization takes
165    /// the database's already-seeded register clock into [`SyncComponents`], and
166    /// every connected command reads that captured clock from the installed loop.
167    ///
168    /// Construction is infallible and synchronous: seeding already happened in
169    /// the open path. The manager is built lazily, only once a provider is
170    /// connected.
171    #[allow(clippy::too_many_arguments)]
172    pub(crate) fn new(
173        config_provider: ConfigProvider,
174        key_service: StoreKeys,
175        custody: Arc<dyn MasterKeyCustody>,
176        identity_custody: Arc<dyn DeviceIdentityCustody>,
177        db: Database,
178        clock: ClockRef,
179        cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
180        observer: Option<Arc<dyn BlobTransitionObserver>>,
181        open_guard: Arc<StoreOpenGuard>,
182        status_tx: tokio::sync::watch::Sender<SyncLoopStatus>,
183    ) -> Self {
184        Self {
185            config_provider,
186            key_service,
187            custody,
188            identity_custody,
189            database: StoreDatabase::from_database(db),
190            clock,
191            cloudkit_ops,
192            observer,
193            open_guard,
194            status_tx,
195            sync_loop_handle: RwLock::new(None),
196            cloud_home: RwLock::new(None),
197            member_ops_lock: tokio::sync::Mutex::new(()),
198        }
199    }
200
201    pub(crate) fn cloud_home(&self) -> Option<Arc<dyn CloudHome>> {
202        self.cloud_home.read().unwrap().clone()
203    }
204
205    fn db(&self) -> &Database {
206        self.database.sqlite()
207    }
208
209    pub(crate) fn sync_loop_handle(&self) -> Option<Arc<SyncLoopHandle>> {
210        self.sync_loop_handle.read().unwrap().clone()
211    }
212
213    pub(crate) async fn abandon_merge_candidate(
214        &self,
215        write_id: coven_core::WriteId,
216    ) -> Result<coven_core::sync::store::MergeCandidateAbandonment, SyncError> {
217        let loop_handle = self.sync_loop_handle().ok_or(SyncError::LoopNotRunning)?;
218        let identity = crate::keys::require_identity(self.identity_custody.as_ref())?;
219        let device_id = self
220            .database
221            .sqlite()
222            .get_protocol_state(coven_core::database::LOCAL_DEVICE_ID_STATE_KEY)
223            .await?
224            .ok_or_else(|| {
225                SyncError::Protocol("local Store device identity is absent".to_string())
226            })?;
227        Store::load(self.database.clone(), Arc::clone(loop_handle.storage()))
228            .await
229            .map_err(|error| SyncError::Protocol(error.to_string()))?
230            .abandon_candidate(&device_id, &identity, write_id)
231            .await
232            .map_err(|error| SyncError::Protocol(error.to_string()))
233    }
234
235    // =========================================================================
236    // Sync lifecycle
237    // =========================================================================
238
239    /// Resolve the home's at-rest cipher from `storage` and this manager's
240    /// custody. A browsable home never consults custody — it stores in the
241    /// clear regardless. An opaque home unlocks the master keyring; no key
242    /// established (a locked store, or a browsable/opaque storage mismatch)
243    /// is [`SyncError::MasterKeyNotEstablished`], surfaced here rather than
244    /// deep inside the home build. The single custody→cipher decision both
245    /// [`start_sync`](Self::start_sync) and the test-home connect path share.
246    fn resolve_cipher(&self, storage: HomeStorage) -> Result<CloudCipher, SyncError> {
247        if storage.is_browsable() {
248            Ok(CloudCipher::Plaintext)
249        } else {
250            let keyring = self.custody.unlock()?;
251            CloudCipher::for_storage(storage, keyring.map(Into::into))
252                .ok_or(SyncError::MasterKeyNotEstablished)
253        }
254    }
255
256    fn routing_encryption(&self) -> Result<Option<EncryptionService>, SyncError> {
257        self.db()
258            .gates()
259            .has_scoped_graph()
260            .then(|| crate::handle::routing_encryption_from_custody(self.custody.as_ref()))
261            .transpose()
262            .map_err(SyncError::from)
263    }
264
265    /// Initialize cloud home and sync loop from current config.
266    /// Called at startup (if already configured) and after connecting a provider.
267    ///
268    /// Two outcomes are success: a configured provider whose home builds and whose
269    /// loop starts, and a store with no configured provider that legitimately
270    /// starts no loop — the latter is a logged `Ok(())` no-op. A cloud-home build
271    /// that *fails* (missing credentials, a bad provider config) is an `Err`, not
272    /// "no provider connected": the caller must not install a manager that reports
273    /// success with nothing started.
274    pub(crate) async fn start_sync(&self) -> Result<(), SyncError> {
275        let config = (self.config_provider)();
276
277        if config.cloud_home.provider.is_none() {
278            self.stop_sync()?;
279            // Not a failure: a store with no configured provider starts no
280            // cloud home or sync loop.
281            info!("start_sync: sync not configured; no loop started");
282            return Ok(());
283        }
284
285        crate::storage::cloud::setup::require_exact_slot_capabilities_config(&config)
286            .map_err(SyncError::StorageSetup)?;
287
288        let routing_encryption = self.routing_encryption()?;
289
290        self.stop_current_connection()?;
291
292        // The home's at-rest cipher, resolved fresh on every start so a
293        // stop/start picks up whatever custody now holds. Built once here so
294        // the sync loop and storage share one instance — a member removal
295        // rotates the key in place through it.
296        let cipher = self.resolve_cipher(config.cloud_home.storage)?;
297
298        // Build the cloud home. A failure here is a real fault — surface it so the
299        // caller never installs a manager that started nothing.
300        let cloud_home = crate::storage::cloud::create_cloud_home_with_cloudkit(
301            &config,
302            &self.key_service,
303            self.clock.clone(),
304            self.cloudkit_ops.clone(),
305        )
306        .await
307        .map_err(SyncError::from)?;
308        let cloud_home: Arc<dyn CloudHome> = Arc::from(cloud_home);
309
310        // Initialize sync loop. The synced-table set is owned by the Database, so
311        // init_sync reads it from there rather than from a separately-held copy.
312        // Sync is enabled here, so `None` means a real startup failure (no synced
313        // tables, storage/keypair/auth/membership bootstrap) that init_sync already
314        // logged — surface it so the caller never installs a manager whose loop
315        // never started.
316        //
317        // Connect never mints a device identity: a locked agent with no
318        // identity established must fail here with `KeyError::NoDeviceIdentity`,
319        // not silently forge one.
320        let storage = crate::storage::cloud::setup::create_sync_storage_with_home(
321            &config,
322            self.custody.as_ref(),
323            self.identity_custody.as_ref(),
324            cloud_home.clone(),
325            Some(cipher.clone()),
326        )
327        .map_err(|error| match error {
328            crate::storage::cloud::setup::StorageSetupError::Key(error) => SyncError::Key(error),
329            error => SyncError::StorageSetup(error),
330        })?;
331
332        let initialization = self.store_initialization().await?;
333        let components = crate::sync::cycle::init_sync_over_storage(
334            &self.database,
335            storage,
336            initialization,
337            routing_encryption,
338        )
339        .await
340        .map_err(SyncError::from)?;
341
342        let _handle = self.install_sync_loop(components, config)?;
343        *self.cloud_home.write().unwrap() = Some(cloud_home);
344
345        Ok(())
346    }
347
348    /// Build the sync-loop handle off `components`, start it, and install it. The
349    /// shared install tail of [`start_sync`](Self::start_sync) and the test-only
350    /// [`start_sync_with_home`](Self::start_sync_with_home): both reach it only
351    /// after the bootstrap has produced [`SyncComponents`], so the loop handle is
352    /// installed whole, never on a half-built bootstrap.
353    fn install_sync_loop(
354        &self,
355        components: SyncComponents,
356        config: Config,
357    ) -> Result<Arc<SyncLoopHandle>, SyncError> {
358        let handle = Arc::new(SyncLoopHandle::new(
359            components,
360            self.custody.clone(),
361            self.clock.clone(),
362            config,
363            self.observer.clone(),
364            self.open_guard.clone(),
365            self.status_tx.clone(),
366        ));
367        handle.start().map_err(SyncError::Loop)?;
368
369        info!("Sync loop started");
370        *self.sync_loop_handle.write().unwrap() = Some(handle.clone());
371        Ok(handle)
372    }
373
374    /// Test-only: stand the sync loop over an injected `home`/`cipher` instead of
375    /// building the cloud home from config via `create_cloud_home`.
376    ///
377    /// The counterpart of [`start_sync`](Self::start_sync) for a host's
378    /// integration tests, which drive coven over a mock [`CloudHome`] no provider
379    /// match would ever produce. It skips the config-provider gate — the injected
380    /// home IS the enablement, there are no real credentials to check — installs
381    /// the home, builds a [`CloudSyncStorage`] over it under the supplied `cipher`
382    /// (and the config's blob-path scheme), runs the same bootstrap
383    /// [`init_sync`](crate::sync::cycle::init_sync) does via
384    /// [`init_sync_over_storage`](crate::sync::cycle::init_sync_over_storage), and
385    /// starts the loop. A bootstrap failure is an `Err`, the same fail-loud
386    /// discipline `start_sync` keeps — and commit-whole: the home and loop handle
387    /// are installed only after the keypair load and bootstrap both succeed, so a
388    /// failure leaves nothing installed.
389    ///
390    /// After this returns, the connected loop's storage is reachable via
391    /// [`sync_loop_handle`](Self::sync_loop_handle)`().storage()`, so the handle's
392    /// read path serves blobs over the same injected home with no separate hook.
393    ///
394    /// Like [`start_sync`](Self::start_sync), this never mints a device
395    /// identity: the caller must establish one under this manager's identity
396    /// custody first, or this fails with `KeyError::NoDeviceIdentity`.
397    #[cfg(any(test, feature = "test-utils"))]
398    pub(crate) async fn start_sync_with_home(
399        &self,
400        home: std::sync::Arc<dyn CloudHome>,
401        cipher: CloudCipher,
402    ) -> Result<(), SyncError> {
403        self.start_sync_with_home_parts(home, cipher).await
404    }
405
406    #[cfg(any(test, feature = "test-utils"))]
407    async fn start_sync_with_home_parts(
408        &self,
409        home: std::sync::Arc<dyn CloudHome>,
410        cipher: CloudCipher,
411    ) -> Result<(), SyncError> {
412        let config = (self.config_provider)();
413        crate::storage::cloud::setup::require_exact_slot_capabilities_home(
414            home.clone(),
415            config.cloud_home.provider.clone(),
416        )
417        .map_err(SyncError::StorageSetup)?;
418        let routing_encryption = self.routing_encryption()?;
419        self.stop_current_connection()?;
420
421        let keypair = crate::keys::require_identity(self.identity_custody.as_ref())?;
422        let blob_paths = if cipher.is_plaintext() {
423            BlobPathScheme::Plain
424        } else {
425            BlobPathScheme::Hashed
426        };
427        let storage = CloudSyncStorage::new(
428            home.clone(),
429            cipher.clone(),
430            blob_paths,
431            config.store_id.clone(),
432            keypair,
433        )?;
434        let initialization = self.store_initialization().await?;
435        let components = crate::sync::cycle::init_sync_over_storage(
436            &self.database,
437            storage,
438            initialization,
439            routing_encryption,
440        )
441        .await
442        .map_err(SyncError::from)?;
443
444        let _handle = self.install_sync_loop(components, config)?;
445        *self.cloud_home.write().unwrap() = Some(home);
446
447        Ok(())
448    }
449
450    async fn store_initialization(
451        &self,
452    ) -> Result<crate::sync::cycle::StoreInitialization, SyncError> {
453        let Some(expected_store_root) = self.database.local_store_root_ref().await? else {
454            return Ok(crate::sync::cycle::StoreInitialization::CreateStore);
455        };
456        Ok(crate::sync::cycle::StoreInitialization::OpenStore {
457            expected_store_root,
458        })
459    }
460
461    /// Test-only: stand the sync loop over an injected `home` while resolving the
462    /// at-rest cipher from custody exactly as [`start_sync`](Self::start_sync)
463    /// does — the counterpart of [`start_sync_with_home`](Self::start_sync_with_home)
464    /// for proving the established master key is the one sealing traffic. Unlike
465    /// that method, which takes the cipher explicitly and never consults custody,
466    /// this drives the real [`resolve_cipher`](Self::resolve_cipher) path: an
467    /// opaque home with no key established fails [`SyncError::MasterKeyNotEstablished`]
468    /// before the loop starts.
469    #[cfg(any(test, feature = "test-utils"))]
470    pub(crate) async fn start_sync_with_test_home_custody(
471        &self,
472        home: std::sync::Arc<dyn CloudHome>,
473    ) -> Result<(), SyncError> {
474        let storage = (self.config_provider)().cloud_home.storage;
475        let cipher = self.resolve_cipher(storage)?;
476        self.start_sync_with_home(home, cipher).await
477    }
478
479    /// Tear down the sync loop and cloud home.
480    pub(crate) fn stop_sync(&self) -> Result<(), SyncError> {
481        let stop_result = self.stop_current_loop();
482        *self.sync_loop_handle.write().unwrap() = None;
483        *self.cloud_home.write().unwrap() = None;
484
485        match stop_result {
486            Ok(()) => {
487                info!("Sync loop stopped");
488                Ok(())
489            }
490            Err(error) => {
491                error!("Sync loop stop failed: {error}");
492                Err(error)
493            }
494        }
495    }
496
497    fn stop_current_loop(&self) -> Result<(), SyncError> {
498        let handle = self.sync_loop_handle.write().unwrap().take();
499        if let Some(handle) = handle {
500            handle.stop().map_err(SyncError::Loop)?;
501        }
502        Ok(())
503    }
504
505    fn stop_current_connection(&self) -> Result<(), SyncError> {
506        let stop_result = self.stop_current_loop();
507        *self.cloud_home.write().unwrap() = None;
508        stop_result
509    }
510
511    // =========================================================================
512    // Status / config queries
513    // =========================================================================
514
515    pub(crate) fn is_sync_ready(&self) -> bool {
516        self.sync_loop_handle
517            .read()
518            .unwrap()
519            .as_ref()
520            .is_some_and(|h| h.is_running())
521    }
522
523    pub(crate) fn trigger_sync(&self) {
524        if let Some(ref sync_loop) = *self.sync_loop_handle.read().unwrap() {
525            sync_loop.trigger();
526        }
527    }
528
529    // =========================================================================
530    // Blob locality transitions (make_remote / make_local / cancel_make_remote)
531    // =========================================================================
532
533    /// Make `(root_table, root_id)` Remote (Local → Remote): enqueue an upload per
534    /// user-provided blob from its external file and record the make_remote intent,
535    /// then return. The drain uploads each and flips the gate true on the last (see
536    /// [`crate::blob::transition::make_remote`]); the gate flip re-emits the subtree,
537    /// the cycle's inline push uploads the root's host-provided blobs, and
538    /// `on_root_made_remote` fires. `pin` keeps the uploaded blobs in coven's cache
539    /// as pinned (offline) copies.
540    pub(crate) async fn make_remote(
541        &self,
542        root_table: &str,
543        root_id: &str,
544        pin: bool,
545    ) -> Result<(), MakeRemoteError> {
546        if !self.is_sync_ready() {
547            return Err(MakeRemoteError::SyncNotReady);
548        }
549        let sync_loop = self
550            .sync_loop_handle()
551            .ok_or(MakeRemoteError::SyncNotReady)?;
552        transition::make_remote(
553            &self.database,
554            sync_loop.store_dir(),
555            sync_loop.hlc(),
556            root_table,
557            root_id,
558            pin,
559        )
560        .await?;
561        self.trigger_sync();
562        Ok(())
563    }
564
565    /// Cancel an in-flight make_remote of `(root_table, root_id)`: clear its intent
566    /// and pending uploads and tombstone any blob that already landed. The gate never
567    /// flips, so the root stays Local.
568    pub(crate) async fn cancel_make_remote(
569        &self,
570        root_table: &str,
571        root_id: &str,
572    ) -> Result<(), MakeRemoteError> {
573        if !self.is_sync_ready() {
574            return Err(MakeRemoteError::SyncNotReady);
575        }
576        transition::cancel_make_remote(self.db(), root_table, root_id).await?;
577        self.trigger_sync();
578        Ok(())
579    }
580
581    /// Make `(root_table, root_id)` Local (Remote → Local): bring each blob back to a
582    /// local file durability-first — a user-provided blob to the path named in `dest`
583    /// (blob id → destination path), a host-provided blob to coven's local store (no
584    /// dest) — then flip the gate false, register the user-provided external refs,
585    /// and enqueue the cloud deletes in one atomic commit. Awaitable; `cancel` aborts
586    /// before the commit (the root stays Remote). `dest` carries user-provided ids
587    /// only. Per-blob materialize progress and the completion event reach the
588    /// observer this manager was built with.
589    pub(crate) async fn make_local(
590        &self,
591        root_table: &str,
592        root_id: &str,
593        dest: &HashMap<String, PathBuf>,
594        cancel: &watch::Receiver<bool>,
595        routing_encryption: Option<EncryptionService>,
596    ) -> Result<(), MakeLocalError> {
597        if !self.is_sync_ready() {
598            return Err(MakeLocalError::SyncNotReady);
599        }
600        let sync_loop = self
601            .sync_loop_handle()
602            .ok_or(MakeLocalError::SyncNotReady)?;
603        let storage: &dyn SyncStorage = &**sync_loop.storage();
604        transition::make_local(
605            &self.database,
606            storage,
607            sync_loop.store_dir(),
608            sync_loop.hlc(),
609            routing_encryption,
610            self.observer.as_deref(),
611            root_table,
612            root_id,
613            dest,
614            cancel,
615        )
616        .await?;
617        self.trigger_sync();
618        Ok(())
619    }
620
621    // =========================================================================
622    // Keys / codes
623    // =========================================================================
624
625    // =========================================================================
626    // Membership
627    // =========================================================================
628
629    pub(crate) async fn get_members(&self) -> Result<Vec<MemberInfo>, SyncError> {
630        let active_loop = self.sync_loop_handle();
631        let config = active_loop
632            .as_ref()
633            .map(|handle| handle.config().clone())
634            .unwrap_or_else(|| (self.config_provider)());
635        if active_loop.is_none() && config.cloud_home.provider.is_none() {
636            info!("get_members: sync not configured; returning no members");
637            return Ok(Vec::new());
638        }
639        let storage = self
640            .storage_for_command(&config, active_loop.as_ref())
641            .await?;
642
643        let user_pubkey = crate::keys::identity_public_key(self.identity_custody.as_ref())?;
644        Store::load(self.database.clone(), storage)
645            .await?
646            .members(user_pubkey.as_ref().map(|key| key.as_slice()))
647            .await
648            .map_err(SyncError::from)
649    }
650
651    pub(crate) async fn membership_conflict(
652        &self,
653    ) -> Result<Option<crate::MembershipConflictInfo>, SyncError> {
654        let active_loop = self.sync_loop_handle();
655        let config = active_loop
656            .as_ref()
657            .map(|handle| handle.config().clone())
658            .unwrap_or_else(|| (self.config_provider)());
659        if active_loop.is_none() && config.cloud_home.provider.is_none() {
660            return Err(SyncError::NotConfigured);
661        }
662        let storage = self
663            .storage_for_command(&config, active_loop.as_ref())
664            .await?;
665        let user_pubkey = crate::keys::identity_public_key(self.identity_custody.as_ref())?;
666        Store::load(self.database.clone(), storage)
667            .await?
668            .membership_conflict(user_pubkey.as_ref().map(|key| key.as_slice()))
669            .await
670            .map_err(SyncError::from)
671    }
672
673    /// Build a restore code for this store: fetch the current membership-head
674    /// floor from the cloud and mint the code from it, so the restorer can seed
675    /// its watermark from mint-time
676    /// state rather than accepting any signed head as a fresh device would
677    /// otherwise have to. Requires a connected provider — unlike the old,
678    /// storage-free `generate_restore_code`, minting a trustworthy floor is a
679    /// network read, not a pure function of local config and keyring state.
680    pub(crate) async fn generate_restore_code(&self) -> Result<String, SyncError> {
681        let active_loop = self.sync_loop_handle();
682        let config = active_loop
683            .as_ref()
684            .map(|handle| handle.config().clone())
685            .unwrap_or_else(|| (self.config_provider)());
686        if active_loop.is_none() && config.cloud_home.provider.is_none() {
687            return Err(SyncError::NotConfigured);
688        }
689        let storage = self
690            .storage_for_command(&config, active_loop.as_ref())
691            .await?;
692
693        let restore_membership = Store::load(self.database.clone(), storage)
694            .await?
695            .restore_membership()
696            .await
697            .map_err(SyncError::from)?;
698        let identity = crate::keys::require_identity(self.identity_custody.as_ref())?;
699        let authority = crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(
700            self.database
701                .export_activated_device_continuation(&identity)
702                .await?,
703        );
704
705        crate::storage::cloud::setup::generate_restore_code(
706            &config,
707            &self.key_service,
708            self.custody.as_ref(),
709            restore_membership.store_root,
710            restore_membership.founder_pubkey,
711            restore_membership.membership_floor,
712            authority,
713        )
714        .map_err(SyncError::from)
715    }
716
717    pub(crate) fn invite_member<'a>(
718        &'a self,
719        public_key_hex: &'a str,
720        invitee_email: Option<&'a str>,
721        role: MemberRole,
722    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, SyncError>> + Send + 'a>>
723    {
724        Box::pin(async move {
725            // Serialize with any other key-minting/rotating member op on this device.
726            let _member_ops = self.member_ops_lock.lock().await;
727
728            let sync_loop = self
729                .sync_loop_handle
730                .read()
731                .unwrap()
732                .clone()
733                .ok_or(SyncError::LoopNotRunning)?;
734
735            // Inviting a member wraps the store key to them, which only an encrypted
736            // home has. Refuse before touching the membership chain.
737            if sync_loop.current_encryption().is_none() {
738                return Err(SyncError::NotEncryptedHome);
739            }
740            let store_name = sync_loop.config().store_name.clone();
741            let invite_code = sync_loop
742                .invite_member(public_key_hex, invitee_email, role, &store_name)
743                .await
744                .map_err(SyncError::from)?;
745
746            Ok(crate::join_code::encode(&invite_code))
747        })
748    }
749
750    pub(crate) fn remove_member<'a>(
751        &'a self,
752        public_key_hex: &'a str,
753    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, SyncError>> + Send + 'a>>
754    {
755        Box::pin(async move {
756            // Serialize with any other key-minting/rotating member op on this device,
757            // so a second removal builds on this one's committed state rather than
758            // cloning the same base cipher and preparing competing rotations.
759            let _member_ops = self.member_ops_lock.lock().await;
760
761            let sync_loop = self
762                .sync_loop_handle
763                .read()
764                .unwrap()
765                .clone()
766                .ok_or(SyncError::LoopNotRunning)?;
767
768            // Removing a member rotates the store key, which only an encrypted home
769            // has. Refuse up front so a plaintext home never mutates the membership
770            // chain or re-wraps keys before the rotation fails.
771            if sync_loop.current_encryption().is_none() {
772                return Err(SyncError::NotEncryptedHome);
773            }
774
775            // Removing a member commits the cloud key rotation and then adopts the
776            // rotated key into this device's keyring and live cipher. The host records
777            // the returned fingerprint and that a key is stored in its own config; an
778            // adoption failure surfaces as its own membership variant naming the
779            // half-applied state and its remedies — and, structurally, this device
780            // seals nothing new for the cloud until one of those remedies adopts it
781            // (`pending_rotation`, shared with the sync loop this same store runs).
782            let outcome = sync_loop.remove_member(public_key_hex).await;
783
784            let fingerprint = outcome.map_err(SyncError::from)?;
785            Ok(fingerprint)
786        })
787    }
788
789    pub(crate) async fn resolve_membership_conflict(
790        &self,
791        choice: &crate::MembershipConflictChoice,
792    ) -> Result<(), SyncError> {
793        let _member_ops = self.member_ops_lock.lock().await;
794        let sync_loop = self.sync_loop_handle().ok_or(SyncError::LoopNotRunning)?;
795        sync_loop
796            .resolve_membership_conflict(choice)
797            .await
798            .map_err(SyncError::from)
799    }
800
801    /// The local identity's pubkey and the current active Store member set — the
802    /// inputs the Circle read queries share.
803    async fn circle_query_inputs(
804        &self,
805    ) -> Result<(String, std::collections::BTreeSet<String>), crate::CircleError> {
806        let identity = crate::keys::require_identity(self.identity_custody.as_ref())
807            .map_err(|error| crate::CircleError::Identity(error.to_string()))?;
808        let identity_pubkey = crate::keys::public_key_hex(&identity);
809        let store_members = self
810            .get_members()
811            .await
812            .map_err(crate::CircleError::from)?
813            .into_iter()
814            .map(|member| member.pubkey)
815            .collect();
816        Ok((identity_pubkey, store_members))
817    }
818
819    pub(crate) async fn create_circle(
820        &self,
821        name: &str,
822    ) -> Result<crate::CircleId, crate::CircleError> {
823        let sync_loop = self
824            .sync_loop_handle()
825            .ok_or(crate::CircleError::LoopNotRunning)?;
826        sync_loop
827            .create_circle(name)
828            .await
829            .map_err(crate::CircleError::from)
830    }
831
832    pub(crate) async fn rename_circle(
833        &self,
834        circle_id: crate::CircleId,
835        name: &str,
836    ) -> Result<(), crate::CircleError> {
837        let sync_loop = self
838            .sync_loop_handle()
839            .ok_or(crate::CircleError::LoopNotRunning)?;
840        sync_loop
841            .rename_circle(circle_id, name)
842            .await
843            .map_err(crate::CircleError::from)
844    }
845
846    pub(crate) async fn add_circle_member(
847        &self,
848        circle_id: crate::CircleId,
849        member_pubkey: String,
850        role: crate::CircleRole,
851    ) -> Result<(), crate::CircleError> {
852        let sync_loop = self
853            .sync_loop_handle()
854            .ok_or(crate::CircleError::LoopNotRunning)?;
855        sync_loop
856            .add_circle_member(circle_id, member_pubkey, role)
857            .await
858            .map_err(crate::CircleError::from)
859    }
860
861    pub(crate) async fn remove_circle_member(
862        &self,
863        circle_id: crate::CircleId,
864        member_pubkey: String,
865    ) -> Result<crate::CircleOperationId, crate::CircleError> {
866        let sync_loop = self
867            .sync_loop_handle()
868            .ok_or(crate::CircleError::LoopNotRunning)?;
869        sync_loop
870            .remove_circle_member(circle_id, member_pubkey)
871            .await
872            .map_err(crate::CircleError::from)
873    }
874
875    pub(crate) async fn resolve_circle_control(
876        &self,
877        circle_id: crate::CircleId,
878        chosen: crate::CircleControlCoord,
879    ) -> Result<(), crate::CircleError> {
880        let sync_loop = self
881            .sync_loop_handle()
882            .ok_or(crate::CircleError::LoopNotRunning)?;
883        sync_loop
884            .resolve_circle_control(circle_id, chosen)
885            .await
886            .map_err(crate::CircleError::from)
887    }
888
889    pub(crate) async fn cancel_circle_epoch_close(
890        &self,
891        circle_id: crate::CircleId,
892    ) -> Result<crate::CircleOperationId, crate::CircleError> {
893        let sync_loop = self
894            .sync_loop_handle()
895            .ok_or(crate::CircleError::LoopNotRunning)?;
896        sync_loop
897            .cancel_circle_epoch_close(circle_id)
898            .await
899            .map_err(crate::CircleError::from)
900    }
901
902    pub(crate) async fn exclude_circle_close_device(
903        &self,
904        circle_id: crate::CircleId,
905        excluded_device_id: crate::StoreDeviceId,
906    ) -> Result<(), crate::CircleError> {
907        let sync_loop = self
908            .sync_loop_handle()
909            .ok_or(crate::CircleError::LoopNotRunning)?;
910        sync_loop
911            .exclude_circle_close_device(circle_id, excluded_device_id)
912            .await
913            .map_err(crate::CircleError::from)
914    }
915
916    pub(crate) async fn delete_circle(
917        &self,
918        circle_id: crate::CircleId,
919    ) -> Result<(), crate::CircleError> {
920        let sync_loop = self
921            .sync_loop_handle()
922            .ok_or(crate::CircleError::LoopNotRunning)?;
923        sync_loop
924            .delete_circle(circle_id)
925            .await
926            .map_err(crate::CircleError::from)
927    }
928
929    pub(crate) async fn retry_circle_operation(
930        &self,
931        operation_id: crate::CircleOperationId,
932    ) -> Result<(), crate::CircleError> {
933        let sync_loop = self
934            .sync_loop_handle()
935            .ok_or(crate::CircleError::LoopNotRunning)?;
936        sync_loop
937            .retry_circle_operation(operation_id)
938            .await
939            .map_err(crate::CircleError::from)
940    }
941
942    pub(crate) async fn discard_circle_operation(
943        &self,
944        operation_id: crate::CircleOperationId,
945    ) -> Result<(), crate::CircleError> {
946        let sync_loop = self
947            .sync_loop_handle()
948            .ok_or(crate::CircleError::LoopNotRunning)?;
949        sync_loop
950            .discard_circle_operation(operation_id)
951            .await
952            .map_err(crate::CircleError::from)
953    }
954
955    pub(crate) async fn circle_close_status(
956        &self,
957        circle_id: crate::CircleId,
958    ) -> Result<crate::CircleCloseStatus, crate::CircleError> {
959        let sync_loop = self
960            .sync_loop_handle()
961            .ok_or(crate::CircleError::LoopNotRunning)?;
962        sync_loop
963            .circle_close_status(circle_id)
964            .await
965            .map_err(crate::CircleError::from)
966    }
967
968    pub(crate) async fn list_circles(&self) -> Result<Vec<crate::Circle>, crate::CircleError> {
969        let (identity_pubkey, store_members) = self.circle_query_inputs().await?;
970        self.database
971            .circle_states(&identity_pubkey, store_members)
972            .await
973            .map_err(|error| crate::CircleError::Protocol(error.to_string()))
974    }
975
976    pub(crate) async fn circle_members(
977        &self,
978        circle_id: crate::CircleId,
979    ) -> Result<Vec<crate::CircleMemberInfo>, crate::CircleError> {
980        let (identity_pubkey, store_members) = self.circle_query_inputs().await?;
981        self.database
982            .get_circle_members(circle_id, &identity_pubkey, store_members)
983            .await
984            .map_err(|error| crate::CircleError::Protocol(error.to_string()))
985    }
986
987    pub(crate) async fn circle_operations(
988        &self,
989    ) -> Result<Vec<crate::CircleOperationInfo>, crate::CircleError> {
990        self.database
991            .get_circle_operations()
992            .await
993            .map_err(|error| crate::CircleError::Protocol(error.to_string()))
994    }
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    use crate::clock::SystemClock;
1002    use crate::config::CloudProvider;
1003    use crate::coven::StoreOpenGuard;
1004    use crate::encryption::MasterKeyring;
1005    use crate::keys::{test_keyring, KeyError, StoreKeys};
1006    use crate::storage::cloud::test_utils::InMemoryCloudHome;
1007    use crate::storage::cloud::CloudHomeJoinInfo;
1008    use crate::store_dir::StoreDir;
1009    use std::sync::Arc;
1010
1011    struct NoImmutableCopyHome;
1012
1013    #[async_trait::async_trait]
1014    impl CloudHome for NoImmutableCopyHome {
1015        async fn put_object(&self, _key: &str, _data: Vec<u8>) -> Result<(), CloudHomeError> {
1016            panic!("incapable home must be rejected before I/O")
1017        }
1018
1019        async fn open_multipart<'a>(
1020            &'a self,
1021            _key: &str,
1022            _total_len: u64,
1023        ) -> Result<crate::storage::cloud::BoxPartSink<'a>, CloudHomeError> {
1024            panic!("incapable home must be rejected before I/O")
1025        }
1026
1027        fn multipart_threshold(&self) -> u64 {
1028            panic!("incapable home must be rejected before I/O")
1029        }
1030
1031        async fn read(&self, _key: &str) -> Result<Vec<u8>, CloudHomeError> {
1032            panic!("incapable home must be rejected before I/O")
1033        }
1034
1035        async fn read_range(
1036            &self,
1037            _key: &str,
1038            _start: u64,
1039            _end: u64,
1040        ) -> Result<Vec<u8>, CloudHomeError> {
1041            panic!("incapable home must be rejected before I/O")
1042        }
1043
1044        async fn list(&self, _prefix: &str) -> Result<Vec<String>, CloudHomeError> {
1045            panic!("incapable home must be rejected before I/O")
1046        }
1047
1048        async fn delete(&self, _key: &str) -> Result<(), CloudHomeError> {
1049            panic!("incapable home must be rejected before I/O")
1050        }
1051
1052        async fn exists(&self, _key: &str) -> Result<bool, CloudHomeError> {
1053            panic!("incapable home must be rejected before I/O")
1054        }
1055
1056        async fn set_access(
1057            &self,
1058            _desired: crate::storage::cloud::CloudAccessState,
1059        ) -> Result<crate::storage::cloud::CloudAccessOutcome, CloudHomeError> {
1060            panic!("incapable home must be rejected before I/O")
1061        }
1062    }
1063
1064    /// A custody that never has a master key established — `unlock` always
1065    /// returns `None`. For tests exercising a locked/unestablished store, or
1066    /// a browsable home where custody is never consulted at all.
1067    struct NoKeyCustody;
1068
1069    impl MasterKeyCustody for NoKeyCustody {
1070        fn unlock(&self) -> Result<Option<MasterKeyring>, KeyError> {
1071            Ok(None)
1072        }
1073        fn persist(&self, _keyring: &MasterKeyring) -> Result<(), KeyError> {
1074            Ok(())
1075        }
1076        fn forget(&self) -> Result<(), KeyError> {
1077            Ok(())
1078        }
1079    }
1080
1081    /// The identity sibling of [`NoKeyCustody`]: `unlock` always returns
1082    /// `None`, for tests exercising a store with no identity established.
1083    struct NoIdentityCustody;
1084
1085    impl DeviceIdentityCustody for NoIdentityCustody {
1086        fn unlock(&self) -> Result<Option<crate::keys::UserKeypair>, KeyError> {
1087            Ok(None)
1088        }
1089        fn persist(&self, _keypair: &crate::keys::UserKeypair) -> Result<(), KeyError> {
1090            Ok(())
1091        }
1092        fn forget(&self) -> Result<(), KeyError> {
1093            Ok(())
1094        }
1095    }
1096
1097    /// A ready-to-use, already-established identity custody for tests whose
1098    /// focus is elsewhere (blob transitions, membership, restore-code
1099    /// generation) — seeded in-memory so it needs no keyring registration.
1100    fn established_identity_custody() -> Arc<dyn DeviceIdentityCustody> {
1101        crate::identity_custody::IdentityCustody::InMemory(crate::keys::UserKeypair::generate())
1102            .resolve("unused-store-id", &StoreDir::new("unused-store-dir"))
1103    }
1104
1105    async fn start_sync_with_home_in_its_own_task(
1106        manager: Arc<SyncManager>,
1107        home: Arc<dyn CloudHome>,
1108        cipher: CloudCipher,
1109    ) -> Result<(), SyncError> {
1110        tokio::spawn(async move { manager.start_sync_with_home(home, cipher).await })
1111            .await
1112            .expect("join injected-home startup task")
1113    }
1114
1115    #[tokio::test]
1116    async fn get_members_surfaces_malformed_cloud_credentials() {
1117        test_keyring::install();
1118        let tmp = tempfile::tempdir().expect("temp dir");
1119        let store_dir = StoreDir::new(tmp.path());
1120        let store_id = "sync-enabled-malformed-credentials";
1121        let key_service = StoreKeys::new(store_id.to_string());
1122        key_service
1123            .cloud_home_credentials_entry_for_test()
1124            .expect("create credentials entry")
1125            .set_password("{")
1126            .expect("write malformed credentials");
1127        let join_info = CloudHomeJoinInfo::S3 {
1128            bucket: "bucket".to_string(),
1129            region: "region".to_string(),
1130            endpoint: None,
1131            access_key: "access".to_string(),
1132            secret_key: "secret".to_string(),
1133            key_prefix: None,
1134        };
1135        let config = crate::sync::join::build_config(
1136            store_id,
1137            "device",
1138            &store_dir,
1139            "store",
1140            &join_info,
1141            &CloudCipher::Plaintext,
1142        );
1143        let manager = SyncManager::new(
1144            Arc::new(move || config.clone()),
1145            key_service,
1146            Arc::new(NoKeyCustody),
1147            established_identity_custody(),
1148            crate::sync::test_helpers::open_test_db(),
1149            Arc::new(SystemClock),
1150            None,
1151            None,
1152            StoreOpenGuard::acquire_for_test(&store_dir),
1153            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1154        );
1155
1156        let error = match manager.get_members().await {
1157            Ok(_) => panic!("malformed stored credentials must fail"),
1158            Err(error) => error,
1159        };
1160        // The typed CloudHomeError survives up through StorageSetup to the public
1161        // SyncError surface — not flattened into a string — so its retryability
1162        // verdict is still readable: malformed credentials are a configuration
1163        // fault the user must fix, not a transient retry.
1164        let SyncError::StorageSetup(StorageSetupError::CloudHome(cloud_home_error)) = &error else {
1165            panic!("expected StorageSetup(CloudHome(_)), got {error:?}");
1166        };
1167        assert!(matches!(cloud_home_error, CloudHomeError::Configuration(_)));
1168        assert!(!cloud_home_error.is_retryable());
1169        assert!(error
1170            .to_string()
1171            .contains("malformed cloud home credentials JSON"));
1172    }
1173
1174    #[tokio::test]
1175    async fn start_sync_rejects_an_opaque_home_without_a_master_key() {
1176        test_keyring::install();
1177        let (_tmp, store_dir) = crate::sync::test_helpers::temp_store_dir();
1178        let open_guard = StoreOpenGuard::acquire_for_test(&store_dir);
1179        let mut config = Config::with_defaults(
1180            "lib-opaque-no-encryption".to_string(),
1181            "test-device".to_string(),
1182            store_dir,
1183            "Test Store".to_string(),
1184        );
1185        // Opaque storage (the default) with a configured provider but no
1186        // established master key is a locked-store contradiction — custody's
1187        // `unlock` returns `None`.
1188        config.cloud_home.provider = Some(CloudProvider::S3);
1189        let manager = SyncManager::new(
1190            Arc::new(move || config.clone()),
1191            StoreKeys::new("lib-opaque-no-encryption".to_string()),
1192            Arc::new(NoKeyCustody),
1193            established_identity_custody(),
1194            crate::sync::test_helpers::open_test_db(),
1195            Arc::new(SystemClock),
1196            None,
1197            None,
1198            open_guard,
1199            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1200        );
1201
1202        let error = manager
1203            .start_sync()
1204            .await
1205            .expect_err("opaque home without an established master key must fail");
1206        assert!(
1207            matches!(error, SyncError::MasterKeyNotEstablished),
1208            "expected MasterKeyNotEstablished, got {error:?}"
1209        );
1210    }
1211
1212    #[tokio::test]
1213    async fn immutable_copy_admission_refuses_before_stopping_the_active_loop() {
1214        let (_tmp, store_dir) = crate::sync::test_helpers::temp_store_dir();
1215        let open_guard = StoreOpenGuard::acquire_for_test(&store_dir);
1216        let config = Arc::new(RwLock::new(Config::with_defaults(
1217            "immutable-admission-before-stop".to_string(),
1218            "test-device".to_string(),
1219            store_dir,
1220            "Blob Store".to_string(),
1221        )));
1222        let db = crate::sync::test_helpers::open_test_db_with_blob(crate::BlobDecl::new(
1223            "photos",
1224            crate::Provenance::HostProvided,
1225            crate::CacheFill::CacheLazy,
1226        ));
1227        let manager = Arc::new(SyncManager::new(
1228            {
1229                let config = config.clone();
1230                Arc::new(move || config.read().expect("read config").clone())
1231            },
1232            StoreKeys::new("immutable-admission-before-stop".to_string()),
1233            Arc::new(NoKeyCustody),
1234            established_identity_custody(),
1235            db,
1236            Arc::new(SystemClock),
1237            None,
1238            None,
1239            open_guard,
1240            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1241        ));
1242        start_sync_with_home_in_its_own_task(
1243            manager.clone(),
1244            Arc::new(InMemoryCloudHome::new()),
1245            CloudCipher::Plaintext,
1246        )
1247        .await
1248        .expect("install active loop");
1249        let active_loop = manager.sync_loop_handle().expect("active loop");
1250
1251        {
1252            let mut config = config.write().expect("write config");
1253            config.cloud_home.provider = Some(CloudProvider::S3);
1254            config.cloud_home.s3_endpoint = Some("https://objects.example".to_string());
1255            config.cloud_home.s3_exact_slots = None;
1256        }
1257        let error = manager
1258            .start_sync()
1259            .await
1260            .expect_err("unsupported immutable-copy provider is refused");
1261
1262        assert!(matches!(
1263            error,
1264            SyncError::StorageSetup(StorageSetupError::ExactSlotsUnavailable {
1265                provider: CloudProvider::S3,
1266            })
1267        ));
1268        assert!(active_loop.is_running());
1269        assert!(manager.cloud_home().is_some());
1270
1271        let error = start_sync_with_home_in_its_own_task(
1272            manager.clone(),
1273            Arc::new(NoImmutableCopyHome),
1274            CloudCipher::Plaintext,
1275        )
1276        .await
1277        .expect_err("injected home without immutable-copy storage is refused");
1278        assert!(matches!(
1279            error,
1280            SyncError::StorageSetup(StorageSetupError::ExactSlotsUnavailable {
1281                provider: CloudProvider::S3,
1282            })
1283        ));
1284        assert!(active_loop.is_running());
1285        assert!(manager.cloud_home().is_some());
1286    }
1287
1288    #[tokio::test]
1289    async fn start_sync_with_home_stops_the_previous_loop_before_replacement() {
1290        test_keyring::install();
1291
1292        let (_tmp, store_dir) = crate::sync::test_helpers::temp_store_dir();
1293        let open_guard = StoreOpenGuard::acquire_for_test(&store_dir);
1294        let config = Config::with_defaults(
1295            "lib-manager-restart".to_string(),
1296            "test-device".to_string(),
1297            store_dir,
1298            "Test Store".to_string(),
1299        );
1300        let manager = Arc::new(SyncManager::new(
1301            Arc::new(move || config.clone()),
1302            StoreKeys::new("lib-manager-restart".to_string()),
1303            Arc::new(NoKeyCustody),
1304            established_identity_custody(),
1305            crate::sync::test_helpers::open_test_db(),
1306            Arc::new(SystemClock),
1307            None,
1308            None,
1309            open_guard,
1310            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1311        ));
1312
1313        let home = Arc::new(InMemoryCloudHome::new());
1314        start_sync_with_home_in_its_own_task(manager.clone(), home.clone(), CloudCipher::Plaintext)
1315            .await
1316            .expect("first test home starts");
1317        let first_loop = manager
1318            .sync_loop_handle()
1319            .expect("first loop handle installed");
1320        assert!(first_loop.is_running(), "first loop starts running");
1321
1322        start_sync_with_home_in_its_own_task(manager.clone(), home, CloudCipher::Plaintext)
1323            .await
1324            .expect("replacement test home starts");
1325        let replacement_loop = manager
1326            .sync_loop_handle()
1327            .expect("replacement loop handle installed");
1328
1329        assert!(
1330            !first_loop.is_running(),
1331            "starting sync again stops the previous loop before replacement",
1332        );
1333        assert!(
1334            replacement_loop.is_running(),
1335            "replacement loop remains running",
1336        );
1337    }
1338
1339    #[tokio::test]
1340    async fn failed_restart_leaves_no_stale_cloud_home() {
1341        test_keyring::install();
1342
1343        let (_tmp, store_dir) = crate::sync::test_helpers::temp_store_dir();
1344        let open_guard = StoreOpenGuard::acquire_for_test(&store_dir);
1345        let config = Arc::new(RwLock::new(Config::with_defaults(
1346            "lib-manager-failed-restart".to_string(),
1347            "test-device".to_string(),
1348            store_dir,
1349            "Test Store".to_string(),
1350        )));
1351        let manager = Arc::new(SyncManager::new(
1352            {
1353                let config = config.clone();
1354                Arc::new(move || config.read().unwrap().clone())
1355            },
1356            StoreKeys::new("lib-manager-failed-restart".to_string()),
1357            // An established master key so the opaque default storage passes
1358            // the cipher precondition and the restart fails at the home build
1359            // itself.
1360            crate::custody::KeyCustody::InMemory(MasterKeyring::generate()).resolve(
1361                "lib-manager-failed-restart",
1362                &StoreDir::new("unused-store-dir"),
1363            ),
1364            established_identity_custody(),
1365            crate::sync::test_helpers::open_test_db(),
1366            Arc::new(SystemClock),
1367            None,
1368            None,
1369            open_guard,
1370            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1371        ));
1372
1373        start_sync_with_home_in_its_own_task(
1374            manager.clone(),
1375            Arc::new(InMemoryCloudHome::new()),
1376            CloudCipher::Plaintext,
1377        )
1378        .await
1379        .expect("injected home starts");
1380        assert!(manager.cloud_home().is_some(), "injected home is installed");
1381
1382        config.write().unwrap().cloud_home.provider = Some(CloudProvider::S3);
1383        let error = manager
1384            .start_sync()
1385            .await
1386            .expect_err("invalid configured provider fails restart");
1387        assert!(
1388            error.to_string().contains("failed to build cloud home"),
1389            "restart failure surfaces the provider setup error: {error}",
1390        );
1391        assert!(
1392            manager.sync_loop_handle().is_none(),
1393            "failed restart leaves no loop installed",
1394        );
1395        assert!(
1396            manager.cloud_home().is_none(),
1397            "failed restart must not leave the previous cloud home installed",
1398        );
1399    }
1400
1401    /// The `NoDeviceIdentity` sibling of
1402    /// `start_sync_rejects_an_opaque_home_without_a_master_key`: connecting
1403    /// with a configured home but no device identity established must fail
1404    /// typed, with nothing installed — never silently mint one. Browsable
1405    /// storage so the master-key precondition is out of the way and this
1406    /// isolates the identity check.
1407    #[tokio::test]
1408    async fn start_sync_rejects_a_connect_with_no_device_identity_established() {
1409        test_keyring::install();
1410
1411        let (_tmp, store_dir) = crate::sync::test_helpers::temp_store_dir();
1412        let open_guard = StoreOpenGuard::acquire_for_test(&store_dir);
1413        let store_id = "lib-no-device-identity".to_string();
1414        let key_service = StoreKeys::new(store_id.clone());
1415        key_service
1416            .set_cloud_home_credentials(&crate::keys::CloudHomeCredentials::S3 {
1417                access_key: "ak".to_string(),
1418                secret_key: "sk".to_string(),
1419            })
1420            .expect("seed S3 credentials");
1421
1422        let mut config = Config::with_defaults(
1423            store_id.clone(),
1424            "test-device".to_string(),
1425            store_dir,
1426            "Test Store".to_string(),
1427        );
1428        config.cloud_home.provider = Some(CloudProvider::S3);
1429        config.cloud_home.storage = HomeStorage::Browsable;
1430        config.cloud_home.s3_bucket = Some("bucket".to_string());
1431        config.cloud_home.s3_region = Some("us-east-1".to_string());
1432
1433        let manager = SyncManager::new(
1434            Arc::new(move || config.clone()),
1435            key_service,
1436            Arc::new(NoKeyCustody),
1437            Arc::new(NoIdentityCustody),
1438            crate::sync::test_helpers::open_test_db(),
1439            Arc::new(SystemClock),
1440            None,
1441            None,
1442            open_guard,
1443            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1444        );
1445
1446        let error = manager
1447            .start_sync()
1448            .await
1449            .expect_err("no device identity established must fail the connect");
1450        assert!(
1451            matches!(error, SyncError::Key(KeyError::NoDeviceIdentity)),
1452            "got {error:?}"
1453        );
1454        assert!(
1455            manager.sync_loop_handle().is_none(),
1456            "a failed connect installs no loop",
1457        );
1458        assert!(
1459            manager.cloud_home().is_none(),
1460            "a failed connect installs no cloud home",
1461        );
1462    }
1463
1464    #[tokio::test]
1465    async fn browsable_test_home_with_a_foreign_founder_installs_nothing() {
1466        test_keyring::install();
1467
1468        let (_tmp, store_dir) = crate::sync::test_helpers::temp_store_dir();
1469        let store_id = "lib-foreign-browsable-founder";
1470        let mut config = Config::with_defaults(
1471            store_id.to_string(),
1472            "test-device".to_string(),
1473            store_dir.clone(),
1474            "Test Store".to_string(),
1475        );
1476        config.cloud_home.storage = HomeStorage::Browsable;
1477        let home = Arc::new(InMemoryCloudHome::new());
1478        let attacker = crate::keys::UserKeypair::generate();
1479        let attacker_storage = CloudSyncStorage::new(
1480            home.clone(),
1481            CloudCipher::Plaintext,
1482            BlobPathScheme::Plain,
1483            store_id,
1484            attacker.clone(),
1485        )
1486        .expect("build attacker storage");
1487        let attacker_db = crate::sync::test_helpers::open_test_db();
1488        crate::sync::test_helpers::create_exact_test_store(
1489            &attacker_db,
1490            &attacker_storage,
1491            store_id,
1492            &attacker,
1493        )
1494        .await
1495        .expect("publish attacker Store root");
1496
1497        let victim = crate::keys::UserKeypair::generate();
1498        let db = crate::sync::test_helpers::open_test_db();
1499        let manager = Arc::new(SyncManager::new(
1500            Arc::new(move || config.clone()),
1501            StoreKeys::new(store_id.to_string()),
1502            Arc::new(NoKeyCustody),
1503            crate::identity_custody::IdentityCustody::InMemory(victim)
1504                .resolve(store_id, &store_dir),
1505            db.clone(),
1506            Arc::new(SystemClock),
1507            None,
1508            None,
1509            StoreOpenGuard::acquire_for_test(&store_dir),
1510            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1511        ));
1512
1513        let error =
1514            start_sync_with_home_in_its_own_task(manager.clone(), home, CloudCipher::Plaintext)
1515                .await
1516                .expect_err("foreign founder must prevent sync startup");
1517        assert!(
1518            matches!(error, SyncError::Init(InitSyncError::StoreProtocolRoot(_))),
1519            "unexpected startup error: {error:?}",
1520        );
1521        assert!(manager.sync_loop_handle().is_none());
1522        assert!(manager.cloud_home().is_none());
1523        assert_eq!(
1524            db.get_protocol_state(crate::sync::store::OWNER_PUBKEY_STATE_KEY)
1525                .await
1526                .unwrap(),
1527            None,
1528        );
1529    }
1530
1531    /// Key material a connect resolves from custody is never cached across
1532    /// connects — `start_sync` re-derives the cipher fresh every call via
1533    /// `resolve_cipher`, this manager's single
1534    /// custody→cipher decision. Persists key A, resolves, swaps what the SAME
1535    /// custody instance serves to key B (a rotation outside any manager call
1536    /// — the way a host's own key-rotation flow would), and resolves again:
1537    /// the second resolution reflects B, not a value cached from the first.
1538    ///
1539    /// This drives `resolve_cipher` directly rather than a full
1540    /// connect/disconnect/reconnect through an opaque home: an opaque store's
1541    /// membership chain is founded and pinned to the local device on first
1542    /// connect, so swapping its master key outright (rather than through the
1543    /// real in-place rotation `remove_member` performs, which also re-wraps
1544    /// existing membership content) would desync a live home — an unrelated
1545    /// concern to what this test pins. `resolve_cipher` is the exact
1546    /// mechanism `start_sync` and the custody-resolving test-home connect
1547    /// path share, so calling it twice with custody mutated in between is the
1548    /// real unit behind "reconnect uses new material," without wading into
1549    /// membership bootstrap.
1550    #[test]
1551    fn resolve_cipher_never_caches_reflects_whatever_custody_now_serves() {
1552        let (_tmp, store_dir) = crate::sync::test_helpers::temp_store_dir();
1553        let store_id = "lib-resolve-cipher-fresh";
1554        let custody = crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir);
1555        let key_a = MasterKeyring::generate();
1556        custody.persist(&key_a).expect("establish key A");
1557
1558        let config = Config::with_defaults(
1559            store_id.to_string(),
1560            "test-device".to_string(),
1561            store_dir.clone(),
1562            "Test Store".to_string(),
1563        );
1564        let manager = SyncManager::new(
1565            Arc::new(move || config.clone()),
1566            StoreKeys::new(store_id.to_string()),
1567            custody.clone(),
1568            established_identity_custody(),
1569            crate::sync::test_helpers::open_test_db(),
1570            Arc::new(SystemClock),
1571            None,
1572            None,
1573            StoreOpenGuard::acquire_for_test(&store_dir),
1574            tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
1575        );
1576
1577        let fingerprint_a = match manager
1578            .resolve_cipher(crate::config::HomeStorage::Opaque)
1579            .expect("resolve the cipher custody serves for key A")
1580        {
1581            CloudCipher::Encrypted(enc) => enc.fingerprint(),
1582            CloudCipher::Plaintext => panic!("opaque storage must resolve an encrypted cipher"),
1583        };
1584        assert_eq!(fingerprint_a, key_a.fingerprint());
1585
1586        // Swap what the SAME custody instance serves — outside any manager
1587        // call, the way a host's own key-rotation flow would.
1588        let key_b = MasterKeyring::generate();
1589        custody
1590            .persist(&key_b)
1591            .expect("rotate custody's served key to B");
1592
1593        let fingerprint_b = match manager
1594            .resolve_cipher(crate::config::HomeStorage::Opaque)
1595            .expect("resolve the cipher custody serves for key B")
1596        {
1597            CloudCipher::Encrypted(enc) => enc.fingerprint(),
1598            CloudCipher::Plaintext => panic!("opaque storage must resolve an encrypted cipher"),
1599        };
1600        assert_eq!(
1601            fingerprint_b,
1602            key_b.fingerprint(),
1603            "the second resolution must reflect key B, not a value cached from the first call",
1604        );
1605        assert_ne!(
1606            fingerprint_a, fingerprint_b,
1607            "the two resolutions must differ — custody actually served different material",
1608        );
1609    }
1610}