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