Skip to main content

coven/sync/
restore.rs

1//! Restore an existing store from cloud storage.
2//!
3//! Unlike join (which unwraps the encryption key from an invite), restore takes
4//! the encryption key directly from the user — present for an opaque home,
5//! absent for a browsable one.
6
7use std::sync::Arc;
8
9use tokio::sync::watch;
10use tracing::info;
11
12use crate::config::{Config, HomeStorage};
13use crate::custody::KeyCustody;
14use crate::encryption::MasterKeyring;
15use crate::identity_custody::IdentityCustody;
16use crate::keys::{DeviceIdentityCustody, MasterKeyCustody, StoreKeys, UserKeypair};
17use crate::migration::Migration;
18use crate::oauth::OAuthTokens;
19use crate::storage::cloud::setup::CoordinationClients;
20use crate::storage::cloud::{CloudHome, CloudHomeJoinInfo};
21use crate::store_dir::StoreLayout;
22use crate::sync::cloud_storage::{BlobPathScheme, CloudCipher, CloudSyncStorage};
23use crate::sync::join::{
24    bootstrap_and_save_store, cleanup_after_bootstrap_failure, BootstrapError,
25};
26use crate::sync::session::SyncedTable;
27
28/// Cloud provider source for restore: the join info a restore code carries
29/// plus the extras it can't (`RestoreCode` omits OAuth tokens because they
30/// expire — the user re-authenticates on restore — and holds no live CloudKit
31/// driver).
32pub struct RestoreSource {
33    pub join_info: CloudHomeJoinInfo,
34    pub custom_s3_serial: Option<crate::CustomS3Serial>,
35    pub custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
36    pub oauth_tokens: Option<OAuthTokens>,
37    pub cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
38}
39
40/// Require OAuth tokens for a provider that needs them and persist them to the
41/// store-scoped keyring, the same way join's parallel arms do, so the next
42/// launch's home construction (`parse_oauth_tokens` in `storage::cloud`) can
43/// read them back instead of erroring on their absence.
44#[cfg(feature = "oauth-providers")]
45fn require_and_persist_oauth(
46    oauth_tokens: Option<OAuthTokens>,
47    store_id: &str,
48    provider_name: &str,
49) -> Result<(OAuthTokens, StoreKeys), BootstrapError> {
50    let tokens = oauth_tokens.ok_or_else(|| {
51        BootstrapError::Provider(format!("{provider_name} restore requires OAuth token"))
52    })?;
53    let ks = StoreKeys::new(store_id.to_string());
54    crate::sync::join::persist_oauth_tokens(&ks, &tokens)?;
55    Ok((tokens, ks))
56}
57
58/// Build the data and coordination views of a cloud home from a `RestoreSource`.
59async fn build_cloud_home(
60    source: RestoreSource,
61    store_id: &str,
62    clock: crate::clock::ClockRef,
63) -> Result<
64    (
65        CloudHomeJoinInfo,
66        Arc<dyn CloudHome>,
67        Option<CoordinationClients>,
68    ),
69    BootstrapError,
70> {
71    use crate::storage::cloud::*;
72
73    let RestoreSource {
74        join_info,
75        custom_s3_serial: _,
76        custom_s3_exact_slots,
77        oauth_tokens,
78        cloudkit_ops,
79    } = source;
80
81    // Consumed only by the oauth provider arms below.
82    #[cfg(not(feature = "oauth-providers"))]
83    let _ = (store_id, &clock, &oauth_tokens);
84
85    let (home, coordination): (Arc<dyn CloudHome>, Option<CoordinationClients>) = match &join_info {
86        CloudHomeJoinInfo::S3 {
87            bucket,
88            region,
89            endpoint,
90            access_key,
91            secret_key,
92            key_prefix,
93        } => {
94            let (home, peer) = s3::S3CloudHome::new_pair(
95                bucket.clone(),
96                region.clone(),
97                endpoint.clone(),
98                access_key.clone(),
99                secret_key.clone(),
100                key_prefix.clone(),
101                custom_s3_exact_slots,
102            )
103            .await?;
104            let home = Arc::new(home);
105            let peer = Arc::new(peer);
106            (home.clone(), Some((home, peer)))
107        }
108
109        CloudHomeJoinInfo::CloudKit => {
110            let ops = cloudkit_ops.ok_or_else(|| {
111                BootstrapError::Provider("CloudKit driver not provided".to_string())
112            })?;
113            let home = Arc::new(cloudkit::CloudKitCloudHome::new_private(ops.clone()));
114            let peer = Arc::new(cloudkit::CloudKitCloudHome::new_private(ops));
115            (home.clone(), Some((home, peer)))
116        }
117
118        // Restore recovers your own zone, never one shared to you;
119        // `decode_restore_code` already rejects this for the code path, but
120        // `RestoreSource` is public API another caller could construct
121        // directly, so this guard is independent of that decode-time check.
122        CloudHomeJoinInfo::CloudKitShare { .. } => {
123            return Err(BootstrapError::Provider(
124                "restoring from a CloudKit share is not supported — restore recovers your own zone, not a shared one".to_string(),
125            ));
126        }
127
128        #[cfg(feature = "oauth-providers")]
129        CloudHomeJoinInfo::GoogleDrive { folder_id } => {
130            let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "Google Drive")?;
131            (
132                Arc::new(google_drive::GoogleDriveCloudHome::new(
133                    folder_id.clone(),
134                    tokens,
135                    ks,
136                    clock,
137                )?),
138                None,
139            )
140        }
141
142        #[cfg(feature = "oauth-providers")]
143        CloudHomeJoinInfo::Dropbox { folder_path } => {
144            let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "Dropbox")?;
145            (
146                Arc::new(dropbox::DropboxCloudHome::new(
147                    folder_path.clone(),
148                    tokens,
149                    ks,
150                    clock,
151                )?),
152                None,
153            )
154        }
155
156        #[cfg(feature = "oauth-providers")]
157        CloudHomeJoinInfo::OneDrive {
158            drive_id,
159            folder_id,
160        } => {
161            let (tokens, ks) = require_and_persist_oauth(oauth_tokens, store_id, "OneDrive")?;
162            (
163                Arc::new(onedrive::OneDriveCloudHome::new(
164                    drive_id.clone(),
165                    folder_id.clone(),
166                    tokens,
167                    ks,
168                    clock,
169                )?),
170                None,
171            )
172        }
173
174        #[cfg(not(feature = "oauth-providers"))]
175        CloudHomeJoinInfo::GoogleDrive { .. }
176        | CloudHomeJoinInfo::Dropbox { .. }
177        | CloudHomeJoinInfo::OneDrive { .. } => {
178            return Err(BootstrapError::Provider(
179                "OAuth cloud providers are not supported in this build".to_string(),
180            ));
181        }
182    };
183
184    Ok((join_info, home, coordination))
185}
186
187/// Restore a store from cloud storage.
188///
189/// Validates inputs, constructs the cloud home from the source, runs the sync
190/// protocol, and sets the store as active. `keypair` is the restored device's
191/// signing identity (recovered from the restore code); the storage signs the
192/// control objects it writes with it, and it is the same key the caller imports
193/// once restore succeeds.
194#[allow(clippy::too_many_arguments)]
195pub async fn restore_from_cloud(
196    store_id: &str,
197    store_root: crate::sync::store_commit::StoreRootRef,
198    founder_pubkey: &str,
199    serialized_keyring: Option<&str>,
200    store_name: &str,
201    synced_tables: &[SyncedTable],
202    migrations: &[Migration],
203    expected_write_policy: crate::WritePolicy,
204    custody: Arc<dyn MasterKeyCustody>,
205    identity_custody: Arc<dyn DeviceIdentityCustody>,
206    source: RestoreSource,
207    membership_floor: &crate::join_code::MembershipFloor,
208    keypair: &UserKeypair,
209    authority: &crate::sync::restore_code::RestoreAuthority,
210    continuation_device_signer: Option<&UserKeypair>,
211    layout: &StoreLayout,
212    clock: crate::clock::ClockRef,
213    ids: crate::id_provider::IdRef,
214    on_status: impl Fn(&str),
215    cancel: &watch::Receiver<bool>,
216) -> Result<Config, BootstrapError> {
217    // Guard the destructive `stores/<id>` create/delete against any direct
218    // caller, independent of the decode-time check on untrusted input.
219    crate::store_dir::validate_path_token(store_id)
220        .map_err(|e| BootstrapError::InvalidCode(format!("invalid store id: {e}")))?;
221    let actual_write_policy = membership_floor.write_policy();
222    if actual_write_policy != expected_write_policy {
223        return Err(BootstrapError::WritePolicyMismatch {
224            expected: expected_write_policy,
225            actual: actual_write_policy,
226        });
227    }
228    crate::storage::cloud::setup::require_serial_coordination_join_info(
229        &source.join_info,
230        source.custom_s3_serial,
231        actual_write_policy,
232    )
233    .map_err(|provider| BootstrapError::SerialCoordinationUnavailable { provider })?;
234    crate::storage::cloud::setup::require_exact_slot_capabilities_join_info(
235        &source.join_info,
236        source.custom_s3_exact_slots,
237    )
238    .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
239    let custom_s3_serial = source.custom_s3_serial;
240    let custom_s3_exact_slots = source.custom_s3_exact_slots;
241
242    let store_dir = layout.store_dir(store_id);
243
244    // Hoisted here, before any durable write below, so a failure at any step —
245    // including `build_cloud_home`'s OAuth persist, which runs before the store
246    // directory is created — funnels through the same rollback instead of a
247    // bare `?` escaping it.
248    let store_keys = StoreKeys::new(store_id.to_string());
249
250    // Refuse a *completed* store (config present) and clear a torn one before
251    // any provider side effect. The decode guaranteed the id is a safe single
252    // component, so the directory is a direct child of the layout's stores dir
253    // and cannot escape it. Re-running a restore for a store you already have
254    // adds nothing — the existing store is the data — and letting it proceed
255    // would, on any bootstrap failure below, delete that store's database and
256    // blobs during cleanup. Dispatching here makes the failure-cleanup only
257    // ever remove a directory this invocation created.
258    crate::sync::join::refuse_completed_or_clear_torn_store(
259        &store_dir,
260        &store_keys,
261        custody.as_ref(),
262        identity_custody.as_ref(),
263        store_id,
264    )?;
265
266    let result = async {
267        on_status("Preparing restore...");
268
269        // The key's presence is the home's storage mode: a key present ⇒ an
270        // opaque home (encrypted, obfuscated blob paths); a key absent ⇒ a
271        // browsable home (plaintext, readable blob paths). The cipher and the
272        // blob-path scheme both follow from it, so this device computes the
273        // same blob keys the source wrote. Parsed once here (not re-parsed
274        // inside `bootstrap_and_save_store`) so the cipher and the persisted
275        // master key always agree on the same value.
276        let storage = if serialized_keyring.is_some() {
277            HomeStorage::Opaque
278        } else {
279            HomeStorage::Browsable
280        };
281        let master_key: Option<MasterKeyring> = match serialized_keyring {
282            Some(serialized_keyring) => {
283                on_status("Verifying encryption key...");
284                Some(MasterKeyring::from_serialized(serialized_keyring)?)
285            }
286            None => None,
287        };
288        let cipher = match &master_key {
289            Some(keyring) => CloudCipher::Encrypted(keyring.clone().into()),
290            None => CloudCipher::Plaintext,
291        };
292
293        let blob_paths = BlobPathScheme::for_storage(storage);
294
295        let (join_info, cloud_home, coordination) =
296            build_cloud_home(source, store_id, clock.clone()).await?;
297
298        let storage = CloudSyncStorage::new(
299            cloud_home,
300            cipher.clone(),
301            blob_paths,
302            store_id.to_string(),
303            keypair.clone(),
304        )?;
305        let storage = match coordination {
306            Some((primary, peer)) => storage.with_serial_coordination_clients(primary, peer),
307            None => storage,
308        };
309
310        // Create the store directory under `stores/` (its non-existence was
311        // checked up front, so this create and the failure-cleanup below own
312        // it entirely).
313        let device_id = match authority {
314            crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
315                continuation.registration.device_id.to_string()
316            }
317            crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_) => ids.new_id(),
318        };
319        std::fs::create_dir_all(&*store_dir)?;
320
321        let continuation = match (authority, continuation_device_signer) {
322            (
323                crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation),
324                Some(device_signer),
325            ) => Some((continuation, device_signer)),
326            (crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(_), None) => {
327                return Err(BootstrapError::InvalidSigningKey(
328                    "activated continuation has no device signing key".to_string(),
329                ));
330            }
331            (crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_), None) => None,
332            (crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_), Some(_)) => {
333                return Err(BootstrapError::InvalidSigningKey(
334                    "Owner recovery cannot carry an activated device signer".to_string(),
335                ));
336            }
337        };
338
339        Box::pin(bootstrap_and_save_store(
340            &storage,
341            &cipher,
342            master_key.as_ref(),
343            &store_dir,
344            store_id,
345            &device_id,
346            store_root,
347            crate::sync::join::RestoreBootstrapContext {
348                founder_pubkey,
349                keypair,
350                authority,
351                continuation,
352            },
353            membership_floor,
354            synced_tables,
355            migrations,
356            &join_info,
357            store_name,
358            custom_s3_serial,
359            custom_s3_exact_slots,
360            &store_keys,
361            custody.as_ref(),
362            identity_custody.as_ref(),
363            &on_status,
364            cancel,
365        ))
366        .await
367    }
368    .await;
369
370    match result {
371        Ok(config) => {
372            // The host records this as the active store after this returns.
373            info!(
374                "Cloud restore complete: store at {}",
375                config.store_dir.display()
376            );
377            Ok(config)
378        }
379        Err(err) => Err(cleanup_after_bootstrap_failure(
380            &store_dir,
381            &store_keys,
382            custody.as_ref(),
383            identity_custody.as_ref(),
384            err,
385        )),
386    }
387}
388
389/// Restore a store from a restore code string.
390///
391/// Decodes the restore code, fills a `RestoreSource` from its join info plus
392/// the caller-supplied OAuth tokens and CloudKit driver, imports the signing
393/// key, and delegates to `restore_from_cloud`.
394#[allow(clippy::too_many_arguments)]
395pub async fn restore_from_code(
396    code: &str,
397    synced_tables: &[SyncedTable],
398    migrations: &[Migration],
399    expected_write_policy: crate::WritePolicy,
400    custom_s3_serial: Option<crate::CustomS3Serial>,
401    custom_s3_exact_slots: Option<crate::CustomS3ExactSlots>,
402    key_custody: KeyCustody,
403    identity_custody: IdentityCustody,
404    oauth_tokens: Option<crate::oauth::OAuthTokens>,
405    cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
406    layout: &StoreLayout,
407    clock: crate::clock::ClockRef,
408    ids: crate::id_provider::IdRef,
409    on_status: impl Fn(&str),
410    cancel: &watch::Receiver<bool>,
411) -> Result<Config, BootstrapError> {
412    use crate::sync::restore_code;
413
414    let parsed = restore_code::decode_restore_code(code)
415        .map_err(|e| BootstrapError::InvalidCode(e.to_string()))?;
416    let actual_write_policy = parsed.membership_floor.write_policy();
417    if actual_write_policy != expected_write_policy {
418        return Err(BootstrapError::WritePolicyMismatch {
419            expected: expected_write_policy,
420            actual: actual_write_policy,
421        });
422    }
423    crate::storage::cloud::setup::require_serial_coordination_join_info(
424        &parsed.provider,
425        custom_s3_serial,
426        actual_write_policy,
427    )
428    .map_err(|provider| BootstrapError::SerialCoordinationUnavailable { provider })?;
429    crate::storage::cloud::setup::require_exact_slot_capabilities_join_info(
430        &parsed.provider,
431        custom_s3_exact_slots,
432    )
433    .map_err(|provider| BootstrapError::ExactSlotsUnavailable { provider })?;
434    let custody = key_custody.resolve(&parsed.sid, &layout.store_dir(&parsed.sid));
435    let identity_custody = identity_custody.resolve(&parsed.sid, &layout.store_dir(&parsed.sid));
436
437    // `decode_restore_code` already validated the field; rebuild this store's
438    // restored signing identity from it. The storage signs its control objects
439    // with this keypair during restore, and `restore_from_cloud` imports it into
440    // custody just before saving the config.
441    let identity_secret = match &parsed.authority {
442        crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
443            &continuation.identity_signing_secret
444        }
445        crate::sync::restore_code::RestoreAuthority::OwnerRecovery(recovery) => {
446            &recovery.owner_identity_secret
447        }
448    };
449    let signing_key: [u8; crate::keys::SIGN_SECRETKEYBYTES] = hex::decode(identity_secret)
450        .map_err(|e| BootstrapError::InvalidSigningKey(format!("invalid encoding: {e}")))?
451        .try_into()
452        .map_err(|_| {
453            BootstrapError::InvalidSigningKey(format!(
454                "Signing key must be {} bytes",
455                crate::keys::SIGN_SECRETKEYBYTES
456            ))
457        })?;
458    let keypair = UserKeypair::from_signing_key_bytes(&signing_key).map_err(BootstrapError::Key)?;
459    let continuation_device_signer = match &parsed.authority {
460        crate::sync::restore_code::RestoreAuthority::ActivatedContinuation(continuation) => {
461            let bytes: [u8; crate::keys::SIGN_SECRETKEYBYTES] =
462                hex::decode(&continuation.device_signing_secret)
463                    .map_err(|error| {
464                        BootstrapError::InvalidSigningKey(format!(
465                            "invalid device signing key encoding: {error}"
466                        ))
467                    })?
468                    .try_into()
469                    .map_err(|_| {
470                        BootstrapError::InvalidSigningKey(format!(
471                            "Device signing key must be {} bytes",
472                            crate::keys::SIGN_SECRETKEYBYTES
473                        ))
474                    })?;
475            Some(UserKeypair::from_signing_key_bytes(&bytes).map_err(BootstrapError::Key)?)
476        }
477        crate::sync::restore_code::RestoreAuthority::OwnerRecovery(_) => None,
478    };
479
480    // `parsed.provider` is already the shared `CloudHomeJoinInfo`; `build_cloud_home`
481    // (via `restore_from_cloud`) matches on it and pulls in these extras, so there's
482    // no per-provider conversion left to do here.
483    let source = RestoreSource {
484        join_info: parsed.provider.clone(),
485        custom_s3_serial,
486        custom_s3_exact_slots,
487        oauth_tokens,
488        cloudkit_ops,
489    };
490
491    // `restore_from_cloud` imports this store's signing identity as the step
492    // before it saves the config, so a saved config always has its identity in
493    // custody. Nothing identity-related is left for this caller to do.
494    Box::pin(restore_from_cloud(
495        &parsed.sid,
496        parsed.store_root,
497        &parsed.founder_pubkey,
498        parsed.ek.as_deref(),
499        &parsed.name,
500        synced_tables,
501        migrations,
502        expected_write_policy,
503        custody.clone(),
504        identity_custody.clone(),
505        source,
506        &parsed.membership_floor,
507        &keypair,
508        &parsed.authority,
509        continuation_device_signer.as_ref(),
510        layout,
511        clock,
512        ids,
513        on_status,
514        cancel,
515    ))
516    .await
517}
518
519// The only test here exercises the OAuth-provider arms of `build_cloud_home`,
520// which only exist under this feature; the module (not just the test fn) is
521// gated so its imports aren't unused in a build without the feature.
522#[cfg(all(test, feature = "oauth-providers"))]
523mod tests {
524    use super::*;
525    use crate::keys::CloudHomeCredentials;
526
527    /// Restore's per-provider `build_cloud_home` must save the caller-supplied
528    /// OAuth tokens to the store-scoped keyring, the same way join's parallel
529    /// arms already do. Launch-time home construction (`parse_oauth_tokens` in
530    /// `storage::cloud`) reads them back from there and errors when they're
531    /// absent, so a store restored over an OAuth provider must be able to build
532    /// its cloud home again on the next launch. Dropbox is the smallest OAuth
533    /// arm, so it stands in for Google Drive and OneDrive here.
534    #[tokio::test]
535    async fn restore_dropbox_build_cloud_home_persists_oauth_tokens() {
536        crate::keys::test_keyring::install();
537        crate::oauth::install_test_client_creds();
538
539        let store_id = "restore-dropbox-persist-test";
540        let tokens = OAuthTokens {
541            access_token: "access-token".to_string(),
542            refresh_token: Some("refresh-token".to_string()),
543            expires_at: None,
544        };
545        let source = RestoreSource {
546            join_info: CloudHomeJoinInfo::Dropbox {
547                folder_path: "/Apps/coven/my-store".to_string(),
548            },
549            custom_s3_serial: None,
550            custom_s3_exact_slots: None,
551            oauth_tokens: Some(tokens.clone()),
552            cloudkit_ops: None,
553        };
554
555        build_cloud_home(source, store_id, Arc::new(crate::clock::SystemClock))
556            .await
557            .expect("build restore cloud home for Dropbox");
558
559        let stored = StoreKeys::new(store_id.to_string())
560            .get_cloud_home_credentials()
561            .expect("read cloud home credentials")
562            .expect("restore must persist OAuth tokens to the keyring");
563        match stored {
564            CloudHomeCredentials::OAuth { token_json } => {
565                let stored_tokens: OAuthTokens =
566                    serde_json::from_str(&token_json).expect("stored OAuth tokens deserialize");
567                assert_eq!(stored_tokens.access_token, tokens.access_token);
568                assert_eq!(stored_tokens.refresh_token, tokens.refresh_token);
569            }
570            other => panic!("expected OAuth credentials, got {other:?}"),
571        }
572    }
573}
574
575// These exercise arms of `build_cloud_home` that don't need the OAuth
576// providers (S3, CloudKit), so unlike the module above they run regardless of
577// the `oauth-providers` feature.
578#[cfg(test)]
579mod build_cloud_home_tests {
580    use super::*;
581
582    /// `RestoreSource.join_info` now carries `CloudHomeJoinInfo::S3` directly —
583    /// there's no more `RestoreSource::S3` hop that dropped `key_prefix` on the
584    /// floor (it built the `CloudHomeJoinInfo` returned to the caller with
585    /// `key_prefix: None` unconditionally). `build_cloud_home` must carry the
586    /// restore code's `key_prefix` through to the join info it returns, which
587    /// becomes `Config.cloud_home.s3_key_prefix` — otherwise every restore of
588    /// an S3 home configured with a key prefix loses it.
589    #[tokio::test]
590    async fn build_cloud_home_s3_preserves_key_prefix() {
591        let source = RestoreSource {
592            join_info: CloudHomeJoinInfo::S3 {
593                bucket: "b".to_string(),
594                region: "us-east-1".to_string(),
595                endpoint: None,
596                access_key: "ak".to_string(),
597                secret_key: "sk".to_string(),
598                key_prefix: Some("prefix/".to_string()),
599            },
600            custom_s3_serial: None,
601            custom_s3_exact_slots: None,
602            oauth_tokens: None,
603            cloudkit_ops: None,
604        };
605
606        let (returned_info, _home, coordination) =
607            build_cloud_home(source, "store-id", Arc::new(crate::clock::SystemClock))
608                .await
609                .expect("build S3 cloud home");
610        assert!(coordination.is_some());
611
612        match returned_info {
613            CloudHomeJoinInfo::S3 { key_prefix, .. } => {
614                assert_eq!(key_prefix, Some("prefix/".to_string()));
615            }
616            other => panic!("expected S3 join info, got {other:?}"),
617        }
618    }
619
620    /// `RestoreSource` is public API a caller can construct directly, bypassing
621    /// `decode_restore_code`'s rejection of `CloudKitShare`. `build_cloud_home`
622    /// must refuse it on its own: restore recovers your own zone, never one
623    /// shared to you.
624    #[tokio::test]
625    async fn build_cloud_home_rejects_cloudkit_share() {
626        let source = RestoreSource {
627            join_info: CloudHomeJoinInfo::CloudKitShare {
628                share_url: "https://share.example".to_string(),
629                owner_name: "owner".to_string(),
630                zone_name: "zone".to_string(),
631            },
632            custom_s3_serial: None,
633            custom_s3_exact_slots: None,
634            oauth_tokens: None,
635            cloudkit_ops: None,
636        };
637
638        let result =
639            build_cloud_home(source, "store-id", Arc::new(crate::clock::SystemClock)).await;
640
641        match result {
642            Err(BootstrapError::Provider(_)) => {}
643            Ok(_) => panic!("expected a Provider error rejecting the CloudKit share, got Ok"),
644            Err(other) => {
645                panic!("expected a Provider error rejecting the CloudKit share, got {other:?}")
646            }
647        }
648    }
649}