//! SyncKit API-key loading and OS-keychain persistence. //! //! Split out of `main.rs`. Resolution order on load: OS keychain, then a //! legacy plaintext file (migrated into the keychain and deleted), then env //! vars, then the bundled `synckit.toml`. use std::path::Path; /// Bundled synckit.toml, embedded at compile time from the project root. const SYNCKIT_TOML: &str = include_str!("../../../synckit.toml"); /// Extract the api_key value from the bundled synckit.toml. fn parse_synckit_toml_key() -> Option { let table: std::collections::HashMap = toml::from_str(SYNCKIT_TOML).ok()?; table.get("api_key").filter(|s| !s.is_empty()).cloned() } /// OS keychain entry for the SyncKit API key. The key is a credential, not a /// regenerable cache, so it belongs in the keychain rather than a plaintext /// file. Returns None when no keychain provider is available (headless Linux /// without secret-service, CI), callers fall back to file/env/bundled. fn api_key_keychain_entry() -> Option { keyring::Entry::new("audiofiles", "sync_api_key").ok() } /// Marker recording that we successfully stored a user key in the OS keychain. /// Holds no secret, its presence alone distinguishes "first run, no key yet" /// from "the keychain lost the key it was holding", which we must not treat /// alike: the latter would otherwise fall through to the bundled shared alpha /// key and sync under the wrong identity. const KEY_SENTINEL: &str = "sync_api_key.stored"; /// Read a trimmed API key from the legacy plaintext file. None if absent/empty. fn read_key_file(data_dir: &Path) -> Option { let key = std::fs::read_to_string(data_dir.join("sync_api_key")).ok()?; let key = key.trim().to_string(); (!key.is_empty()).then_some(key) } /// API key from env vars (dev/CI) or the bundled synckit.toml (alpha shared key). fn api_key_from_env_or_bundled() -> Option { if let (Ok(_url), Ok(key)) = ( std::env::var("AF_SYNC_SERVER_URL"), std::env::var("AF_SYNC_API_KEY"), ) { return Some(key); } parse_synckit_toml_key() } /// Load a saved API key. Order: OS keychain, then a legacy plaintext file /// (migrated into the keychain and deleted on read), then env vars, then /// bundled toml. pub(crate) fn load_api_key(data_dir: &Path) -> Option { // Keychain takes priority. if let Some(entry) = api_key_keychain_entry() { match entry.get_password() { Ok(key) if !key.trim().is_empty() => { tracing::info!("Loaded SyncKit API key from OS keychain"); return Some(key.trim().to_string()); } Ok(_) | Err(keyring::Error::NoEntry) => { // Nothing readable in the keychain. If we previously put a key // there, it is gone (the pre-4.x `linux-native` store kept // entries in memory only and dropped them on reboot). Falling // through would quietly hand back the bundled shared alpha key // and sync as the wrong identity, so stop here and let the user // re-enter their key. if data_dir.join(KEY_SENTINEL).exists() { tracing::error!( "The saved SyncKit API key is missing from the OS keychain. \ Sync is disabled until the key is entered again, not falling \ back to the bundled key, which would sync under a different identity." ); return None; } } Err(e) => tracing::warn!("Keychain read failed, falling back: {e}"), } } // Legacy plaintext file: migrate into the keychain, then remove it so the // credential stops living on disk in cleartext. if let Some(key) = read_key_file(data_dir) { tracing::info!("Migrating SyncKit API key into the keychain"); if save_api_key(data_dir, &key) { let _ = std::fs::remove_file(data_dir.join("sync_api_key")); } return Some(key); } api_key_from_env_or_bundled() } /// Persist an API key to the OS keychain. Returns true on success; best-effort, /// so a missing keychain provider degrades gracefully rather than failing. fn save_api_key(data_dir: &Path, api_key: &str) -> bool { let Some(entry) = api_key_keychain_entry() else { return false; }; match entry.set_password(api_key) { Ok(()) => { // Record that a user key now lives in the keychain, so a later // disappearance is recognised as loss rather than a first run. if let Err(e) = std::fs::write(data_dir.join(KEY_SENTINEL), b"") { tracing::warn!("Failed to write API key sentinel: {e}"); } true } Err(e) => { tracing::warn!("Failed to store API key in keychain: {e}"); false } } } #[cfg(test)] mod tests { use super::*; // The plaintext-file path is tested via `read_key_file` so the tests stay // deterministic, `load_api_key` reads the real OS keychain first, whose // state is global and not test-controlled. #[test] fn read_key_file_returns_key() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap(); assert_eq!(read_key_file(dir.path()), Some("test-key-123".to_string())); } #[test] fn read_key_file_trims_whitespace() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap(); assert_eq!( read_key_file(dir.path()), Some("key-with-spaces".to_string()) ); } #[test] fn read_key_file_empty_is_none() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), "").unwrap(); assert_eq!(read_key_file(dir.path()), None); } #[test] fn read_key_file_whitespace_only_is_none() { let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap(); assert_eq!(read_key_file(dir.path()), None); } #[test] fn read_key_file_missing_is_none() { let dir = tempfile::tempdir().unwrap(); assert_eq!(read_key_file(dir.path()), None); } #[test] fn env_or_bundled_matches_bundled_without_env() { if std::env::var("AF_SYNC_API_KEY").is_err() { assert_eq!(api_key_from_env_or_bundled(), parse_synckit_toml_key()); } } // No save/load round-trip test: `save_api_key` writes to the real OS // keychain under a fixed namespace, so exercising it in unit tests would // pollute (or depend on) the developer's actual keychain. The migration and // fallback ordering are covered by the `read_key_file` / env-or-bundled // tests above. }