Skip to main content

snip_it/
config.rs

1//! Configuration management for snp sync.
2//!
3//! Handles loading and saving sync settings including server configuration,
4//! API keys, and sync preferences. Settings are stored in `sync.toml`.
5
6use 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
55/// Verifies CRC32 integrity of the config file content.
56///
57/// Note: CRC32 detects accidental corruption (e.g., partial writes, disk errors)
58/// but is NOT a cryptographic integrity check. An attacker who can modify the
59/// config file can recalculate the CRC32. This is acceptable because the threat
60/// model assumes local-only access — if an attacker can write to the config
61/// directory, they can already replace the entire file or binary.
62fn verify_integrity(content: &str) -> bool {
63    // The integrity header must be the very first line to avoid matching
64    // user-authored TOML comments like "# integrity: 42".
65    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    // No integrity header found — this is a legacy config file from before the
72    // integrity feature was added. Treat it as valid rather than silently
73    // replacing with defaults (which would cause data loss on upgrade).
74    // The header will be added on the next save.
75    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/// Sync configuration settings.
130///
131/// These settings control how snippets are synchronized with a remote server,
132/// including server URL, authentication, and sync behavior preferences.
133///
134/// The API key is zeroized on drop to minimize exposure in process memory.
135#[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    /// Returns the sync limit value, defaulting to 1000 if not set.
202    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 the key is already the keychain marker, just write the marker
215    // without touching the keychain (avoids overwriting the real key).
216    if api_key == KEYCHAIN_MARKER {
217        return serializer.serialize_str(KEYCHAIN_MARKER);
218    }
219    // Server URL is not available during serialization, so we use the default user
220    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/// Sync direction control.
327///
328/// Determines whether snippets are pushed to the server, pulled from it,
329/// or synchronized bidirectionally.
330#[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        // Backup corrupted file before returning defaults
395        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 existing plaintext API key to keychain on first load. Keep the
416    // plaintext key in this in-memory settings value so the caller can complete
417    // the current sync/register operation with the real credential.
418    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        // Verify default settings work
464        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        // API key is stored in keychain if available, otherwise plaintext
486        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        // Legacy config files without an integrity header should be accepted
499        // to prevent data loss on upgrade from older versions.
500        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}