Skip to main content

rustant_core/
credentials.rs

1//! Credential storage for LLM provider API keys.
2//!
3//! Provides a trait-based abstraction over credential storage with two implementations:
4//! - `KeyringCredentialStore`: Uses the OS-native credential store (macOS Keychain,
5//!   Windows Credential Manager, Linux Secret Service).
6//! - `InMemoryCredentialStore`: In-memory store for testing.
7
8use std::collections::HashMap;
9use std::sync::Mutex;
10
11/// Errors from credential storage operations.
12#[derive(Debug, thiserror::Error)]
13pub enum CredentialError {
14    #[error("Credential not found for {service}:{account}")]
15    NotFound { service: String, account: String },
16
17    #[error("Failed to store credential: {message}")]
18    StoreFailed { message: String },
19
20    #[error("Failed to delete credential: {message}")]
21    DeleteFailed { message: String },
22
23    #[error("Keyring backend not available: {message}")]
24    BackendUnavailable { message: String },
25}
26
27/// Trait for credential storage backends.
28pub trait CredentialStore: Send + Sync {
29    /// Store an API key for the given provider.
30    fn store_key(&self, provider: &str, api_key: &str) -> Result<(), CredentialError>;
31
32    /// Retrieve the API key for the given provider.
33    fn get_key(&self, provider: &str) -> Result<String, CredentialError>;
34
35    /// Delete the API key for the given provider.
36    fn delete_key(&self, provider: &str) -> Result<(), CredentialError>;
37
38    /// Check whether a key exists for the given provider.
39    fn has_key(&self, provider: &str) -> bool;
40}
41
42/// OS-native credential store using the `keyring` crate.
43///
44/// Stores credentials under service `"rustant"` with account names
45/// formatted as `"provider:{name}"`.
46pub struct KeyringCredentialStore {
47    service: String,
48}
49
50impl KeyringCredentialStore {
51    /// Create a new keyring-backed credential store.
52    pub fn new() -> Self {
53        Self {
54            service: "rustant".to_string(),
55        }
56    }
57
58    /// Format the account name for a given provider.
59    pub fn account_name(provider: &str) -> String {
60        format!("provider:{}", provider)
61    }
62}
63
64impl Default for KeyringCredentialStore {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl CredentialStore for KeyringCredentialStore {
71    fn store_key(&self, provider: &str, api_key: &str) -> Result<(), CredentialError> {
72        let account = Self::account_name(provider);
73        let entry = keyring::Entry::new(&self.service, &account).map_err(|e| {
74            CredentialError::BackendUnavailable {
75                message: e.to_string(),
76            }
77        })?;
78        entry
79            .set_password(api_key)
80            .map_err(|e| CredentialError::StoreFailed {
81                message: e.to_string(),
82            })
83    }
84
85    fn get_key(&self, provider: &str) -> Result<String, CredentialError> {
86        let account = Self::account_name(provider);
87        let entry = keyring::Entry::new(&self.service, &account).map_err(|e| {
88            CredentialError::BackendUnavailable {
89                message: e.to_string(),
90            }
91        })?;
92        entry.get_password().map_err(|e| match e {
93            keyring::Error::NoEntry => CredentialError::NotFound {
94                service: self.service.clone(),
95                account,
96            },
97            other => CredentialError::StoreFailed {
98                message: other.to_string(),
99            },
100        })
101    }
102
103    fn delete_key(&self, provider: &str) -> Result<(), CredentialError> {
104        let account = Self::account_name(provider);
105        let entry = keyring::Entry::new(&self.service, &account).map_err(|e| {
106            CredentialError::BackendUnavailable {
107                message: e.to_string(),
108            }
109        })?;
110        entry
111            .delete_credential()
112            .map_err(|e| CredentialError::DeleteFailed {
113                message: e.to_string(),
114            })
115    }
116
117    fn has_key(&self, provider: &str) -> bool {
118        self.get_key(provider).is_ok()
119    }
120}
121
122/// In-memory credential store for testing.
123///
124/// Thread-safe via `Mutex<HashMap>`. Does not persist across process restarts.
125pub struct InMemoryCredentialStore {
126    store: Mutex<HashMap<String, String>>,
127}
128
129impl InMemoryCredentialStore {
130    /// Create an empty in-memory credential store.
131    pub fn new() -> Self {
132        Self {
133            store: Mutex::new(HashMap::new()),
134        }
135    }
136}
137
138impl Default for InMemoryCredentialStore {
139    fn default() -> Self {
140        Self::new()
141    }
142}
143
144impl CredentialStore for InMemoryCredentialStore {
145    fn store_key(&self, provider: &str, api_key: &str) -> Result<(), CredentialError> {
146        let account = KeyringCredentialStore::account_name(provider);
147        self.store
148            .lock()
149            .unwrap()
150            .insert(account, api_key.to_string());
151        Ok(())
152    }
153
154    fn get_key(&self, provider: &str) -> Result<String, CredentialError> {
155        let account = KeyringCredentialStore::account_name(provider);
156        self.store
157            .lock()
158            .unwrap()
159            .get(&account)
160            .cloned()
161            .ok_or_else(|| CredentialError::NotFound {
162                service: "rustant".to_string(),
163                account,
164            })
165    }
166
167    fn delete_key(&self, provider: &str) -> Result<(), CredentialError> {
168        let account = KeyringCredentialStore::account_name(provider);
169        self.store.lock().unwrap().remove(&account);
170        Ok(())
171    }
172
173    fn has_key(&self, provider: &str) -> bool {
174        let account = KeyringCredentialStore::account_name(provider);
175        self.store.lock().unwrap().contains_key(&account)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn make_store() -> InMemoryCredentialStore {
184        InMemoryCredentialStore::new()
185    }
186
187    #[test]
188    fn test_store_and_retrieve_key() {
189        let store = make_store();
190        store.store_key("openai", "sk-test-123").unwrap();
191        assert_eq!(store.get_key("openai").unwrap(), "sk-test-123");
192    }
193
194    #[test]
195    fn test_get_nonexistent_key() {
196        let store = make_store();
197        let result = store.get_key("nonexistent");
198        assert!(result.is_err());
199        assert!(matches!(
200            result.unwrap_err(),
201            CredentialError::NotFound { .. }
202        ));
203    }
204
205    #[test]
206    fn test_delete_key() {
207        let store = make_store();
208        store.store_key("anthropic", "sk-ant-test").unwrap();
209        store.delete_key("anthropic").unwrap();
210        assert!(!store.has_key("anthropic"));
211    }
212
213    #[test]
214    fn test_has_key() {
215        let store = make_store();
216        assert!(!store.has_key("openai"));
217        store.store_key("openai", "sk-test").unwrap();
218        assert!(store.has_key("openai"));
219    }
220
221    #[test]
222    fn test_overwrite_key() {
223        let store = make_store();
224        store.store_key("openai", "sk-old").unwrap();
225        store.store_key("openai", "sk-new").unwrap();
226        assert_eq!(store.get_key("openai").unwrap(), "sk-new");
227    }
228
229    #[test]
230    fn test_account_name_format() {
231        assert_eq!(
232            KeyringCredentialStore::account_name("openai"),
233            "provider:openai"
234        );
235        assert_eq!(
236            KeyringCredentialStore::account_name("anthropic"),
237            "provider:anthropic"
238        );
239    }
240}