coven/sync/
restore.rs

1//! Restore an existing library from cloud storage.
2//!
3//! Unlike join (which unwraps the encryption key from an invite), restore takes the
4//! encryption key directly from the user. Unlike join, restore sets
5//! `cloudkit_is_shared = false` because the restorer is the owner.
6
7use std::path::Path;
8use std::sync::Arc;
9
10use tracing::info;
11
12use crate::blob::BlobPlan;
13use crate::config::{Config, ConfigError};
14use crate::encryption::{EncryptionError, EncryptionService};
15use crate::keys::{KeyError, KeyService};
16use crate::library_dir::LibraryDir;
17use crate::oauth::OAuthTokens;
18use crate::storage::cloud::{CloudHome, CloudHomeJoinInfo};
19use crate::sync::encrypted_storage::EncryptedSyncStorage;
20use crate::sync::join::{build_config, derive_credentials, open_db_and_pull, JoinError};
21use crate::sync::pull::PullError;
22use crate::sync::snapshot::{bootstrap_from_snapshot, SnapshotError};
23use crate::sync::storage::SyncStorage;
24
25/// Cloud provider source for restore. Carries all connection details including
26/// OAuth tokens (unlike RestoreProvider which omits them for serialization).
27pub enum RestoreSource {
28    S3 {
29        bucket: String,
30        region: String,
31        endpoint: Option<String>,
32        access_key: String,
33        secret_key: String,
34    },
35    CloudKit {
36        ops: Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>,
37    },
38    GoogleDrive {
39        folder_id: String,
40        tokens: OAuthTokens,
41    },
42    Dropbox {
43        folder_path: String,
44        tokens: OAuthTokens,
45    },
46    OneDrive {
47        drive_id: String,
48        folder_id: String,
49        tokens: OAuthTokens,
50    },
51    HttpProxy {
52        url: String,
53    },
54}
55
56#[derive(Debug, thiserror::Error)]
57pub enum RestoreError {
58    #[error("encryption: {0}")]
59    Encryption(#[from] EncryptionError),
60    #[error("snapshot: {0}")]
61    Snapshot(#[from] SnapshotError),
62    #[error("pull: {0}")]
63    Pull(#[from] PullError),
64    #[error("config: {0}")]
65    Config(#[from] ConfigError),
66    #[error("keyring: {0}")]
67    Key(#[from] KeyError),
68    #[error("I/O: {0}")]
69    Io(#[from] std::io::Error),
70    #[error("database: {0}")]
71    Database(String),
72}
73
74impl From<JoinError> for RestoreError {
75    fn from(e: JoinError) -> Self {
76        match e {
77            JoinError::Encryption(e) => RestoreError::Encryption(e),
78            JoinError::Snapshot(e) => RestoreError::Snapshot(e),
79            JoinError::Pull(e) => RestoreError::Pull(e),
80            JoinError::Config(e) => RestoreError::Config(e),
81            JoinError::Key(e) => RestoreError::Key(e),
82            JoinError::Io(e) => RestoreError::Io(e),
83            JoinError::Database(s) => RestoreError::Database(s),
84            // CloudHome and Invite errors don't occur in restore path,
85            // but we need to handle them for exhaustiveness.
86            other => RestoreError::Database(other.to_string()),
87        }
88    }
89}
90
91/// Build a `(JoinInfo, Box<dyn CloudHome>)` from a `RestoreSource`.
92async fn build_cloud_home(
93    source: RestoreSource,
94    library_id: &str,
95    dev_mode: bool,
96    global_ks: &KeyService,
97    clock: crate::clock::ClockRef,
98) -> Result<(CloudHomeJoinInfo, Box<dyn CloudHome>), RestoreError> {
99    use crate::storage::cloud::*;
100
101    match source {
102        RestoreSource::S3 {
103            bucket,
104            region,
105            endpoint,
106            access_key,
107            secret_key,
108        } => {
109            let s3_home = s3::S3CloudHome::new(
110                bucket.clone(),
111                region.clone(),
112                endpoint.clone(),
113                access_key.clone(),
114                secret_key.clone(),
115                None,
116            )
117            .await
118            .map_err(|e| RestoreError::Database(format!("Failed to connect to S3: {e}")))?;
119
120            let info = CloudHomeJoinInfo::S3 {
121                bucket,
122                region,
123                endpoint,
124                key_prefix: None,
125                access_key,
126                secret_key,
127            };
128            Ok((info, Box::new(s3_home)))
129        }
130
131        RestoreSource::CloudKit { ops } => {
132            let home = cloudkit::CloudKitCloudHome::new(ops);
133            let info = CloudHomeJoinInfo::CloudKit {
134                share_url: String::new(),
135            };
136            Ok((info, Box::new(home) as Box<dyn CloudHome>))
137        }
138
139        RestoreSource::GoogleDrive { folder_id, tokens } => {
140            let ks = KeyService::new(dev_mode, library_id.to_string());
141            let home =
142                google_drive::GoogleDriveCloudHome::new(folder_id.clone(), tokens, ks, clock);
143            let info = CloudHomeJoinInfo::GoogleDrive { folder_id };
144            Ok((info, Box::new(home) as Box<dyn CloudHome>))
145        }
146
147        RestoreSource::Dropbox {
148            folder_path,
149            tokens,
150        } => {
151            let ks = KeyService::new(dev_mode, library_id.to_string());
152            let home = dropbox::DropboxCloudHome::new(folder_path.clone(), tokens, ks, clock);
153            let info = CloudHomeJoinInfo::Dropbox {
154                shared_folder_id: folder_path,
155            };
156            Ok((info, Box::new(home) as Box<dyn CloudHome>))
157        }
158
159        RestoreSource::OneDrive {
160            drive_id,
161            folder_id,
162            tokens,
163        } => {
164            let ks = KeyService::new(dev_mode, library_id.to_string());
165            let home = onedrive::OneDriveCloudHome::new(
166                drive_id.clone(),
167                folder_id.clone(),
168                tokens,
169                ks,
170                clock,
171            );
172            let info = CloudHomeJoinInfo::OneDrive {
173                drive_id,
174                folder_id,
175            };
176            Ok((info, Box::new(home) as Box<dyn CloudHome>))
177        }
178
179        RestoreSource::HttpProxy { url } => {
180            let keypair = global_ks
181                .get_or_create_user_keypair()
182                .map_err(RestoreError::Key)?;
183            let home = http::HttpCloudHome::new(url.clone(), keypair, clock);
184            let info = CloudHomeJoinInfo::HttpProxy { url };
185            Ok((info, Box::new(home) as Box<dyn CloudHome>))
186        }
187    }
188}
189
190/// Restore a library from cloud storage.
191///
192/// Validates inputs, constructs the cloud home from the source, runs the sync
193/// protocol, and sets the library as active.
194pub async fn restore_from_cloud(
195    library_id: &str,
196    encryption_key_hex: &str,
197    library_name: &str,
198    source: RestoreSource,
199    app_dir: &Path,
200    clock: crate::clock::ClockRef,
201    ids: crate::id_provider::IdRef,
202    make_blob_plan: impl Fn(&LibraryDir) -> Box<dyn BlobPlan>,
203    on_status: impl Fn(&str),
204) -> Result<Config, RestoreError> {
205    if library_id.is_empty() || encryption_key_hex.is_empty() {
206        return Err(RestoreError::Database(
207            "Library ID and encryption key are required".to_string(),
208        ));
209    }
210    if encryption_key_hex.len() != 64 {
211        return Err(RestoreError::Database(
212            "Encryption key must be 64 hex characters (32 bytes)".to_string(),
213        ));
214    }
215    if hex::decode(encryption_key_hex).is_err() {
216        return Err(RestoreError::Database(
217            "Invalid hex encoding in encryption key".to_string(),
218        ));
219    }
220
221    let dev_mode = Config::is_dev_mode();
222    let global_ks = KeyService::new(dev_mode, "global".to_string());
223
224    let (join_info, cloud_home) =
225        build_cloud_home(source, library_id, dev_mode, &global_ks, clock).await?;
226
227    // Create encryption service from the user-provided key.
228    on_status("Verifying encryption key...");
229    let encryption = EncryptionService::new(encryption_key_hex)?;
230    let storage = EncryptedSyncStorage::new(cloud_home, encryption.clone());
231
232    // Create library directory.
233    let device_id = ids.new_id();
234    let library_dir = LibraryDir::new(app_dir.join("libraries").join(library_id));
235    std::fs::create_dir_all(&*library_dir)?;
236    // The host's blob plan is bound to the library dir we just created.
237    let blob_plan = make_blob_plan(&library_dir);
238
239    let key_service = KeyService::new(dev_mode, library_id.to_string());
240
241    let result = bootstrap_and_save(
242        &storage,
243        &encryption,
244        encryption_key_hex,
245        &library_dir,
246        library_id,
247        &device_id,
248        &join_info,
249        library_name,
250        &key_service,
251        blob_plan.as_ref(),
252        &on_status,
253    )
254    .await;
255
256    if result.is_err() {
257        let _ = std::fs::remove_dir_all(&*library_dir);
258        return result;
259    }
260
261    let config = result?;
262    // The host records this as the active library after this returns.
263
264    info!(
265        "Cloud restore complete: library at {}",
266        config.library_dir.display()
267    );
268
269    Ok(config)
270}
271
272/// Restore a library from a restore code string.
273///
274/// Decodes the restore code, converts provider → RestoreSource (adding OAuth
275/// tokens for providers that need them), imports the signing key, and
276/// delegates to `restore_from_cloud`.
277pub async fn restore_from_code(
278    code: &str,
279    oauth_tokens: Option<crate::oauth::OAuthTokens>,
280    cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
281    app_dir: &Path,
282    clock: crate::clock::ClockRef,
283    ids: crate::id_provider::IdRef,
284    make_blob_plan: impl Fn(&LibraryDir) -> Box<dyn BlobPlan>,
285    on_status: impl Fn(&str),
286) -> Result<Config, RestoreError> {
287    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
288    use base64::Engine;
289
290    use crate::sync::restore_code::{self, RestoreProvider};
291
292    let parsed = restore_code::decode_restore_code(code)
293        .map_err(|e| RestoreError::Database(format!("Invalid restore code: {e}")))?;
294
295    // Decode signing key upfront so we fail fast on bad encoding.
296    let signing_key_bytes = URL_SAFE_NO_PAD
297        .decode(&parsed.sk)
298        .map_err(|e| RestoreError::Database(format!("Invalid signing key encoding: {e}")))?;
299
300    let require_oauth = |provider_name: &str| -> Result<crate::oauth::OAuthTokens, RestoreError> {
301        oauth_tokens.clone().ok_or_else(|| {
302            RestoreError::Database(format!("{provider_name} restore requires OAuth token"))
303        })
304    };
305
306    let source = match parsed.provider {
307        RestoreProvider::S3 {
308            bucket,
309            region,
310            endpoint,
311            access_key,
312            secret_key,
313            ..
314        } => RestoreSource::S3 {
315            bucket,
316            region,
317            endpoint,
318            access_key,
319            secret_key,
320        },
321        RestoreProvider::CloudKit => {
322            let ops = cloudkit_ops.ok_or_else(|| {
323                RestoreError::Database("CloudKit driver not provided".to_string())
324            })?;
325            RestoreSource::CloudKit { ops }
326        }
327        RestoreProvider::GoogleDrive { folder_id } => RestoreSource::GoogleDrive {
328            folder_id,
329            tokens: require_oauth("Google Drive")?,
330        },
331        RestoreProvider::Dropbox { folder_path } => RestoreSource::Dropbox {
332            folder_path,
333            tokens: require_oauth("Dropbox")?,
334        },
335        RestoreProvider::OneDrive {
336            drive_id,
337            folder_id,
338        } => RestoreSource::OneDrive {
339            drive_id,
340            folder_id,
341            tokens: require_oauth("OneDrive")?,
342        },
343        RestoreProvider::HttpProxy { url } => RestoreSource::HttpProxy { url },
344    };
345
346    let config = restore_from_cloud(
347        &parsed.lid,
348        &parsed.ek,
349        &parsed.name,
350        source,
351        app_dir,
352        clock,
353        ids,
354        make_blob_plan,
355        on_status,
356    )
357    .await?;
358
359    // Import signing key after restore succeeds so we don't overwrite an existing
360    // keypair if the restore fails.
361    let dev_mode = Config::is_dev_mode();
362    let global_ks = KeyService::new(dev_mode, "global".to_string());
363    global_ks
364        .import_user_keypair(&signing_key_bytes)
365        .map_err(RestoreError::Key)?;
366
367    Ok(config)
368}
369
370/// Inner bootstrap + save logic, separated so the caller can clean up on failure.
371async fn bootstrap_and_save(
372    storage: &EncryptedSyncStorage,
373    encryption: &EncryptionService,
374    encryption_key_hex: &str,
375    library_dir: &LibraryDir,
376    library_id: &str,
377    device_id: &str,
378    join_info: &CloudHomeJoinInfo,
379    library_name: &str,
380    key_service: &KeyService,
381    blob_plan: &dyn BlobPlan,
382    on_status: &impl Fn(&str),
383) -> Result<Config, RestoreError> {
384    // Step 3: Bootstrap from snapshot.
385    on_status("Downloading library snapshot...");
386    let db_path = library_dir.db_path();
387    let bucket_dyn: &dyn SyncStorage = storage;
388    let bootstrap_result = bootstrap_from_snapshot(bucket_dyn, encryption, &db_path).await?;
389
390    info!(
391        "Bootstrapped from snapshot ({} device cursors)",
392        bootstrap_result.cursors.len()
393    );
394
395    // Step 4: Pull changesets since the snapshot.
396    on_status("Applying recent changes...");
397    let cursors = bootstrap_result.cursors;
398
399    let changesets_applied = open_db_and_pull(
400        &db_path,
401        bucket_dyn,
402        device_id,
403        &cursors,
404        library_dir,
405        blob_plan,
406    )
407    .await?;
408
409    if changesets_applied > 0 {
410        info!("Applied {changesets_applied} changesets since snapshot");
411    }
412
413    // Step 5: Save encryption key to keyring.
414    on_status("Saving configuration...");
415    key_service.set_encryption_key(encryption_key_hex)?;
416
417    // Step 6: Save cloud credentials to keyring.
418    let credentials = derive_credentials(join_info);
419    key_service.set_cloud_home_credentials(&credentials)?;
420
421    // Step 7: Create and save config.
422    let mut config = build_config(
423        library_id,
424        device_id,
425        library_dir,
426        library_name,
427        join_info,
428        encryption,
429    );
430
431    // Restore is done by the owner — CloudKit uses the private database.
432    // build_config sets cloudkit_is_shared = true (for joiners); override for restore.
433    if matches!(join_info, CloudHomeJoinInfo::CloudKit { .. }) {
434        config.cloud_home.cloudkit_is_shared = false;
435    }
436
437    config.save_to_config_yaml()?;
438
439    info!(
440        "Restored library {} at {}",
441        library_id,
442        library_dir.display()
443    );
444    Ok(config)
445}