secrets.rs
Language Rust5.5 KB167 lines
//! Where API keys are kept: the OS keychain, or memory in tests, behind a//! per-session cache.use std::collections::HashMap;use std::sync::{Mutex, MutexGuard, PoisonError};pub const SERVICE: &str = "com.mattrguerra.cortex";/// A panic cannot leave these maps half-written, so a poisoned lock is still safe to use.fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {m.lock().unwrap_or_else(PoisonError::into_inner)}/// What people paste is not always just the key: a trailing newline, quotes,/// a `Bearer ` prefix, or an invisible character copied from a web page.pub fn clean_key(raw: &str) -> String {let s: String = raw.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{200b}'..='\u{200d}' | '\u{2060}' | '\u{feff}')).collect();let s = s.trim_matches(|c| c == '"' || c == '\'' || c == '`');let s = s.strip_prefix("Bearer").or_else(|| s.strip_prefix("bearer")).unwrap_or(s);s.to_string()}pub fn account_for(provider_id: &str) -> String {format!("provider:{provider_id}")}pub trait SecretStore: Send + Sync {fn get(&self, account: &str) -> Result<Option<String>, String>;fn set(&self, account: &str, secret: &str) -> Result<(), String>;fn delete(&self, account: &str) -> Result<(), String>;}/// For tests and the end-to-end run.#[derive(Default)]pub struct MemoryStore(Mutex<HashMap<String, String>>);impl SecretStore for MemoryStore {fn get(&self, account: &str) -> Result<Option<String>, String> {Ok(lock(&self.0).get(account).cloned())}fn set(&self, account: &str, secret: &str) -> Result<(), String> {lock(&self.0).insert(account.into(), secret.into());Ok(())}fn delete(&self, account: &str) -> Result<(), String> {lock(&self.0).remove(account);Ok(())}}/// The OS keychain.#[cfg(feature = "keychain")]pub struct KeychainStore;#[cfg(feature = "keychain")]impl SecretStore for KeychainStore {fn get(&self, account: &str) -> Result<Option<String>, String> {let e = keyring::Entry::new(SERVICE, account).map_err(|e| e.to_string())?;match e.get_password() {Ok(s) => Ok(Some(s)),Err(keyring::Error::NoEntry) => Ok(None),Err(e) => Err(e.to_string()),}}fn set(&self, account: &str, secret: &str) -> Result<(), String> {keyring::Entry::new(SERVICE, account).and_then(|e| e.set_password(secret)).map_err(|e| e.to_string())}fn delete(&self, account: &str) -> Result<(), String> {let e = keyring::Entry::new(SERVICE, account).map_err(|e| e.to_string())?;match e.delete_credential() {Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),Err(e) => Err(e.to_string()),}}}/// Reads each key from the store once per session (so macOS asks once) and/// keeps it in memory after that.pub struct KeyCache {store: Box<dyn SecretStore>,cache: Mutex<HashMap<String, Option<String>>>,}impl KeyCache {pub fn new(store: Box<dyn SecretStore>) -> Self {Self { store, cache: Mutex::new(HashMap::new()) }}pub fn get(&self, provider_id: &str) -> Result<Option<String>, String> {let acct = account_for(provider_id);if let Some(v) = lock(&self.cache).get(&acct) {return Ok(v.clone());}let v = self.store.get(&acct)?;lock(&self.cache).insert(acct, v.clone());Ok(v)}pub fn set(&self, provider_id: &str, key: &str) -> Result<(), String> {let key = clean_key(key);let key = key.as_str();let acct = account_for(provider_id);self.store.set(&acct, key)?;lock(&self.cache).insert(acct, Some(key.to_string()));Ok(())}pub fn clear(&self, provider_id: &str) -> Result<(), String> {let acct = account_for(provider_id);self.store.delete(&acct)?;lock(&self.cache).insert(acct, None);Ok(())}pub fn has(&self, provider_id: &str) -> Result<bool, String> {Ok(self.get(provider_id)?.is_some_and(|k| !k.is_empty()))}}#[cfg(test)]mod tests {use std::sync::atomic::{AtomicUsize, Ordering};use std::sync::Arc;use super::*;#[test]fn pasted_keys_are_cleaned() {for raw in["abc123", " abc123\n", "\"abc123\"", "Bearer abc123", "abc\u{200b}123", "'abc123'\r\n", "\u{feff}abc123"]{assert_eq!(clean_key(raw), "abc123", "{raw:?}");}assert_eq!(clean_key(" \n"), "");}struct Counting(MemoryStore, Arc<AtomicUsize>);impl SecretStore for Counting {fn get(&self, a: &str) -> Result<Option<String>, String> {self.1.fetch_add(1, Ordering::SeqCst);self.0.get(a)}fn set(&self, a: &str, s: &str) -> Result<(), String> {self.0.set(a, s)}fn delete(&self, a: &str) -> Result<(), String> {self.0.delete(a)}}#[test]fn reads_the_store_once_per_provider() {let reads = Arc::new(AtomicUsize::new(0));let c = KeyCache::new(Box::new(Counting(MemoryStore::default(), reads.clone())));assert!(!c.has("deepinfra").unwrap());assert!(!c.has("deepinfra").unwrap());assert_eq!(reads.load(Ordering::SeqCst), 1);c.set("deepinfra", " key-1 \n").unwrap();assert_eq!(c.get("deepinfra").unwrap().as_deref(), Some("key-1"));c.clear("deepinfra").unwrap();assert!(!c.has("deepinfra").unwrap());assert_eq!(reads.load(Ordering::SeqCst), 1);}}