spotify_cli/storage/
keyring.rs

1//! Secure token storage using system keychain.
2//!
3//! Uses the platform's native credential manager:
4//! - macOS: Keychain
5//! - Linux: Secret Service (GNOME Keyring, KWallet)
6//! - Windows: Windows Credential Manager
7
8use keyring::Entry;
9use thiserror::Error;
10
11use crate::oauth::token::Token;
12
13const SERVICE_NAME: &str = "spotify-cli";
14const TOKEN_KEY: &str = "oauth_token";
15
16/// Errors that can occur when using keyring storage.
17#[derive(Debug, Error)]
18pub enum KeyringError {
19    #[error("Keyring error: {0}")]
20    Keyring(#[from] keyring::Error),
21
22    #[error("Failed to serialize token: {0}")]
23    Serialize(#[from] serde_json::Error),
24
25    #[error("Token not found in keyring")]
26    NotFound,
27}
28
29/// Secure token storage using system keychain.
30pub struct KeyringStore {
31    entry: Entry,
32}
33
34impl KeyringStore {
35    /// Create a new keyring store.
36    pub fn new() -> Result<Self, KeyringError> {
37        let entry = Entry::new(SERVICE_NAME, TOKEN_KEY)?;
38        Ok(Self { entry })
39    }
40
41    /// Save a token to the keychain.
42    pub fn save(&self, token: &Token) -> Result<(), KeyringError> {
43        let json = serde_json::to_string(token)?;
44        self.entry.set_password(&json)?;
45        Ok(())
46    }
47
48    /// Load a token from the keychain.
49    pub fn load(&self) -> Result<Token, KeyringError> {
50        let json = self.entry.get_password().map_err(|e| match e {
51            keyring::Error::NoEntry => KeyringError::NotFound,
52            other => KeyringError::Keyring(other),
53        })?;
54        let token = serde_json::from_str(&json)?;
55        Ok(token)
56    }
57
58    /// Delete the token from the keychain.
59    pub fn delete(&self) -> Result<(), KeyringError> {
60        match self.entry.delete_credential() {
61            Ok(()) => Ok(()),
62            Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
63            Err(e) => Err(KeyringError::Keyring(e)),
64        }
65    }
66
67    /// Check if a token exists in the keychain.
68    pub fn exists(&self) -> bool {
69        self.entry.get_password().is_ok()
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn keyring_error_display() {
79        let err = KeyringError::NotFound;
80        let display = format!("{}", err);
81        assert!(display.contains("not found"));
82    }
83
84    #[test]
85    fn keyring_error_serialize() {
86        let json_err = serde_json::from_str::<Token>("invalid").unwrap_err();
87        let err = KeyringError::Serialize(json_err);
88        let display = format!("{}", err);
89        assert!(display.contains("serialize"));
90    }
91}