1#[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#[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 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#[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 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 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#[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 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 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
256pub 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 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 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#[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
479pub(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
498pub(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
529pub(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 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#[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 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 #[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 #[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}