1use crate::error::{SnipError, SnipResult};
7pub use crate::utils::config::get_sync_config_path;
8use crate::utils::toml_helpers::{fix_invalid_toml_escapes, quote_strings_containing_backslashes};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::sync::LazyLock;
13use std::sync::Mutex;
14use std::time::SystemTime;
15
16const KEYCHAIN_SERVICE: &str = "snp-sync";
17const KEYCHAIN_MARKER: &str = "@keychain";
18const KEYCHAIN_DEFAULT_USER: &str = "api-key";
19
20pub const DEFAULT_SERVER_URL: &str = "http://localhost:50051";
21
22struct CachedToml {
23 mtime: SystemTime,
24 len: u64,
25 content: String,
26}
27
28const MAX_TOML_CACHE_SIZE: usize = 100;
29
30static TOML_CACHE: LazyLock<Mutex<HashMap<String, CachedToml>>> =
31 LazyLock::new(|| Mutex::new(HashMap::new()));
32
33pub fn invalidate_toml_cache(path: &std::path::Path) {
34 let key = path.to_string_lossy().to_string();
35 if let Ok(mut cache) = TOML_CACHE.lock() {
36 cache.remove(&key);
37 }
38}
39
40fn compute_crc32(data: &str) -> u32 {
41 crc32fast::hash(data.as_bytes())
42}
43
44fn split_integrity_header(content: &str) -> Option<(&str, &str)> {
45 let (first_line, body) = match content.find('\n') {
46 Some(index) => (&content[..index], &content[index + 1..]),
47 None => (content, ""),
48 };
49
50 first_line
51 .strip_prefix("# integrity:")
52 .map(|checksum| (checksum.trim(), body))
53}
54
55fn verify_integrity(content: &str) -> bool {
63 if let Some((checksum, body)) = split_integrity_header(content) {
66 return checksum
67 .parse::<u32>()
68 .is_ok_and(|stored| stored == compute_crc32(body));
69 }
70
71 true
76}
77
78fn strip_integrity_line(content: &str) -> String {
79 split_integrity_header(content)
80 .map(|(_, body)| body.to_string())
81 .unwrap_or_else(|| content.to_string())
82}
83
84pub fn cached_read_toml(path: &std::path::Path) -> SnipResult<String> {
85 let key = path.to_string_lossy().to_string();
86
87 let metadata = fs::metadata(path)
88 .map_err(|e| SnipError::io_error("stat toml file", path.to_path_buf(), e))?;
89 let mtime = metadata
90 .modified()
91 .map_err(|e| SnipError::io_error("read mtime", path.to_path_buf(), e))?;
92 let len = metadata.len();
93
94 let cache = TOML_CACHE.lock().unwrap_or_else(|e| e.into_inner());
95 if let Some(entry) = cache.get(&key)
96 && entry.mtime == mtime
97 && entry.len == len
98 {
99 return Ok(entry.content.clone());
100 }
101 drop(cache);
102
103 let content = fs::read_to_string(path)
104 .map_err(|e| SnipError::io_error("read toml file", path.to_path_buf(), e))?;
105
106 let mut cache = TOML_CACHE.lock().unwrap_or_else(|e| e.into_inner());
107 if cache.len() >= MAX_TOML_CACHE_SIZE {
108 let keys_to_remove: Vec<_> = cache
109 .keys()
110 .take(MAX_TOML_CACHE_SIZE / 2)
111 .cloned()
112 .collect();
113 for key in keys_to_remove {
114 cache.remove(&key);
115 }
116 }
117
118 cache.insert(
119 key,
120 CachedToml {
121 mtime,
122 len,
123 content: content.clone(),
124 },
125 );
126 Ok(content)
127}
128
129#[derive(Serialize, Deserialize)]
136pub struct SyncSettings {
137 pub enabled: bool,
138 pub server_url: String,
139 #[serde(
140 default,
141 serialize_with = "serialize_api_key",
142 deserialize_with = "deserialize_api_key"
143 )]
144 pub api_key: String,
145 #[serde(default)]
146 pub device_id: String,
147 pub sync_interval_minutes: u32,
148 #[serde(default)]
149 pub auto_sync: bool,
150 #[serde(default)]
151 pub sync_direction: SyncDirection,
152 #[serde(default)]
153 pub clipboard_auto_clear_seconds: Option<u32>,
154 #[serde(default)]
155 pub sync_limit: Option<i32>,
156}
157
158impl std::fmt::Debug for SyncSettings {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("SyncSettings")
161 .field("enabled", &self.enabled)
162 .field("server_url", &self.server_url)
163 .field("api_key", &"[REDACTED]")
164 .field("device_id", &self.device_id)
165 .field("sync_interval_minutes", &self.sync_interval_minutes)
166 .field("auto_sync", &self.auto_sync)
167 .field("sync_direction", &self.sync_direction)
168 .field(
169 "clipboard_auto_clear_seconds",
170 &self.clipboard_auto_clear_seconds,
171 )
172 .field("sync_limit", &self.sync_limit)
173 .finish()
174 }
175}
176
177impl Drop for SyncSettings {
178 fn drop(&mut self) {
179 use zeroize::Zeroize;
180 self.api_key.zeroize();
181 }
182}
183
184impl Clone for SyncSettings {
185 fn clone(&self) -> Self {
186 SyncSettings {
187 enabled: self.enabled,
188 server_url: self.server_url.clone(),
189 api_key: self.api_key.clone(),
190 device_id: self.device_id.clone(),
191 sync_interval_minutes: self.sync_interval_minutes,
192 auto_sync: self.auto_sync,
193 sync_direction: self.sync_direction.clone(),
194 clipboard_auto_clear_seconds: self.clipboard_auto_clear_seconds,
195 sync_limit: self.sync_limit,
196 }
197 }
198}
199
200impl SyncSettings {
201 pub fn sync_limit_value(&self) -> i32 {
203 self.sync_limit.filter(|&v| v > 0).unwrap_or(1000)
204 }
205}
206
207fn serialize_api_key<S: serde::Serializer>(
208 api_key: &str,
209 serializer: S,
210) -> Result<S::Ok, S::Error> {
211 if api_key.is_empty() {
212 return serializer.serialize_str("");
213 }
214 if api_key == KEYCHAIN_MARKER {
217 return serializer.serialize_str(KEYCHAIN_MARKER);
218 }
219 match keychain_store(api_key, KEYCHAIN_DEFAULT_USER) {
221 Ok(()) => serializer.serialize_str(KEYCHAIN_MARKER),
222 Err(e) => {
223 if std::env::var_os("SNP_ALLOW_PLAINTEXT_API_KEY").is_some_and(|v| v == "true") {
224 tracing::warn!(
225 "Keychain unavailable, storing API key in config file (explicitly allowed): {}",
226 e
227 );
228 serializer.serialize_str(api_key)
229 } else {
230 tracing::error!(
231 "Keychain unavailable, refusing to store API key in plaintext. \
232 Set SNP_ALLOW_PLAINTEXT_API_KEY=true to allow."
233 );
234 Err(serde::ser::Error::custom(format!(
235 "keychain unavailable: {e}. Set SNP_ALLOW_PLAINTEXT_API_KEY=true to allow plaintext storage."
236 )))
237 }
238 }
239 }
240}
241
242fn deserialize_api_key<'de, D: serde::Deserializer<'de>>(
243 deserializer: D,
244) -> Result<String, D::Error> {
245 let raw: String = Deserialize::deserialize(deserializer)?;
246 if raw == KEYCHAIN_MARKER {
247 match keychain_retrieve(KEYCHAIN_DEFAULT_USER) {
248 Ok(key) => Ok(key),
249 Err(e) => {
250 tracing::error!(
251 "Keychain unavailable, cannot retrieve API key: {}. \
252 Re-save sync settings to store key in config file as fallback.",
253 e
254 );
255 Err(serde::de::Error::custom(
256 "keychain unavailable, cannot retrieve API key",
257 ))
258 }
259 }
260 } else {
261 Ok(raw)
262 }
263}
264
265fn keychain_store(api_key: &str, user: &str) -> SnipResult<()> {
266 let entry = keyring::Entry::new(KEYCHAIN_SERVICE, user)
267 .map_err(|e| SnipError::runtime_error("keychain entry", Some(&e.to_string())))?;
268 entry
269 .set_password(api_key)
270 .map_err(|e| SnipError::runtime_error("keychain store", Some(&e.to_string())))?;
271 Ok(())
272}
273
274fn keychain_retrieve(user: &str) -> SnipResult<String> {
275 let entry = keyring::Entry::new(KEYCHAIN_SERVICE, user)
276 .map_err(|e| SnipError::runtime_error("keychain entry", Some(&e.to_string())))?;
277 entry
278 .get_password()
279 .map_err(|e| SnipError::runtime_error("keychain retrieve", Some(&e.to_string())))
280}
281
282fn migrate_plaintext_api_key<FStore, FSave>(
283 settings: &SyncSettings,
284 store_key: FStore,
285 save_marker: FSave,
286) where
287 FStore: FnOnce(&str) -> SnipResult<()>,
288 FSave: FnOnce(&SyncSettings) -> SnipResult<()>,
289{
290 if settings.api_key.is_empty() || settings.api_key == KEYCHAIN_MARKER {
291 return;
292 }
293
294 if let Err(e) = store_key(&settings.api_key) {
295 tracing::error!(
296 "Failed to migrate API key to keychain (keychain unavailable): {}. \
297 API key will remain in plaintext config file.",
298 e
299 );
300 return;
301 }
302
303 let mut marker_settings = settings.clone();
304 marker_settings.api_key = KEYCHAIN_MARKER.to_string();
305 if let Err(e) = save_marker(&marker_settings) {
306 tracing::error!("Failed to save keychain marker: {}", e);
307 }
308}
309
310impl Default for SyncSettings {
311 fn default() -> Self {
312 SyncSettings {
313 enabled: false,
314 server_url: default_sync_url(),
315 api_key: String::new(),
316 device_id: String::new(),
317 sync_interval_minutes: default_sync_interval(),
318 auto_sync: false,
319 sync_direction: SyncDirection::default(),
320 clipboard_auto_clear_seconds: None,
321 sync_limit: None,
322 }
323 }
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
331#[non_exhaustive]
332pub enum SyncDirection {
333 #[default]
334 Push,
335 Pull,
336 Bidirectional,
337}
338
339fn default_sync_url() -> String {
340 DEFAULT_SERVER_URL.to_string()
341}
342
343fn default_sync_interval() -> u32 {
344 30
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, Default)]
348struct SyncConfigFile {
349 #[serde(default)]
350 settings: SyncConfigSettings,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, Default)]
354struct SyncConfigSettings {
355 #[serde(default)]
356 sync: SyncSettings,
357}
358
359pub fn save_sync_settings(settings: &SyncSettings) -> SnipResult<()> {
360 let path = get_sync_config_path();
361
362 let config = SyncConfigFile {
363 settings: SyncConfigSettings {
364 sync: settings.clone(),
365 },
366 };
367
368 let content = toml::to_string_pretty(&config)
369 .map_err(|e| SnipError::toml_error("serialize sync config", e))?;
370
371 let content = quote_strings_containing_backslashes(&content);
372 let checksum = compute_crc32(&content);
373 let content_with_integrity = format!("# integrity: {checksum}\n{content}");
374
375 crate::utils::atomic::write_private_atomic(&path, &content_with_integrity, "sync")?;
376
377 invalidate_toml_cache(&path);
378 crate::clipboard::invalidate_clipboard_settings_cache();
379
380 Ok(())
381}
382
383pub fn load_sync_settings() -> SnipResult<SyncSettings> {
384 let path = get_sync_config_path();
385
386 if !path.exists() {
387 return Ok(SyncSettings::default());
388 }
389
390 let content = cached_read_toml(&path)?;
391
392 if !verify_integrity(&content) {
393 tracing::warn!("sync.toml integrity check failed — file may be corrupted. Using defaults.");
394 let backup_path = path.with_extension("toml.corrupt.bak");
396 if let Err(backup_err) = fs::copy(&path, &backup_path) {
397 tracing::error!("Failed to backup corrupted sync config: {}", backup_err);
398 } else {
399 tracing::info!(
400 "Backed up corrupted sync config to {}",
401 backup_path.display()
402 );
403 }
404 return Ok(SyncSettings::default());
405 }
406
407 let content = strip_integrity_line(&content);
408 let fixed_content = fix_invalid_toml_escapes(&content);
409
410 let config: SyncConfigFile = toml::from_str(&fixed_content)
411 .map_err(|e| SnipError::toml_error("parse sync config", e))?;
412
413 let settings = config.settings.sync;
414
415 migrate_plaintext_api_key(
419 &settings,
420 |api_key| keychain_store(api_key, KEYCHAIN_DEFAULT_USER),
421 save_sync_settings,
422 );
423
424 Ok(settings)
425}
426
427pub fn get_sync_settings() -> SyncSettings {
428 match load_sync_settings() {
429 Ok(settings) => settings,
430 Err(e) => {
431 tracing::warn!(error = %e, "Failed to load sync settings, using defaults");
432 SyncSettings::default()
433 }
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 #[test]
441 fn test_sync_settings_default() {
442 let settings = SyncSettings::default();
443
444 assert!(!settings.enabled);
445 assert_eq!(settings.server_url, DEFAULT_SERVER_URL);
446 assert!(settings.api_key.is_empty());
447 assert!(settings.device_id.is_empty());
448 assert_eq!(settings.sync_interval_minutes, 30);
449 assert!(!settings.auto_sync);
450 assert_eq!(settings.sync_direction, SyncDirection::Push);
451 assert_eq!(settings.sync_limit, None);
452 }
453
454 #[test]
455 fn test_sync_direction_variants() {
456 assert_eq!(SyncDirection::Push, SyncDirection::Push);
457 assert_eq!(SyncDirection::Pull, SyncDirection::Pull);
458 assert_eq!(SyncDirection::Bidirectional, SyncDirection::Bidirectional);
459 }
460
461 #[test]
462 fn test_save_and_load_sync_settings() {
463 let settings = SyncSettings::default();
465 assert!(!settings.enabled);
466 }
467
468 #[test]
469 fn test_sync_settings_serialization() {
470 let settings = SyncSettings {
471 enabled: true,
472 server_url: "https://sync.example.com".to_string(),
473 api_key: "test-key-123".to_string(),
474 device_id: "device-456".to_string(),
475 sync_interval_minutes: 60,
476 auto_sync: true,
477 sync_direction: SyncDirection::Bidirectional,
478 clipboard_auto_clear_seconds: Some(30),
479 sync_limit: Some(2000),
480 };
481
482 let toml_str = toml::to_string_pretty(&settings).unwrap();
483 assert!(toml_str.contains("enabled = true"));
484 assert!(toml_str.contains("server_url = \"https://sync.example.com\""));
485 assert!(
487 toml_str.contains("api_key = \"@keychain\"")
488 || toml_str.contains("api_key = \"test-key-123\"")
489 );
490 assert!(toml_str.contains("device_id = \"device-456\""));
491 assert!(toml_str.contains("sync_interval_minutes = 60"));
492 assert!(toml_str.contains("auto_sync = true"));
493 assert!(toml_str.contains("sync_direction = \"Bidirectional\""));
494 }
495
496 #[test]
497 fn test_verify_integrity_no_header_returns_true() {
498 let content = "[sync]\nenabled = true\n";
501 assert!(verify_integrity(content));
502 }
503
504 #[test]
505 fn test_verify_integrity_valid_header() {
506 let body = "[sync]\nenabled = true";
507 let checksum = compute_crc32(body);
508 let content = format!("# integrity: {checksum}\n{body}");
509 assert!(verify_integrity(&content));
510 }
511
512 #[test]
513 fn test_verify_integrity_invalid_header() {
514 let body = "[sync]\nenabled = true";
515 let content = format!("# integrity: 999999\n{body}");
516 assert!(!verify_integrity(&content));
517 }
518
519 #[test]
520 fn test_verify_integrity_tampered_body() {
521 let original = "[sync]\nenabled = true";
522 let checksum = compute_crc32(original);
523 let tampered = "[sync]\nenabled = false";
524 let content = format!("# integrity: {checksum}\n{tampered}");
525 assert!(!verify_integrity(&content));
526 }
527
528 #[test]
529 fn test_verify_integrity_preserves_exact_body() {
530 let body = "[sync]\n# integrity: user-authored comment\nenabled = true\n";
531 let checksum = compute_crc32(body);
532 let content = format!("# integrity: {checksum}\n{body}");
533
534 assert!(verify_integrity(&content));
535 assert_eq!(strip_integrity_line(&content), body);
536 }
537
538 #[test]
539 fn test_verify_integrity_malformed_header_fails() {
540 let content = "# integrity: not-a-checksum\n[sync]\nenabled = true\n";
541 assert!(!verify_integrity(content));
542 }
543
544 #[test]
545 fn test_keychain_migration_preserves_in_memory_api_key() {
546 let mut settings = SyncSettings::default();
547 settings.api_key = "test-key-123".to_string();
548
549 migrate_plaintext_api_key(
550 &settings,
551 |api_key| {
552 assert_eq!(api_key, "test-key-123");
553 Ok(())
554 },
555 |saved_settings| {
556 assert_eq!(saved_settings.api_key, KEYCHAIN_MARKER);
557 Ok(())
558 },
559 );
560
561 assert_eq!(settings.api_key, "test-key-123");
562 }
563}