Skip to main content

max / audiofiles

6.9 KB · 175 lines History Blame Raw
1 //! SyncKit API-key loading and OS-keychain persistence.
2 //!
3 //! Split out of `main.rs`. Resolution order on load: OS keychain, then a
4 //! legacy plaintext file (migrated into the keychain and deleted), then env
5 //! vars, then the bundled `synckit.toml`.
6
7 use std::path::Path;
8
9 /// Bundled synckit.toml, embedded at compile time from the project root.
10 const SYNCKIT_TOML: &str = include_str!("../../../synckit.toml");
11
12 /// Extract the api_key value from the bundled synckit.toml.
13 fn parse_synckit_toml_key() -> Option<String> {
14 let table: std::collections::HashMap<String, String> = toml::from_str(SYNCKIT_TOML).ok()?;
15 table.get("api_key").filter(|s| !s.is_empty()).cloned()
16 }
17
18 /// OS keychain entry for the SyncKit API key. The key is a credential, not a
19 /// regenerable cache, so it belongs in the keychain rather than a plaintext
20 /// file. Returns None when no keychain provider is available (headless Linux
21 /// without secret-service, CI), callers fall back to file/env/bundled.
22 fn api_key_keychain_entry() -> Option<keyring::Entry> {
23 keyring::Entry::new("audiofiles", "sync_api_key").ok()
24 }
25
26 /// Marker recording that we successfully stored a user key in the OS keychain.
27 /// Holds no secret, its presence alone distinguishes "first run, no key yet"
28 /// from "the keychain lost the key it was holding", which we must not treat
29 /// alike: the latter would otherwise fall through to the bundled shared alpha
30 /// key and sync under the wrong identity.
31 const KEY_SENTINEL: &str = "sync_api_key.stored";
32
33 /// Read a trimmed API key from the legacy plaintext file. None if absent/empty.
34 fn read_key_file(data_dir: &Path) -> Option<String> {
35 let key = std::fs::read_to_string(data_dir.join("sync_api_key")).ok()?;
36 let key = key.trim().to_string();
37 (!key.is_empty()).then_some(key)
38 }
39
40 /// API key from env vars (dev/CI) or the bundled synckit.toml (alpha shared key).
41 fn api_key_from_env_or_bundled() -> Option<String> {
42 if let (Ok(_url), Ok(key)) = (
43 std::env::var("AF_SYNC_SERVER_URL"),
44 std::env::var("AF_SYNC_API_KEY"),
45 ) {
46 return Some(key);
47 }
48 parse_synckit_toml_key()
49 }
50
51 /// Load a saved API key. Order: OS keychain, then a legacy plaintext file
52 /// (migrated into the keychain and deleted on read), then env vars, then
53 /// bundled toml.
54 pub(crate) fn load_api_key(data_dir: &Path) -> Option<String> {
55 // Keychain takes priority.
56 if let Some(entry) = api_key_keychain_entry() {
57 match entry.get_password() {
58 Ok(key) if !key.trim().is_empty() => {
59 tracing::info!("Loaded SyncKit API key from OS keychain");
60 return Some(key.trim().to_string());
61 }
62 Ok(_) | Err(keyring::Error::NoEntry) => {
63 // Nothing readable in the keychain. If we previously put a key
64 // there, it is gone (the pre-4.x `linux-native` store kept
65 // entries in memory only and dropped them on reboot). Falling
66 // through would quietly hand back the bundled shared alpha key
67 // and sync as the wrong identity, so stop here and let the user
68 // re-enter their key.
69 if data_dir.join(KEY_SENTINEL).exists() {
70 tracing::error!(
71 "The saved SyncKit API key is missing from the OS keychain. \
72 Sync is disabled until the key is entered again, not falling \
73 back to the bundled key, which would sync under a different identity."
74 );
75 return None;
76 }
77 }
78 Err(e) => tracing::warn!("Keychain read failed, falling back: {e}"),
79 }
80 }
81
82 // Legacy plaintext file: migrate into the keychain, then remove it so the
83 // credential stops living on disk in cleartext.
84 if let Some(key) = read_key_file(data_dir) {
85 tracing::info!("Migrating SyncKit API key into the keychain");
86 if save_api_key(data_dir, &key) {
87 let _ = std::fs::remove_file(data_dir.join("sync_api_key"));
88 }
89 return Some(key);
90 }
91
92 api_key_from_env_or_bundled()
93 }
94
95 /// Persist an API key to the OS keychain. Returns true on success; best-effort,
96 /// so a missing keychain provider degrades gracefully rather than failing.
97 fn save_api_key(data_dir: &Path, api_key: &str) -> bool {
98 let Some(entry) = api_key_keychain_entry() else {
99 return false;
100 };
101 match entry.set_password(api_key) {
102 Ok(()) => {
103 // Record that a user key now lives in the keychain, so a later
104 // disappearance is recognised as loss rather than a first run.
105 if let Err(e) = std::fs::write(data_dir.join(KEY_SENTINEL), b"") {
106 tracing::warn!("Failed to write API key sentinel: {e}");
107 }
108 true
109 }
110 Err(e) => {
111 tracing::warn!("Failed to store API key in keychain: {e}");
112 false
113 }
114 }
115 }
116
117 #[cfg(test)]
118 mod tests {
119 use super::*;
120
121 // The plaintext-file path is tested via `read_key_file` so the tests stay
122 // deterministic, `load_api_key` reads the real OS keychain first, whose
123 // state is global and not test-controlled.
124
125 #[test]
126 fn read_key_file_returns_key() {
127 let dir = tempfile::tempdir().unwrap();
128 std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap();
129 assert_eq!(read_key_file(dir.path()), Some("test-key-123".to_string()));
130 }
131
132 #[test]
133 fn read_key_file_trims_whitespace() {
134 let dir = tempfile::tempdir().unwrap();
135 std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap();
136 assert_eq!(
137 read_key_file(dir.path()),
138 Some("key-with-spaces".to_string())
139 );
140 }
141
142 #[test]
143 fn read_key_file_empty_is_none() {
144 let dir = tempfile::tempdir().unwrap();
145 std::fs::write(dir.path().join("sync_api_key"), "").unwrap();
146 assert_eq!(read_key_file(dir.path()), None);
147 }
148
149 #[test]
150 fn read_key_file_whitespace_only_is_none() {
151 let dir = tempfile::tempdir().unwrap();
152 std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap();
153 assert_eq!(read_key_file(dir.path()), None);
154 }
155
156 #[test]
157 fn read_key_file_missing_is_none() {
158 let dir = tempfile::tempdir().unwrap();
159 assert_eq!(read_key_file(dir.path()), None);
160 }
161
162 #[test]
163 fn env_or_bundled_matches_bundled_without_env() {
164 if std::env::var("AF_SYNC_API_KEY").is_err() {
165 assert_eq!(api_key_from_env_or_bundled(), parse_synckit_toml_key());
166 }
167 }
168
169 // No save/load round-trip test: `save_api_key` writes to the real OS
170 // keychain under a fixed namespace, so exercising it in unit tests would
171 // pollute (or depend on) the developer's actual keychain. The migration and
172 // fallback ordering are covered by the `read_key_file` / env-or-bundled
173 // tests above.
174 }
175