Skip to main content

coven/storage/cloud/
setup.rs

1//! Cloud provider setup and management.
2//!
3//! Contains the OAuth sign-in flows for Google Drive, Dropbox, and OneDrive,
4//! as well as managed service signup/login, disconnect, and account display logic.
5
6// `info` is used only by OAuth sign-in flows; `warn` by the always-present
7// account-display path.
8#[cfg(feature = "oauth-providers")]
9use tracing::info;
10
11use crate::config::{CloudProvider, Config};
12use crate::keys::{CloudHomeCredentials, DeviceIdentityCustody, MasterKeyCustody, StoreKeys};
13#[cfg(feature = "oauth-providers")]
14use crate::oauth::OAuthTokens;
15use crate::sync::cloud_storage::BlobPathScheme;
16use crate::sync::cloud_storage::CloudCipher;
17use std::sync::Arc;
18
19#[cfg(feature = "oauth-providers")]
20fn save_oauth_tokens(key_service: &StoreKeys, tokens: &OAuthTokens) -> Result<(), SetupError> {
21    key_service
22        .set_cloud_home_oauth_tokens(tokens)
23        .map_err(|e| SetupError(format!("Failed to save OAuth token: {e}")))
24}
25
26/// Google Drive OAuth sign-in: authorize, find/create the store folder, save
27/// tokens to the keyring. Returns the folder id for the host to persist in its
28/// own config (coven never writes the host's config).
29///
30/// Drives coven's localhost-callback OAuth flow ([`crate::oauth::authorize`]),
31/// which binds a TCP port and opens the system browser. Gated on
32/// `oauth-providers`.
33#[cfg(feature = "oauth-providers")]
34pub async fn sign_in_google_drive(
35    key_service: &StoreKeys,
36    store_name: &str,
37    oauth_cancel: tokio::sync::watch::Receiver<bool>,
38    clock: &dyn crate::clock::Clock,
39) -> Result<String, SetupError> {
40    let oauth_config = super::google_drive::GoogleDriveCloudHome::oauth_config()
41        .map_err(|e| SetupError(e.to_string()))?;
42    let tokens = crate::oauth::authorize(&oauth_config, oauth_cancel, clock)
43        .await
44        .map_err(|e| SetupError(format!("Google Drive authorization failed: {e}")))?;
45
46    let client = reqwest::Client::new();
47
48    // Create or find the folder
49    let folder_name = format!("your-app - {store_name}");
50
51    let search_query = super::google_drive::folder_search_query(&folder_name);
52    let search_resp = super::google_drive::supports_all_drives(
53        client.get("https://www.googleapis.com/drive/v3/files"),
54    )
55    .bearer_auth(&tokens.access_token)
56    .query(&[
57        ("q", search_query.as_str()),
58        ("fields", "files(id)"),
59        ("includeItemsFromAllDrives", "true"),
60    ])
61    .send()
62    .await
63    .map_err(|e| {
64        SetupError(format!(
65            "Failed to search for existing Google Drive folder: {e}"
66        ))
67    })?;
68
69    if !search_resp.status().is_success() {
70        let body = super::http::body_text(search_resp).await;
71        return Err(SetupError(format!(
72            "Failed to search for existing Google Drive folder: {body}"
73        )));
74    }
75
76    let search_json: serde_json::Value = search_resp
77        .json()
78        .await
79        .map_err(|e| SetupError(format!("Failed to parse Google Drive search response: {e}")))?;
80
81    let existing_folder_id = search_json["files"][0]["id"]
82        .as_str()
83        .map(|s| s.to_string());
84
85    let folder_id = if let Some(id) = existing_folder_id {
86        id
87    } else {
88        let create_body = serde_json::json!({
89            "name": folder_name,
90            "mimeType": "application/vnd.google-apps.folder",
91        });
92        let resp = super::google_drive::supports_all_drives(
93            client.post("https://www.googleapis.com/drive/v3/files"),
94        )
95        .bearer_auth(&tokens.access_token)
96        .json(&create_body)
97        .send()
98        .await
99        .map_err(|e| SetupError(format!("Failed to create Google Drive folder: {e}")))?;
100
101        if !resp.status().is_success() {
102            let body = super::http::body_text(resp).await;
103            return Err(SetupError(format!(
104                "Failed to create Google Drive folder: {body}"
105            )));
106        }
107
108        let folder_resp: serde_json::Value = resp
109            .json()
110            .await
111            .map_err(|e| SetupError(format!("Failed to parse folder response: {e}")))?;
112        folder_resp["id"]
113            .as_str()
114            .ok_or_else(|| SetupError("Google Drive folder response missing 'id'".to_string()))?
115            .to_string()
116    };
117
118    save_oauth_tokens(key_service, &tokens)?;
119
120    info!("Authorized Google Drive; folder ready");
121    Ok(folder_id)
122}
123
124/// Dropbox OAuth sign-in: authorize, create the store folder, save tokens to
125/// the keyring. Returns the folder path for the host to persist in its config.
126///
127/// Uses the localhost-callback flow; gated on `oauth-providers`.
128#[cfg(feature = "oauth-providers")]
129pub async fn sign_in_dropbox(
130    key_service: &StoreKeys,
131    store_name: &str,
132    oauth_cancel: tokio::sync::watch::Receiver<bool>,
133    clock: &dyn crate::clock::Clock,
134) -> Result<String, SetupError> {
135    let oauth_config =
136        super::dropbox::DropboxCloudHome::oauth_config().map_err(|e| SetupError(e.to_string()))?;
137    let tokens = crate::oauth::authorize(&oauth_config, oauth_cancel, clock)
138        .await
139        .map_err(|e| SetupError(format!("Dropbox authorization failed: {e}")))?;
140
141    let client = reqwest::Client::new();
142
143    let folder_path = format!("/Apps/your-app/{store_name}");
144
145    // Create the folder (ignore error if it already exists)
146    let create_body = serde_json::json!({
147        "path": folder_path,
148        "autorename": false,
149    });
150    let resp = client
151        .post("https://api.dropboxapi.com/2/files/create_folder_v2")
152        .bearer_auth(&tokens.access_token)
153        .json(&create_body)
154        .send()
155        .await
156        .map_err(|e| SetupError(format!("Failed to create Dropbox folder: {e}")))?;
157
158    let status = resp.status();
159    if !status.is_success() {
160        let body = super::http::body_text(resp).await;
161        // 409 with "path/conflict" means the folder already exists -- fine
162        if !(status == reqwest::StatusCode::CONFLICT && body.contains("conflict")) {
163            return Err(SetupError(format!(
164                "Failed to create Dropbox folder (HTTP {status}): {body}"
165            )));
166        }
167    }
168
169    save_oauth_tokens(key_service, &tokens)?;
170
171    info!("Authorized Dropbox; folder ready");
172    Ok(folder_path)
173}
174
175/// OneDrive OAuth sign-in: authorize, resolve the default drive, create the app
176/// folder, save tokens to the keyring. Returns `(drive_id, folder_id)` for the
177/// host to persist in its config.
178///
179/// Uses the localhost-callback flow; gated on `oauth-providers`.
180#[cfg(feature = "oauth-providers")]
181pub async fn sign_in_onedrive(
182    key_service: &StoreKeys,
183    oauth_cancel: tokio::sync::watch::Receiver<bool>,
184    clock: &dyn crate::clock::Clock,
185) -> Result<(String, String), SetupError> {
186    let oauth_config = super::onedrive::OneDriveCloudHome::oauth_config()
187        .map_err(|e| SetupError(e.to_string()))?;
188    let tokens = crate::oauth::authorize(&oauth_config, oauth_cancel, clock)
189        .await
190        .map_err(|e| SetupError(format!("OneDrive authorization failed: {e}")))?;
191
192    let client = reqwest::Client::new();
193
194    // Get the user's default drive
195    let drive_resp = client
196        .get("https://graph.microsoft.com/v1.0/me/drive")
197        .bearer_auth(&tokens.access_token)
198        .send()
199        .await
200        .map_err(|e| SetupError(format!("Failed to get drive info: {e}")))?;
201
202    if !drive_resp.status().is_success() {
203        let body = super::http::body_text(drive_resp).await;
204        return Err(SetupError(format!("Failed to get OneDrive info: {body}")));
205    }
206
207    let drive_json: serde_json::Value = drive_resp
208        .json()
209        .await
210        .map_err(|e| SetupError(format!("Failed to parse drive response: {e}")))?;
211
212    let drive_id = drive_json["id"]
213        .as_str()
214        .ok_or_else(|| SetupError("Drive response missing 'id' field".to_string()))?
215        .to_string();
216
217    // Create the app folder
218    let create_resp = client
219        .post(format!(
220            "https://graph.microsoft.com/v1.0/drives/{}/root/children",
221            drive_id
222        ))
223        .bearer_auth(&tokens.access_token)
224        .json(&serde_json::json!({
225            "name": "your-app",
226            "folder": {},
227            "@microsoft.graph.conflictBehavior": "useExisting",
228        }))
229        .send()
230        .await
231        .map_err(|e| SetupError(format!("Failed to create OneDrive folder: {e}")))?;
232
233    if !create_resp.status().is_success() {
234        let body = super::http::body_text(create_resp).await;
235        return Err(SetupError(format!(
236            "Failed to create OneDrive folder: {body}"
237        )));
238    }
239
240    let folder_json: serde_json::Value = create_resp
241        .json()
242        .await
243        .map_err(|e| SetupError(format!("Failed to parse folder response: {e}")))?;
244
245    let folder_id = folder_json["id"]
246        .as_str()
247        .ok_or_else(|| SetupError("Folder response missing 'id' field".to_string()))?
248        .to_string();
249
250    save_oauth_tokens(key_service, &tokens)?;
251
252    info!("Authorized OneDrive; folder ready");
253    Ok((drive_id, folder_id))
254}
255
256/// Build a RestoreCode from config and custody, then encode it.
257///
258/// `membership_floor` is the exact policy-shaped membership state at mint
259/// time. The caller fetches it because this function never touches the network.
260pub fn generate_restore_code(
261    config: &Config,
262    key_service: &StoreKeys,
263    custody: &dyn MasterKeyCustody,
264    store_root: crate::sync::store_commit::StoreRootRef,
265    founder_pubkey: String,
266    membership_floor: crate::join_code::MembershipFloor,
267    authority: crate::sync::restore_code::RestoreAuthority,
268) -> Result<String, SetupError> {
269    use crate::storage::cloud::CloudHomeJoinInfo;
270    use crate::sync::restore_code::{encode_restore_code, RestoreCode, RESTORE_CODE_VERSION};
271
272    let cloud_provider = config.cloud_home.provider.as_ref().ok_or_else(|| {
273        SetupError("No cloud provider configured. Set up sync first.".to_string())
274    })?;
275
276    // An opaque home carries its store key in the restore code so a second
277    // device can read the bucket; a browsable home has no key (`ek` is omitted),
278    // and the restorer rebuilds the browsable (plaintext, readable) home from its
279    // absence.
280    let ek = if config.cloud_home.storage.is_opaque() {
281        Some(
282            custody
283                .unlock()
284                .map_err(|e| SetupError(format!("Failed to read master key: {e}")))?
285                .ok_or_else(|| SetupError("No encryption key found".to_string()))?
286                .to_serialized(),
287        )
288    } else {
289        None
290    };
291
292    let provider = match cloud_provider {
293        CloudProvider::S3 => {
294            let creds = key_service
295                .get_cloud_home_credentials()
296                .map_err(|e| SetupError(format!("Failed to read cloud credentials: {e}")))?
297                .ok_or_else(|| SetupError("No S3 credentials found in keyring".to_string()))?;
298            let (access_key, secret_key) = match creds {
299                CloudHomeCredentials::S3 {
300                    access_key,
301                    secret_key,
302                } => (access_key, secret_key),
303                _ => {
304                    return Err(SetupError(
305                        "Expected S3 credentials but found different type".to_string(),
306                    ))
307                }
308            };
309            let bucket = config
310                .cloud_home
311                .s3_bucket
312                .clone()
313                .ok_or_else(|| SetupError("S3 bucket not configured".to_string()))?;
314            let region = config
315                .cloud_home
316                .s3_region
317                .clone()
318                .ok_or_else(|| SetupError("S3 region not configured".to_string()))?;
319            CloudHomeJoinInfo::S3 {
320                bucket,
321                region,
322                endpoint: config.cloud_home.s3_endpoint.clone(),
323                key_prefix: config.cloud_home.s3_key_prefix.clone(),
324                access_key,
325                secret_key,
326            }
327        }
328        CloudProvider::CloudKit => {
329            // A device that joined via a CloudKit share has these set
330            // (`build_config`'s `CloudKitShare` arm); restore recovers your
331            // own zone, never one shared to you — the same line
332            // `decode_restore_code` already draws by rejecting
333            // `CloudHomeJoinInfo::CloudKitShare`. Only a truly private config
334            // (neither set) may emit `CloudHomeJoinInfo::CloudKit`.
335            if config.cloud_home.cloudkit_owner_name.is_some()
336                || config.cloud_home.cloudkit_zone_name.is_some()
337            {
338                return Err(SetupError(
339                    "This store was joined through a CloudKit share; only the store's owner can create a restore code.".to_string(),
340                ));
341            }
342            CloudHomeJoinInfo::CloudKit
343        }
344        CloudProvider::GoogleDrive => CloudHomeJoinInfo::GoogleDrive {
345            folder_id: config
346                .cloud_home
347                .google_drive_folder_id
348                .clone()
349                .ok_or_else(|| SetupError("Google Drive folder ID not configured".to_string()))?,
350        },
351        CloudProvider::Dropbox => CloudHomeJoinInfo::Dropbox {
352            folder_path: config
353                .cloud_home
354                .dropbox_folder_path
355                .clone()
356                .ok_or_else(|| SetupError("Dropbox folder path not configured".to_string()))?,
357        },
358        CloudProvider::OneDrive => {
359            let drive_id = config
360                .cloud_home
361                .onedrive_drive_id
362                .clone()
363                .ok_or_else(|| SetupError("OneDrive drive ID not configured".to_string()))?;
364            let folder_id = config
365                .cloud_home
366                .onedrive_folder_id
367                .clone()
368                .ok_or_else(|| SetupError("OneDrive folder ID not configured".to_string()))?;
369            CloudHomeJoinInfo::OneDrive {
370                drive_id,
371                folder_id,
372            }
373        }
374    };
375
376    let code = RestoreCode {
377        v: RESTORE_CODE_VERSION,
378        sid: config.store_id.clone(),
379        ek,
380        name: config.store_name.clone(),
381        provider,
382        store_root,
383        founder_pubkey,
384        membership_floor,
385        authority,
386    };
387
388    Ok(encode_restore_code(&code))
389}
390
391/// Why building the sync storage from config failed. Each arm preserves the
392/// typed error the layer below produced — notably [`CloudHomeError`], so the
393/// [`CloudHomeError::is_retryable`] verdict survives up to the caller rather than
394/// being flattened into a string here.
395#[derive(Debug, thiserror::Error)]
396pub enum StorageSetupError {
397    #[error("failed to build cloud home: {0}")]
398    CloudHome(#[from] super::CloudHomeError),
399    #[error("key error: {0}")]
400    Key(#[from] crate::keys::KeyError),
401    #[error("no encryption key found for an encrypted cloud home")]
402    NoEncryptionKey,
403    #[error("{provider:?} cannot provide exact protocol and blob slots with this configuration")]
404    ExactSlotsUnavailable { provider: CloudProvider },
405}
406
407pub(crate) fn require_exact_slot_capabilities_config(
408    config: &Config,
409) -> Result<(), StorageSetupError> {
410    let provider = config.cloud_home.provider.clone().ok_or_else(|| {
411        super::CloudHomeError::Configuration(
412            "sync requires a cloud provider with exact-slot storage".to_string(),
413        )
414    })?;
415    if exact_slot_capabilities_supported(
416        &provider,
417        config.cloud_home.s3_endpoint.is_some(),
418        config.cloud_home.s3_exact_slots,
419    ) {
420        Ok(())
421    } else {
422        Err(StorageSetupError::ExactSlotsUnavailable { provider })
423    }
424}
425
426pub(crate) fn require_exact_slot_capabilities_join_info(
427    join_info: &crate::storage::cloud::CloudHomeJoinInfo,
428    custom_s3_exact_slots: Option<crate::config::CustomS3ExactSlots>,
429) -> Result<(), CloudProvider> {
430    let provider = join_info.cloud_provider();
431    let custom_endpoint = matches!(
432        join_info,
433        crate::storage::cloud::CloudHomeJoinInfo::S3 {
434            endpoint: Some(_),
435            ..
436        }
437    );
438    if exact_slot_capabilities_supported(&provider, custom_endpoint, custom_s3_exact_slots) {
439        Ok(())
440    } else {
441        Err(provider)
442    }
443}
444
445pub(crate) fn require_exact_slot_capabilities_home(
446    home: std::sync::Arc<dyn super::CloudHome>,
447    provider: Option<CloudProvider>,
448) -> Result<(), StorageSetupError> {
449    if home.exact_slot_storage().is_some() {
450        Ok(())
451    } else if let Some(provider) = provider {
452        Err(StorageSetupError::ExactSlotsUnavailable { provider })
453    } else {
454        Err(super::CloudHomeError::Configuration(
455            "sync requires a cloud provider with exact-slot storage".to_string(),
456        )
457        .into())
458    }
459}
460
461fn exact_slot_capabilities_supported(
462    provider: &CloudProvider,
463    custom_s3_endpoint: bool,
464    custom_s3_exact_slots: Option<crate::config::CustomS3ExactSlots>,
465) -> bool {
466    match provider {
467        CloudProvider::S3 if !custom_s3_endpoint => true,
468        CloudProvider::S3 => {
469            custom_s3_exact_slots
470                == Some(crate::config::CustomS3ExactSlots::StandardConditionalRequests)
471        }
472        CloudProvider::GoogleDrive
473        | CloudProvider::Dropbox
474        | CloudProvider::OneDrive
475        | CloudProvider::CloudKit => true,
476    }
477}
478
479/// Build the [`CloudCipher`] a store's config selects: an opaque home seals
480/// every object under the keyring's store key; a browsable home
481/// (`cloud_home.storage == Browsable`) stores objects in the clear.
482///
483/// A browsable home has no store key, so it never reads the keyring — the
484/// absence of a key there is expected, not an error.
485pub(crate) fn build_cloud_cipher(
486    config: &Config,
487    custody: &dyn MasterKeyCustody,
488) -> Result<CloudCipher, StorageSetupError> {
489    if config.cloud_home.storage.is_browsable() {
490        return Ok(CloudCipher::Plaintext);
491    }
492    let keyring = custody
493        .unlock()?
494        .ok_or(StorageSetupError::NoEncryptionKey)?;
495    Ok(CloudCipher::Encrypted(keyring.into()))
496}
497
498/// Create sync storage from config and credentials.
499///
500/// This is a lighter version of `sync::cycle::init_sync` that only creates the
501/// storage client without starting a sync session or extracting raw DB handles.
502/// Used by membership management which only needs storage access.
503///
504/// `cipher` lets the caller reuse an already-built cipher (so the sync loop and
505/// storage share one instance for in-place key rotation); when `None` it is
506/// built from config via [`build_cloud_cipher`].
507pub(crate) async fn create_sync_storage_with_cloudkit(
508    config: &Config,
509    key_service: &StoreKeys,
510    custody: &dyn MasterKeyCustody,
511    identity_custody: &dyn DeviceIdentityCustody,
512    cipher: Option<CloudCipher>,
513    clock: crate::clock::ClockRef,
514    cloudkit_ops: Option<std::sync::Arc<dyn super::cloudkit::CloudKitOps>>,
515) -> Result<crate::sync::cloud_storage::CloudSyncStorage, StorageSetupError> {
516    require_exact_slot_capabilities_config(config)?;
517    let cloud_home =
518        super::create_cloud_home_with_cloudkit(config, key_service, clock, cloudkit_ops.clone())
519            .await?;
520    create_sync_storage_with_home(
521        config,
522        custody,
523        identity_custody,
524        Arc::from(cloud_home),
525        cipher,
526    )
527}
528
529/// Create sync storage over an already-built [`CloudHome`](super::CloudHome).
530/// Unlike [`create_sync_storage_with_cloudkit`], this needs no store-scoped
531/// cloud credentials (the home is already built) — only custody, for the
532/// `cipher = None` fallback's master-key read.
533pub(crate) fn create_sync_storage_with_home(
534    config: &Config,
535    custody: &dyn MasterKeyCustody,
536    identity_custody: &dyn DeviceIdentityCustody,
537    home: Arc<dyn super::CloudHome>,
538    cipher: Option<CloudCipher>,
539) -> Result<crate::sync::cloud_storage::CloudSyncStorage, StorageSetupError> {
540    require_exact_slot_capabilities_home(home.clone(), config.cloud_home.provider.clone())?;
541    let cipher = match cipher {
542        Some(c) => c,
543        None => build_cloud_cipher(config, custody)?,
544    };
545
546    // This store's signing identity, used to sign the control objects the
547    // storage writes (its head, the min_schema floor) so a reader can attribute
548    // and verify them against the membership chain. A connect path, so this
549    // never mints: an unestablished identity fails with
550    // `KeyError::NoDeviceIdentity` rather than forging one.
551    let keypair = crate::keys::require_identity(identity_custody)?;
552
553    Ok(crate::sync::cloud_storage::CloudSyncStorage::new(
554        home,
555        cipher,
556        BlobPathScheme::for_storage(config.cloud_home.storage),
557        config.store_id.clone(),
558        keypair,
559    )?)
560}
561
562/// Cloud provider setup error.
563#[derive(Debug, thiserror::Error)]
564#[error("{0}")]
565pub struct SetupError(pub String);
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::config::HomeStorage;
571    use crate::storage::cloud::CloudHomeJoinInfo;
572    use crate::store_dir::StoreDir;
573    use crate::sync::restore_code::decode_restore_code;
574
575    fn membership_floor(author_pubkey: String) -> Vec<crate::sync::membership::MembershipHeadRef> {
576        let coord = crate::sync::membership::MembershipCoord {
577            author_pubkey,
578            author_owner_grant: crate::sync::membership::MembershipGrantId(
579                crate::sync::store_commit::ObjectHash::digest(b"restore test owner grant"),
580            ),
581            stream_id: "0000000000000000000000000000000000000000000000000000000000000001"
582                .parse()
583                .expect("canonical test author stream id"),
584            seq: 1,
585            entry_hash: crate::sync::store_commit::ObjectHash::digest(
586                b"restore test founder entry",
587            ),
588        };
589        let stored = b"restore setup membership head";
590        vec![crate::sync::membership::MembershipHeadRef {
591            coord,
592            head_hash: crate::sync::store_commit::ObjectHash::digest(
593                b"restore setup membership head semantic bytes",
594            ),
595            object: crate::sync::storage::ExactObjectRef::new(
596                crate::storage::cloud::ObjectSlot::logical(
597                    "store-v1/membership/heads/restore-setup/1.json".to_string(),
598                )
599                .expect("valid test membership-head slot"),
600                stored.len() as u64,
601                crate::sync::store_commit::ObjectHash::digest(stored),
602            ),
603        }]
604    }
605
606    fn store_root() -> crate::sync::store_commit::StoreRootRef {
607        let stored = b"restore setup Store root";
608        crate::sync::store_commit::StoreRootRef {
609            store_root_id: crate::sync::store_commit::ObjectHash::digest(
610                b"restore setup Store root identity",
611            ),
612            store_root_hash: crate::sync::store_commit::ObjectHash::digest(stored),
613            object: crate::sync::storage::ExactObjectRef::new(
614                crate::storage::cloud::ObjectSlot::logical(
615                    "store-v1/protocol/root/restore-setup.json".to_string(),
616                )
617                .expect("valid test Store-root slot"),
618                stored.len() as u64,
619                crate::sync::store_commit::ObjectHash::digest(stored),
620            ),
621        }
622    }
623
624    fn restore_authority() -> crate::sync::restore_code::RestoreAuthority {
625        let owner_grant = crate::sync::membership::MembershipGrantId(
626            crate::sync::store_commit::ObjectHash::digest(b"restore test owner grant"),
627        );
628        let root = store_root();
629        let owner_pubkey = hex::encode([7u8; 32]);
630        let anchor = crate::sync::store_commit::GrantStreamAnchor::OwnerRecovery {
631            first_slot: crate::storage::cloud::ObjectSlot::logical(
632                "store-v1/recovery/restore-setup/first.json".to_string(),
633            )
634            .expect("valid recovery slot"),
635        };
636        let activation = crate::sync::store_commit::OwnerRecoveryActivationId::derive(
637            &root,
638            &owner_pubkey,
639            &owner_grant,
640            &anchor,
641        )
642        .expect("valid recovery activation");
643        crate::sync::restore_code::RestoreAuthority::OwnerRecovery(
644            crate::sync::restore_code::OwnerRecoveryAuthority {
645                owner_identity_secret: hex::encode(
646                    crate::keys::UserKeypair::generate().to_keypair_bytes(),
647                ),
648                owner_grant: owner_grant.clone(),
649                recovery: crate::sync::store_commit::OwnerRecoveryCursor {
650                    owner_grant,
651                    position: crate::sync::store_commit::OwnerRecoveryPosition::BeforeFirst {
652                        activation,
653                    },
654                },
655                published_at: "2026-07-17T00:00:00Z".to_string(),
656            },
657        )
658    }
659
660    /// A CloudKit config with `storage: Browsable` so the test exercises only
661    /// the CloudKit provider arm, never the opaque-home encryption-key read
662    /// (that path is unrelated to the restore-code provider guard under test).
663    fn cloudkit_config(owner_zone: Option<(&str, &str)>) -> Config {
664        let mut config = Config::with_defaults(
665            "store-1".to_string(),
666            "device-1".to_string(),
667            StoreDir::new("unused-store-dir"),
668            "CloudKit Store".to_string(),
669        );
670        config.cloud_home.provider = Some(CloudProvider::CloudKit);
671        config.cloud_home.storage = HomeStorage::Browsable;
672        if let Some((owner, zone)) = owner_zone {
673            config.cloud_home.cloudkit_owner_name = Some(owner.to_string());
674            config.cloud_home.cloudkit_zone_name = Some(zone.to_string());
675        }
676        config
677    }
678
679    #[test]
680    fn exact_slot_admission_is_universal_and_uses_local_s3_assertions() {
681        let mut config = Config::with_defaults(
682            "store-1".to_string(),
683            "device-1".to_string(),
684            StoreDir::new("unused-store-dir"),
685            "Provider matrix".to_string(),
686        );
687
688        config.cloud_home.provider = Some(CloudProvider::S3);
689        config.cloud_home.s3_endpoint = None;
690        assert!(require_exact_slot_capabilities_config(&config).is_ok());
691
692        config.cloud_home.s3_endpoint = Some("https://objects.example".to_string());
693        assert!(matches!(
694            require_exact_slot_capabilities_config(&config),
695            Err(StorageSetupError::ExactSlotsUnavailable {
696                provider: CloudProvider::S3
697            })
698        ));
699        config.cloud_home.s3_exact_slots =
700            Some(crate::CustomS3ExactSlots::StandardConditionalRequests);
701        assert!(require_exact_slot_capabilities_config(&config).is_ok());
702
703        for provider in [
704            CloudProvider::GoogleDrive,
705            CloudProvider::Dropbox,
706            CloudProvider::OneDrive,
707            CloudProvider::CloudKit,
708        ] {
709            config.cloud_home.provider = Some(provider.clone());
710            assert!(require_exact_slot_capabilities_config(&config).is_ok());
711        }
712    }
713
714    #[test]
715    fn custom_s3_join_requires_the_local_exact_slot_assertion() {
716        let join_info = CloudHomeJoinInfo::S3 {
717            bucket: "bucket".to_string(),
718            region: "region".to_string(),
719            endpoint: Some("https://objects.example".to_string()),
720            access_key: "access".to_string(),
721            secret_key: "secret".to_string(),
722            key_prefix: None,
723        };
724
725        assert_eq!(
726            require_exact_slot_capabilities_join_info(&join_info, None),
727            Err(CloudProvider::S3),
728        );
729        assert!(require_exact_slot_capabilities_join_info(
730            &join_info,
731            Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
732        )
733        .is_ok());
734    }
735
736    #[test]
737    fn custom_s3_exact_slot_assertion_stays_out_of_restore_wire() {
738        crate::keys::test_keyring::install();
739        let dir = tempfile::tempdir().expect("store directory");
740        let mut config = Config::with_defaults(
741            "local-assertion-wire-test".to_string(),
742            "device-1".to_string(),
743            StoreDir::new(dir.path()),
744            "Wire Test".to_string(),
745        );
746        config.cloud_home.provider = Some(CloudProvider::S3);
747        config.cloud_home.storage = HomeStorage::Browsable;
748        config.cloud_home.s3_bucket = Some("bucket".to_string());
749        config.cloud_home.s3_region = Some("region".to_string());
750        config.cloud_home.s3_endpoint = Some("https://objects.example".to_string());
751        config.cloud_home.s3_exact_slots =
752            Some(crate::CustomS3ExactSlots::StandardConditionalRequests);
753        let key_service = StoreKeys::new(config.store_id.clone());
754        key_service
755            .set_cloud_home_credentials(&crate::keys::CloudHomeCredentials::S3 {
756                access_key: "access".to_string(),
757                secret_key: "secret".to_string(),
758            })
759            .expect("store credentials");
760        let custody =
761            crate::custody::KeyCustody::InMemory(crate::encryption::MasterKeyring::generate())
762                .resolve(&config.store_id, &config.store_dir);
763        let encoded = generate_restore_code(
764            &config,
765            &key_service,
766            custody.as_ref(),
767            store_root(),
768            hex::encode([7u8; 32]),
769            crate::join_code::MembershipFloor(membership_floor(hex::encode([7u8; 32]))),
770            restore_authority(),
771        )
772        .expect("generate restore code");
773        let decoded = decode_restore_code(&encoded).expect("decode restore code");
774        let provider_wire = serde_json::to_string(&decoded.provider).expect("serialize provider");
775
776        assert!(!provider_wire.contains("s3_exact_slots"));
777        assert!(!provider_wire.contains("strong_reads"));
778        assert_eq!(
779            config.cloud_home.s3_exact_slots,
780            Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
781        );
782    }
783
784    /// A device that joined via a CloudKit share has `cloudkit_owner_name` /
785    /// `cloudkit_zone_name` set (`build_config`'s `CloudKitShare` arm).
786    /// Restoring a share is illegitimate — decode already rejects
787    /// `CloudKitShare` (`RestoreCodeError::CloudKitShareNotRestorable`) — so
788    /// generation must refuse a share-joined config too, rather than mapping
789    /// it to `CloudHomeJoinInfo::CloudKit`, which would restore against the
790    /// restoring device's own empty private zone.
791    #[test]
792    fn generate_restore_code_rejects_a_share_joined_cloudkit_config() {
793        crate::keys::test_keyring::install();
794
795        let config = cloudkit_config(Some(("owner-name", "zone-name")));
796        let key_service = StoreKeys::new(config.store_id.clone());
797        let custody =
798            crate::custody::KeyCustody::Keyring.resolve(&config.store_id, &config.store_dir);
799        let err = generate_restore_code(
800            &config,
801            &key_service,
802            custody.as_ref(),
803            store_root(),
804            hex::encode([7u8; 32]),
805            crate::join_code::MembershipFloor(membership_floor(hex::encode([7u8; 32]))),
806            restore_authority(),
807        )
808        .expect_err("a share-joined CloudKit config must not generate a restore code");
809        let message = err.to_string();
810        assert!(
811            message.contains("share"),
812            "error should explain the store was joined via a share: {message}"
813        );
814    }
815
816    /// A truly private CloudKit config (no owner/zone set) still emits a `ck`
817    /// restore code that decodes back to `CloudHomeJoinInfo::CloudKit`.
818    #[test]
819    fn generate_restore_code_private_cloudkit_round_trips() {
820        crate::keys::test_keyring::install();
821
822        let config = cloudkit_config(None);
823        let key_service = StoreKeys::new(config.store_id.clone());
824        let custody =
825            crate::custody::KeyCustody::Keyring.resolve(&config.store_id, &config.store_dir);
826        let code = generate_restore_code(
827            &config,
828            &key_service,
829            custody.as_ref(),
830            store_root(),
831            hex::encode([7u8; 32]),
832            crate::join_code::MembershipFloor(membership_floor(hex::encode([7u8; 32]))),
833            restore_authority(),
834        )
835        .expect("a private CloudKit config generates a restore code");
836        let decoded = decode_restore_code(&code).expect("generated code decodes");
837        assert!(
838            matches!(decoded.provider, CloudHomeJoinInfo::CloudKit),
839            "expected CloudKit provider, got {:?}",
840            decoded.provider
841        );
842    }
843}