coven/storage/cloud/
mod.rs

1//! CloudHome: low-level cloud storage abstraction.
2//!
3//! Each backend (S3, R2, B2, etc.) implements `CloudHome` -- 8 methods for
4//! raw bytes in/out. No encryption, no path layout knowledge, no sync
5//! semantics. Higher-level concerns live in `EncryptedSyncStorage` which wraps
6//! any `dyn CloudHome`.
7
8pub mod cloudkit;
9pub mod dropbox;
10pub mod google_drive;
11pub mod http;
12pub mod oauth_session;
13pub mod onedrive;
14pub mod s3;
15pub mod setup;
16
17#[cfg(any(test, feature = "test-utils"))]
18pub mod test_utils;
19
20use async_trait::async_trait;
21use serde::{Deserialize, Serialize};
22
23/// Errors from raw cloud storage operations.
24#[derive(Debug, thiserror::Error)]
25pub enum CloudHomeError {
26    #[error("not found: {0}")]
27    NotFound(String),
28    #[error("storage error: {0}")]
29    Storage(String),
30    #[error("I/O error: {0}")]
31    Io(#[from] std::io::Error),
32}
33
34/// Information needed to join a cloud home from another device.
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub enum CloudHomeJoinInfo {
37    S3 {
38        bucket: String,
39        region: String,
40        endpoint: Option<String>,
41        access_key: String,
42        secret_key: String,
43        #[serde(default)]
44        key_prefix: Option<String>,
45    },
46    GoogleDrive {
47        folder_id: String,
48    },
49    Dropbox {
50        shared_folder_id: String,
51    },
52    OneDrive {
53        drive_id: String,
54        folder_id: String,
55    },
56    HttpProxy {
57        url: String,
58    },
59    CloudKit {
60        share_url: String,
61    },
62}
63
64impl CloudHomeJoinInfo {
65    pub fn cloud_provider(&self) -> crate::config::CloudProvider {
66        use crate::config::CloudProvider;
67        match self {
68            CloudHomeJoinInfo::S3 { .. } => CloudProvider::S3,
69            CloudHomeJoinInfo::GoogleDrive { .. } => CloudProvider::GoogleDrive,
70            CloudHomeJoinInfo::Dropbox { .. } => CloudProvider::Dropbox,
71            CloudHomeJoinInfo::OneDrive { .. } => CloudProvider::OneDrive,
72            CloudHomeJoinInfo::HttpProxy { .. } => CloudProvider::HttpProxy,
73            CloudHomeJoinInfo::CloudKit { .. } => CloudProvider::CloudKit,
74        }
75    }
76}
77
78/// Low-level cloud storage. Implementations handle a single library.
79///
80/// All methods deal in raw bytes. No encryption or path layout logic.
81#[async_trait]
82pub trait CloudHome: Send + Sync {
83    /// Verify the backend is reachable with the configured credentials.
84    /// Setup flows call this *before* persisting credentials, so a typo or
85    /// missing bucket fails fast at setup time instead of via a delayed
86    /// reconnect banner. Default implementation issues a no-op list against
87    /// a sentinel prefix — backends override with cheaper provider-specific
88    /// auth checks (e.g. S3 HeadBucket) where available.
89    async fn probe(&self) -> Result<(), CloudHomeError> {
90        self.list("__bae_probe__").await.map(drop)
91    }
92
93    /// Write bytes to a key, creating or overwriting.
94    async fn write(&self, key: &str, data: Vec<u8>) -> Result<(), CloudHomeError>;
95
96    /// Read the full contents of a key.
97    async fn read(&self, key: &str) -> Result<Vec<u8>, CloudHomeError>;
98
99    /// Read a byte range from a key. `start` is inclusive, `end` is exclusive.
100    async fn read_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, CloudHomeError>;
101
102    /// List all keys under a prefix.
103    async fn list(&self, prefix: &str) -> Result<Vec<String>, CloudHomeError>;
104
105    /// Delete a key. Not an error if the key does not exist.
106    async fn delete(&self, key: &str) -> Result<(), CloudHomeError>;
107
108    /// Check whether a key exists.
109    async fn exists(&self, key: &str) -> Result<bool, CloudHomeError>;
110
111    /// Grant access to a member and return connection info for the cloud home.
112    /// For S3 this ignores `member_id` and returns bucket/region/endpoint
113    /// (access is managed externally via IAM/pre-shared credentials).
114    /// For consumer clouds this shares the folder with the member's account.
115    async fn grant_access(&self, member_id: &str) -> Result<CloudHomeJoinInfo, CloudHomeError>;
116
117    /// Revoke a previously granted access. No-op for backends where access
118    /// is controlled externally (e.g. S3 with pre-shared credentials).
119    async fn revoke_access(&self, member_id: &str) -> Result<(), CloudHomeError>;
120}
121
122/// Extract the OAuth token JSON from cloud home credentials, or return a storage error.
123fn require_oauth_token(
124    key_service: &crate::keys::KeyService,
125    provider_name: &str,
126) -> Result<String, CloudHomeError> {
127    match key_service
128        .get_cloud_home_credentials()
129        .map_err(|e| CloudHomeError::Storage(format!("{provider_name} credentials error: {e}")))?
130    {
131        Some(crate::keys::CloudHomeCredentials::OAuth { token_json }) => Ok(token_json),
132        _ => Err(CloudHomeError::Storage(format!(
133            "{provider_name} OAuth token not in keyring"
134        ))),
135    }
136}
137
138fn parse_oauth_tokens(
139    key_service: &crate::keys::KeyService,
140    provider_name: &str,
141) -> Result<crate::oauth::OAuthTokens, CloudHomeError> {
142    let token_json = require_oauth_token(key_service, provider_name)?;
143    serde_json::from_str(&token_json)
144        .map_err(|e| CloudHomeError::Storage(format!("invalid OAuth token JSON: {e}")))
145}
146
147/// Construct a CloudHome from the desktop app's Config + KeyService.
148/// Reads provider settings from config and credentials from the OS keyring.
149pub async fn create_cloud_home(
150    config: &crate::config::Config,
151    key_service: &crate::keys::KeyService,
152    clock: crate::clock::ClockRef,
153) -> Result<Box<dyn CloudHome>, CloudHomeError> {
154    use crate::config::CloudProvider;
155
156    match config.cloud_home.provider {
157        Some(CloudProvider::S3) | None => {
158            let bucket =
159                config.cloud_home.s3_bucket.clone().ok_or_else(|| {
160                    CloudHomeError::Storage("S3 bucket not configured".to_string())
161                })?;
162            let region =
163                config.cloud_home.s3_region.clone().ok_or_else(|| {
164                    CloudHomeError::Storage("S3 region not configured".to_string())
165                })?;
166            let endpoint = config.cloud_home.s3_endpoint.clone();
167
168            let (access_key, secret_key) = match key_service
169                .get_cloud_home_credentials()
170                .map_err(|e| CloudHomeError::Storage(format!("S3 credentials error: {e}")))?
171            {
172                Some(crate::keys::CloudHomeCredentials::S3 {
173                    access_key,
174                    secret_key,
175                }) => (access_key, secret_key),
176                _ => {
177                    return Err(CloudHomeError::Storage(
178                        "S3 credentials not in keyring".to_string(),
179                    ))
180                }
181            };
182
183            let s3 = s3::S3CloudHome::new(
184                bucket,
185                region,
186                endpoint,
187                access_key,
188                secret_key,
189                config.cloud_home.s3_key_prefix.clone(),
190            )
191            .await?;
192            Ok(Box::new(s3))
193        }
194        Some(CloudProvider::GoogleDrive) => {
195            let folder_id = config
196                .cloud_home
197                .google_drive_folder_id
198                .clone()
199                .ok_or_else(|| {
200                    CloudHomeError::Storage("Google Drive folder ID not configured".to_string())
201                })?;
202            let tokens = parse_oauth_tokens(key_service, "Google Drive")?;
203            Ok(Box::new(google_drive::GoogleDriveCloudHome::new(
204                folder_id,
205                tokens,
206                key_service.clone(),
207                clock,
208            )))
209        }
210        Some(CloudProvider::Dropbox) => {
211            let folder_path = config
212                .cloud_home
213                .dropbox_folder_path
214                .clone()
215                .ok_or_else(|| {
216                    CloudHomeError::Storage("Dropbox folder path not configured".to_string())
217                })?;
218            let tokens = parse_oauth_tokens(key_service, "Dropbox")?;
219            Ok(Box::new(dropbox::DropboxCloudHome::new(
220                folder_path,
221                tokens,
222                key_service.clone(),
223                clock,
224            )))
225        }
226        Some(CloudProvider::OneDrive) => {
227            let drive_id = config.cloud_home.onedrive_drive_id.clone().ok_or_else(|| {
228                CloudHomeError::Storage("OneDrive drive ID not configured".to_string())
229            })?;
230            let folder_id = config
231                .cloud_home
232                .onedrive_folder_id
233                .clone()
234                .ok_or_else(|| {
235                    CloudHomeError::Storage("OneDrive folder ID not configured".to_string())
236                })?;
237            let tokens = parse_oauth_tokens(key_service, "OneDrive")?;
238            Ok(Box::new(onedrive::OneDriveCloudHome::new(
239                drive_id,
240                folder_id,
241                tokens,
242                key_service.clone(),
243                clock,
244            )))
245        }
246        Some(CloudProvider::HttpProxy) => {
247            let url = config.cloud_home.http_url.clone().ok_or_else(|| {
248                CloudHomeError::Storage("HTTP proxy URL not configured".to_string())
249            })?;
250            let keypair = key_service
251                .get_or_create_user_keypair()
252                .map_err(|e| CloudHomeError::Storage(format!("keypair: {e}")))?;
253            Ok(Box::new(http::HttpCloudHome::new(url, keypair, clock)))
254        }
255        Some(CloudProvider::CloudKit) => Err(CloudHomeError::Storage(
256            "CloudKit requires the native Swift driver; construct via bae-bridge".to_string(),
257        )),
258    }
259}