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