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, 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/// 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] 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    /// A hard crash left a store directory with no saved config, and clearing
66    /// that torn-bootstrap residue before retrying failed — so the retry can't
67    /// proceed over the leftover directory or keyring entries.
68    #[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    /// The caller's cancel signal fired at a phase boundary, so the join or
88    /// restore stopped before saving the store. This returns through the same
89    /// failure-cleanup path a real error takes — removing the partly-created
90    /// store directory and any per-store keyring entries written so far — so a
91    /// cancelled bootstrap leaves no residue in either place.
92    #[error("the operation was cancelled")]
93    Cancelled,
94    /// Bootstrap failed AND cleaning up what it had durably written also failed.
95    /// Both are carried: `cause` is the original bootstrap failure that
96    /// triggered the cleanup, `cleanup` is why the cleanup itself failed — the
97    /// cause is preserved as a value, not flattened into a string.
98    #[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
111/// Undo everything a bootstrap attempt may have durably written (see
112/// [`remove_bootstrap_residue`]), then return the error to propagate. The
113/// completion-marker guard at the top of every join/restore entry point
114/// establishes that this invocation owns everything under the store id — no
115/// completed store existed when it started — which makes total removal
116/// unconditionally safe here. On a clean run the original `cause` is returned
117/// unchanged; if any removal step fails, every failure is carried in a
118/// [`BootstrapError::Cleanup`] so none is lost. Shared by join and restore.
119pub(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
137/// Remove everything a bootstrap attempt may have durably written under this
138/// store id, returning a message for each step that failed (empty ⇒ a clean
139/// removal). Four steps, each best-effort so one failing doesn't skip the
140/// others: the store directory (tolerating it never having existed, and also
141/// covering a Passphrase custody's wrapped file, which lives inside it), the
142/// master key via custody (idempotent regardless of policy), this store's
143/// identity via its own custody (idempotent the same way — a no-op if the
144/// identity was never established), and the cloud-home credentials (OAuth tokens
145/// are stored *as* credentials — see `StoreKeys::set_cloud_home_oauth_tokens` —
146/// so this one delete covers both). Shared by the post-failure cleanup and the
147/// torn-bootstrap guard: both own everything under the store id, because the
148/// completion-marker guard establishes no completed store exists at that id.
149fn 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
178/// Dispatch a join or restore on the completion marker rather than bare
179/// directory existence.
180///
181/// A store is *complete* once its `config.yaml` — the last durable write of a
182/// successful bootstrap — exists. If it does, the directory holds real data:
183/// refuse with [`BootstrapError::StoreExists`], leaving it untouched. A store
184/// directory *without* that config is the residue of a bootstrap a hard crash
185/// (power loss, kill) interrupted before the config save. The host records a
186/// store only on `Ok`, so nothing references that residue and it is this
187/// retry's to clear: remove the directory and the same store-scoped keyring
188/// entries a failed bootstrap's cleanup removes, then let the caller proceed
189/// with a fresh attempt. A residue-removal failure blocks the retry (the
190/// leftovers would collide), so it surfaces as
191/// [`BootstrapError::TornBootstrapCleanup`] rather than a silent proceed.
192///
193/// Cloud protocol state is append-only. A retry reuses the pending join identity
194/// or the identity carried by the restore code, so it verifies and continues the
195/// same signed registration chain instead of deleting published objects.
196pub(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        // The directory and keyring entries are this retry's to clear. Immutable
213        // Store objects remain in cloud storage and are verified on reuse.
214        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
226/// Fail with [`BootstrapError::Cancelled`] if the caller's cancel signal has
227/// fired. Called only at phase boundaries — never mid-download or mid-write —
228/// so a cancellation returns through the same failure-cleanup path a real error
229/// takes, mirroring `make_local`'s cooperative cancellation.
230fn error_if_cancelled(cancel: &watch::Receiver<bool>) -> Result<(), BootstrapError> {
231    if *cancel.borrow() {
232        Err(BootstrapError::Cancelled)
233    } else {
234        Ok(())
235    }
236}
237
238/// The identity and verified authority carried by a restore code. Bootstrap
239/// installs the identity in store custody before writing the config marker.
240pub(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/// Persist the caller-supplied OAuth tokens for the store `ks` is scoped to, so
286/// the next launch's home construction (`parse_oauth_tokens` in
287/// `storage::cloud`) can read them back from the keyring instead of erroring on
288/// their absence. Both join and restore build an OAuth provider home from
289/// tokens the caller already holds; both must save them here before returning
290/// that home.
291#[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
449/// A joining device's local half of the four-transfer admission exchange.
450/// The journal lives outside the incomplete store directory, so every method
451/// can be retried after process termination without losing its exact predecessor.
452pub 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/// Inner restore bootstrap logic, separated so callers can clean up the store
905/// directory on failure.
906#[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    // Join pins the store owner from the invite. Restore adopts the owner
930    // from the chain founder during `open_db_and_pull`, because the restore code
931    // carries the bucket credentials rather than an inviter assertion.
932    //
933    // Cancellation is checked between phases (before the snapshot download here,
934    // before the pull inside `open_db_and_pull`, and per-blob during blob
935    // reconciliation), never inside a download. A cancel returns through the same
936    // failure-cleanup the caller runs, so the store directory is removed.
937    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        // Pull Store commits beyond the snapshot coverage.
968        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        // Persist the master key via custody.
997        on_status("Saving configuration...");
998        if let Some(keyring) = master_key {
999            custody.persist(keyring)?;
1000        }
1001
1002        // Save cloud credentials to keyring.
1003        if let Some(credentials) = derive_credentials(join_info) {
1004            key_service.set_cloud_home_credentials(&credentials)?;
1005        }
1006
1007        // Establish the store's signing identity BEFORE the config save, so
1008        // the saved config — the completion marker — always implies a
1009        // resolvable identity. A crash before the save is a torn bootstrap the
1010        // retry clears and re-establishes from the restore code the user still
1011        // holds. Inside the unit, so a later failure deletes the bootstrap
1012        // reader the same as any other.
1013        crate::keys::import_identity(identity_custody, &context.keypair().to_keypair_bytes())?;
1014
1015        // Create and save config — the last durable write and the
1016        // store's completion marker.
1017        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/// Open a [`crate::database::Database`] over the bootstrapped db file and pull changesets since
1044/// the snapshot. The snapshot already carries the full schema and the writer's
1045/// `user_version`, so the migration ladder only carries the image forward when
1046/// this binary is newer (a no-op when they match).
1047#[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            // This bootstrap database only applies changesets during join; it never runs
1078            // the tombstone GC, so the grace is immaterial and takes the default.
1079            crate::blob::delete::BLOB_TOMBSTONE_GRACE,
1080            crate::blob::TransferLimits::serial(),
1081            device_id.to_string(),
1082            migrations,
1083        )
1084        .await?;
1085
1086    // Seed this device's per-author-stream membership-head watermark from the invite
1087    // or restore code's floor BEFORE the pull below loads and anchors the
1088    // membership chain for the first time. A fresh joiner or restorer has no
1089    // watermark yet, so without this, the first load would accept any signed
1090    // head — including an older one a storage provider chooses to serve, e.g.
1091    // from before a removal the floor already reflects. Seeding it here makes
1092    // that first load monotonic from the start, exactly like every later cycle.
1093    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    // Download the blob files the snapshot's rows reference: the snapshot carried
1109    // the catalog rows but no blob files, and the pull starts past its signed
1110    // coverage, so it never re-walks the INSERTs that first carried them. Missing
1111    // eager blobs abort the bootstrap before the store is saved. Cancellation is
1112    // checked between blobs inside the reconcile, surfacing as `Cancelled` here.
1113    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    // The pull downloads every changeset published since the snapshot — the last
1136    // heavy phase. Check cancellation before entering it; the pull itself is a
1137    // single phase here (per-changeset cancel is the sync loop's own concern).
1138    error_if_cancelled(cancel)?;
1139
1140    // Pull over the synced set coven owns, not the raw host list — one source of
1141    // truth. Load and anchor the membership chain first (join is a standalone,
1142    // non-cycle pull), against the owner pinned above. Restore has not pinned an
1143    // owner yet, so it anchors the chain at its signed founder below.
1144    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            &registration,
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                &registration,
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                    &registration,
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    // Bootstrap installed the snapshot's position vector before pulling anything
1267    // above it, and each higher position committed with its rows. Record the remaining
1268    // bootstrap marker so the first real cycle treats this device as a joiner,
1269    // not a brand-new store that should publish an initial snapshot.
1270    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
1284/// The credentials to persist for this join, or `None` when the provider needs
1285/// no stored secret (OAuth tokens are already saved; CloudKit uses the container).
1286pub(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
1300/// Build the Config struct from join/restore parameters. The cipher records the
1301/// home's storage mode: `Encrypted` ⇒ opaque (a store key is stored, with its
1302/// fingerprint), `Plaintext` ⇒ browsable (no key, no fingerprint).
1303pub(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    /// A store whose `config.yaml` marker is present is a completed store: the
1388    /// guard refuses it with `StoreExists` and touches nothing — neither the
1389    /// directory nor the keyring entries a live store depends on.
1390    #[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    /// A store directory with no `config.yaml` marker is a torn bootstrap a
1426    /// crash interrupted before completion: the guard clears it — the directory
1427    /// and the store-scoped keyring entries — and returns `Ok` so the caller
1428    /// retries from a clean slate.
1429    #[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        // Partial bootstrap residue: a torn database image, no config marker.
1436        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    /// When the post-failure directory cleanup itself fails, both failures are
1484    /// carried: `cleanup` records why the removal failed and `cause` preserves
1485    /// the ORIGINAL bootstrap error as a value — not flattened into a string.
1486    /// Join and restore both route their failure path through this one helper,
1487    /// so exercising it covers both flows' cleanup behavior. The dir removal is
1488    /// the failure here: a *file* sits where the store dir should be, so
1489    /// `remove_dir_all` fails with something other than not-found (which is
1490    /// tolerated, not a failure — see the dedicated test below).
1491    #[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    /// When the cleanup succeeds, the original bootstrap error propagates
1524    /// unchanged — no `Cleanup` wrapper — and the partial store dir is gone.
1525    #[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    /// A bootstrap failure before `create_dir_all` ever ran (e.g. the OAuth
1553    /// persist or cloud-home construction failed first) leaves no store dir to
1554    /// remove. `remove_dir_all` on a path that never existed returns
1555    /// `NotFound`, and that must be tolerated — not folded into `Cleanup` — so a
1556    /// pre-directory failure still reports as the plain original cause.
1557    #[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    /// The extended rollback also removes the store-scoped keyring accounts —
1583    /// the encryption master key, this store's identity, and the cloud-home
1584    /// credentials (which is also where an OAuth token lands, via
1585    /// `set_cloud_home_oauth_tokens`) — not just the directory. Seed all three
1586    /// the way a partial bootstrap would have written them, then assert
1587    /// cleanup leaves none behind.
1588    #[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    /// Only S3 maps to a stored value; every other provider returns `None` so
1646    /// the join never overwrites an already-saved OAuth token (or a CloudKit
1647    /// container) with credentials.
1648    #[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}