1use 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};
31pub(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
39pub(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
89pub(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 open_guard: Arc<StoreOpenGuard>,
110
111 status_tx: tokio::sync::watch::Sender<SyncLoopStatus>,
115
116 sync_loop_handle: RwLock<Option<Arc<SyncLoopHandle>>>,
118 cloud_home: RwLock<Option<Arc<dyn CloudHome>>>,
119
120 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 #[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 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 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 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 let cipher = self.resolve_cipher(config.cloud_home.storage)?;
297
298 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 if sync_loop.current_encryption().is_none() {
772 return Err(SyncError::NotEncryptedHome);
773 }
774
775 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 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 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 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 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 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 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 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 #[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 #[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 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}