1use 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
25pub 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 other => RestoreError::Database(other.to_string()),
87 }
88 }
89}
90
91async 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
190pub 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 on_status("Verifying encryption key...");
229 let encryption = EncryptionService::new(encryption_key_hex)?;
230 let storage = EncryptedSyncStorage::new(cloud_home, encryption.clone());
231
232 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 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 info!(
265 "Cloud restore complete: library at {}",
266 config.library_dir.display()
267 );
268
269 Ok(config)
270}
271
272pub 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 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 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
370async 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 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 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 on_status("Saving configuration...");
415 key_service.set_encryption_key(encryption_key_hex)?;
416
417 let credentials = derive_credentials(join_info);
419 key_service.set_cloud_home_credentials(&credentials)?;
420
421 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 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}