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)]
27#[serde(rename_all = "snake_case")]
28pub enum CustomS3ExactSlots {
29 StandardConditionalRequests,
30}
31
32impl CloudProvider {
33 pub fn needs_oauth(&self) -> bool {
37 matches!(self, Self::GoogleDrive | Self::Dropbox | Self::OneDrive)
38 }
39}
40
41#[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 pub fn is_opaque(self) -> bool {
71 matches!(self, HomeStorage::Opaque)
72 }
73
74 pub fn is_browsable(self) -> bool {
77 matches!(self, HomeStorage::Browsable)
78 }
79}
80
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct CloudHomeConfig {
86 #[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 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#[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#[derive(Clone, Debug, PartialEq)]
152pub struct Config {
153 pub store_id: String,
154 pub device_id: String,
156 pub store_dir: StoreDir,
157 pub store_name: String,
158 pub encryption_key_stored: bool,
160 pub encryption_key_fingerprint: Option<String>,
162 pub cloud_home: CloudHomeConfig,
164}
165
166impl Config {
167 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 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 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#[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 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 #[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 #[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 #[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 #[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 #[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}