coven/sync/
join.rs

1//! Join an existing shared library using an invite code.
2//!
3//! Shared across all platforms (macOS, iOS, CLI).
4
5use std::collections::HashMap;
6use std::ffi::CString;
7use std::path::Path;
8use std::sync::Arc;
9
10use tracing::info;
11
12use crate::blob::BlobPlan;
13use crate::config::{CloudProvider, Config, ConfigError};
14use crate::encryption::{EncryptionError, EncryptionService};
15use crate::join_code::InviteCode;
16use crate::keys::{CloudHomeCredentials, KeyError, KeyService};
17use crate::library_dir::LibraryDir;
18use crate::oauth::OAuthError;
19use crate::storage::cloud::{CloudHome, CloudHomeError, CloudHomeJoinInfo};
20use crate::sync::encrypted_storage::EncryptedSyncStorage;
21use crate::sync::invite::{unwrap_library_key, InviteError};
22use crate::sync::pull::{pull_changes, PullError};
23use crate::sync::snapshot::{bootstrap_from_snapshot, SnapshotError};
24use crate::sync::storage::SyncStorage;
25
26#[derive(Debug, thiserror::Error)]
27pub enum JoinError {
28    #[error("cloud home: {0}")]
29    CloudHome(#[from] CloudHomeError),
30    #[error("encryption: {0}")]
31    Encryption(#[from] EncryptionError),
32    #[error("invite: {0}")]
33    Invite(#[from] InviteError),
34    #[error("snapshot: {0}")]
35    Snapshot(#[from] SnapshotError),
36    #[error("pull: {0}")]
37    Pull(#[from] PullError),
38    #[error("config: {0}")]
39    Config(#[from] ConfigError),
40    #[error("keyring: {0}")]
41    Key(#[from] KeyError),
42    #[error("I/O: {0}")]
43    Io(#[from] std::io::Error),
44    #[error("database: {0}")]
45    Database(String),
46    #[error("oauth: {0}")]
47    OAuth(#[from] OAuthError),
48}
49
50/// Build a CloudHome from a JoinInfo for the join flow.
51///
52/// For OAuth providers, runs the OAuth authorization flow inline and saves
53/// credentials to the library-scoped keyring.
54async fn build_cloud_home_for_join(
55    join_info: &CloudHomeJoinInfo,
56    keypair: &crate::keys::UserKeypair,
57    lib_ks: &KeyService,
58    cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
59    oauth_cancel: tokio::sync::watch::Receiver<bool>,
60    clock: crate::clock::ClockRef,
61) -> Result<Box<dyn CloudHome>, JoinError> {
62    use crate::storage::cloud::*;
63
64    match join_info {
65        CloudHomeJoinInfo::S3 {
66            bucket,
67            region,
68            endpoint,
69            access_key,
70            secret_key,
71            key_prefix,
72        } => {
73            let s3 = s3::S3CloudHome::new(
74                bucket.clone(),
75                region.clone(),
76                endpoint.clone(),
77                access_key.clone(),
78                secret_key.clone(),
79                key_prefix.clone(),
80            )
81            .await?;
82            Ok(Box::new(s3))
83        }
84
85        CloudHomeJoinInfo::HttpProxy { url } => Ok(Box::new(http::HttpCloudHome::new(
86            url.clone(),
87            keypair.clone(),
88            clock,
89        ))),
90
91        CloudHomeJoinInfo::GoogleDrive { folder_id } => {
92            info!("Authorizing with Google Drive...");
93            let tokens = crate::oauth::authorize_provider(
94                CloudProvider::GoogleDrive,
95                oauth_cancel.clone(),
96                clock.as_ref(),
97            )
98            .await?;
99            let token_json = serde_json::to_string(&tokens)
100                .map_err(|e| JoinError::Database(format!("Failed to serialize tokens: {e}")))?;
101            lib_ks.set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })?;
102            Ok(Box::new(google_drive::GoogleDriveCloudHome::new(
103                folder_id.clone(),
104                tokens,
105                lib_ks.clone(),
106                clock,
107            )))
108        }
109
110        CloudHomeJoinInfo::Dropbox { shared_folder_id } => {
111            info!("Authorizing with Dropbox...");
112            let tokens = crate::oauth::authorize_provider(
113                CloudProvider::Dropbox,
114                oauth_cancel.clone(),
115                clock.as_ref(),
116            )
117            .await?;
118            let token_json = serde_json::to_string(&tokens)
119                .map_err(|e| JoinError::Database(format!("Failed to serialize tokens: {e}")))?;
120            lib_ks.set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })?;
121            Ok(Box::new(dropbox::DropboxCloudHome::new(
122                shared_folder_id.clone(),
123                tokens,
124                lib_ks.clone(),
125                clock,
126            )))
127        }
128
129        CloudHomeJoinInfo::OneDrive {
130            drive_id,
131            folder_id,
132        } => {
133            info!("Authorizing with OneDrive...");
134            let tokens = crate::oauth::authorize_provider(
135                CloudProvider::OneDrive,
136                oauth_cancel.clone(),
137                clock.as_ref(),
138            )
139            .await?;
140            let token_json = serde_json::to_string(&tokens)
141                .map_err(|e| JoinError::Database(format!("Failed to serialize tokens: {e}")))?;
142            lib_ks.set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })?;
143            Ok(Box::new(onedrive::OneDriveCloudHome::new(
144                drive_id.clone(),
145                folder_id.clone(),
146                tokens,
147                lib_ks.clone(),
148                clock,
149            )))
150        }
151
152        CloudHomeJoinInfo::CloudKit { .. } => {
153            let ops = cloudkit_ops
154                .ok_or_else(|| JoinError::Database("CloudKit driver not provided".to_string()))?;
155            Ok(Box::new(cloudkit::CloudKitCloudHome::new(ops)))
156        }
157    }
158}
159
160/// Join a shared library using an invite code string.
161///
162/// Handles everything: decode invite, get keypair, build cloud home (including
163/// OAuth flows), run the join protocol, and set as active library.
164pub async fn join_from_invite_code(
165    invite_code_str: &str,
166    app_dir: &Path,
167    cloudkit_ops: Option<Arc<dyn crate::storage::cloud::cloudkit::CloudKitOps>>,
168    oauth_cancel: tokio::sync::watch::Receiver<bool>,
169    clock: crate::clock::ClockRef,
170    ids: crate::id_provider::IdRef,
171    make_blob_plan: impl Fn(&LibraryDir) -> Box<dyn BlobPlan>,
172    on_status: impl Fn(&str),
173) -> Result<Config, JoinError> {
174    let code = crate::join_code::decode(invite_code_str)
175        .map_err(|e| JoinError::Database(format!("Invalid invite code: {e}")))?;
176
177    let dev_mode = Config::is_dev_mode();
178    let global_ks = KeyService::new(dev_mode, "global".to_string());
179    let keypair = global_ks.get_or_create_user_keypair()?;
180    let lib_ks = KeyService::new(dev_mode, code.library_id.clone());
181
182    let cloud_home = build_cloud_home_for_join(
183        &code.join_info,
184        &keypair,
185        &lib_ks,
186        cloudkit_ops,
187        oauth_cancel,
188        clock,
189    )
190    .await?;
191
192    let config = join_library(
193        app_dir,
194        code,
195        &global_ks,
196        cloud_home,
197        ids.as_ref(),
198        make_blob_plan,
199        &on_status,
200    )
201    .await?;
202
203    // The host records this as the active library after this returns.
204    Ok(config)
205}
206
207/// Join an existing shared library using a decoded invite code.
208///
209/// Lower-level function — caller provides pre-built `CloudHome`.
210/// Prefer `join_from_invite_code` for the full flow.
211///
212/// `on_status` is called with progress messages for UI feedback.
213pub async fn join_library(
214    bae_dir: &Path,
215    code: InviteCode,
216    key_service: &KeyService,
217    cloud_home: Box<dyn CloudHome>,
218    ids: &dyn crate::id_provider::IdProvider,
219    make_blob_plan: impl Fn(&LibraryDir) -> Box<dyn BlobPlan>,
220    on_status: impl Fn(&str),
221) -> Result<Config, JoinError> {
222    // Step 1: Load user keypair (must already exist — the inviter wrapped the
223    // library key for this public key).
224    on_status("Loading keypair...");
225    let user_keypair = key_service.get_user_keypair()?;
226
227    // Step 2: Accept invitation to get the library encryption key.
228    // Uses CloudHome directly — wrapped keys are sealed-box encrypted,
229    // no library-key encryption needed.
230    on_status("Accepting invitation...");
231    let encryption_key = unwrap_library_key(cloud_home.as_ref(), &user_keypair).await?;
232    let encryption_key_hex = hex::encode(encryption_key);
233
234    // Step 3: Create the sync storage with the real encryption key.
235    on_status("Downloading library snapshot...");
236    let encryption = EncryptionService::new(&encryption_key_hex)?;
237    let storage = EncryptedSyncStorage::new(cloud_home, encryption.clone());
238
239    // Step 4: Create library directory using the invite code's library_id.
240    let library_id = code.library_id;
241    let device_id = ids.new_id();
242    let library_dir = LibraryDir::new(bae_dir.join("libraries").join(&library_id));
243    std::fs::create_dir_all(&*library_dir)?;
244    // The host's blob plan is bound to the library dir we just created.
245    let blob_plan = make_blob_plan(&library_dir);
246
247    // All steps after directory creation are wrapped so we can clean up on failure.
248    let new_key_service = KeyService::new(key_service.is_dev_mode(), library_id.clone());
249
250    let result = bootstrap_and_save(
251        &storage,
252        &encryption,
253        &encryption_key_hex,
254        &library_dir,
255        &library_id,
256        &device_id,
257        &code.join_info,
258        &code.library_name,
259        &new_key_service,
260        blob_plan.as_ref(),
261        &on_status,
262    )
263    .await;
264
265    if result.is_err() {
266        let _ = std::fs::remove_dir_all(&*library_dir);
267    }
268
269    result
270}
271
272/// Inner bootstrap + save logic, separated so the caller can clean up on failure.
273async fn bootstrap_and_save(
274    storage: &EncryptedSyncStorage,
275    encryption: &EncryptionService,
276    encryption_key_hex: &str,
277    library_dir: &LibraryDir,
278    library_id: &str,
279    device_id: &str,
280    join_info: &CloudHomeJoinInfo,
281    library_name: &str,
282    key_service: &KeyService,
283    blob_plan: &dyn BlobPlan,
284    on_status: &impl Fn(&str),
285) -> Result<Config, JoinError> {
286    // Step 5: Bootstrap from snapshot.
287    let db_path = library_dir.db_path();
288    let bucket_dyn: &dyn SyncStorage = storage;
289    let bootstrap_result = bootstrap_from_snapshot(bucket_dyn, encryption, &db_path).await?;
290
291    info!(
292        "Bootstrapped from snapshot ({} device cursors)",
293        bootstrap_result.cursors.len()
294    );
295
296    // Step 6: Pull changesets since the snapshot.
297    on_status("Applying recent changes...");
298    let cursors = bootstrap_result.cursors;
299
300    let changesets_applied = open_db_and_pull(
301        &db_path,
302        bucket_dyn,
303        device_id,
304        &cursors,
305        library_dir,
306        blob_plan,
307    )
308    .await?;
309
310    if changesets_applied > 0 {
311        info!("Applied {changesets_applied} changesets since snapshot");
312    }
313
314    // Step 7: Save encryption key to keyring.
315    on_status("Saving configuration...");
316    key_service.set_encryption_key(encryption_key_hex)?;
317
318    // Step 8: Save cloud credentials to keyring.
319    let credentials = derive_credentials(join_info);
320    key_service.set_cloud_home_credentials(&credentials)?;
321
322    // Step 9: Create and save config.
323    let config = build_config(
324        library_id,
325        device_id,
326        library_dir,
327        library_name,
328        join_info,
329        encryption,
330    );
331
332    config.save_to_config_yaml()?;
333
334    info!("Joined library {} at {}", library_id, library_dir.display());
335    Ok(config)
336}
337
338/// Open the database, pull changes, close the database.
339pub(crate) async fn open_db_and_pull(
340    db_path: &Path,
341    storage: &dyn SyncStorage,
342    device_id: &str,
343    cursors: &HashMap<String, u64>,
344    library_dir: &LibraryDir,
345    blob_plan: &dyn BlobPlan,
346) -> Result<u64, JoinError> {
347    unsafe {
348        let c_path = CString::new(db_path.to_str().unwrap()).unwrap();
349        let mut db: *mut libsqlite3_sys::sqlite3 = std::ptr::null_mut();
350        let rc = libsqlite3_sys::sqlite3_open(c_path.as_ptr(), &mut db);
351        if rc != libsqlite3_sys::SQLITE_OK {
352            return Err(JoinError::Database(
353                "Failed to open database for changeset application".to_string(),
354            ));
355        }
356
357        let result =
358            match pull_changes(db, storage, device_id, cursors, library_dir, blob_plan).await {
359                Ok((_updated_cursors, pull_result)) => Ok(pull_result.changesets_applied),
360                Err(e) => {
361                    libsqlite3_sys::sqlite3_close(db);
362                    Err(JoinError::Pull(e))
363                }
364            };
365
366        if result.is_ok() {
367            libsqlite3_sys::sqlite3_close(db);
368        }
369
370        result
371    }
372}
373
374/// Derive the CloudHomeCredentials to persist from the JoinInfo.
375///
376/// S3 credentials come from the invite code. HttpProxy uses keypair auth (no stored creds).
377/// OAuth providers store the joiner's token, but that's already saved to the keyring
378/// by the caller before constructing the CloudHome.
379pub(crate) fn derive_credentials(join_info: &CloudHomeJoinInfo) -> CloudHomeCredentials {
380    match join_info {
381        CloudHomeJoinInfo::S3 {
382            access_key,
383            secret_key,
384            ..
385        } => CloudHomeCredentials::S3 {
386            access_key: access_key.clone(),
387            secret_key: secret_key.clone(),
388        },
389        CloudHomeJoinInfo::HttpProxy { .. } => CloudHomeCredentials::None,
390        // OAuth providers: the joiner's token was already saved to the keyring
391        // before constructing the CloudHome. No additional save needed here,
392        // but set_cloud_home_credentials expects a value, so pass what we have.
393        CloudHomeJoinInfo::GoogleDrive { .. }
394        | CloudHomeJoinInfo::Dropbox { .. }
395        | CloudHomeJoinInfo::OneDrive { .. }
396        | CloudHomeJoinInfo::CloudKit { .. } => CloudHomeCredentials::None,
397    }
398}
399
400/// Build the Config struct from join parameters.
401pub(crate) fn build_config(
402    library_id: &str,
403    device_id: &str,
404    library_dir: &LibraryDir,
405    library_name: &str,
406    join_info: &CloudHomeJoinInfo,
407    encryption: &EncryptionService,
408) -> Config {
409    let mut config = Config::with_defaults(
410        library_id.to_string(),
411        device_id.to_string(),
412        library_dir.clone(),
413        library_name.to_string(),
414    );
415
416    config.encryption_key_stored = true;
417    config.encryption_key_fingerprint = Some(encryption.fingerprint());
418
419    config.cloud_home.provider = Some(match join_info {
420        CloudHomeJoinInfo::S3 { .. } => CloudProvider::S3,
421        CloudHomeJoinInfo::HttpProxy { .. } => CloudProvider::HttpProxy,
422        CloudHomeJoinInfo::GoogleDrive { .. } => CloudProvider::GoogleDrive,
423        CloudHomeJoinInfo::Dropbox { .. } => CloudProvider::Dropbox,
424        CloudHomeJoinInfo::OneDrive { .. } => CloudProvider::OneDrive,
425        CloudHomeJoinInfo::CloudKit { .. } => CloudProvider::CloudKit,
426    });
427
428    match join_info {
429        CloudHomeJoinInfo::S3 {
430            bucket,
431            region,
432            endpoint,
433            key_prefix,
434            ..
435        } => {
436            config.cloud_home.s3_bucket = Some(bucket.clone());
437            config.cloud_home.s3_region = Some(region.clone());
438            config.cloud_home.s3_endpoint = endpoint.clone();
439            config.cloud_home.s3_key_prefix = key_prefix.clone();
440        }
441        CloudHomeJoinInfo::HttpProxy { url } => {
442            config.cloud_home.http_url = Some(url.clone());
443        }
444        CloudHomeJoinInfo::GoogleDrive { folder_id } => {
445            config.cloud_home.google_drive_folder_id = Some(folder_id.clone());
446        }
447        CloudHomeJoinInfo::Dropbox { shared_folder_id } => {
448            config.cloud_home.dropbox_folder_path = Some(shared_folder_id.clone());
449        }
450        CloudHomeJoinInfo::OneDrive {
451            drive_id,
452            folder_id,
453        } => {
454            config.cloud_home.onedrive_drive_id = Some(drive_id.clone());
455            config.cloud_home.onedrive_folder_id = Some(folder_id.clone());
456        }
457        CloudHomeJoinInfo::CloudKit { .. } => {
458            config.cloud_home.cloudkit_is_shared = true;
459        }
460    }
461
462    config
463}