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 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
481pub(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
500pub(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
697pub(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 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#[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 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 #[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 #[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}