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