Skip to main content

max / audiofiles

Extract api-key/keychain plumbing from main.rs into api_key module Move the SyncKit API-key subsystem out of main.rs into a new api_key.rs: the bundled-toml const, the six load/save/keychain helpers, and their unit tests. Only load_api_key is pub(crate) (called from create_sync_manager); the rest stay private to the module. main.rs keeps the app wiring and its remaining tests. No behavior change; build and clippy clean, all 97 audiofiles-app tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 17:46 UTC
Commit: 230e3e8555925182db06566ce30f5bc2fb803330
Parent: 9127e2f
2 files changed, +144 insertions, -131 deletions
@@ -0,0 +1,142 @@
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 + /// Read a trimmed API key from the legacy plaintext file. None if absent/empty.
27 + fn read_key_file(data_dir: &Path) -> Option<String> {
28 + let key = std::fs::read_to_string(data_dir.join("sync_api_key")).ok()?;
29 + let key = key.trim().to_string();
30 + (!key.is_empty()).then_some(key)
31 + }
32 +
33 + /// API key from env vars (dev/CI) or the bundled synckit.toml (alpha shared key).
34 + fn api_key_from_env_or_bundled() -> Option<String> {
35 + if let (Ok(_url), Ok(key)) = (
36 + std::env::var("AF_SYNC_SERVER_URL"),
37 + std::env::var("AF_SYNC_API_KEY"),
38 + ) {
39 + return Some(key);
40 + }
41 + parse_synckit_toml_key()
42 + }
43 +
44 + /// Load a saved API key. Order: OS keychain, then a legacy plaintext file
45 + /// (migrated into the keychain and deleted on read), then env vars, then
46 + /// bundled toml.
47 + pub(crate) fn load_api_key(data_dir: &Path) -> Option<String> {
48 + // Keychain takes priority.
49 + if let Some(entry) = api_key_keychain_entry() {
50 + match entry.get_password() {
51 + Ok(key) if !key.trim().is_empty() => {
52 + tracing::info!("Loaded SyncKit API key from OS keychain");
53 + return Some(key.trim().to_string());
54 + }
55 + Ok(_) | Err(keyring::Error::NoEntry) => {}
56 + Err(e) => tracing::warn!("Keychain read failed, falling back: {e}"),
57 + }
58 + }
59 +
60 + // Legacy plaintext file: migrate into the keychain, then remove it so the
61 + // credential stops living on disk in cleartext.
62 + if let Some(key) = read_key_file(data_dir) {
63 + tracing::info!("Migrating SyncKit API key into the keychain");
64 + if save_api_key(&key) {
65 + let _ = std::fs::remove_file(data_dir.join("sync_api_key"));
66 + }
67 + return Some(key);
68 + }
69 +
70 + api_key_from_env_or_bundled()
71 + }
72 +
73 + /// Persist an API key to the OS keychain. Returns true on success; best-effort,
74 + /// so a missing keychain provider degrades gracefully rather than failing.
75 + fn save_api_key(api_key: &str) -> bool {
76 + let Some(entry) = api_key_keychain_entry() else {
77 + return false;
78 + };
79 + match entry.set_password(api_key) {
80 + Ok(()) => true,
81 + Err(e) => {
82 + tracing::warn!("Failed to store API key in keychain: {e}");
83 + false
84 + }
85 + }
86 + }
87 +
88 + #[cfg(test)]
89 + mod tests {
90 + use super::*;
91 +
92 + // The plaintext-file path is tested via `read_key_file` so the tests stay
93 + // deterministic — `load_api_key` reads the real OS keychain first, whose
94 + // state is global and not test-controlled.
95 +
96 + #[test]
97 + fn read_key_file_returns_key() {
98 + let dir = tempfile::tempdir().unwrap();
99 + std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap();
100 + assert_eq!(read_key_file(dir.path()), Some("test-key-123".to_string()));
101 + }
102 +
103 + #[test]
104 + fn read_key_file_trims_whitespace() {
105 + let dir = tempfile::tempdir().unwrap();
106 + std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap();
107 + assert_eq!(read_key_file(dir.path()), Some("key-with-spaces".to_string()));
108 + }
109 +
110 + #[test]
111 + fn read_key_file_empty_is_none() {
112 + let dir = tempfile::tempdir().unwrap();
113 + std::fs::write(dir.path().join("sync_api_key"), "").unwrap();
114 + assert_eq!(read_key_file(dir.path()), None);
115 + }
116 +
117 + #[test]
118 + fn read_key_file_whitespace_only_is_none() {
119 + let dir = tempfile::tempdir().unwrap();
120 + std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap();
121 + assert_eq!(read_key_file(dir.path()), None);
122 + }
123 +
124 + #[test]
125 + fn read_key_file_missing_is_none() {
126 + let dir = tempfile::tempdir().unwrap();
127 + assert_eq!(read_key_file(dir.path()), None);
128 + }
129 +
130 + #[test]
131 + fn env_or_bundled_matches_bundled_without_env() {
132 + if std::env::var("AF_SYNC_API_KEY").is_err() {
133 + assert_eq!(api_key_from_env_or_bundled(), parse_synckit_toml_key());
134 + }
135 + }
136 +
137 + // No save/load round-trip test: `save_api_key` writes to the real OS
138 + // keychain under a fixed namespace, so exercising it in unit tests would
139 + // pollute (or depend on) the developer's actual keychain. The migration and
140 + // fallback ordering are covered by the `read_key_file` / env-or-bundled
141 + // tests above.
142 + }
@@ -23,6 +23,7 @@
23 23 #![cfg_attr(not(test), deny(clippy::unwrap_used))]
24 24
25 25 mod activation;
26 + mod api_key;
26 27 mod audio;
27 28 mod license;
28 29 mod midi;
@@ -163,91 +164,12 @@ fn main() -> eframe::Result<()> {
163 164
164 165 // ── API key persistence ──
165 166
166 - /// Bundled synckit.toml, embedded at compile time from the project root.
167 - const SYNCKIT_TOML: &str = include_str!("../../../synckit.toml");
168 -
169 - /// Extract the api_key value from the bundled synckit.toml.
170 - fn parse_synckit_toml_key() -> Option<String> {
171 - let table: std::collections::HashMap<String, String> = toml::from_str(SYNCKIT_TOML).ok()?;
172 - table.get("api_key").filter(|s| !s.is_empty()).cloned()
173 - }
174 -
175 - /// OS keychain entry for the SyncKit API key. The key is a credential, not a
176 - /// regenerable cache, so it belongs in the keychain rather than a plaintext
177 - /// file. Returns None when no keychain provider is available (headless Linux
178 - /// without secret-service, CI) — callers fall back to file/env/bundled.
179 - fn api_key_keychain_entry() -> Option<keyring::Entry> {
180 - keyring::Entry::new("audiofiles", "sync_api_key").ok()
181 - }
182 -
183 - /// Read a trimmed API key from the legacy plaintext file. None if absent/empty.
184 - fn read_key_file(data_dir: &Path) -> Option<String> {
185 - let key = std::fs::read_to_string(data_dir.join("sync_api_key")).ok()?;
186 - let key = key.trim().to_string();
187 - (!key.is_empty()).then_some(key)
188 - }
189 -
190 - /// API key from env vars (dev/CI) or the bundled synckit.toml (alpha shared key).
191 - fn api_key_from_env_or_bundled() -> Option<String> {
192 - if let (Ok(_url), Ok(key)) = (
193 - std::env::var("AF_SYNC_SERVER_URL"),
194 - std::env::var("AF_SYNC_API_KEY"),
195 - ) {
196 - return Some(key);
197 - }
198 - parse_synckit_toml_key()
199 - }
200 -
201 - /// Load a saved API key. Order: OS keychain, then a legacy plaintext file
202 - /// (migrated into the keychain and deleted on read), then env vars, then
203 - /// bundled toml.
204 - fn load_api_key(data_dir: &Path) -> Option<String> {
205 - // Keychain takes priority.
206 - if let Some(entry) = api_key_keychain_entry() {
207 - match entry.get_password() {
208 - Ok(key) if !key.trim().is_empty() => {
209 - tracing::info!("Loaded SyncKit API key from OS keychain");
210 - return Some(key.trim().to_string());
211 - }
212 - Ok(_) | Err(keyring::Error::NoEntry) => {}
213 - Err(e) => tracing::warn!("Keychain read failed, falling back: {e}"),
214 - }
215 - }
216 -
217 - // Legacy plaintext file: migrate into the keychain, then remove it so the
218 - // credential stops living on disk in cleartext.
219 - if let Some(key) = read_key_file(data_dir) {
220 - tracing::info!("Migrating SyncKit API key into the keychain");
221 - if save_api_key(&key) {
222 - let _ = std::fs::remove_file(data_dir.join("sync_api_key"));
223 - }
224 - return Some(key);
225 - }
226 -
227 - api_key_from_env_or_bundled()
228 - }
229 -
230 - /// Persist an API key to the OS keychain. Returns true on success; best-effort,
231 - /// so a missing keychain provider degrades gracefully rather than failing.
232 - fn save_api_key(api_key: &str) -> bool {
233 - let Some(entry) = api_key_keychain_entry() else {
234 - return false;
235 - };
236 - match entry.set_password(api_key) {
237 - Ok(()) => true,
238 - Err(e) => {
239 - tracing::warn!("Failed to store API key in keychain: {e}");
240 - false
241 - }
242 - }
243 - }
244 -
245 167 /// Create a SyncManager from a saved or env-provided API key.
246 168 fn create_sync_manager(
247 169 data_dir: &Path,
248 170 runtime: &tokio::runtime::Handle,
249 171 ) -> Option<SyncManager> {
250 - let api_key = load_api_key(data_dir)?;
172 + let api_key = api_key::load_api_key(data_dir)?;
251 173 let server_url = std::env::var("AF_SYNC_SERVER_URL")
252 174 .unwrap_or_else(|_| SYNC_SERVER_URL.to_string());
253 175 let config = SyncKitConfig {
@@ -1090,57 +1012,6 @@ fn reveal_in_file_manager(path: &Path) {
1090 1012 mod tests {
1091 1013 use super::*;
1092 1014
1093 - // The plaintext-file path is tested via `read_key_file` so the tests stay
1094 - // deterministic — `load_api_key` reads the real OS keychain first, whose
1095 - // state is global and not test-controlled.
1096 -
1097 - #[test]
1098 - fn read_key_file_returns_key() {
1099 - let dir = tempfile::tempdir().unwrap();
1100 - std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap();
1101 - assert_eq!(read_key_file(dir.path()), Some("test-key-123".to_string()));
1102 - }
1103 -
1104 - #[test]
1105 - fn read_key_file_trims_whitespace() {
1106 - let dir = tempfile::tempdir().unwrap();
1107 - std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap();
1108 - assert_eq!(read_key_file(dir.path()), Some("key-with-spaces".to_string()));
1109 - }
1110 -
1111 - #[test]
1112 - fn read_key_file_empty_is_none() {
1113 - let dir = tempfile::tempdir().unwrap();
1114 - std::fs::write(dir.path().join("sync_api_key"), "").unwrap();
1115 - assert_eq!(read_key_file(dir.path()), None);
1116 - }
1117 -
1118 - #[test]
1119 - fn read_key_file_whitespace_only_is_none() {
1120 - let dir = tempfile::tempdir().unwrap();
1121 - std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap();
1122 - assert_eq!(read_key_file(dir.path()), None);
1123 - }
1124 -
1125 - #[test]
1126 - fn read_key_file_missing_is_none() {
1127 - let dir = tempfile::tempdir().unwrap();
1128 - assert_eq!(read_key_file(dir.path()), None);
1129 - }
1130 -
1131 - #[test]
1132 - fn env_or_bundled_matches_bundled_without_env() {
1133 - if std::env::var("AF_SYNC_API_KEY").is_err() {
1134 - assert_eq!(api_key_from_env_or_bundled(), parse_synckit_toml_key());
1135 - }
1136 - }
1137 -
1138 - // No save/load round-trip test: `save_api_key` writes to the real OS
1139 - // keychain under a fixed namespace, so exercising it in unit tests would
1140 - // pollute (or depend on) the developer's actual keychain. The migration and
1141 - // fallback ordering are covered by the `read_key_file` / env-or-bundled
1142 - // tests above.
1143 -
1144 1015 // ── Initial screen resolution ──
1145 1016
1146 1017 fn make_license_cache() -> license::LicenseCache {