koan_core/credentials.rs
1use std::collections::HashMap;
2
3use keyring::Entry;
4use thiserror::Error;
5
6const SERVICE_NAME: &str = "koan";
7
8#[derive(Debug, Error)]
9pub enum CredentialError {
10 #[error("keyring error: {0}")]
11 Keyring(#[from] keyring::Error),
12 #[error("password not found")]
13 NotFound,
14}
15
16/// Store a password in the platform credential store.
17/// macOS: Keychain. Linux: secret-service (GNOME Keyring / KDE Wallet).
18/// `account` should be the server URL or identifier.
19pub fn store_password(account: &str, password: &str) -> Result<(), CredentialError> {
20 if let Ok(mut cache) = CACHE.write() {
21 cache.insert(account.to_string(), Some(password.to_string()));
22 }
23 if keychain_disabled() {
24 return Ok(());
25 }
26 let entry = Entry::new(SERVICE_NAME, account)?;
27 entry.set_password(password)?;
28 Ok(())
29}
30
31/// Whether this process may touch the credential store at all.
32///
33/// A keychain item's ACL is keyed on the reading binary's code signature, and a
34/// `cargo test` binary is unsigned with a fresh hash every compile — so it can
35/// never match, and "Always Allow" grants access to a binary that will not exist
36/// after the next build. The prompt therefore returns on every single run.
37///
38/// `KOAN_NO_KEYCHAIN=1` opts out. `just check` sets it, so the test suite never
39/// asks; set it yourself if you run `cargo test` directly.
40fn keychain_disabled() -> bool {
41 std::env::var_os("KOAN_NO_KEYCHAIN").is_some_and(|v| v != "0")
42}
43
44/// Answers already given, so the store is asked once per account per process.
45///
46/// Every `subsonic_client` builds its credentials from scratch, and the download
47/// queue, radio, sync and sharing all build one — so a single session asked the
48/// keychain repeatedly. Each ask is a chance for macOS to put up its "wants to
49/// use your confidential information" dialog, and being asked five times for one
50/// password is indistinguishable from the app being broken.
51///
52/// A miss is remembered too: an account with no password should be asked about
53/// once, not on every attempt to reach a server that is not configured.
54static CACHE: std::sync::LazyLock<std::sync::RwLock<HashMap<String, Option<String>>>> =
55 std::sync::LazyLock::new(|| std::sync::RwLock::new(HashMap::new()));
56
57/// Retrieve a password from the platform credential store.
58pub fn get_password(account: &str) -> Result<String, CredentialError> {
59 // Nothing to look up, and asking would prompt for a keychain koan has no
60 // business opening.
61 if account.is_empty() || keychain_disabled() {
62 return Err(CredentialError::NotFound);
63 }
64
65 if let Ok(cache) = CACHE.read()
66 && let Some(cached) = cache.get(account)
67 {
68 return cached.clone().ok_or(CredentialError::NotFound);
69 }
70
71 let entry = Entry::new(SERVICE_NAME, account)?;
72 let result = match entry.get_password() {
73 Ok(pw) => Ok(pw),
74 Err(keyring::Error::NoEntry) => Err(CredentialError::NotFound),
75 Err(e) => return Err(CredentialError::Keyring(e)),
76 };
77
78 // Only a definite answer is cached. A transient failure — the user dismissed
79 // the dialog, the keychain was locked — must not be remembered as "no
80 // password" for the rest of the session.
81 if let Ok(mut cache) = CACHE.write() {
82 cache.insert(account.to_string(), result.as_ref().ok().cloned());
83 }
84 result
85}
86
87/// Delete a password from the platform credential store.
88pub fn delete_password(account: &str) -> Result<(), CredentialError> {
89 if let Ok(mut cache) = CACHE.write() {
90 cache.remove(account);
91 }
92 if keychain_disabled() {
93 return Ok(());
94 }
95 let entry = Entry::new(SERVICE_NAME, account)?;
96 entry.delete_credential()?;
97 Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 /// `KOAN_NO_KEYCHAIN` has to be honoured, or the test suite prompts for the
105 /// login password on every run — a cargo test binary is unsigned and gets a
106 /// fresh hash each compile, so no keychain ACL can ever match it.
107 #[test]
108 fn the_opt_out_is_honoured() {
109 // Safety: single-threaded within this test, and the value is restored.
110 let previous = std::env::var_os("KOAN_NO_KEYCHAIN");
111 unsafe { std::env::set_var("KOAN_NO_KEYCHAIN", "1") };
112 assert!(keychain_disabled());
113 assert!(matches!(
114 get_password("https://example.invalid"),
115 Err(CredentialError::NotFound)
116 ));
117 unsafe {
118 match previous {
119 Some(v) => std::env::set_var("KOAN_NO_KEYCHAIN", v),
120 None => std::env::remove_var("KOAN_NO_KEYCHAIN"),
121 }
122 }
123 }
124
125 /// An empty account is not a lookup. Asking the keychain to find out would
126 /// put a dialog in front of someone who has not configured a server.
127 #[test]
128 fn an_empty_account_never_reaches_the_store() {
129 assert!(matches!(get_password(""), Err(CredentialError::NotFound)));
130 }
131}