Skip to main content

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}