1use std::collections::HashMap;
29use std::path::PathBuf;
30use std::sync::{Arc, RwLock};
31
32use tokio::sync::watch;
33use tracing::{debug, error, info};
34
35use crate::blob::cache::BlobCacheError;
36use crate::blob::transition::{MakeLocalError, MakeRemoteError};
37use crate::blob::upload::DrainOutcome;
38use crate::blob::{BlobRef, BlobTransitionObserver, RowBlobRef};
39use crate::clock::ClockRef;
40use crate::config::Config;
41use crate::coven::StoreOpenGuard;
42use crate::database::{Database, DbError};
43use crate::encryption::{EncryptionService, MasterKeyring, SealError};
44use crate::keys::{
45 DeviceIdentityCustody, IdentityError, KeyError, MasterKeyCustody, MasterKeyError, StoreKeys,
46};
47#[cfg(any(test, feature = "test-utils"))]
48use crate::storage::cloud::CloudHome;
49use crate::store_dir::StoreDir;
50#[cfg(any(test, feature = "test-utils"))]
51use crate::sync::cloud_storage::CloudCipher;
52use crate::sync::cloud_storage::{BlobPathScheme, CloudSyncStorage};
53use crate::sync::membership::MemberRole;
54use crate::sync::storage::{StorageError, SyncStorage};
55use crate::sync::sync_loop::SyncLoopStatus;
56use crate::sync::sync_manager::MemberInfo;
57use crate::sync::sync_manager::{ConfigProvider, SyncError, SyncManager};
58
59impl From<crate::storage::cloud::setup::StorageSetupError> for BlobCacheError {
65 fn from(e: crate::storage::cloud::setup::StorageSetupError) -> Self {
66 BlobCacheError::StorageSetup(e.to_string())
67 }
68}
69
70pub(crate) fn app_data_cipher(
81 custody: &dyn MasterKeyCustody,
82) -> Result<EncryptionService, SealError> {
83 let keyring = custody.unlock()?.ok_or(SealError::Locked)?;
84 Ok(EncryptionService::from(keyring))
85}
86
87pub(crate) fn routing_encryption_from_custody(
88 custody: &dyn MasterKeyCustody,
89) -> Result<EncryptionService, DbError> {
90 let keyring = custody
91 .unlock()
92 .map_err(|error| DbError::Message(format!("unlock Store key for row routing: {error}")))?
93 .ok_or_else(|| {
94 DbError::Message("Merge scoped write requires an established Store key".to_string())
95 })?;
96 Ok(EncryptionService::from(keyring))
97}
98
99#[derive(Clone)]
139pub struct CovenHandle {
140 db: Database,
141
142 read_db: Database,
151 stamper: crate::sync::hlc::UpdatedAtStamper,
152 store_dir: StoreDir,
153
154 config_provider: ConfigProvider,
158 key_service: StoreKeys,
159
160 key_custody: Arc<dyn MasterKeyCustody>,
166
167 identity_custody: Arc<dyn DeviceIdentityCustody>,
173 clock: ClockRef,
174 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
175
176 observer: Option<Arc<dyn BlobTransitionObserver>>,
180
181 open_guard: Arc<StoreOpenGuard>,
186
187 sync: Arc<RwLock<Option<Arc<SyncManager>>>>,
191
192 sync_lifecycle: Arc<tokio::sync::Mutex<()>>,
195
196 sync_status_tx: tokio::sync::watch::Sender<SyncLoopStatus>,
204}
205
206impl CovenHandle {
207 #[allow(clippy::too_many_arguments)]
217 pub(crate) fn new(
218 db: Database,
219 read_db: Database,
220 stamper: crate::sync::hlc::UpdatedAtStamper,
221 store_dir: StoreDir,
222 config_provider: ConfigProvider,
223 key_service: StoreKeys,
224 key_custody: Arc<dyn MasterKeyCustody>,
225 identity_custody: Arc<dyn DeviceIdentityCustody>,
226 clock: ClockRef,
227 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
228 observer: Option<Arc<dyn BlobTransitionObserver>>,
229 open_guard: Arc<StoreOpenGuard>,
230 ) -> Self {
231 Self {
232 db,
233 read_db,
234 stamper,
235 store_dir,
236 config_provider,
237 key_service,
238 key_custody,
239 identity_custody,
240 clock,
241 cloudkit_ops,
242 observer,
243 open_guard,
244 sync: Arc::new(RwLock::new(None)),
245 sync_lifecycle: Arc::new(tokio::sync::Mutex::new(())),
246 sync_status_tx: tokio::sync::watch::channel(SyncLoopStatus::Offline).0,
247 }
248 }
249
250 fn config(&self) -> Config {
251 (self.config_provider)()
252 }
253
254 pub(crate) fn db(&self) -> &Database {
262 &self.db
263 }
264
265 pub(crate) fn read_db(&self) -> &Database {
268 &self.read_db
269 }
270
271 pub(crate) fn stamper(&self) -> crate::sync::hlc::UpdatedAtStamper {
272 self.stamper.clone()
273 }
274
275 pub(crate) fn store_dir(&self) -> StoreDir {
276 self.store_dir.clone()
277 }
278
279 pub(crate) fn routing_encryption(&self) -> Result<EncryptionService, DbError> {
280 routing_encryption_from_custody(self.key_custody.as_ref())
281 }
282
283 pub(crate) fn sync_manager(&self) -> Option<Arc<SyncManager>> {
292 self.sync.read().unwrap().clone()
293 }
294
295 pub fn subscribe_sync_status(&self) -> tokio::sync::watch::Receiver<SyncLoopStatus> {
305 self.sync_status_tx.subscribe()
306 }
307
308 pub async fn pending_writes(&self) -> Result<Vec<coven_core::PendingWrite>, crate::CovenError> {
310 self.db
311 .pending_writes()
312 .await
313 .map_err(crate::CovenError::from)
314 }
315
316 pub async fn blocked_writes(&self) -> Result<Vec<coven_core::PendingWrite>, crate::CovenError> {
319 self.db
320 .blocked_writes()
321 .await
322 .map_err(crate::CovenError::from)
323 }
324
325 pub async fn retry_blocked_write(
329 &self,
330 write_id: &coven_core::WriteId,
331 ) -> Result<Vec<coven_core::WriteId>, crate::CovenError> {
332 let retried = self
333 .db
334 .retry_blocked_write(write_id)
335 .await
336 .map_err(crate::CovenError::from)?;
337 self.sync_now();
338 Ok(retried)
339 }
340
341 pub async fn discard_blocked_write(
344 &self,
345 write_id: &coven_core::WriteId,
346 ) -> Result<Vec<coven_core::WriteId>, crate::CovenError> {
347 let merge_cleanup_pending = self
348 .db
349 .merge_candidate_cleanup_pending(write_id)
350 .await
351 .map_err(crate::CovenError::from)?;
352 let merge_abandonment_required = if merge_cleanup_pending {
353 false
354 } else {
355 self.db
356 .merge_candidate_abandonment_required(write_id)
357 .await
358 .map_err(crate::CovenError::from)?
359 };
360 if merge_cleanup_pending || merge_abandonment_required {
361 let abandonment = self
362 .sync_manager()
363 .ok_or_else(|| {
364 crate::CovenError::CandidateResolution("sync is not connected".to_string())
365 })?
366 .abandon_merge_candidate(write_id.clone())
367 .await
368 .map_err(|error| crate::CovenError::CandidateResolution(error.to_string()))?;
369 match abandonment {
370 coven_core::sync::store_outbound::MergeCandidateAbandonment::NotRequired => {
371 return Err(crate::CovenError::CandidateResolution(
372 "blocked Merge candidate has no abandonment authority".to_string(),
373 ));
374 }
375 coven_core::sync::store_outbound::MergeCandidateAbandonment::Abandoned => {}
376 coven_core::sync::store_outbound::MergeCandidateAbandonment::CandidateActivated => {
377 return Err(crate::CovenError::CandidateResolution(
378 "Merge candidate activated before abandonment and cannot be discarded"
379 .to_string(),
380 ));
381 }
382 }
383 }
384 self.db
385 .discard_blocked_write(write_id)
386 .await
387 .map_err(crate::CovenError::from)
388 }
389
390 pub async fn pending_branches(
391 &self,
392 ) -> Result<Option<coven_core::PendingBranch>, crate::CovenError> {
393 self.db
394 .pending_branches()
395 .await
396 .map_err(crate::CovenError::from)
397 }
398
399 pub async fn write_status(
401 &self,
402 write_id: &coven_core::WriteId,
403 ) -> Result<coven_core::WriteStatus, crate::CovenError> {
404 self.db
405 .write_status(write_id)
406 .await
407 .map_err(crate::CovenError::from)
408 }
409
410 pub async fn subscribe_write_status(
413 &self,
414 write_id: &coven_core::WriteId,
415 ) -> Result<tokio::sync::watch::Receiver<coven_core::WriteStatus>, crate::CovenError> {
416 self.db
417 .subscribe_write_status(write_id)
418 .await
419 .map_err(crate::CovenError::from)
420 }
421
422 pub async fn connect_sync(&self) -> Result<(), SyncError> {
434 self.build_and_install_sync(self.cloudkit_ops.clone(), |manager| async move {
435 manager.start_sync().await
436 })
437 .await?;
438 info!("coven handle: sync manager connected");
439 Ok(())
440 }
441
442 pub async fn connect_sync_with_cloudkit(
443 &self,
444 cloudkit_ops: Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>,
445 ) -> Result<(), SyncError> {
446 self.build_and_install_sync(Some(cloudkit_ops), |manager| async move {
447 manager.start_sync().await
448 })
449 .await?;
450 info!("coven handle: sync manager connected with CloudKit driver");
451 Ok(())
452 }
453
454 async fn build_and_install_sync<F, Fut>(
464 &self,
465 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
466 start: F,
467 ) -> Result<Arc<SyncManager>, SyncError>
468 where
469 F: FnOnce(Arc<SyncManager>) -> Fut,
470 Fut: std::future::Future<Output = Result<(), SyncError>>,
471 {
472 let _lifecycle = self.sync_lifecycle.lock().await;
473 let previous = self.sync.write().unwrap().take();
474 if let Some(manager) = previous {
475 manager.stop_sync()?;
476 }
477
478 let manager = Arc::new(SyncManager::new(
479 self.config_provider.clone(),
480 self.key_service.clone(),
481 self.key_custody.clone(),
482 self.identity_custody.clone(),
483 self.db.clone(),
484 self.clock.clone(),
485 cloudkit_ops,
486 self.observer.clone(),
487 self.open_guard.clone(),
488 self.sync_status_tx.clone(),
489 ));
490 Box::pin(start(manager.clone())).await?;
491 *self.sync.write().unwrap() = Some(manager.clone());
492 Ok(manager)
493 }
494
495 #[cfg(any(test, feature = "test-utils"))]
514 pub async fn connect_sync_with_test_home(
515 &self,
516 home: Arc<dyn CloudHome>,
517 cipher: CloudCipher,
518 ) -> Result<(), SyncError> {
519 self.build_and_install_sync(self.cloudkit_ops.clone(), move |manager| async move {
520 manager.start_sync_with_home(home, cipher).await
521 })
522 .await?;
523 info!("coven handle: sync manager connected over an injected test cloud home");
524 Ok(())
525 }
526
527 #[cfg(any(test, feature = "test-utils"))]
528 pub async fn connect_sync_with_test_home_and_coordination(
529 &self,
530 home: Arc<dyn CloudHome>,
531 coordination: Arc<dyn crate::storage::cloud::CloudHeadStorage>,
532 cipher: CloudCipher,
533 ) -> Result<(), SyncError> {
534 self.build_and_install_sync(self.cloudkit_ops.clone(), move |manager| async move {
535 manager
536 .start_sync_with_home_and_coordination(home, coordination, cipher)
537 .await
538 })
539 .await?;
540 info!("coven handle: sync manager connected over an injected coordinated test home");
541 Ok(())
542 }
543
544 #[cfg(any(test, feature = "test-utils"))]
556 pub async fn connect_sync_with_test_home_custody(
557 &self,
558 home: Arc<dyn CloudHome>,
559 ) -> Result<(), SyncError> {
560 self.build_and_install_sync(self.cloudkit_ops.clone(), move |manager| async move {
561 manager.start_sync_with_test_home_custody(home).await
562 })
563 .await?;
564 info!(
565 "coven handle: sync manager connected over an injected test cloud home with custody-resolved cipher"
566 );
567 Ok(())
568 }
569
570 pub async fn start_sync(&self) -> Result<(), SyncError> {
574 let _lifecycle = self.sync_lifecycle.lock().await;
575 match self.sync_manager() {
576 Some(manager) => manager.start_sync().await,
577 None => {
578 debug!("start_sync: no provider connected; nothing to start");
579 Ok(())
580 }
581 }
582 }
583
584 pub fn stop_sync(&self) {
596 match self.sync_manager() {
597 Some(manager) => {
598 if let Err(stop_error) = manager.stop_sync() {
599 error!("stop_sync failed: {stop_error}");
600 }
601 }
602 None => debug!("stop_sync: no provider connected; nothing to stop"),
603 }
604 }
605
606 pub fn disconnect_sync(&self) {
616 if let Some(manager) = self.sync_manager() {
617 if let Err(stop_error) = manager.stop_sync() {
618 error!("disconnect_sync failed to stop sync: {stop_error}");
619 }
620 }
621 *self.sync.write().unwrap() = None;
622 info!("coven handle: sync manager disconnected");
623 }
624
625 pub fn sync_now(&self) {
628 match self.sync_manager() {
629 Some(manager) => manager.trigger_sync(),
630 None => debug!("sync_now: no provider connected; sync wake ignored"),
631 }
632 }
633
634 pub fn is_syncing(&self) -> bool {
636 self.sync_manager()
637 .is_some_and(|manager| manager.is_sync_ready())
638 }
639
640 pub fn is_connected(&self) -> bool {
645 self.sync_manager().is_some()
646 }
647
648 pub fn initialize_master_key(&self) -> Result<String, MasterKeyError> {
660 if self.key_custody.unlock()?.is_some() {
661 return Err(MasterKeyError::AlreadyEstablished);
662 }
663 let keyring = MasterKeyring::generate();
664 self.key_custody.persist(&keyring)?;
665 Ok(keyring.fingerprint())
666 }
667
668 pub fn import_master_key(&self, serialized: &str) -> Result<String, MasterKeyError> {
672 let keyring = MasterKeyring::from_serialized(serialized)?;
673 self.key_custody.persist(&keyring)?;
674 Ok(keyring.fingerprint())
675 }
676
677 pub fn forget_master_key(&self) -> Result<(), KeyError> {
680 self.key_custody.forget()
681 }
682
683 pub fn master_key_fingerprint(&self) -> Result<Option<String>, KeyError> {
687 Ok(self.key_custody.unlock()?.map(|k| k.fingerprint()))
688 }
689
690 pub fn initialize_identity(&self) -> Result<String, IdentityError> {
703 if self.identity_custody.unlock()?.is_some() {
704 return Err(IdentityError::AlreadyEstablished);
705 }
706 let keypair = crate::keys::UserKeypair::generate();
707 self.identity_custody.persist(&keypair)?;
708 Ok(crate::keys::public_key_hex(&keypair))
709 }
710
711 pub fn set_host_secret(&self, name: &str, value: &str) -> Result<(), KeyError> {
723 self.key_service.set_host_secret(name, value)
724 }
725
726 pub fn host_secret(&self, name: &str) -> Result<Option<String>, KeyError> {
730 self.key_service.get_host_secret(name)
731 }
732
733 pub fn delete_host_secret(&self, name: &str) -> Result<(), KeyError> {
735 self.key_service.delete_host_secret(name)
736 }
737
738 pub fn seal_app_data(&self, plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
757 Ok(app_data_cipher(self.key_custody.as_ref())?.seal_app_data(plaintext, aad))
758 }
759
760 pub fn open_app_data(&self, sealed: &[u8], aad: &[u8]) -> Result<Vec<u8>, SealError> {
768 app_data_cipher(self.key_custody.as_ref())?.open_app_data(sealed, aad)
769 }
770
771 async fn blob_storage(
798 &self,
799 ) -> Result<Option<Arc<dyn SyncStorage>>, crate::storage::cloud::setup::StorageSetupError> {
800 if let Some(manager) = self.sync_manager() {
801 if let Some(loop_handle) = manager.sync_loop_handle() {
802 let storage: Arc<dyn SyncStorage> = loop_handle.storage().clone();
803 return Ok(Some(storage));
804 }
805 if let Some(home) = manager.cloud_home() {
806 let config = self.config();
807 let storage = crate::storage::cloud::setup::create_sync_storage_with_home(
808 &config,
809 self.key_custody.as_ref(),
810 self.identity_custody.as_ref(),
811 home,
812 None,
813 )?;
814 return Ok(Some(Arc::new(storage)));
815 }
816 }
817 let config = self.config();
818 if config.cloud_home.provider.is_none() {
819 return Ok(None);
820 }
821 let storage = crate::storage::cloud::setup::create_sync_storage_with_cloudkit(
822 &config,
823 &self.key_service,
824 self.key_custody.as_ref(),
825 self.identity_custody.as_ref(),
826 None,
827 self.clock.clone(),
828 self.cloudkit_ops.clone(),
829 )
830 .await?;
831 Ok(Some(Arc::new(storage)))
832 }
833
834 pub async fn row_blob_ref(&self, table: &str, row_id: &str) -> Result<RowBlobRef, DbError> {
837 self.db.row_blob_ref(table, row_id).await
838 }
839
840 pub async fn read_blob(&self, blob: &RowBlobRef) -> Result<Vec<u8>, BlobCacheError> {
847 let storage = self.blob_storage().await?;
848 crate::blob::cache::read_blob(&self.db, &self.store_dir, storage.as_deref(), blob).await
849 }
850
851 pub async fn open_blob_stream(
856 &self,
857 blob: &RowBlobRef,
858 offset: u64,
859 len: u64,
860 ) -> Result<Vec<u8>, BlobCacheError> {
861 let storage = self.blob_storage().await?;
862 crate::blob::cache::open_blob_stream(
863 &self.db,
864 &self.store_dir,
865 storage.as_deref(),
866 blob,
867 offset,
868 len,
869 )
870 .await
871 }
872
873 pub async fn pin(&self, blobs: &[RowBlobRef]) -> Result<(), BlobCacheError> {
877 let storage = self.blob_storage().await?;
878 crate::blob::cache::pin(&self.db, &self.store_dir, storage.as_deref(), blobs).await
879 }
880
881 pub async fn unpin(&self, blobs: &[RowBlobRef]) -> Result<(), BlobCacheError> {
884 crate::blob::cache::unpin(&self.store_dir, blobs).await
885 }
886
887 pub fn blob_cloud_key(&self, blob: &BlobRef) -> Result<String, StorageError> {
897 let active_loop = self
898 .sync_manager()
899 .and_then(|manager| manager.sync_loop_handle());
900 let (scheme, uploader) = match active_loop {
901 Some(sync_loop) => (
902 sync_loop.blob_path_scheme(),
903 Some(sync_loop.self_uploader()),
904 ),
905 None => {
906 let scheme = BlobPathScheme::for_storage(self.config().cloud_home.storage);
907 let uploader = crate::keys::identity_public_key(self.identity_custody.as_ref())
908 .map_err(|e| StorageError::Storage(format!("read this store's identity: {e}")))?
909 .map(hex::encode);
910 (scheme, uploader)
911 }
912 };
913 CloudSyncStorage::blob_key(
914 scheme,
915 &blob.namespace,
916 uploader.as_deref(),
917 &blob.id,
918 blob.cloud_path.as_deref(),
919 )
920 }
921
922 pub async fn is_pinned(&self, blobs: &[BlobRef]) -> Result<bool, BlobCacheError> {
929 for blob in blobs {
930 if !crate::blob::cache::is_pinned(&self.store_dir, &blob.namespace, &blob.id).await? {
931 return Ok(false);
932 }
933 }
934 Ok(true)
935 }
936
937 pub async fn evict_blob(&self, blob: &BlobRef) -> Result<(), BlobCacheError> {
943 crate::blob::cache::drop_cached_blob(&self.store_dir, &blob.namespace, &blob.id).await
944 }
945
946 pub async fn make_remote(
954 &self,
955 root_table: &str,
956 root_id: &str,
957 pin: bool,
958 ) -> Result<(), MakeRemoteError> {
959 match self.sync_manager() {
960 Some(manager) => manager.make_remote(root_table, root_id, pin).await,
961 None => Err(MakeRemoteError::SyncNotReady),
962 }
963 }
964
965 pub async fn cancel_make_remote(
970 &self,
971 root_table: &str,
972 root_id: &str,
973 ) -> Result<(), MakeRemoteError> {
974 match self.sync_manager() {
975 Some(manager) => manager.cancel_make_remote(root_table, root_id).await,
976 None => Err(MakeRemoteError::SyncNotReady),
977 }
978 }
979
980 pub async fn make_local(
988 &self,
989 root_table: &str,
990 root_id: &str,
991 dest: &HashMap<String, PathBuf>,
992 cancel: &watch::Receiver<bool>,
993 ) -> Result<(), MakeLocalError> {
994 let manager = self.sync_manager().ok_or(MakeLocalError::SyncNotReady)?;
995 let routing_encryption = (self.db.write_policy() == crate::WritePolicy::MergeConcurrent
996 && self.db.gates().has_scoped_graph())
997 .then(|| self.routing_encryption())
998 .transpose()?;
999 manager
1000 .make_local(root_table, root_id, dest, cancel, routing_encryption)
1001 .await
1002 }
1003
1004 pub async fn drain_uploads(&self) -> Result<DrainOutcome, SyncError> {
1013 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1014 let sync_loop = manager
1015 .sync_loop_handle()
1016 .ok_or(SyncError::LoopNotRunning)?;
1017 sync_loop
1018 .drain_uploads()
1019 .await
1020 .map_err(SyncError::BlobUpload)
1021 }
1022
1023 pub async fn get_cache_budget(&self, namespace: &str) -> Result<Option<u64>, crate::DbError> {
1024 self.db.get_cache_budget(namespace).await
1025 }
1026
1027 pub async fn set_cache_budget(
1028 &self,
1029 namespace: &str,
1030 max_bytes: u64,
1031 ) -> Result<(), crate::DbError> {
1032 self.db.set_cache_budget(namespace, max_bytes).await
1033 }
1034
1035 pub fn get_user_pubkey(&self) -> Result<Option<String>, SyncError> {
1036 crate::keys::identity_public_key(self.identity_custody.as_ref())
1037 .map(|opt| opt.map(hex::encode))
1038 .map_err(SyncError::from)
1039 }
1040
1041 pub async fn generate_restore_code(&self) -> Result<String, SyncError> {
1049 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1050 manager.generate_restore_code().await
1051 }
1052
1053 pub async fn get_members(&self) -> Result<Vec<MemberInfo>, SyncError> {
1054 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1055 manager.get_members().await
1056 }
1057
1058 pub async fn begin_device_join(
1059 &self,
1060 member_pubkey: &str,
1061 provider_administrator: crate::ProviderAdminGrantId,
1062 ) -> Result<crate::DeviceJoinOffer, SyncError> {
1063 let storage = self.device_join_storage()?;
1064 let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1065 let authorization = self.device_join_authorization(&storage).await?;
1066 Ok(crate::sync::device_join::begin_device_join(
1067 &self.db,
1068 storage.as_ref(),
1069 &authorization,
1070 &signer,
1071 member_pubkey,
1072 provider_administrator,
1073 )
1074 .await?)
1075 }
1076
1077 pub async fn authorize_device_provider_access(
1078 &self,
1079 request: crate::DeviceProviderAccessRequest,
1080 access_administrator: Option<&dyn crate::DeviceProviderAccessAdministrator>,
1081 ) -> Result<crate::DeviceProviderAdmissionApproval, SyncError> {
1082 let storage = self.device_join_storage()?;
1083 let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1084 let authorization = self.device_join_authorization(&storage).await?;
1085 let exact = self.device_join_exact_storage()?;
1086 let coordination = self.device_join_coordination(&storage)?;
1087 Ok(crate::sync::device_join::authorize_device_provider_access(
1088 &self.db,
1089 storage.as_ref(),
1090 coordination,
1091 Some(exact.as_ref()),
1092 access_administrator,
1093 &authorization,
1094 &signer,
1095 request,
1096 )
1097 .await?)
1098 }
1099
1100 pub async fn accept_device_registration_request(
1101 &self,
1102 request: crate::DeviceRegistrationRequest,
1103 ) -> Result<crate::ProvisionalDeviceBootstrap, SyncError> {
1104 let storage = self.device_join_storage()?;
1105 let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1106 let authorization = self.device_join_authorization(&storage).await?;
1107 let coordination = self.device_join_coordination(&storage)?;
1108 Ok(
1109 crate::sync::device_join::accept_device_registration_request(
1110 &self.db,
1111 storage.as_ref(),
1112 coordination,
1113 &authorization,
1114 &signer,
1115 request,
1116 )
1117 .await?,
1118 )
1119 }
1120
1121 pub async fn publish_device_provider_challenge(
1122 &self,
1123 bootstrap: crate::ProvisionalDeviceBootstrap,
1124 ) -> Result<crate::ProviderReadyDeviceBootstrap, SyncError> {
1125 let storage = self.device_join_storage()?;
1126 let exact = self.device_join_exact_storage()?;
1127 Ok(crate::sync::device_join::publish_device_provider_challenge(
1128 &self.db,
1129 storage.as_ref(),
1130 Some(exact.as_ref()),
1131 bootstrap,
1132 )
1133 .await?)
1134 }
1135
1136 pub async fn complete_device_provider_admission(
1137 &self,
1138 readiness: crate::DeviceJoinReadiness,
1139 ) -> Result<crate::DeviceProviderAdmissionCompletion, SyncError> {
1140 let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1141 let exact = self.device_join_exact_storage()?;
1142 Ok(
1143 crate::sync::device_join::complete_device_provider_admission(
1144 &self.db,
1145 Some(exact.as_ref()),
1146 &signer,
1147 readiness,
1148 )
1149 .await?,
1150 )
1151 }
1152
1153 pub async fn finalize_device_join(
1154 &self,
1155 completion: crate::DeviceProviderAdmissionCompletion,
1156 ) -> Result<crate::DeviceJoinActivation, SyncError> {
1157 let storage = self.device_join_storage()?;
1158 let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1159 let authorization = self.device_join_authorization(&storage).await?;
1160 let coordination = self.device_join_coordination(&storage)?;
1161 Ok(crate::sync::device_join::finalize_device_join(
1162 &self.db,
1163 storage.as_ref(),
1164 coordination,
1165 &authorization,
1166 &signer,
1167 completion,
1168 )
1169 .await?)
1170 }
1171
1172 pub async fn cancel_device_join(
1173 &self,
1174 attempt: crate::DeviceJoinAttemptRef,
1175 ) -> Result<crate::DeviceJoinCancellation, SyncError> {
1176 let storage = self.device_join_storage()?;
1177 let signer = crate::keys::require_identity(self.identity_custody.as_ref())?;
1178 let authorization = self.device_join_authorization(&storage).await?;
1179 let coordination = self.device_join_coordination(&storage)?;
1180 Ok(crate::sync::device_join::cancel_device_join(
1181 &self.db,
1182 storage.as_ref(),
1183 coordination,
1184 &authorization,
1185 &signer,
1186 attempt,
1187 )
1188 .await?)
1189 }
1190
1191 pub async fn device_join_status(
1192 &self,
1193 attempt_id: crate::DeviceJoinAttemptId,
1194 role: crate::DeviceJoinRole,
1195 ) -> Result<Option<crate::DeviceJoinStatus>, SyncError> {
1196 Ok(
1197 crate::sync::device_join::load_store_device_join_status(&self.db, attempt_id, role)
1198 .await?,
1199 )
1200 }
1201
1202 fn device_join_storage(&self) -> Result<Arc<CloudSyncStorage>, SyncError> {
1203 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1204 let loop_handle = manager
1205 .sync_loop_handle()
1206 .ok_or(SyncError::LoopNotRunning)?;
1207 Ok(loop_handle.storage().clone())
1208 }
1209
1210 fn device_join_exact_storage(
1211 &self,
1212 ) -> Result<Arc<dyn crate::storage::cloud::ExactSlotStorage>, SyncError> {
1213 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1214 let home = manager.cloud_home().ok_or(SyncError::NotConfigured)?;
1215 home.exact_slot_storage()
1216 .ok_or_else(|| SyncError::Protocol("provider has no exact-slot adapter".to_string()))
1217 }
1218
1219 fn device_join_coordination<'a>(
1220 &self,
1221 storage: &'a CloudSyncStorage,
1222 ) -> Result<Option<&'a dyn crate::sync::storage::CoordinationStorage>, SyncError> {
1223 match self.db.write_policy() {
1224 crate::WritePolicy::MergeConcurrent => Ok(None),
1225 crate::WritePolicy::Serial => storage
1226 .serial_coordination()
1227 .map(Some)
1228 .map_err(|error| SyncError::Protocol(error.to_string())),
1229 }
1230 }
1231
1232 async fn device_join_authorization(
1233 &self,
1234 storage: &CloudSyncStorage,
1235 ) -> Result<crate::DeviceJoinAuthorization, SyncError> {
1236 let coordination = self.device_join_coordination(storage)?;
1237 Ok(
1238 crate::sync::device_join::load_current_device_join_authorization(
1239 &self.db,
1240 storage,
1241 coordination,
1242 )
1243 .await?,
1244 )
1245 }
1246
1247 pub async fn invite_member(
1248 &self,
1249 public_key_hex: &str,
1250 invitee_email: Option<&str>,
1251 role: MemberRole,
1252 ) -> Result<String, SyncError> {
1253 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1254 manager
1255 .invite_member(public_key_hex, invitee_email, role)
1256 .await
1257 }
1258
1259 pub async fn remove_member(&self, public_key_hex: &str) -> Result<String, SyncError> {
1260 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1261 manager.remove_member(public_key_hex).await
1262 }
1263
1264 pub async fn create_circle(&self, name: &str) -> Result<crate::CircleId, SyncError> {
1268 let manager = self.sync_manager().ok_or(SyncError::NotConfigured)?;
1269 manager.create_circle(name).await
1270 }
1271
1272 pub async fn get_circles(&self) -> Result<Vec<crate::CircleInfo>, SyncError> {
1274 let identity = crate::keys::require_identity(self.identity_custody.as_ref())?;
1275 self.db
1276 .get_circles(&crate::keys::public_key_hex(&identity))
1277 .await
1278 .map_err(SyncError::from)
1279 }
1280
1281 pub async fn get_circle_operations(
1283 &self,
1284 ) -> Result<Vec<crate::CircleOperationInfo>, SyncError> {
1285 self.db
1286 .get_circle_operations()
1287 .await
1288 .map_err(SyncError::from)
1289 }
1290
1291 pub async fn discard_circle_operation(
1294 &self,
1295 circle_id: crate::CircleId,
1296 ) -> Result<(), SyncError> {
1297 self.db
1298 .discard_blocked_circle_operation(circle_id)
1299 .await
1300 .map_err(SyncError::from)
1301 }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306 use super::*;
1307
1308 use crate::blob::{CacheFill, Provenance};
1309 use crate::clock::SystemClock;
1310 use crate::config::{CloudProvider, Config, HomeStorage};
1311 use crate::encryption::EncryptionService;
1312 use crate::keys::{test_keyring, StoreKeys};
1313 use crate::storage::cloud::cloudkit::{
1314 CloudKitAcceptedShareRecord, CloudKitAtomicCreateBatch, CloudKitOps,
1315 CloudKitProviderIdentity, CloudKitRecordCreate, CloudKitRecordVersion, CloudKitScope,
1316 CloudKitShare,
1317 };
1318 use crate::storage::cloud::test_utils::InMemoryCloudHome;
1319 use crate::storage::cloud::CloudHomeError;
1320 use crate::sync::cloud_storage::CloudCipher;
1321 use crate::sync::sync_manager::{ConfigProvider, SyncError};
1322 use crate::sync::test_helpers::{
1323 open_test_db_with_blob, plant_blob_row, read_test_db, temp_store_dir, TestStore,
1324 };
1325 use std::collections::HashMap;
1326 use std::sync::atomic::{AtomicUsize, Ordering};
1327 use std::sync::Mutex;
1328 use std::time::Duration;
1329
1330 type TestCloudKitCoordinate = (CloudKitScope, String);
1331 type TestCloudKitObject = (Vec<u8>, u64);
1332
1333 struct TestCloudKitOps {
1334 store: Mutex<HashMap<TestCloudKitCoordinate, TestCloudKitObject>>,
1335 shares: Mutex<HashMap<String, CloudKitShare>>,
1336 batches: Mutex<HashMap<String, Vec<CloudKitRecordCreate>>>,
1337 next_batch: AtomicUsize,
1338 }
1339
1340 fn test_key_custody() -> Arc<dyn crate::keys::MasterKeyCustody> {
1345 crate::custody::KeyCustody::InMemory(crate::encryption::MasterKeyring::generate()).resolve(
1346 "unused-store-id",
1347 &crate::store_dir::StoreDir::new("unused-store-dir"),
1348 )
1349 }
1350
1351 fn test_identity_custody() -> Arc<dyn DeviceIdentityCustody> {
1355 crate::identity_custody::IdentityCustody::InMemory(crate::keys::UserKeypair::generate())
1356 .resolve(
1357 "unused-store-id",
1358 &crate::store_dir::StoreDir::new("unused-store-dir"),
1359 )
1360 }
1361
1362 fn host_blob_test_db(namespace: &str) -> Database {
1363 open_test_db_with_blob(
1364 crate::sync::session::BlobDecl::new(
1365 namespace,
1366 Provenance::HostProvided,
1367 CacheFill::CacheLazy,
1368 )
1369 .with_cloud_path_column("cloud_path"),
1370 )
1371 }
1372
1373 struct PausedUploadDrain {
1374 paused: std::sync::atomic::AtomicBool,
1375 reached: tokio::sync::Notify,
1376 }
1377
1378 impl PausedUploadDrain {
1379 fn new() -> Self {
1380 Self {
1381 paused: std::sync::atomic::AtomicBool::new(true),
1382 reached: tokio::sync::Notify::new(),
1383 }
1384 }
1385
1386 fn resume(&self) {
1387 self.paused
1388 .store(false, std::sync::atomic::Ordering::SeqCst);
1389 }
1390 }
1391
1392 #[async_trait::async_trait]
1393 impl crate::blob::BlobTransitionObserver for PausedUploadDrain {
1394 async fn on_blob_upload_started(&self, _blob_id: &str) {}
1395
1396 async fn on_blob_uploaded(&self, _blob_id: &str) {}
1397
1398 async fn on_blob_upload_failed(&self, _blob_id: &str, _error: &str) {}
1399
1400 fn should_skip_uploads(&self) -> bool {
1401 let paused = self.paused.load(std::sync::atomic::Ordering::SeqCst);
1402 if paused {
1403 self.reached.notify_one();
1404 }
1405 paused
1406 }
1407 }
1408
1409 async fn queue_host_blob(
1410 handle: &CovenHandle,
1411 id: &str,
1412 cloud_path: &str,
1413 bytes: &[u8],
1414 remote: bool,
1415 ) -> coven_core::WriteId {
1416 let note_id = format!("note-{id}");
1417 let id = id.to_string();
1418 let cloud_path = cloud_path.to_string();
1419 let bytes = bytes.to_vec();
1420 let size = bytes.len() as i64;
1421 let hash = crate::blob::content_hash(&bytes);
1422 let write = handle
1423 .write(
1424 {
1425 let id = id.clone();
1426 let bytes = bytes.clone();
1427 move |batch| {
1428 batch.put_blob("images", id, bytes);
1429 Ok(())
1430 }
1431 },
1432 {
1433 let id = id.clone();
1434 move |sql| {
1435 let stamp = sql.stamp();
1436 sql.execute(
1437 "INSERT INTO notes \
1438 (id, title, shared, _updated_at, created_at) \
1439 VALUES (?1, 'blob owner', ?2, ?3, '2026-01-01')",
1440 rusqlite::params![note_id, remote as i64, stamp],
1441 )?;
1442 sql.execute(
1443 "INSERT INTO note_photos \
1444 (id, note_id, kind, size, hash, _updated_at, created_at, cloud_path) \
1445 VALUES (?1, ?2, 'cover', ?3, ?4, ?5, '2026-01-01', ?6)",
1446 rusqlite::params![id, note_id, size, hash, stamp, cloud_path],
1447 )?;
1448 Ok(())
1449 }
1450 },
1451 )
1452 .await
1453 .expect("queue host blob write");
1454 write.write_id
1455 }
1456
1457 async fn wait_for_host_blob_publication(
1458 handle: &CovenHandle,
1459 id: &str,
1460 write_id: &coven_core::WriteId,
1461 ) -> RowBlobRef {
1462 let mut status = handle
1463 .subscribe_write_status(write_id)
1464 .await
1465 .expect("subscribe to host blob publication");
1466 handle.sync_now();
1467 tokio::time::timeout(Duration::from_secs(20), async {
1468 loop {
1469 let current = status.borrow().clone();
1470 match current {
1471 coven_core::WriteStatus::Published(_) => break,
1472 coven_core::WriteStatus::Pending | coven_core::WriteStatus::Publishing => {
1473 status
1474 .changed()
1475 .await
1476 .expect("write status channel remains open")
1477 }
1478 other => panic!("host blob write did not publish: {other:?}"),
1479 }
1480 }
1481 })
1482 .await
1483 .expect("host blob publishes");
1484 handle
1485 .row_blob_ref("note_photos", id)
1486 .await
1487 .expect("capture published host blob row")
1488 }
1489
1490 async fn publish_host_blob(
1491 handle: &CovenHandle,
1492 id: &str,
1493 cloud_path: &str,
1494 bytes: &[u8],
1495 ) -> RowBlobRef {
1496 let write_id = queue_host_blob(handle, id, cloud_path, bytes, true).await;
1497 wait_for_host_blob_publication(handle, id, &write_id).await
1498 }
1499
1500 #[tokio::test]
1501 async fn read_blob_with_unbuildable_storage_is_a_typed_setup_error_not_io() {
1502 let (_tmp, store_dir) = temp_store_dir();
1503 let db = host_blob_test_db("images");
1504 let mut config = Config::with_defaults(
1505 "lib-setup-error".to_string(),
1506 "device".to_string(),
1507 store_dir.clone(),
1508 "Test".to_string(),
1509 );
1510 config.cloud_home.provider = Some(CloudProvider::S3);
1515 let config_provider: ConfigProvider = Arc::new(move || config.clone());
1516 let handle = CovenHandle::new(
1517 db.clone(),
1518 db.clone(),
1522 db.stamper(),
1523 store_dir.clone(),
1524 config_provider,
1525 StoreKeys::new("lib-setup-error".to_string()),
1526 test_key_custody(),
1527 test_identity_custody(),
1528 Arc::new(SystemClock),
1529 None,
1530 None,
1531 StoreOpenGuard::acquire_for_test(&store_dir),
1532 );
1533
1534 plant_blob_row(&db, "anyblob0", false, b"typed setup error").await;
1535 let blob = db
1536 .row_blob_ref("note_photos", "anyblob0")
1537 .await
1538 .expect("capture local blob row");
1539 let err = handle
1540 .read_blob(&blob)
1541 .await
1542 .expect_err("no sync storage can be built from the broken config");
1543 assert!(
1544 matches!(err, BlobCacheError::StorageSetup(_)),
1545 "got {err:?}"
1546 );
1547 }
1548
1549 fn test_handle(store_id: &str, store_dir: StoreDir, db: Database) -> CovenHandle {
1550 test_handle_with_custody(store_id, store_dir, db, test_key_custody())
1551 }
1552
1553 fn test_handle_with_custody(
1554 store_id: &str,
1555 store_dir: StoreDir,
1556 db: Database,
1557 key_custody: Arc<dyn crate::keys::MasterKeyCustody>,
1558 ) -> CovenHandle {
1559 let config = Config::with_defaults(
1560 store_id.to_string(),
1561 "test-device".to_string(),
1562 store_dir.clone(),
1563 "Test Store".to_string(),
1564 );
1565 let config_provider: ConfigProvider = Arc::new(move || config.clone());
1566 CovenHandle::new(
1567 db.clone(),
1568 db.clone(),
1572 db.stamper(),
1573 store_dir.clone(),
1574 config_provider,
1575 StoreKeys::new(store_id.to_string()),
1576 key_custody,
1577 test_identity_custody(),
1578 Arc::new(SystemClock),
1579 None,
1580 None,
1581 StoreOpenGuard::acquire_for_test(&store_dir),
1582 )
1583 }
1584
1585 impl TestCloudKitOps {
1586 fn new() -> Self {
1587 Self {
1588 store: Mutex::new(HashMap::new()),
1589 shares: Mutex::new(HashMap::new()),
1590 batches: Mutex::new(HashMap::new()),
1591 next_batch: AtomicUsize::new(0),
1592 }
1593 }
1594 }
1595
1596 impl CloudKitOps for TestCloudKitOps {
1597 fn provider_identity(
1598 &self,
1599 scope: &CloudKitScope,
1600 ) -> Result<CloudKitProviderIdentity, CloudHomeError> {
1601 let (owner_name, zone_name) = match scope {
1602 CloudKitScope::Private => ("test-owner", "test-zone"),
1603 CloudKitScope::Shared {
1604 owner_name,
1605 zone_name,
1606 } => (owner_name.as_str(), zone_name.as_str()),
1607 };
1608 Ok(CloudKitProviderIdentity {
1609 container_id: "iCloud.test.coven".to_string(),
1610 environment: crate::CloudKitEnvironment::Development,
1611 owner_name: owner_name.to_string(),
1612 zone_name: zone_name.to_string(),
1613 current_user_record_name: "test-user".to_string(),
1614 })
1615 }
1616
1617 fn accepted_read_write_share(
1618 &self,
1619 _scope: &CloudKitScope,
1620 ) -> Result<CloudKitAcceptedShareRecord, CloudHomeError> {
1621 Err(CloudHomeError::NotFound(
1622 "accepted CloudKit share".to_string(),
1623 ))
1624 }
1625
1626 fn write_record(
1627 &self,
1628 scope: &CloudKitScope,
1629 key: &str,
1630 data: Vec<u8>,
1631 ) -> Result<(), CloudHomeError> {
1632 let mut store = self.store.lock().unwrap();
1633 let coordinate = (scope.clone(), key.to_string());
1634 let version = store.get(&coordinate).map_or(1, |(_, version)| version + 1);
1635 store.insert(coordinate, (data, version));
1636 Ok(())
1637 }
1638
1639 fn read_record(&self, scope: &CloudKitScope, key: &str) -> Result<Vec<u8>, CloudHomeError> {
1640 self.store
1641 .lock()
1642 .unwrap()
1643 .get(&(scope.clone(), key.to_string()))
1644 .map(|(bytes, _)| bytes.clone())
1645 .ok_or_else(|| CloudHomeError::NotFound(key.to_string()))
1646 }
1647
1648 fn list_records(
1649 &self,
1650 scope: &CloudKitScope,
1651 prefix: &str,
1652 ) -> Result<Vec<String>, CloudHomeError> {
1653 Ok(self
1654 .store
1655 .lock()
1656 .unwrap()
1657 .keys()
1658 .filter(|(stored_scope, key)| stored_scope == scope && key.starts_with(prefix))
1659 .map(|(_, key)| key.clone())
1660 .collect())
1661 }
1662
1663 fn delete_record(&self, scope: &CloudKitScope, key: &str) -> Result<(), CloudHomeError> {
1664 self.store
1665 .lock()
1666 .unwrap()
1667 .remove(&(scope.clone(), key.to_string()));
1668 Ok(())
1669 }
1670
1671 fn record_exists(&self, scope: &CloudKitScope, key: &str) -> Result<bool, CloudHomeError> {
1672 Ok(self
1673 .store
1674 .lock()
1675 .unwrap()
1676 .contains_key(&(scope.clone(), key.to_string())))
1677 }
1678
1679 fn read_versioned_record(
1680 &self,
1681 scope: &CloudKitScope,
1682 key: &str,
1683 ) -> Result<crate::storage::cloud::CloudVersionedHead, CloudHomeError> {
1684 let store = self.store.lock().unwrap();
1685 let (bytes, version) = store
1686 .get(&(scope.clone(), key.to_string()))
1687 .ok_or_else(|| CloudHomeError::NotFound(key.to_string()))?;
1688 Ok(crate::storage::cloud::CloudVersionedHead {
1689 bytes: bytes.clone(),
1690 version: crate::storage::cloud::CloudHeadVersion::from_provider(
1691 version.to_string(),
1692 )?,
1693 })
1694 }
1695
1696 fn create_record(
1697 &self,
1698 scope: &CloudKitScope,
1699 key: &str,
1700 data: Vec<u8>,
1701 ) -> Result<
1702 crate::storage::cloud::CloudVersionedHead,
1703 crate::storage::cloud::CloudHeadCreateError,
1704 > {
1705 let mut store = self.store.lock().unwrap();
1706 let coordinate = (scope.clone(), key.to_string());
1707 if store.contains_key(&coordinate) {
1708 return Err(crate::storage::cloud::CloudHeadCreateError::AlreadyExists);
1709 }
1710 store.insert(coordinate, (data.clone(), 1));
1711 Ok(crate::storage::cloud::CloudVersionedHead {
1712 bytes: data,
1713 version: crate::storage::cloud::CloudHeadVersion::from_provider("1".to_string())?,
1714 })
1715 }
1716
1717 fn begin_atomic_create(
1718 &self,
1719 _scope: &CloudKitScope,
1720 ) -> Result<CloudKitAtomicCreateBatch, CloudHomeError> {
1721 let batch = CloudKitAtomicCreateBatch::from_provider(format!(
1722 "handle-batch-{}",
1723 self.next_batch.fetch_add(1, Ordering::SeqCst)
1724 ))?;
1725 self.batches
1726 .lock()
1727 .unwrap()
1728 .insert(batch.as_provider().to_string(), Vec::new());
1729 Ok(batch)
1730 }
1731
1732 fn stage_atomic_create_record(
1733 &self,
1734 _scope: &CloudKitScope,
1735 batch: &CloudKitAtomicCreateBatch,
1736 create: CloudKitRecordCreate,
1737 ) -> Result<(), CloudHomeError> {
1738 self.batches
1739 .lock()
1740 .unwrap()
1741 .get_mut(batch.as_provider())
1742 .ok_or_else(|| CloudHomeError::NotFound(batch.as_provider().to_string()))?
1743 .push(create);
1744 Ok(())
1745 }
1746
1747 fn commit_atomic_create(
1748 &self,
1749 scope: &CloudKitScope,
1750 batch: &CloudKitAtomicCreateBatch,
1751 ) -> Result<Vec<CloudKitRecordVersion>, CloudHomeError> {
1752 let mut batches = self.batches.lock().unwrap();
1753 let creates = batches
1754 .get(batch.as_provider())
1755 .ok_or_else(|| CloudHomeError::NotFound(batch.as_provider().to_string()))?;
1756 let mut store = self.store.lock().unwrap();
1757 for create in creates {
1758 if store.contains_key(&(scope.clone(), create.key.clone())) {
1759 return Err(CloudHomeError::AlreadyExists(create.key.clone()));
1760 }
1761 }
1762 let creates = batches
1763 .remove(batch.as_provider())
1764 .expect("validated handle CloudKit batch disappeared");
1765 let mut created = Vec::with_capacity(creates.len());
1766 for create in creates {
1767 store.insert((scope.clone(), create.key.clone()), (create.data, 1));
1768 created.push(CloudKitRecordVersion {
1769 key: create.key,
1770 version: crate::storage::cloud::CloudHeadVersion::from_provider(
1771 "1".to_string(),
1772 )?,
1773 });
1774 }
1775 Ok(created)
1776 }
1777
1778 fn discard_atomic_create(
1779 &self,
1780 _scope: &CloudKitScope,
1781 batch: &CloudKitAtomicCreateBatch,
1782 ) -> Result<(), CloudHomeError> {
1783 self.batches.lock().unwrap().remove(batch.as_provider());
1784 Ok(())
1785 }
1786
1787 fn replace_record(
1788 &self,
1789 scope: &CloudKitScope,
1790 key: &str,
1791 expected: &crate::storage::cloud::CloudHeadVersion,
1792 data: Vec<u8>,
1793 ) -> Result<
1794 crate::storage::cloud::CloudVersionedHead,
1795 crate::storage::cloud::CloudHeadReplaceError,
1796 > {
1797 let mut store = self.store.lock().unwrap();
1798 let coordinate = (scope.clone(), key.to_string());
1799 let Some((_, current)) = store.get(&coordinate) else {
1800 return Err(
1801 crate::storage::cloud::CloudHomeError::NotFound(key.to_string()).into(),
1802 );
1803 };
1804 if expected.as_provider() != current.to_string() {
1805 return Err(crate::storage::cloud::CloudHeadReplaceError::VersionMismatch);
1806 }
1807 let version = current + 1;
1808 store.insert(coordinate, (data.clone(), version));
1809 Ok(crate::storage::cloud::CloudVersionedHead {
1810 bytes: data,
1811 version: crate::storage::cloud::CloudHeadVersion::from_provider(
1812 version.to_string(),
1813 )?,
1814 })
1815 }
1816
1817 fn delete_record_versions(
1818 &self,
1819 scope: &CloudKitScope,
1820 exact_records: &[CloudKitRecordVersion],
1821 ) -> Result<(), CloudHomeError> {
1822 let mut store = self.store.lock().unwrap();
1823 for record in exact_records {
1824 let coordinate = (scope.clone(), record.key.clone());
1825 let (_, version) = store
1826 .get(&coordinate)
1827 .ok_or_else(|| CloudHomeError::NotFound(record.key.clone()))?;
1828 if version.to_string() != record.version.as_provider() {
1829 return Err(CloudHomeError::Transport(format!(
1830 "handle CloudKit record {:?} changed before exact deletion",
1831 record.key
1832 )));
1833 }
1834 }
1835 for record in exact_records {
1836 store.remove(&(scope.clone(), record.key.clone()));
1837 }
1838 Ok(())
1839 }
1840
1841 fn grant_share(&self, member_pubkey: &str) -> Result<CloudKitShare, CloudHomeError> {
1842 let share = CloudKitShare {
1843 share_url: format!("coven-test-share-{member_pubkey}"),
1844 owner_name: "owner".to_string(),
1845 zone_name: "zone".to_string(),
1846 };
1847 self.shares
1848 .lock()
1849 .unwrap()
1850 .insert(member_pubkey.to_string(), share.clone());
1851 Ok(share)
1852 }
1853
1854 fn share_for_member(
1855 &self,
1856 member_pubkey: &str,
1857 ) -> Result<Option<CloudKitShare>, CloudHomeError> {
1858 Ok(self.shares.lock().unwrap().get(member_pubkey).cloned())
1859 }
1860
1861 fn revoke_share(&self, member_pubkey: &str) -> Result<(), CloudHomeError> {
1862 self.shares.lock().unwrap().remove(member_pubkey);
1863 Ok(())
1864 }
1865
1866 fn accept_share(&self, _share_url: &str) -> Result<CloudKitShare, CloudHomeError> {
1867 Ok(CloudKitShare {
1868 share_url: "coven-test-share".to_string(),
1869 owner_name: "owner".to_string(),
1870 zone_name: "zone".to_string(),
1871 })
1872 }
1873 }
1874
1875 #[tokio::test]
1881 async fn test_home_drives_drain_and_read_through_the_handle() {
1882 let local = tokio::task::LocalSet::new();
1883 local
1884 .run_until(async {
1885 tokio::task::spawn_local(run_test_home_drives_drain_and_read_through_the_handle())
1886 .await
1887 .expect("test-home handle task");
1888 })
1889 .await;
1890 }
1891
1892 async fn run_test_home_drives_drain_and_read_through_the_handle() {
1893 test_keyring::install();
1894
1895 let (_tmp, store_dir) = temp_store_dir();
1896 let db = host_blob_test_db("images");
1900
1901 let mut config = Config::with_defaults(
1904 "lib-test".to_string(),
1905 "test-device".to_string(),
1906 store_dir.clone(),
1907 "Test Store".to_string(),
1908 );
1909 config.cloud_home.storage = HomeStorage::Opaque;
1910 let config_provider: ConfigProvider = {
1911 let config = config.clone();
1912 Arc::new(move || config.clone())
1913 };
1914 let upload_pause = Arc::new(PausedUploadDrain::new());
1915 let signer = crate::keys::UserKeypair::generate();
1916 let store = TestStore::create(&db, "lib-test", signer.clone())
1917 .await
1918 .expect("create exact test Store");
1919 let identity_custody = crate::identity_custody::IdentityCustody::InMemory(signer)
1920 .resolve("lib-test", &store_dir);
1921
1922 let stamper = db.stamper();
1923 let handle = CovenHandle::new(
1924 db.clone(),
1925 db.clone(),
1929 stamper,
1930 store_dir.clone(),
1931 config_provider,
1932 StoreKeys::new("lib-test".to_string()),
1933 test_key_custody(),
1934 identity_custody,
1935 Arc::new(SystemClock),
1936 None,
1937 Some(upload_pause.clone()),
1938 StoreOpenGuard::acquire_for_test(&store_dir),
1939 );
1940
1941 let home = store.home.clone();
1943 handle
1944 .connect_sync_with_test_home(
1945 home.clone(),
1946 CloudCipher::Encrypted(EncryptionService::from_key([42; 32])),
1947 )
1948 .await
1949 .expect("connect over the injected test home");
1950
1951 let plaintext = b"cover-art-bytes-for-the-test-home".to_vec();
1954 queue_host_blob(&handle, "cover-1", "cover-cover-1.jpg", &plaintext, false).await;
1955 handle
1956 .make_remote("notes", "note-cover-1", false)
1957 .await
1958 .expect("queue the exact row/blob transition");
1959 tokio::time::timeout(Duration::from_secs(20), upload_pause.reached.notified())
1960 .await
1961 .expect("the loop reaches the paused upload drain");
1962 let local = handle
1963 .row_blob_ref("note_photos", "cover-1")
1964 .await
1965 .expect("capture Local row while upload is paused");
1966 assert!(
1967 matches!(local.authority(), crate::blob::RowBlobAuthority::Local),
1968 "the row stays Local until the exact upload completes",
1969 );
1970 assert!(local.stored().is_none());
1971
1972 upload_pause.resume();
1973 let outcome = handle
1974 .drain_uploads()
1975 .await
1976 .expect("drain the prepared exact blob through the public handle");
1977 assert_eq!(outcome.uploaded, 1);
1978 assert!(outcome.yielded_for_publish);
1979 assert!(outcome.failures.failures().is_empty());
1980
1981 let blob = handle
1982 .row_blob_ref("note_photos", "cover-1")
1983 .await
1984 .expect("capture Remote row after exact upload");
1985 let object = blob
1986 .stored()
1987 .expect("published blob has exact storage")
1988 .object();
1989 let exact = home
1990 .clone()
1991 .exact_slot_storage()
1992 .expect("test home supports exact object slots");
1993 let at_rest = exact
1994 .read_at(object.slot())
1995 .await
1996 .expect("the exact blob object exists");
1997 assert!(
1998 !at_rest.is_empty(),
1999 "the exact blob object contains its sealed payload",
2000 );
2001
2002 let read = handle
2005 .read_blob(&blob)
2006 .await
2007 .expect("read through the handle");
2008 assert_eq!(
2009 read, plaintext,
2010 "read_blob fetched the blob's plaintext from the injected test home",
2011 );
2012 }
2013
2014 #[tokio::test]
2015 async fn connected_manager_reuses_cloud_home_for_loop_storage() {
2016 test_keyring::install();
2017
2018 let (_tmp, store_dir) = temp_store_dir();
2019 let db = host_blob_test_db("images");
2020
2021 let mut config = Config::with_defaults(
2022 "lib-cloudkit-home-reuse".to_string(),
2023 "test-device".to_string(),
2024 store_dir.clone(),
2025 "Test Store".to_string(),
2026 );
2027 config.cloud_home.provider = Some(CloudProvider::CloudKit);
2028 config.cloud_home.storage = HomeStorage::Browsable;
2029 let config_provider: ConfigProvider = {
2030 let config = config.clone();
2031 Arc::new(move || config.clone())
2032 };
2033
2034 let handle = CovenHandle::new(
2035 db.clone(),
2036 db.clone(),
2040 db.stamper(),
2041 store_dir.clone(),
2042 config_provider,
2043 StoreKeys::new("lib-cloudkit-home-reuse".to_string()),
2044 test_key_custody(),
2045 test_identity_custody(),
2046 Arc::new(SystemClock),
2047 Some(Arc::new(TestCloudKitOps::new())),
2048 None,
2049 StoreOpenGuard::acquire_for_test(&store_dir),
2050 );
2051
2052 handle
2053 .connect_sync()
2054 .await
2055 .expect("connect sync over the test CloudKit driver");
2056
2057 let manager = handle
2058 .sync_manager()
2059 .expect("connect_sync installs a manager");
2060 let stored_home = manager.cloud_home().expect("manager stores cloud home");
2061 let loop_handle = manager
2062 .sync_loop_handle()
2063 .expect("connect_sync starts the sync loop");
2064
2065 assert!(
2066 std::ptr::addr_eq(stored_home.as_ref(), loop_handle.storage().cloud_home()),
2067 "the sync loop storage must wrap the same cloud home stored on the manager",
2068 );
2069 }
2070
2071 #[tokio::test]
2077 async fn read_only_handle_resolves_an_encrypted_cipher_through_custody() {
2078 let local = tokio::task::LocalSet::new();
2079 local
2080 .run_until(async {
2081 tokio::task::spawn_local(
2082 run_read_only_handle_resolves_an_encrypted_cipher_through_custody(),
2083 )
2084 .await
2085 .expect("encrypted read-only handle task");
2086 })
2087 .await;
2088 }
2089
2090 async fn run_read_only_handle_resolves_an_encrypted_cipher_through_custody() {
2091 test_keyring::install();
2092
2093 let store_id = "ro-encrypted-custody-test";
2094 let (_tmp, store_dir) = temp_store_dir();
2095 let db = host_blob_test_db("images");
2096
2097 let mut config = Config::with_defaults(
2098 store_id.to_string(),
2099 "test-device".to_string(),
2100 store_dir.clone(),
2101 "Test Store".to_string(),
2102 );
2103 config.cloud_home.provider = Some(CloudProvider::CloudKit);
2104 config.cloud_home.storage = HomeStorage::Opaque;
2105
2106 let custody = crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir);
2107 custody
2108 .persist(&crate::encryption::MasterKeyring::generate())
2109 .expect("establish a master key");
2110
2111 let identity_custody =
2114 crate::identity_custody::IdentityCustody::Keyring.resolve(store_id, &store_dir);
2115 identity_custody
2116 .persist(&crate::keys::UserKeypair::generate())
2117 .expect("establish this store's signing identity");
2118
2119 let ops = Arc::new(TestCloudKitOps::new());
2120 let key_service = StoreKeys::new(store_id.to_string());
2121 let config_provider: ConfigProvider = {
2122 let config = config.clone();
2123 Arc::new(move || config.clone())
2124 };
2125 let writer = CovenHandle::new(
2126 db.clone(),
2127 db.clone(),
2128 db.stamper(),
2129 store_dir.clone(),
2130 config_provider,
2131 key_service.clone(),
2132 custody.clone(),
2133 identity_custody.clone(),
2134 Arc::new(SystemClock),
2135 Some(ops.clone()),
2136 None,
2137 StoreOpenGuard::acquire_for_test(&store_dir),
2138 );
2139 writer
2140 .connect_sync_with_cloudkit(ops.clone())
2141 .await
2142 .expect("connect encrypted CloudKit writer");
2143 let plaintext = b"encrypted-cloud-blob-for-the-read-only-handle".to_vec();
2144 let blob = publish_host_blob(&writer, "cover-1", "cover-cover-1.jpg", &plaintext).await;
2145
2146 let config_provider: ConfigProvider = {
2147 let config = config.clone();
2148 Arc::new(move || config.clone())
2149 };
2150 let reader = crate::read_handle::CovenReadHandle::new(
2151 db,
2152 store_dir,
2153 config_provider,
2154 key_service,
2155 custody,
2156 identity_custody,
2157 Arc::new(SystemClock),
2158 Some(ops),
2159 );
2160
2161 let read = reader
2162 .read_blob(&blob)
2163 .await
2164 .expect("the read-only handle resolves the same cipher through custody");
2165 assert_eq!(
2166 read, plaintext,
2167 "the blob decrypts back to its original plaintext",
2168 );
2169 }
2170
2171 #[tokio::test]
2172 async fn sync_not_configured_is_typed() {
2173 let (_tmp, store_dir) = temp_store_dir();
2174 let db = read_test_db("images");
2175 let handle = test_handle("lib-no-sync", store_dir, db);
2176
2177 let result = handle.get_members().await;
2178
2179 assert!(matches!(result, Err(SyncError::NotConfigured)));
2180 }
2181
2182 #[tokio::test]
2186 async fn initialize_master_key_refuses_a_second_call() {
2187 test_keyring::install();
2188 let (_tmp, store_dir) = temp_store_dir();
2189 let db = read_test_db("images");
2190 let custody =
2191 crate::custody::KeyCustody::Keyring.resolve("lib-init-master-key-twice", &store_dir);
2192 let handle = test_handle_with_custody("lib-init-master-key-twice", store_dir, db, custody);
2193
2194 let fingerprint = handle
2195 .initialize_master_key()
2196 .expect("the first call establishes a master key");
2197 assert!(!fingerprint.is_empty());
2198 assert_eq!(
2199 handle.master_key_fingerprint().unwrap(),
2200 Some(fingerprint),
2201 "master_key_fingerprint reflects what initialize_master_key just established",
2202 );
2203
2204 let error = handle
2205 .initialize_master_key()
2206 .expect_err("a second call must refuse rather than generate over an existing key");
2207 assert!(matches!(
2208 error,
2209 crate::keys::MasterKeyError::AlreadyEstablished
2210 ));
2211 }
2212
2213 #[tokio::test]
2223 async fn initialize_master_key_seals_cloud_traffic_the_custody_path_reads_back() {
2224 let local = tokio::task::LocalSet::new();
2225 local
2226 .run_until(async {
2227 tokio::task::spawn_local(Box::pin(
2228 run_initialize_master_key_seals_cloud_traffic_the_custody_path_reads_back(),
2229 ))
2230 .await
2231 .expect("master-key cloud traffic test task");
2232 })
2233 .await;
2234 }
2235
2236 async fn run_initialize_master_key_seals_cloud_traffic_the_custody_path_reads_back() {
2237 test_keyring::install();
2238
2239 let (_tmp, store_dir) = temp_store_dir();
2240 let db = host_blob_test_db("images");
2241 let store_id = "lib-init-master-key-seals-traffic";
2242
2243 let mut config = Config::with_defaults(
2247 store_id.to_string(),
2248 "test-device".to_string(),
2249 store_dir.clone(),
2250 "Test Store".to_string(),
2251 );
2252 config.cloud_home.storage = HomeStorage::Opaque;
2253 let config_provider: ConfigProvider = {
2254 let config = config.clone();
2255 Arc::new(move || config.clone())
2256 };
2257
2258 let custody = crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir);
2259 let identity_custody =
2260 crate::identity_custody::IdentityCustody::Keyring.resolve(store_id, &store_dir);
2261 let handle = CovenHandle::new(
2262 db.clone(),
2263 db.clone(),
2264 db.stamper(),
2265 store_dir.clone(),
2266 config_provider,
2267 StoreKeys::new(store_id.to_string()),
2268 custody,
2269 identity_custody,
2270 Arc::new(SystemClock),
2271 None,
2272 None,
2273 StoreOpenGuard::acquire_for_test(&store_dir),
2274 );
2275
2276 handle
2277 .initialize_master_key()
2278 .expect("establish the master key before connecting");
2279 handle
2280 .initialize_identity()
2281 .expect("establish this store's identity before connecting");
2282
2283 let home = Arc::new(InMemoryCloudHome::new());
2288 handle
2289 .connect_sync_with_test_home_custody(home.clone())
2290 .await
2291 .expect("connect over the injected opaque home, resolving the cipher from custody");
2292
2293 let plaintext = b"cover-art-sealed-under-the-established-master-key".to_vec();
2296 let blob = publish_host_blob(&handle, "cover-1", "cover-cover-1.jpg", &plaintext).await;
2297 let cloud_key = blob
2298 .stored()
2299 .expect("published blob has exact storage")
2300 .object()
2301 .slot()
2302 .logical_key();
2303
2304 let at_rest = home.get(cloud_key).expect("the blob landed in the home");
2307 assert_ne!(
2308 at_rest, plaintext,
2309 "the master key sealed the upload — the bytes at rest are not the plaintext",
2310 );
2311 assert!(
2312 home.keys()
2313 .iter()
2314 .all(|k| home.get(k).as_deref() != Some(plaintext.as_slice())),
2315 "no object in the home holds the plaintext",
2316 );
2317
2318 let read = handle
2321 .read_blob(&blob)
2322 .await
2323 .expect("read through the handle");
2324 assert_eq!(
2325 read, plaintext,
2326 "read_blob decrypts the sealed blob back to its original plaintext",
2327 );
2328 }
2329
2330 #[tokio::test]
2331 async fn import_master_key_rejects_raw_hex() {
2332 let (_tmp, store_dir) = temp_store_dir();
2333 let db = read_test_db("images");
2334 let handle = test_handle("lib-import-master-key", store_dir, db);
2335
2336 let raw_hex = hex::encode([0x22u8; 32]);
2337 assert!(handle.import_master_key(&raw_hex).is_err());
2338 }
2339
2340 #[tokio::test]
2341 async fn import_master_key_accepts_the_current_serialized_keyring() {
2342 let (_tmp, store_dir) = temp_store_dir();
2343 let db = read_test_db("images");
2344 let handle = test_handle("lib-import-master-key", store_dir, db);
2345
2346 let keyring = crate::encryption::MasterKeyring::generate();
2347 let imported_fingerprint = handle
2348 .import_master_key(&keyring.to_serialized())
2349 .expect("import the serialized keyring");
2350 assert_eq!(imported_fingerprint, keyring.fingerprint());
2351 assert_eq!(
2352 handle.master_key_fingerprint().unwrap(),
2353 Some(imported_fingerprint),
2354 );
2355 }
2356
2357 #[tokio::test]
2360 async fn forget_master_key_clears_an_established_key_and_is_idempotent() {
2361 let (_tmp, store_dir) = temp_store_dir();
2362 let db = read_test_db("images");
2363 let handle = test_handle("lib-forget-master-key", store_dir, db);
2364
2365 assert!(handle.master_key_fingerprint().unwrap().is_some());
2366 handle
2367 .forget_master_key()
2368 .expect("forget an established key");
2369 assert!(handle.master_key_fingerprint().unwrap().is_none());
2370 handle
2371 .forget_master_key()
2372 .expect("forgetting an already-absent key is not an error");
2373 }
2374
2375 fn test_handle_with_real_identity(
2383 store_id: &str,
2384 store_dir: StoreDir,
2385 db: Database,
2386 ) -> CovenHandle {
2387 let config = Config::with_defaults(
2388 store_id.to_string(),
2389 "test-device".to_string(),
2390 store_dir.clone(),
2391 "Test Store".to_string(),
2392 );
2393 let config_provider: ConfigProvider = Arc::new(move || config.clone());
2394 CovenHandle::new(
2395 db.clone(),
2396 db.clone(),
2397 db.stamper(),
2398 store_dir.clone(),
2399 config_provider,
2400 StoreKeys::new(store_id.to_string()),
2401 test_key_custody(),
2402 crate::identity_custody::IdentityCustody::Keyring.resolve(store_id, &store_dir),
2403 Arc::new(SystemClock),
2404 None,
2405 None,
2406 StoreOpenGuard::acquire_for_test(&store_dir),
2407 )
2408 }
2409
2410 #[tokio::test]
2415 async fn initialize_identity_refuses_a_second_call() {
2416 test_keyring::install();
2417 let (_tmp, store_dir) = temp_store_dir();
2418 let db = read_test_db("images");
2419 let handle = test_handle_with_real_identity("lib-init-identity-twice", store_dir, db);
2420
2421 let pubkey = handle
2422 .initialize_identity()
2423 .expect("the first call establishes an identity");
2424 assert!(!pubkey.is_empty());
2425 assert_eq!(
2426 handle.get_user_pubkey().unwrap(),
2427 Some(pubkey),
2428 "get_user_pubkey reflects what initialize_identity just established",
2429 );
2430
2431 let error = handle
2432 .initialize_identity()
2433 .expect_err("a second call must refuse rather than generate over an existing identity");
2434 assert!(matches!(
2435 error,
2436 crate::keys::IdentityError::AlreadyEstablished
2437 ));
2438 }
2439
2440 #[tokio::test]
2444 async fn creating_two_stores_yields_two_different_identities() {
2445 test_keyring::install();
2446 let (_tmp_a, store_dir_a) = temp_store_dir();
2447 let (_tmp_b, store_dir_b) = temp_store_dir();
2448 let handle_a = test_handle_with_real_identity(
2449 "lib-two-stores-identity-a",
2450 store_dir_a,
2451 read_test_db("images"),
2452 );
2453 let handle_b = test_handle_with_real_identity(
2454 "lib-two-stores-identity-b",
2455 store_dir_b,
2456 read_test_db("images"),
2457 );
2458
2459 let pubkey_a = handle_a
2460 .initialize_identity()
2461 .expect("establish store a's identity");
2462 let pubkey_b = handle_b
2463 .initialize_identity()
2464 .expect("establish store b's identity");
2465
2466 assert_ne!(
2467 pubkey_a, pubkey_b,
2468 "two stores on one device must not share an identity",
2469 );
2470 assert_eq!(handle_a.get_user_pubkey().unwrap(), Some(pubkey_a));
2471 assert_eq!(handle_b.get_user_pubkey().unwrap(), Some(pubkey_b));
2472 }
2473
2474 #[tokio::test]
2482 async fn host_secret_round_trips_through_the_handle() {
2483 test_keyring::install();
2484 let (_tmp, store_dir) = temp_store_dir();
2485 let db = read_test_db("images");
2486 let handle = test_handle("lib-host-secret-round-trip", store_dir, db);
2487
2488 assert_eq!(
2489 handle.host_secret("discogs_api_key").expect("get"),
2490 None,
2491 "an unset host secret reads as absent",
2492 );
2493
2494 handle
2495 .set_host_secret("discogs_api_key", "the-discogs-key")
2496 .expect("set");
2497 assert_eq!(
2498 handle.host_secret("discogs_api_key").expect("get"),
2499 Some("the-discogs-key".to_string()),
2500 );
2501
2502 handle
2503 .delete_host_secret("discogs_api_key")
2504 .expect("delete");
2505 assert_eq!(
2506 handle
2507 .host_secret("discogs_api_key")
2508 .expect("get after delete"),
2509 None,
2510 );
2511 }
2512
2513 #[tokio::test]
2522 async fn seal_and_open_app_data_round_trip_through_the_handle() {
2523 test_keyring::install();
2524 let (_tmp, store_dir) = temp_store_dir();
2525 let db = read_test_db("images");
2526 let store_id = "lib-app-data-round-trip";
2527 let handle = test_handle_with_custody(
2528 store_id,
2529 store_dir.clone(),
2530 db,
2531 crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2532 );
2533 handle
2534 .initialize_master_key()
2535 .expect("establish the store's master key");
2536
2537 let sealed = handle
2538 .seal_app_data(b"entry-secret", b"row-42")
2539 .expect("seal under the established key");
2540 assert_ne!(
2541 sealed, b"entry-secret",
2542 "the sealed payload is not the plaintext",
2543 );
2544
2545 assert_eq!(
2546 handle.open_app_data(&sealed, b"row-42").unwrap(),
2547 b"entry-secret",
2548 "the handle opens what it sealed",
2549 );
2550
2551 let error = handle
2552 .open_app_data(&sealed, b"row-99")
2553 .expect_err("a different aad must not open the payload");
2554 assert!(matches!(error, SealError::Crypto(_)), "{error:?}");
2555 }
2556
2557 #[tokio::test]
2562 async fn open_app_data_round_trips_through_the_read_handle() {
2563 test_keyring::install();
2564 let (_tmp, store_dir) = temp_store_dir();
2565 let db = read_test_db("images");
2566 let store_id = "lib-app-data-read-handle";
2567
2568 let writer = test_handle_with_custody(
2569 store_id,
2570 store_dir.clone(),
2571 db.clone(),
2572 crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2573 );
2574 writer
2575 .initialize_master_key()
2576 .expect("establish the store's master key");
2577 let sealed = writer
2578 .seal_app_data(b"read-me-back", b"ctx")
2579 .expect("seal through the write handle");
2580
2581 let config_provider: ConfigProvider = {
2582 let config = Config::with_defaults(
2583 store_id.to_string(),
2584 "test-device".to_string(),
2585 store_dir.clone(),
2586 "Test Store".to_string(),
2587 );
2588 Arc::new(move || config.clone())
2589 };
2590 let reader = crate::read_handle::CovenReadHandle::new(
2591 db,
2592 store_dir.clone(),
2593 config_provider,
2594 StoreKeys::new(store_id.to_string()),
2595 crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2596 test_identity_custody(),
2597 Arc::new(SystemClock),
2598 None,
2599 );
2600
2601 assert_eq!(
2602 reader.open_app_data(&sealed, b"ctx").unwrap(),
2603 b"read-me-back",
2604 "the read handle opens what the write handle sealed",
2605 );
2606 }
2607
2608 #[tokio::test]
2614 async fn app_data_is_locked_when_no_master_key_is_established() {
2615 test_keyring::install();
2616 let (_tmp, store_dir) = temp_store_dir();
2617 let db = read_test_db("images");
2618 let store_id = "lib-app-data-locked";
2619 let handle = test_handle_with_custody(
2620 store_id,
2621 store_dir.clone(),
2622 db,
2623 crate::custody::KeyCustody::Keyring.resolve(store_id, &store_dir),
2624 );
2625 assert!(
2626 handle.master_key_fingerprint().unwrap().is_none(),
2627 "the store starts with no established master key",
2628 );
2629
2630 let seal_error = handle
2631 .seal_app_data(b"nothing to seal under", b"ctx")
2632 .expect_err("sealing a locked store must refuse");
2633 assert!(matches!(seal_error, SealError::Locked), "{seal_error:?}");
2634
2635 let open_error = handle
2636 .open_app_data(b"nothing to open with", b"ctx")
2637 .expect_err("opening on a locked store must refuse");
2638 assert!(matches!(open_error, SealError::Locked), "{open_error:?}");
2639 }
2640
2641 #[tokio::test]
2642 async fn plaintext_membership_operations_are_typed() {
2643 let local = tokio::task::LocalSet::new();
2644 local
2645 .run_until(async {
2646 tokio::task::spawn_local(run_plaintext_membership_operations_are_typed())
2647 .await
2648 .expect("plaintext membership test task");
2649 })
2650 .await;
2651 }
2652
2653 async fn run_plaintext_membership_operations_are_typed() {
2654 await_test_orchestration(tokio::spawn(async {
2655 test_keyring::install();
2656
2657 let (_tmp, store_dir) = temp_store_dir();
2658 let db = read_test_db("images");
2659 let handle = test_handle("lib-plaintext-membership", store_dir, db);
2660 handle
2661 .connect_sync_with_test_home(
2662 Arc::new(InMemoryCloudHome::new()),
2663 CloudCipher::Plaintext,
2664 )
2665 .await
2666 .expect("connect plaintext home");
2667
2668 let public_key_hex = hex::encode(crate::keys::UserKeypair::generate().public_key());
2669 let invite = handle
2670 .invite_member(&public_key_hex, None, MemberRole::Member)
2671 .await;
2672 let remove = handle.remove_member(&public_key_hex).await;
2673 let circle = handle.create_circle("Household").await;
2674
2675 assert!(matches!(invite, Err(SyncError::NotEncryptedHome)));
2676 assert!(matches!(remove, Err(SyncError::NotEncryptedHome)));
2677 assert!(matches!(
2678 circle,
2679 Err(SyncError::Circle(
2680 crate::sync::circle_ops::CircleOperationError::BrowsableStorage
2681 ))
2682 ));
2683 }))
2684 .await;
2685 }
2686
2687 async fn await_test_orchestration(task: tokio::task::JoinHandle<()>) {
2688 task.await.expect("test orchestration task completes");
2689 }
2690
2691 #[tokio::test]
2692 async fn create_circle_returns_after_merge_activation_is_materialized() {
2693 await_test_orchestration(tokio::spawn(async {
2694 test_keyring::install();
2695
2696 let (_tmp, store_dir) = temp_store_dir();
2697 let db = read_test_db("images");
2698 let keyring = crate::encryption::MasterKeyring::generate();
2699 let custody = crate::custody::KeyCustody::InMemory(keyring.clone())
2700 .resolve("lib-create-circle-merge", &store_dir);
2701 let handle =
2702 test_handle_with_custody("lib-create-circle-merge", store_dir, db.clone(), custody);
2703 handle
2704 .connect_sync_with_test_home(
2705 Arc::new(InMemoryCloudHome::new()),
2706 CloudCipher::Encrypted(EncryptionService::from(keyring)),
2707 )
2708 .await
2709 .expect("connect encrypted Merge home");
2710
2711 let circle_id = handle
2712 .create_circle("Household")
2713 .await
2714 .expect("create and activate circle");
2715
2716 assert_eq!(
2717 handle.get_circles().await.expect("read active circles"),
2718 vec![crate::CircleInfo {
2719 id: circle_id,
2720 name: "Household".to_string(),
2721 role: crate::CircleRole::Owner,
2722 }]
2723 );
2724 assert!(handle
2725 .get_circle_operations()
2726 .await
2727 .expect("read completed circle operations")
2728 .is_empty());
2729
2730 let circle = circle_id.to_string();
2731 db.call(move |conn| {
2732 let activated: i64 = conn.query_row(
2733 "SELECT COUNT(*) FROM circle_control_activations WHERE circle_id = ?1",
2734 [&circle],
2735 |row| row.get(0),
2736 )?;
2737 let active_access: i64 = conn.query_row(
2738 "SELECT COUNT(*) FROM circle_access_cache
2739 WHERE circle_id = ?1 AND disposition = 'active'",
2740 [&circle],
2741 |row| row.get(0),
2742 )?;
2743 let pending: i64 = conn.query_row(
2744 "SELECT COUNT(*) FROM circle_operations WHERE circle_id = ?1",
2745 [&circle],
2746 |row| row.get(0),
2747 )?;
2748 assert_eq!((activated, active_access, pending), (1, 1, 0));
2749 Ok::<_, crate::DbError>(())
2750 })
2751 .await
2752 .expect("read activated circle state");
2753 }))
2754 .await;
2755 }
2756
2757 #[tokio::test]
2758 async fn create_circle_returns_after_serial_activation_is_materialized() {
2759 await_test_orchestration(tokio::spawn(async {
2760 test_keyring::install();
2761
2762 let (_tmp, store_dir) = temp_store_dir();
2763 let db = crate::sync::test_helpers::open_serial_test_db();
2764 let keyring = crate::encryption::MasterKeyring::generate();
2765 let custody = crate::custody::KeyCustody::InMemory(keyring.clone())
2766 .resolve("lib-create-circle-serial", &store_dir);
2767 let handle = test_handle_with_custody(
2768 "lib-create-circle-serial",
2769 store_dir,
2770 db.clone(),
2771 custody,
2772 );
2773 let home = Arc::new(InMemoryCloudHome::new());
2774 handle
2775 .connect_sync_with_test_home_and_coordination(
2776 home.clone(),
2777 home,
2778 CloudCipher::Encrypted(EncryptionService::from(keyring)),
2779 )
2780 .await
2781 .expect("connect encrypted Serial home");
2782
2783 let circle_id = handle
2784 .create_circle("Household")
2785 .await
2786 .expect("create and activate Serial circle");
2787
2788 let circle = circle_id.to_string();
2789 db.call(move |conn| {
2790 let activated: i64 = conn.query_row(
2791 "SELECT COUNT(*) FROM circle_control_activations WHERE circle_id = ?1",
2792 [&circle],
2793 |row| row.get(0),
2794 )?;
2795 let pending: i64 = conn.query_row(
2796 "SELECT COUNT(*) FROM circle_operations WHERE circle_id = ?1",
2797 [&circle],
2798 |row| row.get(0),
2799 )?;
2800 let serial_positions: i64 = conn.query_row(
2801 "SELECT COUNT(*) FROM materialized_commits WHERE device_id = 'serial'",
2802 [],
2803 |row| row.get(0),
2804 )?;
2805 assert_eq!((activated, pending), (1, 0));
2806 assert!(serial_positions >= 1);
2807 Ok::<_, crate::DbError>(())
2808 })
2809 .await
2810 .expect("read activated Serial circle state");
2811 }))
2812 .await;
2813 }
2814
2815 #[tokio::test]
2816 async fn reconnect_sync_stops_the_previous_loop() {
2817 let local = tokio::task::LocalSet::new();
2818 local
2819 .run_until(async {
2820 tokio::task::spawn_local(run_reconnect_sync_stops_the_previous_loop())
2821 .await
2822 .expect("sync reconnect test task");
2823 })
2824 .await;
2825 }
2826
2827 async fn run_reconnect_sync_stops_the_previous_loop() {
2828 test_keyring::install();
2829
2830 let (_tmp, store_dir) = temp_store_dir();
2831 let db = read_test_db("images");
2832 let config = Config::with_defaults(
2833 "lib-reconnect-loop".to_string(),
2834 "test-device".to_string(),
2835 store_dir.clone(),
2836 "Test Store".to_string(),
2837 );
2838 let config_provider: ConfigProvider = {
2839 let config = config.clone();
2840 Arc::new(move || config.clone())
2841 };
2842 let handle = CovenHandle::new(
2843 db.clone(),
2844 db.clone(),
2848 db.stamper(),
2849 store_dir.clone(),
2850 config_provider,
2851 StoreKeys::new("lib-reconnect-loop".to_string()),
2852 test_key_custody(),
2853 test_identity_custody(),
2854 Arc::new(SystemClock),
2855 None,
2856 None,
2857 StoreOpenGuard::acquire_for_test(&store_dir),
2858 );
2859
2860 let home = Arc::new(InMemoryCloudHome::new());
2861 handle
2862 .connect_sync_with_test_home(home.clone(), CloudCipher::Plaintext)
2863 .await
2864 .expect("first connect over injected home");
2865 let first_loop = handle
2866 .sync_manager()
2867 .expect("first manager installed")
2868 .sync_loop_handle()
2869 .expect("first loop installed");
2870 assert!(first_loop.is_running(), "first loop starts running");
2871
2872 handle
2873 .connect_sync_with_test_home(home, CloudCipher::Plaintext)
2874 .await
2875 .expect("second connect over injected home");
2876 let replacement_loop = handle
2877 .sync_manager()
2878 .expect("replacement manager installed")
2879 .sync_loop_handle()
2880 .expect("replacement loop installed");
2881
2882 assert!(
2883 !first_loop.is_running(),
2884 "reconnect must stop the old loop before installing a replacement",
2885 );
2886 assert!(
2887 replacement_loop.is_running(),
2888 "reconnect leaves the replacement loop running",
2889 );
2890 }
2891
2892 #[tokio::test]
2893 async fn stopped_installed_loop_blocks_blob_transitions() {
2894 let local = tokio::task::LocalSet::new();
2895 local
2896 .run_until(async {
2897 tokio::task::spawn_local(run_stopped_installed_loop_blocks_blob_transitions())
2898 .await
2899 .expect("stopped-loop readiness test task");
2900 })
2901 .await;
2902 }
2903
2904 async fn run_stopped_installed_loop_blocks_blob_transitions() {
2905 test_keyring::install();
2906
2907 let (_tmp, store_dir) = temp_store_dir();
2908 let db = read_test_db("images");
2909 let config = Config::with_defaults(
2910 "lib-stopped-loop-readiness".to_string(),
2911 "test-device".to_string(),
2912 store_dir.clone(),
2913 "Test Store".to_string(),
2914 );
2915 let config_provider: ConfigProvider = {
2916 let config = config.clone();
2917 Arc::new(move || config.clone())
2918 };
2919 let handle = CovenHandle::new(
2920 db.clone(),
2921 db.clone(),
2925 db.stamper(),
2926 store_dir.clone(),
2927 config_provider,
2928 StoreKeys::new("lib-stopped-loop-readiness".to_string()),
2929 test_key_custody(),
2930 test_identity_custody(),
2931 Arc::new(SystemClock),
2932 None,
2933 None,
2934 StoreOpenGuard::acquire_for_test(&store_dir),
2935 );
2936
2937 handle
2938 .connect_sync_with_test_home(Arc::new(InMemoryCloudHome::new()), CloudCipher::Plaintext)
2939 .await
2940 .expect("connect over injected home");
2941 let manager = handle.sync_manager().expect("manager installed");
2942 let loop_handle = manager.sync_loop_handle().expect("loop installed");
2943
2944 loop_handle.stop().expect("stop installed loop");
2945
2946 let make_remote = manager.make_remote("notes", "note-1", false).await;
2947 assert!(matches!(make_remote, Err(MakeRemoteError::SyncNotReady)));
2948
2949 let (_cancel_tx, cancel_rx) = watch::channel(false);
2950 let make_local = manager
2951 .make_local("notes", "note-1", &HashMap::new(), &cancel_rx, None)
2952 .await;
2953 assert!(matches!(make_local, Err(MakeLocalError::SyncNotReady)));
2954 }
2955
2956 #[tokio::test]
2957 async fn encrypted_session_keeps_its_binding_after_config_changes() {
2958 let local = tokio::task::LocalSet::new();
2959 local
2960 .run_until(async {
2961 tokio::task::spawn_local(
2962 run_encrypted_session_keeps_its_binding_after_config_changes(),
2963 )
2964 .await
2965 .expect("encrypted-session binding test task");
2966 })
2967 .await;
2968 }
2969
2970 async fn run_encrypted_session_keeps_its_binding_after_config_changes() {
2971 test_keyring::install();
2972
2973 let (tmp, store_dir) = temp_store_dir();
2974 let db = host_blob_test_db("images");
2975
2976 let config = Config::with_defaults(
2977 "lib-test".to_string(),
2978 "test-device".to_string(),
2979 store_dir.clone(),
2980 "Test Store".to_string(),
2981 );
2982 let live_config = Arc::new(RwLock::new(config));
2983 let config_provider: ConfigProvider = {
2984 let live_config = live_config.clone();
2985 Arc::new(move || {
2986 live_config
2987 .read()
2988 .expect("test config lock is not poisoned")
2989 .clone()
2990 })
2991 };
2992
2993 let handle = CovenHandle::new(
2994 db.clone(),
2995 db.clone(),
2999 db.stamper(),
3000 store_dir.clone(),
3001 config_provider,
3002 StoreKeys::new("lib-test".to_string()),
3003 test_key_custody(),
3004 test_identity_custody(),
3005 Arc::new(SystemClock),
3006 None,
3007 None,
3008 StoreOpenGuard::acquire_for_test(&store_dir),
3009 );
3010
3011 let home = Arc::new(InMemoryCloudHome::new());
3012 handle
3013 .connect_sync_with_test_home(
3014 home.clone(),
3015 CloudCipher::Encrypted(EncryptionService::from_key([7u8; 32])),
3016 )
3017 .await
3018 .expect("connect encrypted injected home");
3019 let manager = handle.sync_manager().expect("sync manager installed");
3020 let loop_handle = manager.sync_loop_handle().expect("sync loop installed");
3021
3022 {
3023 let mut next_config = live_config
3024 .write()
3025 .expect("test config lock is not poisoned");
3026 next_config.store_id = "next-lib".to_string();
3027 next_config.store_dir = StoreDir::new(tmp.path().join("next-store"));
3028 next_config.cloud_home.storage = HomeStorage::Browsable;
3029 }
3030
3031 assert_eq!(loop_handle.config().store_id, "lib-test");
3032 assert_eq!(loop_handle.store_dir(), &store_dir);
3033 assert!(matches!(
3034 loop_handle.blob_path_scheme(),
3035 BlobPathScheme::Hashed
3036 ));
3037
3038 let rotated = EncryptionService::from_key([7u8; 32])
3039 .with_appended_generation(2, [8u8; 32])
3040 .expect("append generation");
3041 loop_handle
3042 .adopt_key_rotation_for_test(rotated)
3043 .expect("adopt encrypted generation");
3044 assert_eq!(
3045 loop_handle
3046 .current_encryption()
3047 .expect("session remains encrypted")
3048 .current_generation(),
3049 2,
3050 );
3051
3052 let plaintext = b"encrypted-drain-bytes-after-key-rotation".to_vec();
3053 let blob = publish_host_blob(&handle, "plain-cover", "plain-cover", &plaintext).await;
3054 let cloud_key = blob
3055 .stored()
3056 .expect("published blob has exact storage")
3057 .object()
3058 .slot()
3059 .logical_key();
3060 let stored = home.get(cloud_key).expect("uploaded cloud object");
3061 assert_ne!(
3062 stored.as_slice(),
3063 plaintext.as_slice(),
3064 "an encrypted session must never upload plaintext cloud bytes",
3065 );
3066
3067 let aad_context = |store_id: &str| {
3068 let mut context = Vec::new();
3069 context.extend_from_slice(&(store_id.len() as u64).to_le_bytes());
3070 context.extend_from_slice(store_id.as_bytes());
3071 context.extend_from_slice(&(cloud_key.len() as u64).to_le_bytes());
3072 context.extend_from_slice(cloud_key.as_bytes());
3073 context
3074 };
3075 let cipher = CloudCipher::Encrypted(
3076 loop_handle
3077 .current_encryption()
3078 .expect("session remains encrypted"),
3079 );
3080 assert_eq!(
3081 cipher
3082 .open(stored.clone(), &aad_context("lib-test"))
3083 .expect("open with the installed session binding"),
3084 plaintext,
3085 );
3086 assert!(
3087 cipher.open(stored, &aad_context("next-lib")).is_err(),
3088 "a later config must not change the installed session's store binding",
3089 );
3090 }
3091
3092 fn status_test_handle(store_id: &str) -> (tempfile::TempDir, CovenHandle) {
3093 let (tmp, store_dir) = temp_store_dir();
3094 let db = read_test_db("images");
3095 let config = Config::with_defaults(
3096 store_id.to_string(),
3097 "test-device".to_string(),
3098 store_dir.clone(),
3099 "Test Store".to_string(),
3100 );
3101 let config_provider: ConfigProvider = {
3102 let config = config.clone();
3103 Arc::new(move || config.clone())
3104 };
3105 let handle = CovenHandle::new(
3106 db.clone(),
3107 db.clone(),
3111 db.stamper(),
3112 store_dir.clone(),
3113 config_provider,
3114 StoreKeys::new(store_id.to_string()),
3115 test_key_custody(),
3116 test_identity_custody(),
3117 Arc::new(SystemClock),
3118 None,
3119 None,
3120 StoreOpenGuard::acquire_for_test(&store_dir),
3121 );
3122 (tmp, handle)
3123 }
3124
3125 #[tokio::test]
3128 async fn subscribed_host_sees_offline_checking_publishing_then_synchronized() {
3129 let local = tokio::task::LocalSet::new();
3130 local
3131 .run_until(async {
3132 tokio::task::spawn_local(
3133 run_subscribed_host_sees_offline_checking_publishing_then_synchronized(),
3134 )
3135 .await
3136 .expect("sync status sequence test task");
3137 })
3138 .await;
3139 }
3140
3141 async fn run_subscribed_host_sees_offline_checking_publishing_then_synchronized() {
3142 test_keyring::install();
3143
3144 let (_tmp, handle) = status_test_handle("lib-status-syncing");
3145 let mut rx = handle.subscribe_sync_status();
3146 assert_eq!(format!("{:?}", *rx.borrow()), "Offline");
3147
3148 let home = InMemoryCloudHome::new();
3149 let (probe_reached, release_probe) = home.pause_next_probe();
3150 handle
3151 .connect_sync_with_test_home(Arc::new(home.clone()), CloudCipher::Plaintext)
3152 .await
3153 .expect("connect over injected home");
3154
3155 tokio::time::timeout(Duration::from_secs(20), probe_reached.notified())
3156 .await
3157 .expect("the reachability probe reaches its test pause");
3158 assert_eq!(format!("{:?}", *rx.borrow()), "CheckingStorage");
3159
3160 let (publication_reached, release_publication) = home.pause_after_exact_create_call(1);
3161 release_probe.notify_one();
3162 tokio::time::timeout(Duration::from_secs(20), publication_reached.notified())
3163 .await
3164 .expect("publication reaches its test pause");
3165 let publishing = rx.borrow().clone();
3166 assert_eq!(format!("{publishing:?}"), "Publishing");
3167
3168 release_publication.notify_one();
3169 tokio::time::timeout(Duration::from_secs(20), async {
3170 loop {
3171 if matches!(&*rx.borrow(), SyncLoopStatus::Synchronized(_)) {
3172 break;
3173 }
3174 rx.changed().await.expect("the status channel remains open");
3175 }
3176 })
3177 .await
3178 .expect("a synchronized status arrives within the timeout");
3179 let done = rx.borrow().clone();
3180 assert!(
3181 format!("{done:?}").starts_with("Synchronized("),
3182 "a successful cycle ends synchronized, got {done:?}",
3183 );
3184 }
3185
3186 #[tokio::test]
3187 async fn transport_failure_after_reachability_probe_returns_to_offline() {
3188 let local = tokio::task::LocalSet::new();
3189 local
3190 .run_until(async {
3191 tokio::task::spawn_local(
3192 run_transport_failure_after_reachability_probe_returns_to_offline(),
3193 )
3194 .await
3195 .expect("transport failure status test task");
3196 })
3197 .await;
3198 }
3199
3200 async fn run_transport_failure_after_reachability_probe_returns_to_offline() {
3201 test_keyring::install();
3202
3203 let (_tmp, handle) = status_test_handle("lib-status-cycle-transport");
3204 let mut rx = handle.subscribe_sync_status();
3205 let home = InMemoryCloudHome::new();
3206 let (probe_reached, release_probe) = home.pause_next_probe();
3207 handle
3208 .connect_sync_with_test_home(Arc::new(home.clone()), CloudCipher::Plaintext)
3209 .await
3210 .expect("connect over injected home");
3211
3212 tokio::time::timeout(Duration::from_secs(20), probe_reached.notified())
3213 .await
3214 .expect("the reachability probe reaches the provider");
3215 assert_eq!(format!("{:?}", *rx.borrow()), "CheckingStorage");
3216 home.arm_write_failures();
3217 release_probe.notify_one();
3218
3219 tokio::time::timeout(Duration::from_secs(20), async {
3220 loop {
3221 rx.changed().await.expect("the status channel remains open");
3222 match rx.borrow().clone() {
3223 SyncLoopStatus::CheckingStorage | SyncLoopStatus::Publishing => {}
3224 SyncLoopStatus::Offline => break,
3225 status => {
3226 panic!("a provider transport failure must end offline, got {status:?}")
3227 }
3228 }
3229 }
3230 })
3231 .await
3232 .expect("the failed cycle publishes a terminal status");
3233 }
3234
3235 #[tokio::test]
3240 async fn subscription_survives_a_reconnect() {
3241 let local = tokio::task::LocalSet::new();
3242 local
3243 .run_until(async {
3244 tokio::task::spawn_local(run_subscription_survives_a_reconnect())
3245 .await
3246 .expect("status subscription reconnect test task");
3247 })
3248 .await;
3249 }
3250
3251 async fn run_subscription_survives_a_reconnect() {
3252 test_keyring::install();
3253
3254 let (_tmp, handle) = status_test_handle("lib-status-reconnect");
3255
3256 let mut rx = handle.subscribe_sync_status();
3259 let home = Arc::new(InMemoryCloudHome::new());
3260
3261 handle
3262 .connect_sync_with_test_home(home.clone(), CloudCipher::Plaintext)
3263 .await
3264 .expect("first connect");
3265 handle
3268 .connect_sync_with_test_home(home, CloudCipher::Plaintext)
3269 .await
3270 .expect("reconnect");
3271
3272 tokio::time::timeout(Duration::from_secs(20), rx.changed())
3273 .await
3274 .expect("a status arrives from the post-reconnect loop")
3275 .expect("a reconnect does not close the handle-owned status channel");
3276 let status = rx.borrow().clone();
3277 assert!(
3278 matches!(
3279 status,
3280 SyncLoopStatus::CheckingStorage | SyncLoopStatus::Publishing
3281 ),
3282 "the received status is a cycle start marker, got {status:?}",
3283 );
3284 }
3285
3286 #[tokio::test]
3296 async fn disconnect_sync_drops_the_installed_manager_not_just_the_loop() {
3297 let local = tokio::task::LocalSet::new();
3298 local
3299 .run_until(async {
3300 tokio::task::spawn_local(
3301 run_disconnect_sync_drops_the_installed_manager_not_just_the_loop(),
3302 )
3303 .await
3304 .expect("disconnect-manager test task");
3305 })
3306 .await;
3307 }
3308
3309 async fn run_disconnect_sync_drops_the_installed_manager_not_just_the_loop() {
3310 test_keyring::install();
3311
3312 let (_tmp, store_dir) = temp_store_dir();
3313 let db = read_test_db("images");
3314 let handle = test_handle("lib-disconnect-drops-manager", store_dir, db);
3315
3316 handle
3317 .connect_sync_with_test_home(Arc::new(InMemoryCloudHome::new()), CloudCipher::Plaintext)
3318 .await
3319 .expect("connect over injected home");
3320 assert!(
3321 handle.sync_manager().is_some(),
3322 "connect installs a manager"
3323 );
3324
3325 handle.stop_sync();
3326 assert!(
3327 handle.sync_manager().is_some(),
3328 "stop_sync keeps the manager installed so start_sync can resume it",
3329 );
3330
3331 handle.disconnect_sync();
3332 assert!(
3333 handle.sync_manager().is_none(),
3334 "disconnect_sync drops the installed manager entirely — nothing it \
3335 cached survives past this call",
3336 );
3337 }
3338}