Skip to main content

coven_core/
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 itself is not
7//! part of the file — the caller supplies it at both save and load.
8
9use serde::{Deserialize, Serialize};
10
11use crate::store_dir::StoreDir;
12
13/// Cloud home provider selection.
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
15pub enum CloudProvider {
16    S3,
17    GoogleDrive,
18    Dropbox,
19    OneDrive,
20    CloudKit,
21}
22
23/// Local operator assertion required before a custom S3 endpoint may provide
24/// exact protocol and blob slots. This is local configuration and is never
25/// accepted from invite or restore data.
26#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum CustomS3ExactSlots {
29    StandardConditionalRequests,
30}
31
32impl CloudProvider {
33    /// Whether connecting, restoring, or joining on this provider requires
34    /// running an OAuth flow first — true for the account-based consumer clouds
35    /// (Google Drive, Dropbox, OneDrive), false for S3 and CloudKit.
36    pub fn needs_oauth(&self) -> bool {
37        matches!(self, Self::GoogleDrive | Self::Dropbox | Self::OneDrive)
38    }
39}
40
41/// How a cloud home stores its objects: opaque (encrypted, unreadable to anyone
42/// who can read the bucket) or browsable (stored in the clear at readable paths).
43/// This is *not* about who can reach the bucket — the storage provider's own
44/// access control applies either way; it is about whether what they store is
45/// legible. The host picks it once, when it creates the home; it cannot change
46/// later (it determines how every object is written). One choice drives two
47/// mechanisms together:
48///
49/// - `Opaque` (the default): every object is encrypted at rest under the store
50///   key (the `.enc` suffix) and blobs use coven's content-addressed path under
51///   the uploading device, `{namespace}/{uploader}/{ab}/{cd}/{id}`. Anyone with
52///   bucket access sees only ciphertext
53///   under opaque keys. Sharing a store (inviting members) requires an opaque
54///   home, because it wraps and rotates the store key.
55/// - `Browsable`: every object is stored in the clear (no `.enc` suffix) and
56///   blobs use the consumer-supplied readable path `{namespace}/{cloud_path}`, so
57///   anyone with bucket access can read the actual files by name. Browsable
58///   storage cannot be combined with per-row audiences declared through
59///   [`crate::SyncedTable::scoped_by`].
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum HomeStorage {
63    Opaque,
64    Browsable,
65}
66
67impl HomeStorage {
68    /// An opaque home is encrypted at rest and obfuscates its blob paths; a
69    /// browsable home does neither.
70    pub fn is_opaque(self) -> bool {
71        matches!(self, HomeStorage::Opaque)
72    }
73
74    /// Whether this home stores its objects in the clear at readable paths (the
75    /// inverse of [`Self::is_opaque`]).
76    pub fn is_browsable(self) -> bool {
77        matches!(self, HomeStorage::Browsable)
78    }
79}
80
81/// The cloud home: which provider backs sync and its per-provider settings.
82/// One cohesive unit — connecting picks a provider and fills its fields;
83/// disconnecting resets the whole thing to default.
84#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct CloudHomeConfig {
86    /// Selected provider. None = not configured.
87    #[serde(default)]
88    pub provider: Option<CloudProvider>,
89    #[serde(default)]
90    pub s3_bucket: Option<String>,
91    #[serde(default)]
92    pub s3_region: Option<String>,
93    #[serde(default)]
94    pub s3_endpoint: Option<String>,
95    #[serde(default)]
96    pub s3_key_prefix: Option<String>,
97    #[serde(default)]
98    pub s3_exact_slots: Option<CustomS3ExactSlots>,
99    #[serde(default)]
100    pub google_drive_folder_id: Option<String>,
101    #[serde(default)]
102    pub dropbox_folder_path: Option<String>,
103    #[serde(default)]
104    pub onedrive_drive_id: Option<String>,
105    #[serde(default)]
106    pub onedrive_folder_id: Option<String>,
107    #[serde(default)]
108    pub cloudkit_owner_name: Option<String>,
109    #[serde(default)]
110    pub cloudkit_zone_name: Option<String>,
111    /// How this home stores its objects: opaque ([`HomeStorage::Opaque`]) or
112    /// browsable ([`HomeStorage::Browsable`]). Drives both the at-rest cipher and
113    /// the blob-path scheme — see [`HomeStorage`].
114    pub storage: HomeStorage,
115}
116
117impl Default for CloudHomeConfig {
118    fn default() -> Self {
119        Self {
120            provider: None,
121            s3_bucket: None,
122            s3_region: None,
123            s3_endpoint: None,
124            s3_key_prefix: None,
125            s3_exact_slots: None,
126            google_drive_folder_id: None,
127            dropbox_folder_path: None,
128            onedrive_drive_id: None,
129            onedrive_folder_id: None,
130            cloudkit_owner_name: None,
131            cloudkit_zone_name: None,
132            storage: HomeStorage::Opaque,
133        }
134    }
135}
136
137/// Configuration errors.
138#[derive(thiserror::Error, Debug)]
139pub enum ConfigError {
140    #[error("Serialization error: {0}")]
141    Serialization(String),
142    #[error("Configuration error: {0}")]
143    Config(String),
144    #[error("IO error: {0}")]
145    Io(#[from] std::io::Error),
146    #[error("store already exists locally: {0}")]
147    StoreExists(String),
148}
149
150/// Sync + storage configuration for one store.
151#[derive(Clone, Debug, PartialEq)]
152pub struct Config {
153    pub store_id: String,
154    /// Unique device identifier for sync changeset namespacing.
155    pub device_id: String,
156    pub store_dir: StoreDir,
157    pub store_name: String,
158    /// Whether an encryption key is stored in the keyring (hint flag).
159    pub encryption_key_stored: bool,
160    /// SHA-256 fingerprint of the encryption key (detects wrong key without decryption).
161    pub encryption_key_fingerprint: Option<String>,
162    /// Cloud home provider + its settings.
163    pub cloud_home: CloudHomeConfig,
164}
165
166impl Config {
167    /// Construct a config with defaults for a new or joined store.
168    pub fn with_defaults(
169        store_id: String,
170        device_id: String,
171        store_dir: StoreDir,
172        store_name: String,
173    ) -> Self {
174        Self {
175            store_id,
176            device_id,
177            store_dir,
178            store_name,
179            encryption_key_stored: false,
180            encryption_key_fingerprint: None,
181            cloud_home: CloudHomeConfig::default(),
182        }
183    }
184
185    /// Persist the sync config to `store_dir/config.yaml`.
186    pub fn save_to_config_yaml(&self) -> Result<(), ConfigError> {
187        let yaml: ConfigYaml = self.into();
188        let text =
189            serde_yaml::to_string(&yaml).map_err(|e| ConfigError::Serialization(e.to_string()))?;
190        crate::local_blob::write_atomic_durable_blocking(
191            &self.store_dir.config_path(),
192            text.as_bytes(),
193        )
194        .map_err(ConfigError::Config)
195    }
196
197    /// Read `store_dir/config.yaml` back into a runtime `Config`; `store_dir`
198    /// is supplied by the caller, since it is not itself persisted (see the
199    /// module doc). A missing or unparseable file is a loud [`ConfigError`]
200    /// naming the path.
201    pub fn load_from_config_yaml(store_dir: StoreDir) -> Result<Config, ConfigError> {
202        let path = store_dir.config_path();
203        let text = std::fs::read_to_string(&path)
204            .map_err(|e| ConfigError::Config(format!("failed to read {}: {e}", path.display())))?;
205        let yaml: ConfigYaml = serde_yaml::from_str(&text)
206            .map_err(|e| ConfigError::Config(format!("failed to parse {}: {e}", path.display())))?;
207        Ok(yaml.into_config(store_dir))
208    }
209}
210
211/// On-disk form of [`Config`] (the runtime `store_dir` is supplied separately).
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct ConfigYaml {
214    pub store_id: String,
215    pub store_name: String,
216    pub device_id: String,
217    #[serde(default)]
218    pub encryption_key_stored: bool,
219    #[serde(default)]
220    pub encryption_key_fingerprint: Option<String>,
221    #[serde(default, flatten)]
222    pub cloud_home: CloudHomeConfig,
223}
224
225impl From<&Config> for ConfigYaml {
226    fn from(config: &Config) -> Self {
227        Self {
228            store_id: config.store_id.clone(),
229            store_name: config.store_name.clone(),
230            device_id: config.device_id.clone(),
231            encryption_key_stored: config.encryption_key_stored,
232            encryption_key_fingerprint: config.encryption_key_fingerprint.clone(),
233            cloud_home: config.cloud_home.clone(),
234        }
235    }
236}
237
238impl ConfigYaml {
239    /// Pair to [`From<&Config> for ConfigYaml`]: rebuild a runtime `Config`
240    /// from its on-disk form plus the `store_dir` the file doesn't carry.
241    fn into_config(self, store_dir: StoreDir) -> Config {
242        Config {
243            store_id: self.store_id,
244            device_id: self.device_id,
245            store_dir,
246            store_name: self.store_name,
247            encryption_key_stored: self.encryption_key_stored,
248            encryption_key_fingerprint: self.encryption_key_fingerprint,
249            cloud_home: self.cloud_home,
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    /// Saving a `Config` and loading it back must reproduce every field,
259    /// including `store_dir` — the caller supplies the same `store_dir` to
260    /// both `save_to_config_yaml` (via `self`) and `load_from_config_yaml`,
261    /// so it must come back unchanged along with everything the file does
262    /// carry.
263    #[test]
264    fn round_trips_through_save_and_load() {
265        let dir = tempfile::tempdir().expect("temp dir");
266        let store_dir = StoreDir::new(dir.path());
267        let mut config = Config::with_defaults(
268            "store-1".to_string(),
269            "device-1".to_string(),
270            store_dir.clone(),
271            "My Store".to_string(),
272        );
273        config.encryption_key_stored = true;
274        config.encryption_key_fingerprint = Some("abc123".to_string());
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            s3_exact_slots: Some(CustomS3ExactSlots::StandardConditionalRequests),
280            storage: HomeStorage::Opaque,
281            ..CloudHomeConfig::default()
282        };
283
284        config.save_to_config_yaml().expect("save");
285        let config_yaml = std::fs::read_to_string(config.store_dir.config_path())
286            .expect("read saved local config");
287        assert!(config_yaml.contains("s3_exact_slots"));
288        let loaded = Config::load_from_config_yaml(store_dir).expect("load");
289
290        assert_eq!(loaded, config);
291
292        let join_info = crate::storage::cloud::CloudHomeJoinInfo::S3 {
293            bucket: "bucket".to_string(),
294            region: "us-east-1".to_string(),
295            endpoint: Some("https://objects.example".to_string()),
296            access_key: "access".to_string(),
297            secret_key: "secret".to_string(),
298            key_prefix: None,
299        };
300        let shared_wire = serde_json::to_string(&join_info).expect("serialize shared join info");
301        assert!(!shared_wire.contains("s3_exact_slots"));
302        assert!(!shared_wire.contains("strong_reads"));
303    }
304
305    /// A CloudKit share join persists `cloudkit_owner_name` and
306    /// `cloudkit_zone_name` — the only two fields the share arm writes — and
307    /// both come back unchanged.
308    #[test]
309    fn round_trips_cloudkit_share_owner_and_zone() {
310        let dir = tempfile::tempdir().expect("temp dir");
311        let store_dir = StoreDir::new(dir.path());
312        let mut config = Config::with_defaults(
313            "store-1".to_string(),
314            "device-1".to_string(),
315            store_dir.clone(),
316            "Shared CloudKit Store".to_string(),
317        );
318        config.cloud_home = CloudHomeConfig {
319            provider: Some(CloudProvider::CloudKit),
320            cloudkit_owner_name: Some("owner-name".to_string()),
321            cloudkit_zone_name: Some("zone-name".to_string()),
322            storage: HomeStorage::Opaque,
323            ..CloudHomeConfig::default()
324        };
325
326        config.save_to_config_yaml().expect("save");
327        let loaded = Config::load_from_config_yaml(store_dir).expect("load");
328        assert_eq!(loaded, config);
329    }
330
331    /// A file that omits every field with a designed default
332    /// (`encryption_key_stored`, `encryption_key_fingerprint`, the flattened
333    /// `cloud_home`) still loads — those absences are real inputs, not bugs.
334    /// `storage` has no default (the host must pick opaque vs. browsable when
335    /// it creates the home), so it is the one `cloud_home` field still spelled
336    /// out.
337    #[test]
338    fn load_with_absent_optional_fields_uses_defaults() {
339        let dir = tempfile::tempdir().expect("temp dir");
340        let store_dir = StoreDir::new(dir.path());
341        std::fs::write(
342            store_dir.config_path(),
343            "store_id: store-1\nstore_name: My Store\ndevice_id: device-1\nstorage: opaque\n",
344        )
345        .expect("write config.yaml");
346
347        let loaded = Config::load_from_config_yaml(store_dir.clone()).expect("load");
348
349        assert_eq!(loaded.store_id, "store-1");
350        assert_eq!(loaded.store_name, "My Store");
351        assert_eq!(loaded.device_id, "device-1");
352        assert!(!loaded.encryption_key_stored);
353        assert_eq!(loaded.encryption_key_fingerprint, None);
354        assert_eq!(loaded.cloud_home, CloudHomeConfig::default());
355        assert_eq!(loaded.store_dir, store_dir);
356    }
357
358    /// `device_id` is a required field on the wire, unlike the designed-default
359    /// ones above: the save side always writes it, so a file without one is bad
360    /// data, not an absence to tolerate — it must fail loudly, not default.
361    #[test]
362    fn load_with_missing_device_id_errors() {
363        let dir = tempfile::tempdir().expect("temp dir");
364        let store_dir = StoreDir::new(dir.path());
365        std::fs::write(
366            store_dir.config_path(),
367            "store_id: store-1\nstore_name: My Store\n",
368        )
369        .expect("write config.yaml");
370
371        let err = Config::load_from_config_yaml(store_dir).expect_err("missing device_id");
372        assert!(matches!(err, ConfigError::Config(_)));
373    }
374
375    /// No `config.yaml` at all names the path in the error rather than
376    /// failing opaquely.
377    #[test]
378    fn load_with_no_file_errors_naming_the_path() {
379        let dir = tempfile::tempdir().expect("temp dir");
380        let store_dir = StoreDir::new(dir.path());
381
382        let err = Config::load_from_config_yaml(store_dir.clone()).expect_err("no file");
383        let message = err.to_string();
384        assert!(
385            message.contains(&store_dir.config_path().display().to_string()),
386            "error should name the missing path, got: {message}",
387        );
388    }
389}