Skip to main content

coven_domain/restoration/
restore.rs

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