Skip to main content

coven_foundation/
config.rs

1//! Sync + storage configuration.
2//!
3//! `Config` is the runtime struct the sync manager reads. coven persists the
4//! sync-relevant fields to `config.yaml` in the store directory
5//! ([`Config::save_to_config_yaml`]) and reads them back
6//! ([`Config::load_from_config_yaml`]). The store directory is part of the
7//! owner graph, not configuration, so callers supply it to those operations.
8
9use serde::{Deserialize, Serialize};
10
11use crate::store_dir::StoreDir;
12
13/// Cloud home provider selection.
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
15pub enum CloudProvider {
16    S3,
17    GoogleDrive,
18    Dropbox,
19    OneDrive,
20    CloudKit,
21}
22
23/// How an exact cloud write proves that the stored bytes match their declared
24/// object reference. This is local host policy and is never accepted from an
25/// invitation or another device.
26#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum ExactUploadVerification {
29    /// The provider rejects an upload whose request checksum does not match the
30    /// received body.
31    UploadChecksum,
32    /// The provider exposes a content hash and size through object metadata.
33    MetadataHash,
34    /// Coven downloads the complete stored body and compares it locally.
35    Readback,
36    /// Coven trusts the provider's successful create response without checking
37    /// the resulting bytes.
38    Unchecked,
39}
40
41fn default_exact_upload_verification() -> ExactUploadVerification {
42    ExactUploadVerification::MetadataHash
43}
44
45impl CloudProvider {
46    /// Whether connecting, restoring, or joining on this provider requires
47    /// running an OAuth flow first — true for the account-based consumer clouds
48    /// (Google Drive, Dropbox, OneDrive), false for S3 and CloudKit.
49    pub fn needs_oauth(&self) -> bool {
50        matches!(self, Self::GoogleDrive | Self::Dropbox | Self::OneDrive)
51    }
52}
53
54/// How a cloud home stores its objects: opaque (encrypted, unreadable to anyone
55/// who can read the bucket) or browsable (stored in the clear at readable paths).
56/// This is *not* about who can reach the bucket — the storage provider's own
57/// access control applies either way; it is about whether what they store is
58/// legible. The host picks it once, when it creates the home; it cannot change
59/// later (it determines how every object is written). One choice drives two
60/// mechanisms together:
61///
62/// - `Opaque` (the default): every object is encrypted at rest under the store
63///   key (the `.enc` suffix) and blobs use coven's content-addressed path under
64///   the uploading device, `{namespace}/{uploader}/{ab}/{cd}/{id}`. Anyone with
65///   bucket access sees only ciphertext
66///   under opaque keys. Sharing a store (admitting members) requires an opaque
67///   home, because it wraps and rotates the store key.
68/// - `Browsable`: every object is stored in the clear (no `.enc` suffix) and
69///   blobs use the consumer-supplied readable path `{namespace}/{cloud_path}`, so
70///   anyone with bucket access can read the actual files by name. Browsable
71///   storage cannot be combined with per-row audiences declared through
72///   `SyncedTable::scoped_by`.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum HomeStorage {
76    Opaque,
77    Browsable,
78}
79
80impl HomeStorage {
81    /// An opaque home is encrypted at rest and obfuscates its blob paths; a
82    /// browsable home does neither.
83    pub fn is_opaque(self) -> bool {
84        matches!(self, HomeStorage::Opaque)
85    }
86
87    /// Whether this home stores its objects in the clear at readable paths (the
88    /// inverse of [`Self::is_opaque`]).
89    pub fn is_browsable(self) -> bool {
90        matches!(self, HomeStorage::Browsable)
91    }
92}
93
94/// The cloud home: which provider backs sync and its per-provider settings.
95/// One cohesive unit — connecting picks a provider and fills its fields;
96/// disconnecting resets the whole thing to default.
97#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
98pub struct CloudHomeConfig {
99    /// Selected provider. None = not configured.
100    pub provider: Option<CloudProvider>,
101    pub s3_bucket: Option<String>,
102    pub s3_region: Option<String>,
103    pub s3_endpoint: Option<String>,
104    pub s3_key_prefix: Option<String>,
105    pub exact_upload_verification: ExactUploadVerification,
106    pub google_drive_folder_id: Option<String>,
107    pub dropbox_folder_path: Option<String>,
108    pub onedrive_drive_id: Option<String>,
109    pub onedrive_folder_id: Option<String>,
110    pub cloudkit_owner_name: Option<String>,
111    pub cloudkit_zone_name: Option<String>,
112    /// How this home stores its objects: opaque ([`HomeStorage::Opaque`]) or
113    /// browsable ([`HomeStorage::Browsable`]). Drives both the at-rest cipher and
114    /// the blob-path scheme — see [`HomeStorage`].
115    pub storage: HomeStorage,
116}
117
118impl Default for CloudHomeConfig {
119    fn default() -> Self {
120        Self {
121            provider: None,
122            s3_bucket: None,
123            s3_region: None,
124            s3_endpoint: None,
125            s3_key_prefix: None,
126            exact_upload_verification: default_exact_upload_verification(),
127            google_drive_folder_id: None,
128            dropbox_folder_path: None,
129            onedrive_drive_id: None,
130            onedrive_folder_id: None,
131            cloudkit_owner_name: None,
132            cloudkit_zone_name: None,
133            storage: HomeStorage::Opaque,
134        }
135    }
136}
137
138/// Configuration errors.
139#[derive(thiserror::Error, Debug)]
140pub enum ConfigError {
141    #[error("serialize configuration: {0}")]
142    Serialize(#[source] serde_yaml::Error),
143    #[error("parse configuration {}: {source}", path.display())]
144    Parse {
145        path: std::path::PathBuf,
146        #[source]
147        source: serde_yaml::Error,
148    },
149    #[error("configuration file: {0}")]
150    File(#[from] crate::atomic_file::FileError),
151}
152
153/// Sync + storage configuration for one store.
154#[derive(Clone, Debug, PartialEq)]
155pub struct Config {
156    pub store_id: String,
157    /// Unique device identifier for sync changeset namespacing.
158    pub device_id: String,
159    pub store_name: String,
160    /// Cloud home provider + its settings.
161    pub cloud_home: CloudHomeConfig,
162}
163
164impl Config {
165    /// Construct a config with defaults for a new or joined store.
166    pub fn with_defaults(store_id: String, device_id: String, store_name: String) -> Self {
167        Self {
168            store_id,
169            device_id,
170            store_name,
171            cloud_home: CloudHomeConfig::default(),
172        }
173    }
174
175    /// Persist the sync config to `store_dir/config.yaml`.
176    pub fn save_to_config_yaml(&self, store_dir: &StoreDir) -> Result<(), ConfigError> {
177        let yaml: ConfigYaml = self.into();
178        let text = serde_yaml::to_string(&yaml).map_err(ConfigError::Serialize)?;
179        crate::atomic_file::AtomicFile::new(store_dir.config_path())
180            .replace(text.as_bytes())
181            .map_err(ConfigError::File)
182    }
183
184    /// Read `store_dir/config.yaml` back into a runtime `Config`. A missing or
185    /// unparseable file is a loud [`ConfigError`] naming the path.
186    pub fn load_from_config_yaml(store_dir: &StoreDir) -> Result<Config, ConfigError> {
187        let path = store_dir.config_path();
188        let text = std::fs::read_to_string(&path).map_err(|source| {
189            ConfigError::File(crate::atomic_file::FileError::at(
190                "read configuration",
191                &path,
192                source,
193            ))
194        })?;
195        let yaml: ConfigYaml =
196            serde_yaml::from_str(&text).map_err(|source| ConfigError::Parse {
197                path: path.clone(),
198                source,
199            })?;
200        Ok(yaml.into_config())
201    }
202}
203
204/// On-disk form of [`Config`] (the runtime `store_dir` is supplied separately).
205///
206/// This is the `config.yaml` wire format, not published API: hosts read and
207/// write it through [`Config::save_to_config_yaml`] and
208/// [`Config::load_from_config_yaml`], which are the only things that name it.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub(crate) struct ConfigYaml {
211    pub(crate) store_id: String,
212    pub(crate) store_name: String,
213    pub(crate) device_id: String,
214    #[serde(flatten)]
215    pub(crate) cloud_home: CloudHomeConfig,
216}
217
218impl From<&Config> for ConfigYaml {
219    fn from(config: &Config) -> Self {
220        Self {
221            store_id: config.store_id.clone(),
222            store_name: config.store_name.clone(),
223            device_id: config.device_id.clone(),
224            cloud_home: config.cloud_home.clone(),
225        }
226    }
227}
228
229impl ConfigYaml {
230    /// Pair to [`From<&Config> for ConfigYaml`]: rebuild the runtime config.
231    fn into_config(self) -> Config {
232        Config {
233            store_id: self.store_id,
234            device_id: self.device_id,
235            store_name: self.store_name,
236            cloud_home: self.cloud_home,
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn oauth_requirement_follows_the_provider() {
247        assert!(!CloudProvider::S3.needs_oauth());
248        assert!(!CloudProvider::CloudKit.needs_oauth());
249        assert!(CloudProvider::GoogleDrive.needs_oauth());
250        assert!(CloudProvider::Dropbox.needs_oauth());
251        assert!(CloudProvider::OneDrive.needs_oauth());
252    }
253
254    /// Saving a `Config` and loading it back must reproduce every configured
255    /// field; the store directory selects the file but is not configuration.
256    #[test]
257    fn round_trips_through_save_and_load() {
258        let dir = tempfile::tempdir().expect("temp dir");
259        let store_dir = StoreDir::new_ephemeral(dir.path());
260        let mut config = Config::with_defaults(
261            "store-1".to_string(),
262            "device-1".to_string(),
263            "My Store".to_string(),
264        );
265        config.cloud_home = CloudHomeConfig {
266            provider: Some(CloudProvider::S3),
267            s3_bucket: Some("bucket".to_string()),
268            s3_region: Some("us-east-1".to_string()),
269            exact_upload_verification: ExactUploadVerification::Readback,
270            storage: HomeStorage::Opaque,
271            ..CloudHomeConfig::default()
272        };
273
274        config.save_to_config_yaml(&store_dir).expect("save");
275        let config_yaml =
276            std::fs::read_to_string(store_dir.config_path()).expect("read saved local config");
277        assert!(config_yaml.contains("exact_upload_verification: readback"));
278        let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
279
280        assert_eq!(loaded, config);
281    }
282
283    /// A CloudKit share join persists `cloudkit_owner_name` and
284    /// `cloudkit_zone_name` — the only two fields the share arm writes — and
285    /// both come back unchanged.
286    #[test]
287    fn round_trips_cloudkit_share_owner_and_zone() {
288        let dir = tempfile::tempdir().expect("temp dir");
289        let store_dir = StoreDir::new_ephemeral(dir.path());
290        let mut config = Config::with_defaults(
291            "store-1".to_string(),
292            "device-1".to_string(),
293            "Shared CloudKit Store".to_string(),
294        );
295        config.cloud_home = CloudHomeConfig {
296            provider: Some(CloudProvider::CloudKit),
297            cloudkit_owner_name: Some("owner-name".to_string()),
298            cloudkit_zone_name: Some("zone-name".to_string()),
299            storage: HomeStorage::Opaque,
300            ..CloudHomeConfig::default()
301        };
302
303        config.save_to_config_yaml(&store_dir).expect("save");
304        let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
305        assert_eq!(loaded, config);
306    }
307
308    /// Optional provider fields are absent for a local store, while the two
309    /// required cloud-home policy fields remain explicit on disk.
310    #[test]
311    fn load_with_absent_optional_provider_fields() {
312        let dir = tempfile::tempdir().expect("temp dir");
313        let store_dir = StoreDir::new_ephemeral(dir.path());
314        std::fs::write(
315            store_dir.config_path(),
316            "store_id: store-1\nstore_name: My Store\ndevice_id: device-1\nexact_upload_verification: metadata_hash\nstorage: opaque\n",
317        )
318        .expect("write config.yaml");
319
320        let loaded = Config::load_from_config_yaml(&store_dir).expect("load");
321
322        assert_eq!(loaded.store_id, "store-1");
323        assert_eq!(loaded.store_name, "My Store");
324        assert_eq!(loaded.device_id, "device-1");
325        assert_eq!(loaded.cloud_home, CloudHomeConfig::default());
326    }
327
328    #[test]
329    fn load_with_missing_upload_verification_errors() {
330        let dir = tempfile::tempdir().expect("temp dir");
331        let store_dir = StoreDir::new_ephemeral(dir.path());
332        std::fs::write(
333            store_dir.config_path(),
334            "store_id: store-1\nstore_name: My Store\ndevice_id: device-1\nstorage: opaque\n",
335        )
336        .expect("write config.yaml");
337
338        let error = Config::load_from_config_yaml(&store_dir)
339            .expect_err("missing exact upload verification");
340        assert!(matches!(error, ConfigError::Parse { .. }));
341    }
342
343    /// `device_id` is a required field on the wire: the save side always writes
344    /// it, so a file without one is bad data and must fail loudly.
345    #[test]
346    fn load_with_missing_device_id_errors() {
347        let dir = tempfile::tempdir().expect("temp dir");
348        let store_dir = StoreDir::new_ephemeral(dir.path());
349        std::fs::write(
350            store_dir.config_path(),
351            "store_id: store-1\nstore_name: My Store\n",
352        )
353        .expect("write config.yaml");
354
355        let err = Config::load_from_config_yaml(&store_dir).expect_err("missing device_id");
356        assert!(matches!(err, ConfigError::Parse { .. }));
357    }
358
359    /// No `config.yaml` at all names the path in the error rather than
360    /// failing opaquely.
361    #[test]
362    fn load_with_no_file_errors_naming_the_path() {
363        let dir = tempfile::tempdir().expect("temp dir");
364        let store_dir = StoreDir::new_ephemeral(dir.path());
365
366        let err = Config::load_from_config_yaml(&store_dir).expect_err("no file");
367        let message = err.to_string();
368        assert!(
369            message.contains(&store_dir.config_path().display().to_string()),
370            "error should name the missing path, got: {message}",
371        );
372    }
373}