Skip to main content

coven/sync/
join.rs

1//! Join an existing shared store using an invite code.
2//!
3//! Shared across all platforms (macOS, iOS, CLI).
4
5use 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/// Why joining or restoring a store failed. Both are the same operation —
31/// bootstrap a store from the cloud — differing only in their entry data (an
32/// invite that wraps the store key vs a restore code that carries the bucket
33/// credentials), so they share one error shape rather than two that duplicate
34/// most of their variants and then have to map between each other.
35#[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    /// A hard crash left a store directory with no saved config, and clearing
68    /// that torn-bootstrap residue before retrying failed — so the retry can't
69    /// proceed over the leftover directory or keyring entries.
70    #[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    /// The caller's cancel signal fired at a phase boundary, so the join or
85    /// restore stopped before saving the store. This returns through the same
86    /// failure-cleanup path a real error takes — removing the partly-created
87    /// store directory and any per-store keyring entries written so far — so a
88    /// cancelled bootstrap leaves no residue in either place.
89    #[error("the operation was cancelled")]
90    Cancelled,
91    /// Bootstrap failed AND cleaning up what it had durably written also failed.
92    /// Both are carried: `cause` is the original bootstrap failure that
93    /// triggered the cleanup, `cleanup` is why the cleanup itself failed — the
94    /// cause is preserved as a value, not flattened into a string.
95    #[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
108/// Undo everything a bootstrap attempt may have durably written (see
109/// [`remove_bootstrap_residue`]), then return the error to propagate. The
110/// completion-marker guard at the top of every join/restore entry point
111/// establishes that this invocation owns everything under the store id — no
112/// completed store existed when it started — which makes total removal
113/// unconditionally safe here. On a clean run the original `cause` is returned
114/// unchanged; if any removal step fails, every failure is carried in a
115/// [`BootstrapError::Cleanup`] so none is lost. Shared by join and restore.
116pub(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
134/// Remove everything a bootstrap attempt may have durably written under this
135/// store id, returning a message for each step that failed (empty ⇒ a clean
136/// removal). Four steps, each best-effort so one failing doesn't skip the
137/// others: the store directory (tolerating it never having existed, and also
138/// covering a Passphrase custody's wrapped file, which lives inside it), the
139/// master key via custody (idempotent regardless of policy), this store's
140/// identity via its own custody (idempotent the same way — a no-op if the
141/// identity was never established), and the cloud-home credentials (OAuth tokens
142/// are stored *as* credentials — see `StoreKeys::set_cloud_home_oauth_tokens` —
143/// so this one delete covers both). Shared by the post-failure cleanup and the
144/// torn-bootstrap guard: both own everything under the store id, because the
145/// completion-marker guard establishes no completed store exists at that id.
146fn 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
175/// Dispatch a join or restore on the completion marker rather than bare
176/// directory existence.
177///
178/// A store is *complete* once its `config.yaml` — the last durable write of a
179/// successful bootstrap — exists. If it does, the directory holds real data:
180/// refuse with [`BootstrapError::StoreExists`], leaving it untouched. A store
181/// directory *without* that config is the residue of a bootstrap a hard crash
182/// (power loss, kill) interrupted before the config save. The host records a
183/// store only on `Ok`, so nothing references that residue and it is this
184/// retry's to clear: remove the directory and the same store-scoped keyring
185/// entries a failed bootstrap's cleanup removes, then let the caller proceed
186/// with a fresh attempt. A residue-removal failure blocks the retry (the
187/// leftovers would collide), so it surfaces as
188/// [`BootstrapError::TornBootstrapCleanup`] rather than a silent proceed.
189///
190/// Cloud protocol state is append-only. A retry reuses the pending join identity
191/// or the identity carried by the restore code, so it verifies and continues the
192/// same signed registration chain instead of deleting published objects.
193pub(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        // The directory and keyring entries are this retry's to clear. Immutable
210        // Store objects remain in cloud storage and are verified on reuse.
211        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
223/// Fail with [`BootstrapError::Cancelled`] if the caller's cancel signal has
224/// fired. Called only at phase boundaries — never mid-download or mid-write —
225/// so a cancellation returns through the same failure-cleanup path a real error
226/// takes, mirroring `make_local`'s cooperative cancellation.
227fn error_if_cancelled(cancel: &watch::Receiver<bool>) -> Result<(), BootstrapError> {
228    if *cancel.borrow() {
229        Err(BootstrapError::Cancelled)
230    } else {
231        Ok(())
232    }
233}
234
235/// The identity and verified authority carried by a restore code. Bootstrap
236/// installs the identity in store custody before writing the config marker.
237pub(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/// Persist the caller-supplied OAuth tokens for the store `ks` is scoped to, so
283/// the next launch's home construction (`parse_oauth_tokens` in
284/// `storage::cloud`) can read them back from the keyring instead of erroring on
285/// their absence. Both join and restore build an OAuth provider home from
286/// tokens the caller already holds; both must save them here before returning
287/// that home.
288#[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
423/// A joining device's local half of the four-transfer admission exchange.
424/// The journal lives outside the incomplete store directory, so every method
425/// can be retried after process termination without losing its exact predecessor.
426pub 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    /// Plaintext storage over the joining device's cloud home.
876    ///
877    /// Both the pre-key bootstrap reads and the device-join transport go
878    /// through this: the transport's objects carry their own per-attempt seal,
879    /// so they need no store key — which is what lets a joiner publish its
880    /// access request before it has unwrapped the store keyring at all.
881    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/// Inner restore bootstrap logic, separated so callers can clean up the store
936/// directory on failure.
937#[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    // Join pins the store owner from the invite. Restore adopts the owner
960    // from the chain founder during `open_db_and_pull`, because the restore code
961    // carries the bucket credentials rather than an inviter assertion.
962    //
963    // Cancellation is checked between phases (before the snapshot download here,
964    // before the pull inside `open_db_and_pull`, and per-blob during blob
965    // reconciliation), never inside a download. A cancel returns through the same
966    // failure-cleanup the caller runs, so the store directory is removed.
967    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        // Pull Store commits beyond the snapshot coverage.
989        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        // Persist the master key via custody.
1020        on_status("Saving configuration...");
1021        if let Some(keyring) = master_key {
1022            custody.persist(keyring)?;
1023        }
1024
1025        // Save cloud credentials to keyring.
1026        if let Some(credentials) = derive_credentials(join_info) {
1027            key_service.set_cloud_home_credentials(&credentials)?;
1028        }
1029
1030        // Establish the store's signing identity BEFORE the config save, so
1031        // the saved config — the completion marker — always implies a
1032        // resolvable identity. A crash before the save is a torn bootstrap the
1033        // retry clears and re-establishes from the restore code the user still
1034        // holds. Inside the unit, so a later failure deletes the bootstrap
1035        // reader the same as any other.
1036        crate::keys::import_identity(identity_custody, &context.keypair().to_keypair_bytes())?;
1037
1038        // Create and save config — the last durable write and the
1039        // store's completion marker.
1040        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/// Open a [`crate::database::Database`] over the bootstrapped db file and pull changesets since
1061/// the snapshot. The snapshot already carries the full schema and the writer's
1062/// `user_version`, so the migration ladder only carries the image forward when
1063/// this binary is newer (a no-op when they match).
1064#[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            // This bootstrap database only applies changesets during join; it never runs
1096            // the tombstone GC, so the grace is immaterial and takes the default.
1097            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    // Seed this device's per-author-stream membership-head watermark from the invite
1108    // or restore code's floor BEFORE the pull below loads and anchors the
1109    // membership chain for the first time. A fresh joiner or restorer has no
1110    // watermark yet, so without this, the first load would accept any signed
1111    // head — including an older one a storage provider chooses to serve, e.g.
1112    // from before a removal the floor already reflects. Seeding it here makes
1113    // that first load monotonic from the start, exactly like every later cycle.
1114    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    // Download the blob files the snapshot's rows reference: the snapshot carried
1125    // the catalog rows but no blob files, and the pull starts past its signed
1126    // coverage, so it never re-walks the INSERTs that first carried them. Missing
1127    // eager blobs abort the bootstrap before the store is saved. Cancellation is
1128    // checked between blobs inside the reconcile, surfacing as `Cancelled` here.
1129    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    // The pull downloads every changeset published since the snapshot — the last
1153    // heavy phase. Check cancellation before entering it; the pull itself is a
1154    // single phase here (per-changeset cancel is the sync loop's own concern).
1155    error_if_cancelled(cancel)?;
1156
1157    // Pull over the synced set coven owns, not the raw host list — one source of
1158    // truth. Load and anchor the membership chain first (join is a standalone,
1159    // non-cycle pull), against the owner pinned above. Restore has not pinned an
1160    // owner yet, so it anchors the chain at its signed founder below.
1161    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            &registration,
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                    &registration,
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                    &registration,
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    // Bootstrap installed the snapshot's position vector before pulling anything
1253    // above it, and each higher position committed with its rows. Record the remaining
1254    // bootstrap marker so the first real cycle treats this device as a joiner,
1255    // not a brand-new store that should publish an initial snapshot.
1256    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
1270/// The credentials to persist for this join, or `None` when the provider needs
1271/// no stored secret (OAuth tokens are already saved; CloudKit uses the container).
1272pub(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
1286/// Build the Config struct from join/restore parameters. The cipher records the
1287/// home's storage mode: `Encrypted` ⇒ opaque (a store key is stored, with its
1288/// fingerprint), `Plaintext` ⇒ browsable (no key, no fingerprint).
1289pub(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    /// A store whose `config.yaml` marker is present is a completed store: the
1372    /// guard refuses it with `StoreExists` and touches nothing — neither the
1373    /// directory nor the keyring entries a live store depends on.
1374    #[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    /// A store directory with no `config.yaml` marker is a torn bootstrap a
1410    /// crash interrupted before completion: the guard clears it — the directory
1411    /// and the store-scoped keyring entries — and returns `Ok` so the caller
1412    /// retries from a clean slate.
1413    #[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        // Partial bootstrap residue: a torn database image, no config marker.
1420        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    /// When the post-failure directory cleanup itself fails, both failures are
1468    /// carried: `cleanup` records why the removal failed and `cause` preserves
1469    /// the ORIGINAL bootstrap error as a value — not flattened into a string.
1470    /// Join and restore both route their failure path through this one helper,
1471    /// so exercising it covers both flows' cleanup behavior. The dir removal is
1472    /// the failure here: a *file* sits where the store dir should be, so
1473    /// `remove_dir_all` fails with something other than not-found (which is
1474    /// tolerated, not a failure — see the dedicated test below).
1475    #[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    /// When the cleanup succeeds, the original bootstrap error propagates
1508    /// unchanged — no `Cleanup` wrapper — and the partial store dir is gone.
1509    #[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    /// A bootstrap failure before `create_dir_all` ever ran (e.g. the OAuth
1537    /// persist or cloud-home construction failed first) leaves no store dir to
1538    /// remove. `remove_dir_all` on a path that never existed returns
1539    /// `NotFound`, and that must be tolerated — not folded into `Cleanup` — so a
1540    /// pre-directory failure still reports as the plain original cause.
1541    #[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    /// The extended rollback also removes the store-scoped keyring accounts —
1567    /// the encryption master key, this store's identity, and the cloud-home
1568    /// credentials (which is also where an OAuth token lands, via
1569    /// `set_cloud_home_oauth_tokens`) — not just the directory. Seed all three
1570    /// the way a partial bootstrap would have written them, then assert
1571    /// cleanup leaves none behind.
1572    #[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    /// Only S3 maps to a stored value; every other provider returns `None` so
1630    /// the join never overwrites an already-saved OAuth token (or a CloudKit
1631    /// container) with credentials.
1632    #[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}