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 coordinate a Serial Store with this configuration")]
404    SerialCoordinationUnavailable { provider: CloudProvider },
405    #[error("{provider:?} cannot provide exact protocol and blob slots with this configuration")]
406    ExactSlotsUnavailable { provider: CloudProvider },
407}
408
409pub(crate) fn require_exact_slot_capabilities_config(
410    config: &Config,
411) -> Result<(), StorageSetupError> {
412    let provider = config.cloud_home.provider.clone().ok_or_else(|| {
413        super::CloudHomeError::Configuration(
414            "sync requires a cloud provider with exact-slot storage".to_string(),
415        )
416    })?;
417    if exact_slot_capabilities_supported(
418        &provider,
419        config.cloud_home.s3_endpoint.is_some(),
420        config.cloud_home.s3_exact_slots,
421    ) {
422        Ok(())
423    } else {
424        Err(StorageSetupError::ExactSlotsUnavailable { provider })
425    }
426}
427
428pub(crate) fn require_exact_slot_capabilities_join_info(
429    join_info: &crate::storage::cloud::CloudHomeJoinInfo,
430    custom_s3_exact_slots: Option<crate::config::CustomS3ExactSlots>,
431) -> Result<(), CloudProvider> {
432    let provider = join_info.cloud_provider();
433    let custom_endpoint = matches!(
434        join_info,
435        crate::storage::cloud::CloudHomeJoinInfo::S3 {
436            endpoint: Some(_),
437            ..
438        }
439    );
440    if exact_slot_capabilities_supported(&provider, custom_endpoint, custom_s3_exact_slots) {
441        Ok(())
442    } else {
443        Err(provider)
444    }
445}
446
447pub(crate) fn require_exact_slot_capabilities_home(
448    home: std::sync::Arc<dyn super::CloudHome>,
449    provider: Option<CloudProvider>,
450) -> Result<(), StorageSetupError> {
451    if home.exact_slot_storage().is_some() {
452        Ok(())
453    } else if let Some(provider) = provider {
454        Err(StorageSetupError::ExactSlotsUnavailable { provider })
455    } else {
456        Err(super::CloudHomeError::Configuration(
457            "sync requires a cloud provider with exact-slot storage".to_string(),
458        )
459        .into())
460    }
461}
462
463fn exact_slot_capabilities_supported(
464    provider: &CloudProvider,
465    custom_s3_endpoint: bool,
466    custom_s3_exact_slots: Option<crate::config::CustomS3ExactSlots>,
467) -> bool {
468    match provider {
469        CloudProvider::S3 if !custom_s3_endpoint => true,
470        CloudProvider::S3 => {
471            custom_s3_exact_slots
472                == Some(crate::config::CustomS3ExactSlots::StandardConditionalRequests)
473        }
474        CloudProvider::GoogleDrive
475        | CloudProvider::Dropbox
476        | CloudProvider::OneDrive
477        | CloudProvider::CloudKit => true,
478    }
479}
480
481/// Build the [`CloudCipher`] a store's config selects: an opaque home seals
482/// every object under the keyring's store key; a browsable home
483/// (`cloud_home.storage == Browsable`) stores objects in the clear.
484///
485/// A browsable home has no store key, so it never reads the keyring — the
486/// absence of a key there is expected, not an error.
487pub(crate) fn build_cloud_cipher(
488    config: &Config,
489    custody: &dyn MasterKeyCustody,
490) -> Result<CloudCipher, StorageSetupError> {
491    if config.cloud_home.storage.is_browsable() {
492        return Ok(CloudCipher::Plaintext);
493    }
494    let keyring = custody
495        .unlock()?
496        .ok_or(StorageSetupError::NoEncryptionKey)?;
497    Ok(CloudCipher::Encrypted(keyring.into()))
498}
499
500/// Create sync storage from config and credentials.
501///
502/// This is a lighter version of `sync::cycle::init_sync` that only creates the
503/// storage client without starting a sync session or extracting raw DB handles.
504/// Used by membership management which only needs storage access.
505///
506/// `cipher` lets the caller reuse an already-built cipher (so the sync loop and
507/// storage share one instance for in-place key rotation); when `None` it is
508/// built from config via [`build_cloud_cipher`].
509pub(crate) async fn create_sync_storage_with_cloudkit(
510    config: &Config,
511    key_service: &StoreKeys,
512    custody: &dyn MasterKeyCustody,
513    identity_custody: &dyn DeviceIdentityCustody,
514    cipher: Option<CloudCipher>,
515    clock: crate::clock::ClockRef,
516    cloudkit_ops: Option<std::sync::Arc<dyn super::cloudkit::CloudKitOps>>,
517) -> Result<crate::sync::cloud_storage::CloudSyncStorage, StorageSetupError> {
518    require_exact_slot_capabilities_config(config)?;
519    let cloud_home =
520        super::create_cloud_home_with_cloudkit(config, key_service, clock, cloudkit_ops.clone())
521            .await?;
522    let coordination = match config.cloud_home.provider.as_ref() {
523        Some(CloudProvider::S3) if serial_coordination_eligible(config) => {
524            s3_coordination(config, key_service).await?
525        }
526        Some(CloudProvider::CloudKit) if serial_coordination_eligible(config) => {
527            cloudkit_coordination(config, cloudkit_ops.clone())?
528        }
529        _ => None,
530    };
531    let storage = create_sync_storage_with_home(
532        config,
533        custody,
534        identity_custody,
535        Arc::from(cloud_home),
536        cipher,
537    )?;
538    Ok(match coordination {
539        Some((primary, peer)) => storage.with_serial_coordination_clients(primary, peer),
540        None => storage,
541    })
542}
543
544pub(crate) type CoordinationClients = (
545    Arc<dyn crate::storage::cloud::CloudHeadStorage>,
546    Arc<dyn crate::storage::cloud::CloudHeadStorage>,
547);
548
549fn cloudkit_coordination(
550    config: &Config,
551    ops: Option<Arc<dyn super::cloudkit::CloudKitOps>>,
552) -> Result<Option<CoordinationClients>, StorageSetupError> {
553    if config.cloud_home.provider != Some(CloudProvider::CloudKit) {
554        return Ok(None);
555    }
556    let ops = ops.ok_or_else(|| {
557        super::CloudHomeError::Configuration("CloudKit driver not provided".to_string())
558    })?;
559    let (primary, peer) = match (
560        config.cloud_home.cloudkit_owner_name.as_ref(),
561        config.cloud_home.cloudkit_zone_name.as_ref(),
562    ) {
563        (None, None) => (
564            super::cloudkit::CloudKitCloudHome::new_private(ops.clone()),
565            super::cloudkit::CloudKitCloudHome::new_private(ops),
566        ),
567        (Some(owner), Some(zone)) => (
568            super::cloudkit::CloudKitCloudHome::new_shared(
569                ops.clone(),
570                owner.clone(),
571                zone.clone(),
572            ),
573            super::cloudkit::CloudKitCloudHome::new_shared(ops, owner.clone(), zone.clone()),
574        ),
575        _ => {
576            return Err(StorageSetupError::CloudHome(
577                super::CloudHomeError::Configuration(
578                    "CloudKit share config requires both cloudkit_owner_name and cloudkit_zone_name"
579                        .to_string(),
580                ),
581            ));
582        }
583    };
584    Ok(Some((Arc::new(primary), Arc::new(peer))))
585}
586
587async fn s3_coordination(
588    config: &Config,
589    key_service: &StoreKeys,
590) -> Result<Option<CoordinationClients>, StorageSetupError> {
591    if config.cloud_home.provider != Some(CloudProvider::S3) {
592        return Ok(None);
593    }
594    let bucket = config.cloud_home.s3_bucket.clone().ok_or_else(|| {
595        super::CloudHomeError::Configuration("S3 bucket not configured".to_string())
596    })?;
597    let region = config.cloud_home.s3_region.clone().ok_or_else(|| {
598        super::CloudHomeError::Configuration("S3 region not configured".to_string())
599    })?;
600    let credentials = key_service
601        .get_cloud_home_credentials()
602        .map_err(StorageSetupError::Key)?
603        .ok_or_else(|| {
604            super::CloudHomeError::Configuration("S3 credentials not in keyring".to_string())
605        })?;
606    let crate::keys::CloudHomeCredentials::S3 {
607        access_key,
608        secret_key,
609    } = credentials
610    else {
611        return Err(StorageSetupError::CloudHome(
612            super::CloudHomeError::Configuration(
613                "configured cloud credentials are not S3 credentials".to_string(),
614            ),
615        ));
616    };
617    let (primary, peer) = super::s3::S3CloudHome::new_pair(
618        bucket,
619        region,
620        config.cloud_home.s3_endpoint.clone(),
621        access_key,
622        secret_key,
623        config.cloud_home.s3_key_prefix.clone(),
624        config.cloud_home.s3_exact_slots,
625    )
626    .await?;
627    Ok(Some((Arc::new(primary), Arc::new(peer))))
628}
629
630pub(crate) fn require_serial_coordination_config(
631    config: &Config,
632    write_policy: crate::WritePolicy,
633) -> Result<(), StorageSetupError> {
634    if write_policy != crate::WritePolicy::Serial || serial_coordination_eligible(config) {
635        return Ok(());
636    }
637    Err(StorageSetupError::SerialCoordinationUnavailable {
638        provider: config.cloud_home.provider.clone().ok_or_else(|| {
639            super::CloudHomeError::Configuration(
640                "Serial coordination requires a configured provider".to_string(),
641            )
642        })?,
643    })
644}
645
646pub(crate) fn require_serial_coordination_join_info(
647    join_info: &crate::storage::cloud::CloudHomeJoinInfo,
648    custom_s3_serial: Option<crate::config::CustomS3Serial>,
649    write_policy: crate::WritePolicy,
650) -> Result<(), CloudProvider> {
651    if write_policy != crate::WritePolicy::Serial {
652        return Ok(());
653    }
654    let supported = serial_coordination_supported(
655        &join_info.cloud_provider(),
656        matches!(
657            join_info,
658            crate::storage::cloud::CloudHomeJoinInfo::S3 {
659                endpoint: Some(_),
660                ..
661            }
662        ),
663        custom_s3_serial,
664    );
665    if supported {
666        Ok(())
667    } else {
668        Err(join_info.cloud_provider())
669    }
670}
671
672fn serial_coordination_eligible(config: &Config) -> bool {
673    config.cloud_home.provider.as_ref().is_some_and(|provider| {
674        serial_coordination_supported(
675            provider,
676            config.cloud_home.s3_endpoint.is_some(),
677            config.cloud_home.s3_serial,
678        )
679    })
680}
681
682fn serial_coordination_supported(
683    provider: &CloudProvider,
684    custom_s3_endpoint: bool,
685    custom_s3_serial: Option<crate::config::CustomS3Serial>,
686) -> bool {
687    match provider {
688        CloudProvider::CloudKit => true,
689        CloudProvider::S3 if !custom_s3_endpoint => true,
690        CloudProvider::S3 => {
691            custom_s3_serial == Some(crate::config::CustomS3Serial::ConditionalPutAndStrongReads)
692        }
693        CloudProvider::GoogleDrive | CloudProvider::Dropbox | CloudProvider::OneDrive => false,
694    }
695}
696
697/// Create sync storage over an already-built [`CloudHome`](super::CloudHome).
698/// Unlike [`create_sync_storage_with_cloudkit`], this needs no store-scoped
699/// cloud credentials (the home is already built) — only custody, for the
700/// `cipher = None` fallback's master-key read.
701pub(crate) fn create_sync_storage_with_home(
702    config: &Config,
703    custody: &dyn MasterKeyCustody,
704    identity_custody: &dyn DeviceIdentityCustody,
705    home: Arc<dyn super::CloudHome>,
706    cipher: Option<CloudCipher>,
707) -> Result<crate::sync::cloud_storage::CloudSyncStorage, StorageSetupError> {
708    require_exact_slot_capabilities_home(home.clone(), config.cloud_home.provider.clone())?;
709    let cipher = match cipher {
710        Some(c) => c,
711        None => build_cloud_cipher(config, custody)?,
712    };
713
714    // This store's signing identity, used to sign the control objects the
715    // storage writes (its head, the min_schema floor) so a reader can attribute
716    // and verify them against the membership chain. A connect path, so this
717    // never mints: an unestablished identity fails with
718    // `KeyError::NoDeviceIdentity` rather than forging one.
719    let keypair = crate::keys::require_identity(identity_custody)?;
720
721    Ok(crate::sync::cloud_storage::CloudSyncStorage::new(
722        home,
723        cipher,
724        BlobPathScheme::for_storage(config.cloud_home.storage),
725        config.store_id.clone(),
726        keypair,
727    )?)
728}
729
730/// Cloud provider setup error.
731#[derive(Debug, thiserror::Error)]
732#[error("{0}")]
733pub struct SetupError(pub String);
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use crate::config::HomeStorage;
739    use crate::storage::cloud::CloudHomeJoinInfo;
740    use crate::store_dir::StoreDir;
741    use crate::sync::restore_code::decode_restore_code;
742
743    fn membership_floor(author_pubkey: String) -> Vec<crate::sync::membership::MembershipHeadRef> {
744        let coord = crate::sync::membership::MembershipCoord {
745            author_pubkey,
746            author_owner_grant: crate::sync::membership::MembershipGrantId(
747                crate::sync::store_commit::ObjectHash::digest(b"restore test owner grant"),
748            ),
749            stream_id: "0000000000000000000000000000000000000000000000000000000000000001"
750                .parse()
751                .expect("canonical test author stream id"),
752            seq: 1,
753            entry_hash: crate::sync::store_commit::ObjectHash::digest(
754                b"restore test founder entry",
755            ),
756        };
757        let stored = b"restore setup membership head";
758        vec![crate::sync::membership::MembershipHeadRef {
759            coord,
760            head_hash: crate::sync::store_commit::ObjectHash::digest(
761                b"restore setup membership head semantic bytes",
762            ),
763            object: crate::sync::storage::ExactObjectRef::new(
764                crate::storage::cloud::ObjectSlot::logical(
765                    "store-v1/membership/heads/restore-setup/1.json".to_string(),
766                )
767                .expect("valid test membership-head slot"),
768                stored.len() as u64,
769                crate::sync::store_commit::ObjectHash::digest(stored),
770            ),
771        }]
772    }
773
774    fn store_root() -> crate::sync::store_commit::StoreRootRef {
775        let stored = b"restore setup Store root";
776        crate::sync::store_commit::StoreRootRef {
777            store_root_id: crate::sync::store_commit::ObjectHash::digest(
778                b"restore setup Store root identity",
779            ),
780            store_root_hash: crate::sync::store_commit::ObjectHash::digest(stored),
781            object: crate::sync::storage::ExactObjectRef::new(
782                crate::storage::cloud::ObjectSlot::logical(
783                    "store-v1/protocol/root/restore-setup.json".to_string(),
784                )
785                .expect("valid test Store-root slot"),
786                stored.len() as u64,
787                crate::sync::store_commit::ObjectHash::digest(stored),
788            ),
789        }
790    }
791
792    fn restore_authority() -> crate::sync::restore_code::RestoreAuthority {
793        let owner_grant = crate::sync::membership::MembershipGrantId(
794            crate::sync::store_commit::ObjectHash::digest(b"restore test owner grant"),
795        );
796        let root = store_root();
797        let owner_pubkey = hex::encode([7u8; 32]);
798        let anchor = crate::sync::store_commit::GrantStreamAnchor::OwnerRecovery {
799            first_slot: crate::storage::cloud::ObjectSlot::logical(
800                "store-v1/recovery/restore-setup/first.json".to_string(),
801            )
802            .expect("valid recovery slot"),
803        };
804        let activation = crate::sync::store_commit::OwnerRecoveryActivationId::derive(
805            &root,
806            &owner_pubkey,
807            &owner_grant,
808            &anchor,
809        )
810        .expect("valid recovery activation");
811        crate::sync::restore_code::RestoreAuthority::OwnerRecovery(
812            crate::sync::restore_code::OwnerRecoveryAuthority {
813                owner_identity_secret: hex::encode(
814                    crate::keys::UserKeypair::generate().to_keypair_bytes(),
815                ),
816                owner_grant: owner_grant.clone(),
817                recovery: crate::sync::store_commit::OwnerRecoveryCursor {
818                    owner_grant,
819                    position: crate::sync::store_commit::OwnerRecoveryPosition::BeforeFirst {
820                        activation,
821                    },
822                },
823                published_at: "2026-07-17T00:00:00Z".to_string(),
824            },
825        )
826    }
827
828    /// A CloudKit config with `storage: Browsable` so the test exercises only
829    /// the CloudKit provider arm, never the opaque-home encryption-key read
830    /// (that path is unrelated to the restore-code provider guard under test).
831    fn cloudkit_config(owner_zone: Option<(&str, &str)>) -> Config {
832        let mut config = Config::with_defaults(
833            "store-1".to_string(),
834            "device-1".to_string(),
835            StoreDir::new("unused-store-dir"),
836            "CloudKit Store".to_string(),
837        );
838        config.cloud_home.provider = Some(CloudProvider::CloudKit);
839        config.cloud_home.storage = HomeStorage::Browsable;
840        if let Some((owner, zone)) = owner_zone {
841            config.cloud_home.cloudkit_owner_name = Some(owner.to_string());
842            config.cloud_home.cloudkit_zone_name = Some(zone.to_string());
843        }
844        config
845    }
846
847    #[test]
848    fn serial_coordination_provider_matrix_is_explicit() {
849        let mut config = Config::with_defaults(
850            "store-1".to_string(),
851            "device-1".to_string(),
852            StoreDir::new("unused-store-dir"),
853            "Provider matrix".to_string(),
854        );
855
856        config.cloud_home.provider = Some(CloudProvider::CloudKit);
857        assert!(serial_coordination_eligible(&config));
858
859        config.cloud_home.provider = Some(CloudProvider::S3);
860        config.cloud_home.s3_endpoint = None;
861        assert!(serial_coordination_eligible(&config));
862
863        config.cloud_home.s3_endpoint = Some("https://objects.example".to_string());
864        config.cloud_home.s3_serial = None;
865        assert!(!serial_coordination_eligible(&config));
866        config.cloud_home.s3_serial =
867            Some(crate::config::CustomS3Serial::ConditionalPutAndStrongReads);
868        assert!(serial_coordination_eligible(&config));
869
870        for provider in [
871            CloudProvider::GoogleDrive,
872            CloudProvider::Dropbox,
873            CloudProvider::OneDrive,
874        ] {
875            config.cloud_home.provider = Some(provider);
876            assert!(!serial_coordination_eligible(&config));
877        }
878    }
879
880    #[test]
881    fn exact_slot_admission_is_universal_and_uses_local_s3_assertions() {
882        let mut config = Config::with_defaults(
883            "store-1".to_string(),
884            "device-1".to_string(),
885            StoreDir::new("unused-store-dir"),
886            "Provider matrix".to_string(),
887        );
888
889        config.cloud_home.provider = Some(CloudProvider::S3);
890        config.cloud_home.s3_endpoint = None;
891        assert!(require_exact_slot_capabilities_config(&config).is_ok());
892
893        config.cloud_home.s3_endpoint = Some("https://objects.example".to_string());
894        assert!(matches!(
895            require_exact_slot_capabilities_config(&config),
896            Err(StorageSetupError::ExactSlotsUnavailable {
897                provider: CloudProvider::S3
898            })
899        ));
900        config.cloud_home.s3_exact_slots =
901            Some(crate::CustomS3ExactSlots::StandardConditionalRequests);
902        assert!(require_exact_slot_capabilities_config(&config).is_ok());
903
904        for provider in [
905            CloudProvider::GoogleDrive,
906            CloudProvider::Dropbox,
907            CloudProvider::OneDrive,
908            CloudProvider::CloudKit,
909        ] {
910            config.cloud_home.provider = Some(provider.clone());
911            assert!(require_exact_slot_capabilities_config(&config).is_ok());
912        }
913    }
914
915    #[test]
916    fn custom_s3_join_requires_the_local_exact_slot_assertion() {
917        let join_info = CloudHomeJoinInfo::S3 {
918            bucket: "bucket".to_string(),
919            region: "region".to_string(),
920            endpoint: Some("https://objects.example".to_string()),
921            access_key: "access".to_string(),
922            secret_key: "secret".to_string(),
923            key_prefix: None,
924        };
925
926        assert_eq!(
927            require_exact_slot_capabilities_join_info(&join_info, None),
928            Err(CloudProvider::S3),
929        );
930        assert!(require_exact_slot_capabilities_join_info(
931            &join_info,
932            Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
933        )
934        .is_ok());
935    }
936
937    #[test]
938    fn custom_s3_exact_slot_assertion_stays_out_of_restore_wire() {
939        crate::keys::test_keyring::install();
940        let dir = tempfile::tempdir().expect("store directory");
941        let mut config = Config::with_defaults(
942            "local-assertion-wire-test".to_string(),
943            "device-1".to_string(),
944            StoreDir::new(dir.path()),
945            "Wire Test".to_string(),
946        );
947        config.cloud_home.provider = Some(CloudProvider::S3);
948        config.cloud_home.storage = HomeStorage::Browsable;
949        config.cloud_home.s3_bucket = Some("bucket".to_string());
950        config.cloud_home.s3_region = Some("region".to_string());
951        config.cloud_home.s3_endpoint = Some("https://objects.example".to_string());
952        config.cloud_home.s3_exact_slots =
953            Some(crate::CustomS3ExactSlots::StandardConditionalRequests);
954        let key_service = StoreKeys::new(config.store_id.clone());
955        key_service
956            .set_cloud_home_credentials(&crate::keys::CloudHomeCredentials::S3 {
957                access_key: "access".to_string(),
958                secret_key: "secret".to_string(),
959            })
960            .expect("store credentials");
961        let custody =
962            crate::custody::KeyCustody::InMemory(crate::encryption::MasterKeyring::generate())
963                .resolve(&config.store_id, &config.store_dir);
964        let encoded = generate_restore_code(
965            &config,
966            &key_service,
967            custody.as_ref(),
968            store_root(),
969            hex::encode([7u8; 32]),
970            crate::join_code::MembershipFloor::MergeConcurrent(membership_floor(hex::encode(
971                [7u8; 32],
972            ))),
973            restore_authority(),
974        )
975        .expect("generate restore code");
976        let decoded = decode_restore_code(&encoded).expect("decode restore code");
977        let provider_wire = serde_json::to_string(&decoded.provider).expect("serialize provider");
978
979        assert!(!provider_wire.contains("s3_exact_slots"));
980        assert!(!provider_wire.contains("strong_reads"));
981        assert_eq!(
982            config.cloud_home.s3_exact_slots,
983            Some(crate::CustomS3ExactSlots::StandardConditionalRequests),
984        );
985    }
986
987    #[test]
988    fn serial_join_provider_matrix_requires_the_local_custom_s3_assertion() {
989        let custom_s3 = CloudHomeJoinInfo::S3 {
990            bucket: "bucket".to_string(),
991            region: "region".to_string(),
992            endpoint: Some("https://objects.example".to_string()),
993            access_key: "access".to_string(),
994            secret_key: "secret".to_string(),
995            key_prefix: None,
996        };
997        assert_eq!(
998            require_serial_coordination_join_info(&custom_s3, None, crate::WritePolicy::Serial,),
999            Err(CloudProvider::S3),
1000        );
1001        assert!(require_serial_coordination_join_info(
1002            &custom_s3,
1003            Some(crate::CustomS3Serial::ConditionalPutAndStrongReads),
1004            crate::WritePolicy::Serial,
1005        )
1006        .is_ok());
1007        assert!(require_serial_coordination_join_info(
1008            &CloudHomeJoinInfo::GoogleDrive {
1009                folder_id: "folder".to_string(),
1010            },
1011            None,
1012            crate::WritePolicy::Serial,
1013        )
1014        .is_err());
1015    }
1016
1017    /// A device that joined via a CloudKit share has `cloudkit_owner_name` /
1018    /// `cloudkit_zone_name` set (`build_config`'s `CloudKitShare` arm).
1019    /// Restoring a share is illegitimate — decode already rejects
1020    /// `CloudKitShare` (`RestoreCodeError::CloudKitShareNotRestorable`) — so
1021    /// generation must refuse a share-joined config too, rather than mapping
1022    /// it to `CloudHomeJoinInfo::CloudKit`, which would restore against the
1023    /// restoring device's own empty private zone.
1024    #[test]
1025    fn generate_restore_code_rejects_a_share_joined_cloudkit_config() {
1026        crate::keys::test_keyring::install();
1027
1028        let config = cloudkit_config(Some(("owner-name", "zone-name")));
1029        let key_service = StoreKeys::new(config.store_id.clone());
1030        let custody =
1031            crate::custody::KeyCustody::Keyring.resolve(&config.store_id, &config.store_dir);
1032        let err = generate_restore_code(
1033            &config,
1034            &key_service,
1035            custody.as_ref(),
1036            store_root(),
1037            hex::encode([7u8; 32]),
1038            crate::join_code::MembershipFloor::MergeConcurrent(membership_floor(hex::encode(
1039                [7u8; 32],
1040            ))),
1041            restore_authority(),
1042        )
1043        .expect_err("a share-joined CloudKit config must not generate a restore code");
1044        let message = err.to_string();
1045        assert!(
1046            message.contains("share"),
1047            "error should explain the store was joined via a share: {message}"
1048        );
1049    }
1050
1051    /// A truly private CloudKit config (no owner/zone set) still emits a `ck`
1052    /// restore code that decodes back to `CloudHomeJoinInfo::CloudKit`.
1053    #[test]
1054    fn generate_restore_code_private_cloudkit_round_trips() {
1055        crate::keys::test_keyring::install();
1056
1057        let config = cloudkit_config(None);
1058        let key_service = StoreKeys::new(config.store_id.clone());
1059        let custody =
1060            crate::custody::KeyCustody::Keyring.resolve(&config.store_id, &config.store_dir);
1061        let code = generate_restore_code(
1062            &config,
1063            &key_service,
1064            custody.as_ref(),
1065            store_root(),
1066            hex::encode([7u8; 32]),
1067            crate::join_code::MembershipFloor::MergeConcurrent(membership_floor(hex::encode(
1068                [7u8; 32],
1069            ))),
1070            restore_authority(),
1071        )
1072        .expect("a private CloudKit config generates a restore code");
1073        let decoded = decode_restore_code(&code).expect("generated code decodes");
1074        assert!(
1075            matches!(decoded.provider, CloudHomeJoinInfo::CloudKit),
1076            "expected CloudKit provider, got {:?}",
1077            decoded.provider
1078        );
1079    }
1080
1081    #[test]
1082    fn generate_restore_code_round_trips_an_empty_serial_store_floor() {
1083        crate::keys::test_keyring::install();
1084
1085        let config = cloudkit_config(None);
1086        let key_service = StoreKeys::new(config.store_id.clone());
1087        let custody =
1088            crate::custody::KeyCustody::Keyring.resolve(&config.store_id, &config.store_dir);
1089        let code = generate_restore_code(
1090            &config,
1091            &key_service,
1092            custody.as_ref(),
1093            store_root(),
1094            hex::encode([7u8; 32]),
1095            crate::join_code::MembershipFloor::Serial(None),
1096            restore_authority(),
1097        )
1098        .expect("mint empty Serial restore code");
1099        let decoded = decode_restore_code(&code).expect("decode empty Serial restore code");
1100        assert_eq!(
1101            decoded.membership_floor,
1102            crate::join_code::MembershipFloor::Serial(None)
1103        );
1104    }
1105}