1use serde::{Deserialize, Serialize};
10
11use crate::store_dir::StoreDir;
12
13#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
15pub enum CloudProvider {
16 S3,
17 GoogleDrive,
18 Dropbox,
19 OneDrive,
20 CloudKit,
21}
22
23#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "snake_case")]
27pub enum CustomS3Serial {
28 ConditionalPutAndStrongReads,
29}
30
31#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum CustomS3ExactSlots {
37 StandardConditionalRequests,
38}
39
40impl CloudProvider {
41 pub fn needs_oauth(&self) -> bool {
45 matches!(self, Self::GoogleDrive | Self::Dropbox | Self::OneDrive)
46 }
47}
48
49#[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 pub fn is_opaque(self) -> bool {
79 matches!(self, HomeStorage::Opaque)
80 }
81
82 pub fn is_browsable(self) -> bool {
85 matches!(self, HomeStorage::Browsable)
86 }
87}
88
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
93pub struct CloudHomeConfig {
94 #[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 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#[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#[derive(Clone, Debug, PartialEq)]
163pub struct Config {
164 pub store_id: String,
165 pub device_id: String,
167 pub store_dir: StoreDir,
168 pub store_name: String,
169 pub encryption_key_stored: bool,
171 pub encryption_key_fingerprint: Option<String>,
173 pub cloud_home: CloudHomeConfig,
175}
176
177impl Config {
178 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 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 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#[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 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 #[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 #[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 #[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 #[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 #[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}