1use std::path::Path;
6use std::sync::Arc;
7
8use tokio::sync::watch;
9use tracing::{info, warn};
10
11use crate::config::{CloudProvider, Config, ConfigError, HomeStorage};
12use crate::database::Database;
13use crate::encryption::{EncryptionError, EncryptionService, MasterKeyring};
14use crate::identity_custody::IdentityCustody;
15use crate::join_code::{InviteCode, MembershipFloor};
16use crate::keys::{
17 CloudHomeCredentials, DeviceIdentityCustody, KeyError, MasterKeyCustody, StoreKeys, UserKeypair,
18};
19use crate::migration::{supported_version, Migration};
20use crate::storage::cloud::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
21use crate::store_dir::{StoreDir, StoreLayout};
22use crate::sync::cloud_storage::{BlobPathScheme, CloudCipher, CloudSyncStorage};
23use crate::sync::session::SyncedTable;
24use crate::sync::storage::SyncStorage;
25use crate::sync::store::{
26 bootstrap_from_snapshot, unwrap_store_keyring, BootstrapResult, InviteError, PullError,
27 SnapshotBlobReconcile, SnapshotError,
28};
29
30#[derive(Debug, thiserror::Error)]
36pub enum BootstrapError {
37 #[error("cloud home: {0}")]
38 CloudHome(#[from] CloudHomeError),
39 #[error("encryption: {0}")]
40 Encryption(#[from] EncryptionError),
41 #[error("invite: {0}")]
42 Invite(#[source] Box<InviteError>),
43 #[error("snapshot: {0}")]
44 Snapshot(#[from] SnapshotError),
45 #[error("pull: {0}")]
46 Pull(#[from] PullError),
47 #[error("Store pull: {0}")]
48 StorePull(#[from] coven_core::sync::store::StorePullError),
49 #[error("Store device registration: {0}")]
50 StoreRegistration(#[from] crate::sync::store::StoreRegistrationError),
51 #[error("Store device join: {0}")]
52 DeviceJoin(#[from] crate::DeviceJoinError),
53 #[error("Store device join transport: {0}")]
54 DeviceJoinTransport(#[from] crate::sync::store::DeviceJoinTransportError),
55 #[error("storage: {0}")]
56 Storage(#[from] crate::sync::storage::StorageError),
57 #[error("config: {0}")]
58 Config(#[from] ConfigError),
59 #[error("keyring: {0}")]
60 Key(#[from] KeyError),
61 #[error("I/O: {0}")]
62 Io(#[from] std::io::Error),
63 #[error("invalid code: {0}")]
64 InvalidCode(String),
65 #[error("store already exists locally: {0}")]
66 StoreExists(String),
67 #[error("could not clear a torn bootstrap for {store_id}: {failures}")]
71 TornBootstrapCleanup { store_id: String, failures: String },
72 #[error("could not remove cancelled join state for {store_id}: {failures}")]
73 CancelledJoinCleanup { store_id: String, failures: String },
74 #[error("provider: {0}")]
75 Provider(String),
76 #[error("membership: {0}")]
77 Membership(String),
78 #[error("{provider:?} cannot provide exact protocol and blob slots with this configuration")]
79 ExactSlotsUnavailable { provider: crate::CloudProvider },
80 #[error("database: {0}")]
81 Database(String),
82 #[error("invalid signing key: {0}")]
83 InvalidSigningKey(String),
84 #[error("the operation was cancelled")]
90 Cancelled,
91 #[error("could not clean up the partial store after bootstrap failed: {cleanup} (bootstrap error: {cause})")]
96 Cleanup {
97 cleanup: String,
98 cause: Box<BootstrapError>,
99 },
100}
101
102impl From<InviteError> for BootstrapError {
103 fn from(error: InviteError) -> Self {
104 Self::Invite(Box::new(error))
105 }
106}
107
108pub(crate) fn cleanup_after_bootstrap_failure(
117 store_dir: &StoreDir,
118 store_keys: &StoreKeys,
119 custody: &dyn MasterKeyCustody,
120 identity_custody: &dyn DeviceIdentityCustody,
121 cause: BootstrapError,
122) -> BootstrapError {
123 let failures = remove_bootstrap_residue(store_dir, store_keys, custody, identity_custody);
124 if failures.is_empty() {
125 cause
126 } else {
127 BootstrapError::Cleanup {
128 cleanup: failures.join("; "),
129 cause: Box::new(cause),
130 }
131 }
132}
133
134fn remove_bootstrap_residue(
147 store_dir: &StoreDir,
148 store_keys: &StoreKeys,
149 custody: &dyn MasterKeyCustody,
150 identity_custody: &dyn DeviceIdentityCustody,
151) -> Vec<String> {
152 let mut failures: Vec<String> = Vec::new();
153
154 match std::fs::remove_dir_all(&**store_dir) {
155 Ok(()) => {}
156 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
157 Err(e) => failures.push(format!("store directory: {e}")),
158 }
159
160 if let Err(e) = custody.forget() {
161 failures.push(format!("master key: {e}"));
162 }
163
164 if let Err(e) = identity_custody.forget() {
165 failures.push(format!("identity: {e}"));
166 }
167
168 if let Err(e) = store_keys.delete_cloud_home_credentials() {
169 failures.push(format!("cloud home credentials: {e}"));
170 }
171
172 failures
173}
174
175pub(crate) fn refuse_completed_or_clear_torn_store(
194 store_dir: &StoreDir,
195 store_keys: &StoreKeys,
196 custody: &dyn MasterKeyCustody,
197 identity_custody: &dyn DeviceIdentityCustody,
198 store_id: &str,
199) -> Result<(), BootstrapError> {
200 if store_dir.config_path().exists() {
201 return Err(BootstrapError::StoreExists(store_id.to_string()));
202 }
203
204 if store_dir.exists() {
205 warn!(
206 store_dir = %store_dir.display(),
207 "clearing a torn bootstrap: a store directory with no saved config, left by a join or restore a crash interrupted before completion"
208 );
209 let failures = remove_bootstrap_residue(store_dir, store_keys, custody, identity_custody);
212 if !failures.is_empty() {
213 return Err(BootstrapError::TornBootstrapCleanup {
214 store_id: store_id.to_string(),
215 failures: failures.join("; "),
216 });
217 }
218 }
219
220 Ok(())
221}
222
223fn error_if_cancelled(cancel: &watch::Receiver<bool>) -> Result<(), BootstrapError> {
228 if *cancel.borrow() {
229 Err(BootstrapError::Cancelled)
230 } else {
231 Ok(())
232 }
233}
234
235pub(crate) struct RestoreBootstrapContext<'a> {
238 pub founder_pubkey: &'a str,
239 pub keypair: &'a UserKeypair,
240 pub authority: &'a crate::sync::restore_code::RestoreAuthority,
241 pub continuation: Option<(
242 &'a crate::sync::restore_code::ActivatedContinuation,
243 &'a UserKeypair,
244 )>,
245}
246
247impl RestoreBootstrapContext<'_> {
248 fn owner_pubkey(&self) -> &str {
249 self.founder_pubkey
250 }
251
252 fn keypair(&self) -> &UserKeypair {
253 self.keypair
254 }
255
256 fn activated_continuation(
257 &self,
258 ) -> Option<(
259 &crate::sync::restore_code::ActivatedContinuation,
260 &UserKeypair,
261 &UserKeypair,
262 )> {
263 self.continuation
264 .map(|(continuation, device)| (continuation, self.keypair, device))
265 }
266
267 fn owner_recovery(
268 &self,
269 ) -> Option<(
270 &crate::sync::restore_code::OwnerRecoveryAuthority,
271 &UserKeypair,
272 )> {
273 match self.authority {
274 crate::sync::restore_code::RestoreAuthority::OwnerRecovery(authority) => {
275 Some((authority, self.keypair))
276 }
277 crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(_) => None,
278 }
279 }
280}
281
282#[cfg(feature = "oauth-providers")]
289pub(crate) fn persist_oauth_tokens(
290 ks: &StoreKeys,
291 tokens: &crate::oauth::OAuthTokens,
292) -> Result<(), KeyError> {
293 ks.set_cloud_home_oauth_tokens(tokens)
294}
295
296#[cfg(feature = "oauth-providers")]
297fn require_join_oauth(
298 oauth_tokens: Option<crate::oauth::OAuthTokens>,
299 provider_name: &str,
300) -> Result<crate::oauth::OAuthTokens, BootstrapError> {
301 oauth_tokens.ok_or_else(|| {
302 BootstrapError::Provider(format!("{provider_name} join requires an OAuth token"))
303 })
304}
305
306struct JoinCloudHome {
307 home: Arc<dyn CloudHome>,
308}
309
310async fn build_cloud_home_for_join(
311 join_info: &CloudHomeJoinInfo,
312 lib_ks: &StoreKeys,
313 oauth_tokens: Option<crate::oauth::OAuthTokens>,
314 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
315 clock: crate::clock::ClockRef,
316 custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
317) -> Result<JoinCloudHome, BootstrapError> {
318 use crate::storage::cloud::*;
319
320 #[cfg(not(feature = "oauth-providers"))]
321 let _ = (&lib_ks, &oauth_tokens, &clock);
322
323 match join_info {
324 CloudHomeJoinInfo::S3 {
325 bucket,
326 region,
327 endpoint,
328 access_key,
329 secret_key,
330 key_prefix,
331 } => {
332 let s3 = s3::S3CloudHome::new(
333 bucket.clone(),
334 region.clone(),
335 endpoint.clone(),
336 access_key.clone(),
337 secret_key.clone(),
338 key_prefix.clone(),
339 custom_s3_exact_slots,
340 )
341 .await?;
342 Ok(JoinCloudHome { home: Arc::new(s3) })
343 }
344 #[cfg(feature = "oauth-providers")]
345 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
346 let tokens = require_join_oauth(oauth_tokens, "Google Drive")?;
347 Ok(JoinCloudHome {
348 home: Arc::new(google_drive::GoogleDriveCloudHome::new(
349 folder_id.clone(),
350 tokens,
351 lib_ks.clone(),
352 clock,
353 )?),
354 })
355 }
356 #[cfg(feature = "oauth-providers")]
357 CloudHomeJoinInfo::Dropbox { folder_path } => {
358 let tokens = require_join_oauth(oauth_tokens, "Dropbox")?;
359 Ok(JoinCloudHome {
360 home: Arc::new(dropbox::DropboxCloudHome::new(
361 folder_path.clone(),
362 tokens,
363 lib_ks.clone(),
364 clock,
365 )?),
366 })
367 }
368 #[cfg(feature = "oauth-providers")]
369 CloudHomeJoinInfo::OneDrive {
370 drive_id,
371 folder_id,
372 } => {
373 let tokens = require_join_oauth(oauth_tokens, "OneDrive")?;
374 Ok(JoinCloudHome {
375 home: Arc::new(onedrive::OneDriveCloudHome::new(
376 drive_id.clone(),
377 folder_id.clone(),
378 tokens,
379 lib_ks.clone(),
380 clock,
381 )?),
382 })
383 }
384 #[cfg(not(feature = "oauth-providers"))]
385 CloudHomeJoinInfo::GoogleDrive { .. }
386 | CloudHomeJoinInfo::Dropbox { .. }
387 | CloudHomeJoinInfo::OneDrive { .. } => Err(BootstrapError::Provider(
388 "OAuth cloud providers are not supported in this build".to_string(),
389 )),
390 CloudHomeJoinInfo::CloudKit => {
391 let ops = cloudkit_ops.ok_or_else(|| {
392 BootstrapError::Provider("CloudKit driver not provided".to_string())
393 })?;
394 Ok(JoinCloudHome {
395 home: Arc::new(cloudkit::CloudKitCloudHome::new_private(ops)),
396 })
397 }
398 CloudHomeJoinInfo::CloudKitShare {
399 share_url,
400 owner_name,
401 zone_name,
402 } => {
403 let ops = cloudkit_ops.ok_or_else(|| {
404 BootstrapError::Provider("CloudKit driver not provided".to_string())
405 })?;
406 let accepted = cloudkit::accept_share(ops.clone(), share_url.clone()).await?;
407 if accepted.owner_name != *owner_name || accepted.zone_name != *zone_name {
408 return Err(BootstrapError::Provider(format!(
409 "CloudKit accepted share zone mismatch: invite owner/zone {owner_name}/{zone_name}, accepted {}/{}",
410 accepted.owner_name, accepted.zone_name
411 )));
412 }
413 let home = Arc::new(cloudkit::CloudKitCloudHome::new_shared(
414 ops.clone(),
415 owner_name.clone(),
416 zone_name.clone(),
417 ));
418 Ok(JoinCloudHome { home })
419 }
420 }
421}
422
423pub struct DeviceJoinClient {
427 code: InviteCode,
428 member_pubkey: String,
429 layout: StoreLayout,
430 synced_tables: Vec<SyncedTable>,
431 migrations: Vec<Migration>,
432 custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
433 custody: Arc<dyn MasterKeyCustody>,
434 identity_custody: Arc<dyn DeviceIdentityCustody>,
435 oauth_tokens: Option<crate::oauth::OAuthTokens>,
436 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
437 clock: crate::clock::ClockRef,
438 #[cfg(test)]
439 test_home: Option<Arc<dyn CloudHome>>,
440}
441
442struct DeviceJoinStorage {
443 storage: CloudSyncStorage,
444 exact: Arc<dyn crate::storage::cloud::ExactSlotStorage>,
445 keyring: MasterKeyring,
446}
447
448impl DeviceJoinClient {
449 #[allow(clippy::too_many_arguments)]
450 pub fn new(
451 invite_code: &str,
452 join_request_code: &str,
453 layout: StoreLayout,
454 synced_tables: Vec<SyncedTable>,
455 migrations: Vec<Migration>,
456 custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
457 key_custody: crate::custody::KeyCustody,
458 identity_custody: IdentityCustody,
459 oauth_tokens: Option<crate::oauth::OAuthTokens>,
460 cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
461 clock: crate::clock::ClockRef,
462 ) -> Result<Self, BootstrapError> {
463 let code = crate::join_code::decode(invite_code)
464 .map_err(|error| BootstrapError::InvalidCode(error.to_string()))?;
465 let member_pubkey = crate::join_code::decode_join_request(join_request_code)
466 .map_err(|error| BootstrapError::InvalidCode(error.to_string()))?
467 .public_key;
468 crate::storage::cloud::setup::require_exact_slot_capabilities_join_info(
469 &code.join_info,
470 custom_s3_exact_slots,
471 )
472 .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
473 crate::store_dir::validate_path_token(&code.store_id)
474 .map_err(|error| BootstrapError::InvalidCode(format!("invalid store id: {error}")))?;
475 let store_dir = layout.store_dir(&code.store_id);
476 let custody = key_custody.resolve(&code.store_id, &store_dir);
477 let identity_custody = identity_custody.resolve(&code.store_id, &store_dir);
478 Ok(Self {
479 code,
480 member_pubkey,
481 layout,
482 synced_tables,
483 migrations,
484 custom_s3_exact_slots,
485 custody,
486 identity_custody,
487 oauth_tokens,
488 cloudkit_ops,
489 clock,
490 #[cfg(test)]
491 test_home: None,
492 })
493 }
494
495 #[cfg(test)]
496 pub(crate) fn with_test_bootstrap_home(mut self, home: Arc<dyn CloudHome>) -> Self {
497 self.test_home = Some(home);
498 self
499 }
500
501 pub async fn prepare_provider_access_request(
502 &self,
503 offer: crate::DeviceJoinOffer,
504 ) -> Result<crate::DeviceProviderAccessRequest, BootstrapError> {
505 self.require_offer(&offer)?;
506 let signer = crate::keys::peek_pending_identity(&offer.member_pubkey)?;
507 let cloud = self.build_cloud_home().await?;
508 let exact = cloud.home.clone().exact_slot_storage().ok_or_else(|| {
509 BootstrapError::Provider("provider has no exact-slot adapter".to_string())
510 })?;
511 let binding = exact
512 .provider_binding()
513 .await
514 .map_err(BootstrapError::CloudHome)?;
515 let pending = self.open_pending_journal()?;
516 Ok(crate::sync::store::prepare_device_provider_access_request(
517 &pending, binding, &signer, offer,
518 )
519 .await?)
520 }
521
522 pub async fn accept_device_join_abandonment(
523 &self,
524 abandonment: crate::DeviceJoinAbandonment,
525 ) -> Result<crate::DeviceJoinAbandonment, BootstrapError> {
526 let signer = crate::keys::peek_pending_identity(&self.member_pubkey)?;
527 let join = self.build_storage(&signer).await?;
528 let pending = self.open_pending_journal()?;
529 Ok(crate::sync::store::observe_device_join_abandonment(
530 &pending,
531 &join.storage,
532 &self.code.store_root,
533 abandonment,
534 )
535 .await?)
536 }
537
538 pub fn device_join_status(
539 &self,
540 attempt_id: crate::DeviceJoinAttemptId,
541 ) -> Result<Option<crate::DeviceJoinStatus>, BootstrapError> {
542 let pending = self.open_pending_journal()?;
543 Ok(crate::sync::store::load_pending_device_join_status(
544 &pending, attempt_id,
545 )?)
546 }
547
548 pub fn resume_device_joins(&self) -> Result<Vec<crate::DeviceJoinAction>, BootstrapError> {
549 let pending = self.open_pending_journal()?;
550 Ok(crate::sync::store::load_pending_device_join_actions(
551 &pending,
552 )?)
553 }
554
555 pub async fn close_pending_device_join(
556 &self,
557 cancellation: crate::DeviceJoinCancellation,
558 ) -> Result<crate::JoinerJoinTerminal, BootstrapError> {
559 let signer = crate::keys::peek_pending_identity(&self.member_pubkey)?;
560 let join = self.build_storage(&signer).await?;
561 let pending = self.open_pending_journal()?;
562 Ok(crate::sync::store::close_joining_device(
563 &pending,
564 &join.storage,
565 join.exact.as_ref(),
566 &self.code.store_root,
567 &signer,
568 cancellation,
569 )
570 .await?)
571 }
572
573 pub async fn complete_cancelled_device_join(
574 &self,
575 activation: crate::DeviceJoinCleanupActivation,
576 ) -> Result<(), BootstrapError> {
577 let pending = self.open_pending_journal()?;
578 match crate::sync::store::load_pending_device_join_status(
579 &pending,
580 activation.receipt.attempt_id,
581 )? {
582 Some(crate::DeviceJoinStatus::CleanupActivated {
583 activation: durable,
584 }) if durable == activation => {}
585 Some(crate::DeviceJoinStatus::CleanupActivated { .. }) => {
586 return Err(crate::DeviceJoinError::JournalConflict.into());
587 }
588 _ => {
589 let signer = crate::keys::peek_pending_identity(&self.member_pubkey)?;
590 let join = self.build_storage(&signer).await?;
591 crate::sync::store::accept_joiner_device_join_cleanup(
592 &pending,
593 &join.storage,
594 &self.code.store_root,
595 activation.clone(),
596 )
597 .await?;
598 }
599 }
600
601 let store_dir = self.layout.store_dir(&self.code.store_id);
602 if store_dir.config_path().exists() {
603 return Err(BootstrapError::StoreExists(self.code.store_id.clone()));
604 }
605 let failures = remove_bootstrap_residue(
606 &store_dir,
607 &StoreKeys::new(self.code.store_id.clone()),
608 self.custody.as_ref(),
609 self.identity_custody.as_ref(),
610 );
611 if !failures.is_empty() {
612 return Err(BootstrapError::CancelledJoinCleanup {
613 store_id: self.code.store_id.clone(),
614 failures: failures.join("; "),
615 });
616 }
617 crate::keys::discard_pending_identity(&self.member_pubkey)?;
618 crate::sync::store::complete_joiner_device_join_cleanup(&pending, activation)?;
619 Ok(())
620 }
621
622 pub async fn prepare_registration_request(
623 &self,
624 approval: crate::DeviceProviderAdmissionApproval,
625 ) -> Result<crate::DeviceRegistrationRequest, BootstrapError> {
626 let offer = &approval.request.offer;
627 self.require_offer(offer)?;
628 let signer = crate::keys::peek_pending_identity(&offer.member_pubkey)?;
629 let join = self.build_storage(&signer).await?;
630 let pending = self.open_pending_journal()?;
631 Ok(crate::sync::store::prepare_device_registration_request(
632 &pending,
633 &join.storage,
634 Some(join.exact.as_ref()),
635 &signer,
636 approval,
637 )
638 .await?)
639 }
640
641 pub async fn bootstrap_pending_device(
642 &self,
643 bootstrap: crate::ProviderReadyDeviceBootstrap,
644 on_status: impl Fn(&str),
645 cancel: &watch::Receiver<bool>,
646 ) -> Result<crate::sync::store::DeviceJoinReadiness, BootstrapError> {
647 let offer = &bootstrap.bootstrap.request.approval.request.offer;
648 self.require_offer(offer)?;
649 let attempt = &bootstrap.bootstrap.publication_authorization.attempt;
650 let pending = self.open_pending_journal()?;
651 if let Some(record) = pending.load(attempt.attempt_id, crate::DeviceJoinRole::Joiner)? {
652 if let crate::sync::store::DeviceJoinRoleProgress::Joiner(
653 crate::sync::store::JoinerJoinProgress::Ready(readiness),
654 ) = &*record.progress
655 {
656 if readiness.proof.attempt != *attempt {
657 return Err(crate::DeviceJoinError::JournalConflict.into());
658 }
659 let store_dir = self.layout.store_dir(&self.code.store_id);
660 if store_dir.db_path().exists() {
661 return Ok(readiness.clone());
662 }
663 }
664 }
665 error_if_cancelled(cancel)?;
666 let signer = crate::keys::peek_pending_identity(&offer.member_pubkey)?;
667 let join = self.build_storage(&signer).await?;
668 let store_dir = self.layout.store_dir(&self.code.store_id);
669 if store_dir.config_path().exists() {
670 return Err(BootstrapError::StoreExists(self.code.store_id.clone()));
671 }
672 std::fs::create_dir_all(&*store_dir)?;
673 on_status("Downloading store snapshot...");
674 let db_path = store_dir.db_path();
675 let snapshot = bootstrap_from_snapshot(
676 &join.storage,
677 &self.code.store_id,
678 offer.store_root.clone(),
679 &self.code.membership_floor,
680 supported_version(&self.migrations),
681 &db_path,
682 )
683 .await?;
684 let routing_encryption = EncryptionService::from(join.keyring.clone());
685 let device_id = bootstrap
686 .bootstrap
687 .request
688 .expected_registration
689 .device_id
690 .to_string();
691 let db = snapshot
692 .open_database(
693 &self.code.store_id,
694 &db_path,
695 self.synced_tables.clone(),
696 crate::blob::delete::BLOB_TOMBSTONE_GRACE,
697 crate::blob::TransferLimits::one_at_a_time(),
698 device_id,
699 &self.migrations,
700 Some(&routing_encryption),
701 &join.storage,
702 &signer,
703 )
704 .await?;
705 let published_at = self.clock.now().to_rfc3339();
706 on_status("Installing device registration...");
707 let database = crate::sync::store::StoreDatabase::from_database(db.clone());
708 let readiness = crate::sync::store::bootstrap_joining_device(
709 &database,
710 &pending,
711 &join.storage,
712 Some(join.exact.as_ref()),
713 &signer,
714 bootstrap,
715 &published_at,
716 )
717 .await?;
718 match crate::sync::store::reconcile_snapshot_blobs(
719 &database,
720 &db_path,
721 &join.storage,
722 &store_dir,
723 db.synced_tables(),
724 cancel,
725 )
726 .await
727 .map_err(|error| BootstrapError::Database(error.to_string()))?
728 {
729 SnapshotBlobReconcile::Complete => Ok(readiness),
730 SnapshotBlobReconcile::Incomplete => Err(BootstrapError::Database(
731 "snapshot blob reconciliation did not land every required eager blob".to_string(),
732 )),
733 SnapshotBlobReconcile::Cancelled => Err(BootstrapError::Cancelled),
734 }
735 }
736
737 pub async fn complete_device_join(
738 &self,
739 activation: crate::DeviceJoinActivation,
740 on_status: impl Fn(&str),
741 ) -> Result<Config, BootstrapError> {
742 let attempt_id = activation.outcome.attempt().attempt_id;
743 let pending = self.open_pending_journal()?;
744 let pending_record = pending.load(attempt_id, crate::DeviceJoinRole::Joiner)?;
745 let pending_readiness = match pending_record.as_ref().map(|record| &*record.progress) {
746 Some(crate::sync::store::DeviceJoinRoleProgress::Joiner(
747 crate::sync::store::JoinerJoinProgress::Ready(_),
748 )) => Some(crate::sync::store::observe_device_join_activation(
749 &pending,
750 &activation,
751 )?),
752 Some(crate::sync::store::DeviceJoinRoleProgress::Joiner(
753 crate::sync::store::JoinerJoinProgress::ActivationObserved { .. },
754 )) => Some(crate::sync::store::observe_device_join_activation(
755 &pending,
756 &activation,
757 )?),
758 Some(_) => return Err(crate::DeviceJoinError::JournalConflict.into()),
759 None => None,
760 };
761 let store_dir = self.layout.store_dir(&self.code.store_id);
762 let completed_config = if store_dir.config_path().exists() {
763 Some(Config::load_from_config_yaml(store_dir.clone())?)
764 } else {
765 None
766 };
767 if completed_config
768 .as_ref()
769 .is_some_and(|config| config.store_id != self.code.store_id)
770 {
771 return Err(crate::DeviceJoinError::JournalConflict.into());
772 }
773 let device_id = match (pending_readiness.as_ref(), completed_config.as_ref()) {
774 (Some(readiness), _) => readiness.proof.registration.device_id.to_string(),
775 (None, Some(config)) => config.device_id.clone(),
776 (None, None) => return Err(crate::DeviceJoinError::JournalConflict.into()),
777 };
778 let signer = match completed_config {
779 Some(_) => crate::keys::require_identity(self.identity_custody.as_ref())?,
780 None => crate::keys::peek_pending_identity(&self.member_pubkey)?,
781 };
782 let join = self.build_storage(&signer).await?;
783 let db_path = store_dir.db_path();
784 let (db, _stamper) = Database::open(
785 &db_path,
786 self.synced_tables.clone(),
787 crate::blob::delete::BLOB_TOMBSTONE_GRACE,
788 crate::blob::TransferLimits::one_at_a_time(),
789 device_id.clone(),
790 &self.migrations,
791 )
792 .map_err(|error| BootstrapError::Database(error.to_string()))?;
793 let database = crate::sync::store::StoreDatabase::from_database(db.clone());
794 let joined = crate::sync::store::materialize_joined_store_activation(
795 &database,
796 &join.storage,
797 activation.clone(),
798 )
799 .await?;
800 if pending_readiness
801 .as_ref()
802 .is_some_and(|readiness| joined.registration != readiness.proof.registration)
803 || joined.registration.device_id.to_string() != device_id
804 {
805 return Err(crate::DeviceJoinError::JournalConflict.into());
806 }
807 on_status("Saving configuration...");
808 self.custody.persist(&join.keyring)?;
809 crate::keys::import_identity(self.identity_custody.as_ref(), &signer.to_keypair_bytes())?;
810 let store_keys = StoreKeys::new(self.code.store_id.clone());
811 if let Some(credentials) = derive_credentials(&self.code.join_info) {
812 store_keys.set_cloud_home_credentials(&credentials)?;
813 }
814 #[cfg(feature = "oauth-providers")]
815 if let Some(tokens) = &self.oauth_tokens {
816 persist_oauth_tokens(&store_keys, tokens)?;
817 }
818 let cipher = CloudCipher::Encrypted(join.keyring.clone().into());
819 let mut config = build_config(
820 &self.code.store_id,
821 &device_id,
822 &store_dir,
823 &self.code.store_name,
824 &self.code.join_info,
825 &cipher,
826 );
827 if matches!(
828 self.code.join_info,
829 CloudHomeJoinInfo::S3 {
830 endpoint: Some(_),
831 ..
832 }
833 ) {
834 config.cloud_home.s3_exact_slots = self.custom_s3_exact_slots;
835 }
836 config.save_to_config_yaml()?;
837 crate::sync::store::complete_device_join(&database, &pending, &join.storage, activation)
838 .await?;
839 crate::keys::discard_pending_identity(&self.member_pubkey)?;
840 info!(store_id = %self.code.store_id, "joined Store device");
841 Ok(config)
842 }
843
844 fn require_offer(&self, offer: &crate::DeviceJoinOffer) -> Result<(), BootstrapError> {
845 if offer.store_root != self.code.store_root || offer.member_pubkey != self.member_pubkey {
846 return Err(crate::DeviceJoinError::OfferMismatch.into());
847 }
848 Ok(())
849 }
850
851 fn open_pending_journal(&self) -> Result<crate::DeviceJoinJournalDatabase, BootstrapError> {
852 let directory = self.layout.stores_root().join(".pending-device-joins");
853 std::fs::create_dir_all(&directory)?;
854 Ok(crate::DeviceJoinJournalDatabase::open(
855 directory.join(format!("{}.sqlite", self.code.store_id)),
856 )?)
857 }
858
859 async fn build_cloud_home(&self) -> Result<JoinCloudHome, BootstrapError> {
860 #[cfg(test)]
861 if let Some(home) = &self.test_home {
862 return Ok(JoinCloudHome { home: home.clone() });
863 }
864 build_cloud_home_for_join(
865 &self.code.join_info,
866 &StoreKeys::new(self.code.store_id.clone()),
867 self.oauth_tokens.clone(),
868 self.cloudkit_ops.clone(),
869 self.clock.clone(),
870 self.custom_s3_exact_slots,
871 )
872 .await
873 }
874
875 pub(crate) async fn transport_storage(&self) -> Result<CloudSyncStorage, BootstrapError> {
882 let signer = crate::keys::peek_pending_identity(&self.member_pubkey)?;
883 let cloud = self.build_cloud_home().await?;
884 self.plaintext_storage(cloud.home, &signer)
885 }
886
887 fn plaintext_storage(
888 &self,
889 home: Arc<dyn CloudHome>,
890 signer: &UserKeypair,
891 ) -> Result<CloudSyncStorage, BootstrapError> {
892 Ok(CloudSyncStorage::new(
893 home,
894 CloudCipher::Plaintext,
895 BlobPathScheme::for_storage(HomeStorage::Opaque),
896 self.code.store_id.clone(),
897 signer.clone(),
898 )?)
899 }
900
901 async fn build_storage(
902 &self,
903 signer: &UserKeypair,
904 ) -> Result<DeviceJoinStorage, BootstrapError> {
905 let cloud = self.build_cloud_home().await?;
906 let bootstrap_storage = self.plaintext_storage(cloud.home.clone(), signer)?;
907 let encryption = unwrap_store_keyring(
908 &bootstrap_storage,
909 signer,
910 &self.code.store_root,
911 &self.code.owner_pubkey,
912 &self.code.wrapped_key,
913 &self.code.membership_floor.0,
914 )
915 .await?;
916 let exact = cloud.home.clone().exact_slot_storage().ok_or_else(|| {
917 BootstrapError::Provider("provider has no exact-slot adapter".to_string())
918 })?;
919 let keyring = MasterKeyring::from(encryption.clone());
920 let storage = CloudSyncStorage::new(
921 cloud.home,
922 CloudCipher::Encrypted(encryption),
923 BlobPathScheme::for_storage(HomeStorage::Opaque),
924 self.code.store_id.clone(),
925 signer.clone(),
926 )?;
927 Ok(DeviceJoinStorage {
928 storage,
929 exact,
930 keyring,
931 })
932 }
933}
934
935#[allow(clippy::too_many_arguments)]
938pub(crate) async fn bootstrap_and_save_store(
939 storage: &CloudSyncStorage,
940 cipher: &CloudCipher,
941 master_key: Option<&MasterKeyring>,
942 store_dir: &StoreDir,
943 store_id: &str,
944 device_id: &str,
945 store_root: crate::sync::store_commit::StoreRootRef,
946 context: RestoreBootstrapContext<'_>,
947 membership_floor: &MembershipFloor,
948 synced_tables: &[SyncedTable],
949 migrations: &[Migration],
950 join_info: &CloudHomeJoinInfo,
951 store_name: &str,
952 custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
953 key_service: &StoreKeys,
954 custody: &dyn MasterKeyCustody,
955 identity_custody: &dyn DeviceIdentityCustody,
956 on_status: &impl Fn(&str),
957 cancel: &watch::Receiver<bool>,
958) -> Result<Config, BootstrapError> {
959 error_if_cancelled(cancel)?;
968 on_status("Downloading store snapshot...");
969 let db_path = store_dir.db_path();
970 let bucket_dyn: &dyn SyncStorage = storage;
971 let binary_schema_version = supported_version(migrations);
972 let bootstrap_result = bootstrap_from_snapshot(
973 bucket_dyn,
974 store_id,
975 store_root.clone(),
976 membership_floor,
977 binary_schema_version,
978 &db_path,
979 )
980 .await?;
981
982 info!(
983 "Bootstrapped from snapshot ({} device coverage entries)",
984 bootstrap_result.coverage_count()
985 );
986
987 let committed = async {
988 error_if_cancelled(cancel)?;
990 on_status("Applying recent changes...");
991 let routing_encryption = master_key.map(|keyring| EncryptionService::from(keyring.clone()));
992 let changesets_applied = open_db_and_pull(
993 store_id,
994 &db_path,
995 synced_tables,
996 migrations,
997 device_id,
998 context.owner_pubkey(),
999 store_root.clone(),
1000 context.activated_continuation(),
1001 context.owner_recovery(),
1002 membership_floor,
1003 routing_encryption.as_ref(),
1004 storage,
1005 bootstrap_result,
1006 context.keypair(),
1007 store_dir,
1008 cancel,
1009 )
1010 .await?;
1011
1012 if changesets_applied.changesets_applied > 0 {
1013 info!(
1014 "Applied {} changesets since snapshot",
1015 changesets_applied.changesets_applied
1016 );
1017 }
1018
1019 on_status("Saving configuration...");
1021 if let Some(keyring) = master_key {
1022 custody.persist(keyring)?;
1023 }
1024
1025 if let Some(credentials) = derive_credentials(join_info) {
1027 key_service.set_cloud_home_credentials(&credentials)?;
1028 }
1029
1030 crate::keys::import_identity(identity_custody, &context.keypair().to_keypair_bytes())?;
1037
1038 let mut config = build_config(
1041 store_id, device_id, store_dir, store_name, join_info, cipher,
1042 );
1043 if matches!(
1044 join_info,
1045 CloudHomeJoinInfo::S3 {
1046 endpoint: Some(_),
1047 ..
1048 }
1049 ) {
1050 config.cloud_home.s3_exact_slots = custom_s3_exact_slots;
1051 }
1052 config.save_to_config_yaml()?;
1053 Ok(config)
1054 }
1055 .await;
1056
1057 committed
1058}
1059
1060#[allow(clippy::too_many_arguments)]
1065pub(crate) async fn open_db_and_pull(
1066 store_id: &str,
1067 db_path: &Path,
1068 synced_tables: &[SyncedTable],
1069 migrations: &[Migration],
1070 device_id: &str,
1071 owner_pubkey: &str,
1072 store_root: crate::sync::store_commit::StoreRootRef,
1073 activated_continuation: Option<(
1074 &crate::sync::restore_code::ActivatedContinuation,
1075 &UserKeypair,
1076 &UserKeypair,
1077 )>,
1078 owner_recovery: Option<(
1079 &crate::sync::restore_code::OwnerRecoveryAuthority,
1080 &UserKeypair,
1081 )>,
1082 membership_floor: &MembershipFloor,
1083 routing_encryption: Option<&EncryptionService>,
1084 storage: &dyn SyncStorage,
1085 bootstrap: BootstrapResult,
1086 restorer_identity: &UserKeypair,
1087 store_dir: &StoreDir,
1088 cancel: &watch::Receiver<bool>,
1089) -> Result<OpenDbPullOutcome, BootstrapError> {
1090 let db = bootstrap
1091 .open_database(
1092 store_id,
1093 db_path,
1094 synced_tables.to_vec(),
1095 crate::blob::delete::BLOB_TOMBSTONE_GRACE,
1098 crate::blob::TransferLimits::one_at_a_time(),
1099 device_id.to_string(),
1100 migrations,
1101 routing_encryption,
1102 storage,
1103 restorer_identity,
1104 )
1105 .await?;
1106
1107 crate::sync::store::seed_head_watermark(&db, &membership_floor.0)
1115 .await
1116 .map_err(|e| {
1117 BootstrapError::Database(format!("Failed to seed membership head watermark: {e}"))
1118 })?;
1119
1120 db.set_protocol_state(crate::sync::store::OWNER_PUBKEY_STATE_KEY, owner_pubkey)
1121 .await
1122 .map_err(|e| BootstrapError::Database(format!("Failed to pin store owner: {e}")))?;
1123
1124 let database = crate::sync::store::StoreDatabase::from_database(db.clone());
1130 match crate::sync::store::reconcile_snapshot_blobs(
1131 &database,
1132 db_path,
1133 storage,
1134 store_dir,
1135 db.synced_tables(),
1136 cancel,
1137 )
1138 .await
1139 .map_err(|e| BootstrapError::Database(format!("Failed to reconcile snapshot blobs: {e}")))?
1140 {
1141 SnapshotBlobReconcile::Complete => {}
1142 SnapshotBlobReconcile::Incomplete => {
1143 return Err(BootstrapError::Database(
1144 "Snapshot blob reconciliation did not land every required eager blob".to_string(),
1145 ));
1146 }
1147 SnapshotBlobReconcile::Cancelled => {
1148 return Err(BootstrapError::Cancelled);
1149 }
1150 }
1151
1152 error_if_cancelled(cancel)?;
1156
1157 let membership = crate::sync::store::load_cycle_membership(storage, &database)
1162 .await
1163 .map_err(BootstrapError::Pull)?;
1164 let pull_result = Box::pin(crate::sync::store::pull_store_commits(
1165 &database,
1166 db.synced_tables(),
1167 storage,
1168 store_root.store_root_hash,
1169 store_dir,
1170 membership
1171 .chain
1172 .as_ref()
1173 .ok_or_else(|| BootstrapError::Membership("membership is unresolved".to_string()))?,
1174 None,
1175 routing_encryption,
1176 ))
1177 .await?;
1178
1179 if let Some((continuation, identity_signer, device_signer)) = activated_continuation {
1180 let registration = crate::sync::store_commit::StoreDeviceRegistration::parse_at(
1181 &continuation.registration_bytes,
1182 &store_root,
1183 continuation.registration.device_id,
1184 )
1185 .map_err(|error| BootstrapError::Database(error.to_string()))?;
1186 let latest_ack = crate::sync::store_objects::load_store_ack_ref(
1187 storage,
1188 &store_root,
1189 &continuation.latest_ack,
1190 ®istration,
1191 )
1192 .await
1193 .map_err(|error| BootstrapError::Database(error.to_string()))?;
1194 let mut ack_chain = vec![(continuation.latest_ack.clone(), latest_ack.value)];
1195 loop {
1196 let (successor_ref, successor) = ack_chain.last().expect("ack chain is nonempty");
1197 let Some((predecessor, loaded)) =
1198 crate::sync::store_objects::load_store_ack_predecessor(
1199 storage,
1200 &store_root,
1201 successor_ref,
1202 successor,
1203 ®istration,
1204 )
1205 .await
1206 .map_err(|error| BootstrapError::Database(error.to_string()))?
1207 else {
1208 break;
1209 };
1210 ack_chain.push((predecessor, loaded.value));
1211 }
1212 let latest_snapshot = match &continuation.latest_snapshot {
1213 Some(reference) => Some(
1214 crate::sync::store::load_store_snapshot_ref(
1215 storage,
1216 &store_root,
1217 &continuation.registration,
1218 ®istration,
1219 reference,
1220 )
1221 .await
1222 .map_err(|error| BootstrapError::Database(error.to_string()))?,
1223 ),
1224 None => None,
1225 };
1226 database
1227 .install_activated_device_continuation(
1228 continuation.clone(),
1229 identity_signer,
1230 device_signer,
1231 ack_chain,
1232 latest_snapshot,
1233 )
1234 .await
1235 .map_err(|error| BootstrapError::Database(error.to_string()))?;
1236 }
1237
1238 if let Some((authority, identity_signer)) = owner_recovery {
1239 let membership = membership.chain.as_ref().ok_or_else(|| {
1240 BootstrapError::Membership("Owner recovery requires resolved membership".to_string())
1241 })?;
1242 Box::pin(crate::sync::store::recover_owner_device(
1243 &database,
1244 storage,
1245 identity_signer,
1246 authority,
1247 membership,
1248 ))
1249 .await?;
1250 }
1251
1252 db.set_protocol_state("snapshot_seq", "0")
1257 .await
1258 .map_err(|e| BootstrapError::Database(format!("Failed to persist snapshot_seq: {e}")))?;
1259
1260 Ok(OpenDbPullOutcome {
1261 changesets_applied: pull_result.changesets_applied,
1262 })
1263}
1264
1265#[derive(Debug, PartialEq, Eq)]
1266pub(crate) struct OpenDbPullOutcome {
1267 pub changesets_applied: u64,
1268}
1269
1270pub(crate) fn derive_credentials(join_info: &CloudHomeJoinInfo) -> Option<CloudHomeCredentials> {
1273 match join_info {
1274 CloudHomeJoinInfo::S3 {
1275 access_key,
1276 secret_key,
1277 ..
1278 } => Some(CloudHomeCredentials::S3 {
1279 access_key: access_key.clone(),
1280 secret_key: secret_key.clone(),
1281 }),
1282 _ => None,
1283 }
1284}
1285
1286pub(crate) fn build_config(
1290 store_id: &str,
1291 device_id: &str,
1292 store_dir: &StoreDir,
1293 store_name: &str,
1294 join_info: &CloudHomeJoinInfo,
1295 cipher: &CloudCipher,
1296) -> Config {
1297 let mut config = Config::with_defaults(
1298 store_id.to_string(),
1299 device_id.to_string(),
1300 store_dir.clone(),
1301 store_name.to_string(),
1302 );
1303
1304 match cipher {
1305 CloudCipher::Encrypted(enc) => {
1306 config.cloud_home.storage = HomeStorage::Opaque;
1307 config.encryption_key_stored = true;
1308 config.encryption_key_fingerprint = Some(enc.fingerprint());
1309 }
1310 CloudCipher::Plaintext => {
1311 config.cloud_home.storage = HomeStorage::Browsable;
1312 config.encryption_key_stored = false;
1313 config.encryption_key_fingerprint = None;
1314 }
1315 }
1316
1317 config.cloud_home.provider = Some(match join_info {
1318 CloudHomeJoinInfo::S3 { .. } => CloudProvider::S3,
1319 CloudHomeJoinInfo::GoogleDrive { .. } => CloudProvider::GoogleDrive,
1320 CloudHomeJoinInfo::Dropbox { .. } => CloudProvider::Dropbox,
1321 CloudHomeJoinInfo::OneDrive { .. } => CloudProvider::OneDrive,
1322 CloudHomeJoinInfo::CloudKit | CloudHomeJoinInfo::CloudKitShare { .. } => {
1323 CloudProvider::CloudKit
1324 }
1325 });
1326
1327 match join_info {
1328 CloudHomeJoinInfo::S3 {
1329 bucket,
1330 region,
1331 endpoint,
1332 key_prefix,
1333 ..
1334 } => {
1335 config.cloud_home.s3_bucket = Some(bucket.clone());
1336 config.cloud_home.s3_region = Some(region.clone());
1337 config.cloud_home.s3_endpoint = endpoint.clone();
1338 config.cloud_home.s3_key_prefix = key_prefix.clone();
1339 }
1340 CloudHomeJoinInfo::GoogleDrive { folder_id } => {
1341 config.cloud_home.google_drive_folder_id = Some(folder_id.clone());
1342 }
1343 CloudHomeJoinInfo::Dropbox { folder_path } => {
1344 config.cloud_home.dropbox_folder_path = Some(folder_path.clone());
1345 }
1346 CloudHomeJoinInfo::OneDrive {
1347 drive_id,
1348 folder_id,
1349 } => {
1350 config.cloud_home.onedrive_drive_id = Some(drive_id.clone());
1351 config.cloud_home.onedrive_folder_id = Some(folder_id.clone());
1352 }
1353 CloudHomeJoinInfo::CloudKit => {}
1354 CloudHomeJoinInfo::CloudKitShare {
1355 owner_name,
1356 zone_name,
1357 ..
1358 } => {
1359 config.cloud_home.cloudkit_owner_name = Some(owner_name.clone());
1360 config.cloud_home.cloudkit_zone_name = Some(zone_name.clone());
1361 }
1362 }
1363
1364 config
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369 use super::*;
1370
1371 #[test]
1375 fn guard_refuses_a_completed_store_and_leaves_it_untouched() {
1376 crate::keys::test_keyring::install();
1377 let tmp = tempfile::tempdir().expect("temp dir");
1378 let store_dir = StoreDir::new(tmp.path().join("completed"));
1379 std::fs::create_dir_all(&*store_dir).expect("create store dir");
1380 std::fs::write(store_dir.config_path(), b"store_id: completed\n")
1381 .expect("seed completion marker");
1382 let store_keys = StoreKeys::new("guard-completed-test".to_string());
1383 let custody =
1384 crate::custody::KeyCustody::Keyring.resolve("guard-completed-test", &store_dir);
1385 custody
1386 .persist(&MasterKeyring::generate())
1387 .expect("seed the master key");
1388 let identity_custody = IdentityCustody::Keyring.resolve("guard-completed-test", &store_dir);
1389
1390 let result = refuse_completed_or_clear_torn_store(
1391 &store_dir,
1392 &store_keys,
1393 custody.as_ref(),
1394 identity_custody.as_ref(),
1395 "guard-completed-test",
1396 );
1397
1398 assert!(
1399 matches!(result, Err(BootstrapError::StoreExists(ref id)) if id == "guard-completed-test"),
1400 "a completed store must be refused with StoreExists, got {result:?}",
1401 );
1402 assert!(store_dir.config_path().exists(), "the marker is untouched");
1403 assert!(
1404 custody.unlock().expect("read master key").is_some(),
1405 "a refused completed store keeps its keyring entries",
1406 );
1407 }
1408
1409 #[test]
1414 fn guard_clears_a_torn_bootstrap_and_lets_the_retry_proceed() {
1415 crate::keys::test_keyring::install();
1416 let tmp = tempfile::tempdir().expect("temp dir");
1417 let store_dir = StoreDir::new(tmp.path().join("torn"));
1418 std::fs::create_dir_all(&*store_dir).expect("create store dir");
1419 std::fs::write(store_dir.db_path(), b"half-written-db").expect("seed torn db");
1421 let store_keys = StoreKeys::new("guard-torn-test".to_string());
1422 let custody = crate::custody::KeyCustody::Keyring.resolve("guard-torn-test", &store_dir);
1423 custody
1424 .persist(&MasterKeyring::generate())
1425 .expect("seed the master key");
1426 let identity_custody = IdentityCustody::Keyring.resolve("guard-torn-test", &store_dir);
1427 identity_custody
1428 .persist(&UserKeypair::generate())
1429 .expect("seed the identity");
1430 store_keys
1431 .set_cloud_home_credentials(&CloudHomeCredentials::S3 {
1432 access_key: "ak".to_string(),
1433 secret_key: "sk".to_string(),
1434 })
1435 .expect("seed cloud home credentials");
1436
1437 let result = refuse_completed_or_clear_torn_store(
1438 &store_dir,
1439 &store_keys,
1440 custody.as_ref(),
1441 identity_custody.as_ref(),
1442 "guard-torn-test",
1443 );
1444
1445 assert!(
1446 result.is_ok(),
1447 "a torn bootstrap clears and proceeds, got {result:?}"
1448 );
1449 assert!(!store_dir.exists(), "the torn directory was removed");
1450 assert!(
1451 custody.unlock().expect("read master key").is_none(),
1452 "the torn store's master key was cleared",
1453 );
1454 assert!(
1455 identity_custody.unlock().expect("read identity").is_none(),
1456 "the torn store's identity was cleared",
1457 );
1458 assert!(
1459 store_keys
1460 .get_cloud_home_credentials()
1461 .expect("read keyring")
1462 .is_none(),
1463 "the torn store's cloud home credentials were cleared",
1464 );
1465 }
1466
1467 #[test]
1476 fn cleanup_failure_carries_the_original_bootstrap_cause() {
1477 crate::keys::test_keyring::install();
1478 let tmp = tempfile::tempdir().expect("temp dir");
1479 let blocked = StoreDir::new(tmp.path().join("blocked-by-a-file"));
1480 std::fs::write(&*blocked, b"not a directory").expect("seed a file at the store dir path");
1481 let store_keys = StoreKeys::new("cleanup-failure-cause-test".to_string());
1482 let custody =
1483 crate::custody::KeyCustody::Keyring.resolve("cleanup-failure-cause-test", &blocked);
1484 let identity_custody =
1485 IdentityCustody::Keyring.resolve("cleanup-failure-cause-test", &blocked);
1486
1487 let wrapped = cleanup_after_bootstrap_failure(
1488 &blocked,
1489 &store_keys,
1490 custody.as_ref(),
1491 identity_custody.as_ref(),
1492 BootstrapError::Database("bootstrap boom".to_string()),
1493 );
1494
1495 match wrapped {
1496 BootstrapError::Cleanup { cleanup, cause } => {
1497 assert!(!cleanup.is_empty(), "the removal failure is recorded");
1498 assert!(
1499 matches!(*cause, BootstrapError::Database(ref m) if m == "bootstrap boom"),
1500 "the original bootstrap cause is preserved as a value, got {cause:?}",
1501 );
1502 }
1503 other => panic!("a failed cleanup must yield Cleanup, got {other:?}"),
1504 }
1505 }
1506
1507 #[test]
1510 fn successful_cleanup_returns_the_cause_unchanged() {
1511 crate::keys::test_keyring::install();
1512 let tmp = tempfile::tempdir().expect("temp dir");
1513 let store_dir = StoreDir::new(tmp.path().join("to-remove"));
1514 std::fs::create_dir_all(&*store_dir).expect("create store dir");
1515 let store_keys = StoreKeys::new("successful-cleanup-test".to_string());
1516 let custody =
1517 crate::custody::KeyCustody::Keyring.resolve("successful-cleanup-test", &store_dir);
1518 let identity_custody =
1519 IdentityCustody::Keyring.resolve("successful-cleanup-test", &store_dir);
1520
1521 let returned = cleanup_after_bootstrap_failure(
1522 &store_dir,
1523 &store_keys,
1524 custody.as_ref(),
1525 identity_custody.as_ref(),
1526 BootstrapError::Database("bootstrap boom".to_string()),
1527 );
1528
1529 assert!(
1530 matches!(returned, BootstrapError::Database(ref m) if m == "bootstrap boom"),
1531 "a clean removal returns the cause unchanged, got {returned:?}",
1532 );
1533 assert!(!store_dir.exists(), "the partial store dir was removed");
1534 }
1535
1536 #[test]
1542 fn cleanup_tolerates_a_store_dir_that_was_never_created() {
1543 crate::keys::test_keyring::install();
1544 let tmp = tempfile::tempdir().expect("temp dir");
1545 let never_created = StoreDir::new(tmp.path().join("never-created"));
1546 let store_keys = StoreKeys::new("never-created-dir-test".to_string());
1547 let custody =
1548 crate::custody::KeyCustody::Keyring.resolve("never-created-dir-test", &never_created);
1549 let identity_custody =
1550 IdentityCustody::Keyring.resolve("never-created-dir-test", &never_created);
1551
1552 let returned = cleanup_after_bootstrap_failure(
1553 &never_created,
1554 &store_keys,
1555 custody.as_ref(),
1556 identity_custody.as_ref(),
1557 BootstrapError::Database("bootstrap boom".to_string()),
1558 );
1559
1560 assert!(
1561 matches!(returned, BootstrapError::Database(ref m) if m == "bootstrap boom"),
1562 "a missing store dir must not itself count as a cleanup failure, got {returned:?}",
1563 );
1564 }
1565
1566 #[test]
1573 fn cleanup_also_removes_both_keyring_accounts() {
1574 crate::keys::test_keyring::install();
1575 let tmp = tempfile::tempdir().expect("temp dir");
1576 let store_dir = StoreDir::new(tmp.path().join("keyring-cleanup-test"));
1577 std::fs::create_dir_all(&*store_dir).expect("create store dir");
1578 let store_keys = StoreKeys::new("keyring-cleanup-test".to_string());
1579 let custody =
1580 crate::custody::KeyCustody::Keyring.resolve("keyring-cleanup-test", &store_dir);
1581 custody
1582 .persist(&MasterKeyring::generate())
1583 .expect("seed the master key via custody");
1584 let identity_custody = IdentityCustody::Keyring.resolve("keyring-cleanup-test", &store_dir);
1585 identity_custody
1586 .persist(&crate::keys::UserKeypair::generate())
1587 .expect("seed this store's identity via custody");
1588 store_keys
1589 .set_cloud_home_credentials(&CloudHomeCredentials::S3 {
1590 access_key: "ak".to_string(),
1591 secret_key: "sk".to_string(),
1592 })
1593 .expect("seed cloud home credentials");
1594
1595 let returned = cleanup_after_bootstrap_failure(
1596 &store_dir,
1597 &store_keys,
1598 custody.as_ref(),
1599 identity_custody.as_ref(),
1600 BootstrapError::Database("bootstrap boom".to_string()),
1601 );
1602
1603 assert!(
1604 matches!(returned, BootstrapError::Database(ref m) if m == "bootstrap boom"),
1605 "a clean removal returns the cause unchanged, got {returned:?}",
1606 );
1607 assert!(!store_dir.exists(), "the partial store dir was removed");
1608 assert_eq!(
1609 store_keys.get_encryption_key().expect("read keyring"),
1610 None,
1611 "the encryption key must be removed from the keyring",
1612 );
1613 assert!(
1614 identity_custody
1615 .unlock()
1616 .expect("read identity custody")
1617 .is_none(),
1618 "this store's identity must be removed from custody",
1619 );
1620 assert!(
1621 store_keys
1622 .get_cloud_home_credentials()
1623 .expect("read keyring")
1624 .is_none(),
1625 "the cloud home credentials must be removed from the keyring",
1626 );
1627 }
1628
1629 #[test]
1633 fn derive_credentials_only_stores_for_s3() {
1634 let s3 = CloudHomeJoinInfo::S3 {
1635 bucket: "b".to_string(),
1636 region: "r".to_string(),
1637 endpoint: None,
1638 key_prefix: None,
1639 access_key: "ak".to_string(),
1640 secret_key: "sk".to_string(),
1641 };
1642 match derive_credentials(&s3) {
1643 Some(CloudHomeCredentials::S3 {
1644 access_key,
1645 secret_key,
1646 }) => {
1647 assert_eq!(access_key, "ak");
1648 assert_eq!(secret_key, "sk");
1649 }
1650 other => panic!("expected Some(S3), got {other:?}"),
1651 }
1652
1653 for oauth in [
1654 CloudHomeJoinInfo::GoogleDrive {
1655 folder_id: "f".to_string(),
1656 },
1657 CloudHomeJoinInfo::Dropbox {
1658 folder_path: "f".to_string(),
1659 },
1660 CloudHomeJoinInfo::OneDrive {
1661 drive_id: "d".to_string(),
1662 folder_id: "f".to_string(),
1663 },
1664 CloudHomeJoinInfo::CloudKit,
1665 ] {
1666 assert!(
1667 derive_credentials(&oauth).is_none(),
1668 "non-S3 provider must not map to stored credentials"
1669 );
1670 }
1671 }
1672}