| 164 |
164 |
|
table.get("api_key").filter(|s| !s.is_empty()).cloned()
|
| 165 |
165 |
|
}
|
| 166 |
166 |
|
|
| 167 |
|
- |
/// Load a saved API key from the data directory, falling back to env vars and bundled toml.
|
| 168 |
|
- |
fn load_api_key(data_dir: &Path) -> Option<String> {
|
| 169 |
|
- |
// Saved key file takes priority
|
| 170 |
|
- |
let key_path = data_dir.join("sync_api_key");
|
| 171 |
|
- |
if let Ok(key) = std::fs::read_to_string(&key_path) {
|
| 172 |
|
- |
let key = key.trim().to_string();
|
| 173 |
|
- |
if !key.is_empty() {
|
| 174 |
|
- |
tracing::info!("Loaded SyncKit API key from {}", key_path.display());
|
| 175 |
|
- |
return Some(key);
|
| 176 |
|
- |
}
|
| 177 |
|
- |
}
|
| 178 |
|
- |
// Fall back to env vars (for development / CI)
|
|
167 |
+ |
/// OS keychain entry for the SyncKit API key. The key is a credential, not a
|
|
168 |
+ |
/// regenerable cache, so it belongs in the keychain rather than a plaintext
|
|
169 |
+ |
/// file. Returns None when no keychain provider is available (headless Linux
|
|
170 |
+ |
/// without secret-service, CI) — callers fall back to file/env/bundled.
|
|
171 |
+ |
fn api_key_keychain_entry() -> Option<keyring::Entry> {
|
|
172 |
+ |
keyring::Entry::new("audiofiles", "sync_api_key").ok()
|
|
173 |
+ |
}
|
|
174 |
+ |
|
|
175 |
+ |
/// Read a trimmed API key from the legacy plaintext file. None if absent/empty.
|
|
176 |
+ |
fn read_key_file(data_dir: &Path) -> Option<String> {
|
|
177 |
+ |
let key = std::fs::read_to_string(data_dir.join("sync_api_key")).ok()?;
|
|
178 |
+ |
let key = key.trim().to_string();
|
|
179 |
+ |
(!key.is_empty()).then_some(key)
|
|
180 |
+ |
}
|
|
181 |
+ |
|
|
182 |
+ |
/// API key from env vars (dev/CI) or the bundled synckit.toml (alpha shared key).
|
|
183 |
+ |
fn api_key_from_env_or_bundled() -> Option<String> {
|
| 179 |
184 |
|
if let (Ok(_url), Ok(key)) = (
|
| 180 |
185 |
|
std::env::var("AF_SYNC_SERVER_URL"),
|
| 181 |
186 |
|
std::env::var("AF_SYNC_API_KEY"),
|
| 182 |
187 |
|
) {
|
| 183 |
188 |
|
return Some(key);
|
| 184 |
189 |
|
}
|
| 185 |
|
- |
// Fall back to bundled synckit.toml
|
| 186 |
|
- |
parse_synckit_toml_key()}
|
|
190 |
+ |
parse_synckit_toml_key()
|
|
191 |
+ |
}
|
| 187 |
192 |
|
|
| 188 |
|
- |
/// Save an API key to the data directory for future launches.
|
| 189 |
|
- |
#[cfg(test)]
|
| 190 |
|
- |
fn save_api_key(data_dir: &Path, api_key: &str) {
|
| 191 |
|
- |
let key_path = data_dir.join("sync_api_key");
|
| 192 |
|
- |
if let Err(e) = std::fs::write(&key_path, api_key) {
|
| 193 |
|
- |
tracing::error!("Failed to save API key to {}: {e}", key_path.display());
|
|
193 |
+ |
/// Load a saved API key. Order: OS keychain, then a legacy plaintext file
|
|
194 |
+ |
/// (migrated into the keychain and deleted on read), then env vars, then
|
|
195 |
+ |
/// bundled toml.
|
|
196 |
+ |
fn load_api_key(data_dir: &Path) -> Option<String> {
|
|
197 |
+ |
// Keychain takes priority.
|
|
198 |
+ |
if let Some(entry) = api_key_keychain_entry() {
|
|
199 |
+ |
match entry.get_password() {
|
|
200 |
+ |
Ok(key) if !key.trim().is_empty() => {
|
|
201 |
+ |
tracing::info!("Loaded SyncKit API key from OS keychain");
|
|
202 |
+ |
return Some(key.trim().to_string());
|
|
203 |
+ |
}
|
|
204 |
+ |
Ok(_) | Err(keyring::Error::NoEntry) => {}
|
|
205 |
+ |
Err(e) => tracing::warn!("Keychain read failed, falling back: {e}"),
|
|
206 |
+ |
}
|
|
207 |
+ |
}
|
|
208 |
+ |
|
|
209 |
+ |
// Legacy plaintext file: migrate into the keychain, then remove it so the
|
|
210 |
+ |
// credential stops living on disk in cleartext.
|
|
211 |
+ |
if let Some(key) = read_key_file(data_dir) {
|
|
212 |
+ |
tracing::info!("Migrating SyncKit API key into the keychain");
|
|
213 |
+ |
if save_api_key(&key) {
|
|
214 |
+ |
let _ = std::fs::remove_file(data_dir.join("sync_api_key"));
|
|
215 |
+ |
}
|
|
216 |
+ |
return Some(key);
|
|
217 |
+ |
}
|
|
218 |
+ |
|
|
219 |
+ |
api_key_from_env_or_bundled()
|
|
220 |
+ |
}
|
|
221 |
+ |
|
|
222 |
+ |
/// Persist an API key to the OS keychain. Returns true on success; best-effort,
|
|
223 |
+ |
/// so a missing keychain provider degrades gracefully rather than failing.
|
|
224 |
+ |
fn save_api_key(api_key: &str) -> bool {
|
|
225 |
+ |
let Some(entry) = api_key_keychain_entry() else {
|
|
226 |
+ |
return false;
|
|
227 |
+ |
};
|
|
228 |
+ |
match entry.set_password(api_key) {
|
|
229 |
+ |
Ok(()) => true,
|
|
230 |
+ |
Err(e) => {
|
|
231 |
+ |
tracing::warn!("Failed to store API key in keychain: {e}");
|
|
232 |
+ |
false
|
|
233 |
+ |
}
|
| 194 |
234 |
|
}
|
| 195 |
235 |
|
}
|
| 196 |
236 |
|
|
| 950 |
990 |
|
mod tests {
|
| 951 |
991 |
|
use super::*;
|
| 952 |
992 |
|
|
|
993 |
+ |
// The plaintext-file path is tested via `read_key_file` so the tests stay
|
|
994 |
+ |
// deterministic — `load_api_key` reads the real OS keychain first, whose
|
|
995 |
+ |
// state is global and not test-controlled.
|
|
996 |
+ |
|
| 953 |
997 |
|
#[test]
|
| 954 |
|
- |
fn load_api_key_from_file() {
|
|
998 |
+ |
fn read_key_file_returns_key() {
|
| 955 |
999 |
|
let dir = tempfile::tempdir().unwrap();
|
| 956 |
1000 |
|
std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap();
|
| 957 |
|
- |
let result = load_api_key(dir.path());
|
| 958 |
|
- |
assert_eq!(result, Some("test-key-123".to_string()));
|
|
1001 |
+ |
assert_eq!(read_key_file(dir.path()), Some("test-key-123".to_string()));
|
| 959 |
1002 |
|
}
|
| 960 |
1003 |
|
|
| 961 |
1004 |
|
#[test]
|
| 962 |
|
- |
fn load_api_key_trims_whitespace() {
|
|
1005 |
+ |
fn read_key_file_trims_whitespace() {
|
| 963 |
1006 |
|
let dir = tempfile::tempdir().unwrap();
|
| 964 |
1007 |
|
std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap();
|
| 965 |
|
- |
let result = load_api_key(dir.path());
|
| 966 |
|
- |
assert_eq!(result, Some("key-with-spaces".to_string()));
|
|
1008 |
+ |
assert_eq!(read_key_file(dir.path()), Some("key-with-spaces".to_string()));
|
| 967 |
1009 |
|
}
|
| 968 |
1010 |
|
|
| 969 |
1011 |
|
#[test]
|
| 970 |
|
- |
fn load_api_key_empty_file_falls_back_to_bundled() {
|
|
1012 |
+ |
fn read_key_file_empty_is_none() {
|
| 971 |
1013 |
|
let dir = tempfile::tempdir().unwrap();
|
| 972 |
1014 |
|
std::fs::write(dir.path().join("sync_api_key"), "").unwrap();
|
| 973 |
|
- |
// Empty file → falls through to bundled synckit.toml key
|
| 974 |
|
- |
if std::env::var("AF_SYNC_API_KEY").is_err() {
|
| 975 |
|
- |
let key = load_api_key(dir.path());
|
| 976 |
|
- |
assert_eq!(key, parse_synckit_toml_key());
|
| 977 |
|
- |
}
|
|
1015 |
+ |
assert_eq!(read_key_file(dir.path()), None);
|
| 978 |
1016 |
|
}
|
| 979 |
1017 |
|
|
| 980 |
1018 |
|
#[test]
|
| 981 |
|
- |
fn load_api_key_whitespace_only_falls_back_to_bundled() {
|
|
1019 |
+ |
fn read_key_file_whitespace_only_is_none() {
|
| 982 |
1020 |
|
let dir = tempfile::tempdir().unwrap();
|
| 983 |
1021 |
|
std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap();
|
| 984 |
|
- |
if std::env::var("AF_SYNC_API_KEY").is_err() {
|
| 985 |
|
- |
let key = load_api_key(dir.path());
|
| 986 |
|
- |
assert_eq!(key, parse_synckit_toml_key());
|
| 987 |
|
- |
}
|
|
1022 |
+ |
assert_eq!(read_key_file(dir.path()), None);
|
| 988 |
1023 |
|
}
|
| 989 |
1024 |
|
|
| 990 |
1025 |
|
#[test]
|
| 991 |
|
- |
fn load_api_key_no_file_falls_back_to_bundled() {
|
|
1026 |
+ |
fn read_key_file_missing_is_none() {
|
| 992 |
1027 |
|
let dir = tempfile::tempdir().unwrap();
|
| 993 |
|
- |
if std::env::var("AF_SYNC_API_KEY").is_err() {
|
| 994 |
|
- |
let key = load_api_key(dir.path());
|
| 995 |
|
- |
assert_eq!(key, parse_synckit_toml_key());
|
| 996 |
|
- |
}
|
|
1028 |
+ |
assert_eq!(read_key_file(dir.path()), None);
|
| 997 |
1029 |
|
}
|
| 998 |
1030 |
|
|
| 999 |
1031 |
|
#[test]
|
| 1000 |
|
- |
fn save_api_key_creates_file() {
|
| 1001 |
|
- |
let dir = tempfile::tempdir().unwrap();
|
| 1002 |
|
- |
save_api_key(dir.path(), "saved-key");
|
| 1003 |
|
- |
let content = std::fs::read_to_string(dir.path().join("sync_api_key")).unwrap();
|
| 1004 |
|
- |
assert_eq!(content, "saved-key");
|
|
1032 |
+ |
fn env_or_bundled_matches_bundled_without_env() {
|
|
1033 |
+ |
if std::env::var("AF_SYNC_API_KEY").is_err() {
|
|
1034 |
+ |
assert_eq!(api_key_from_env_or_bundled(), parse_synckit_toml_key());
|
|
1035 |
+ |
}
|
| 1005 |
1036 |
|
}
|
| 1006 |
1037 |
|
|
| 1007 |
|
- |
#[test]
|
| 1008 |
|
- |
fn save_and_load_roundtrip() {
|
| 1009 |
|
- |
let dir = tempfile::tempdir().unwrap();
|
| 1010 |
|
- |
save_api_key(dir.path(), "roundtrip-key");
|
| 1011 |
|
- |
let result = load_api_key(dir.path());
|
| 1012 |
|
- |
assert_eq!(result, Some("roundtrip-key".to_string()));
|
| 1013 |
|
- |
}
|
|
1038 |
+ |
// No save/load round-trip test: `save_api_key` writes to the real OS
|
|
1039 |
+ |
// keychain under a fixed namespace, so exercising it in unit tests would
|
|
1040 |
+ |
// pollute (or depend on) the developer's actual keychain. The migration and
|
|
1041 |
+ |
// fallback ordering are covered by the `read_key_file` / env-or-bundled
|
|
1042 |
+ |
// tests above.
|
| 1014 |
1043 |
|
|
| 1015 |
1044 |
|
// ── Initial screen resolution ──
|
| 1016 |
1045 |
|
|