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