skill_context/providers/
keychain.rs1use async_trait::async_trait;
9use keyring::Entry;
10use zeroize::Zeroizing;
11
12use super::{SecretProvider, SecretValue};
13use crate::ContextError;
14
15const SERVICE_NAME: &str = "skill-engine-context";
17
18pub struct KeychainProvider {
20 prefix: Option<String>,
22}
23
24impl KeychainProvider {
25 pub fn new() -> Self {
27 Self { prefix: None }
28 }
29
30 pub fn with_prefix(prefix: impl Into<String>) -> Self {
32 Self {
33 prefix: Some(prefix.into()),
34 }
35 }
36
37 fn build_key(&self, context_id: &str, key: &str) -> String {
39 match &self.prefix {
40 Some(p) => format!("{}/{}/{}/{}", p, SERVICE_NAME, context_id, key),
41 None => format!("{}/{}/{}", SERVICE_NAME, context_id, key),
42 }
43 }
44
45 fn get_entry(&self, context_id: &str, key: &str) -> Result<Entry, ContextError> {
47 let user = self.build_key(context_id, key);
48 Entry::new(SERVICE_NAME, &user).map_err(|e| {
49 ContextError::SecretProvider(format!("Failed to create keyring entry: {}", e))
50 })
51 }
52}
53
54impl Default for KeychainProvider {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60#[async_trait]
61impl SecretProvider for KeychainProvider {
62 async fn get_secret(
63 &self,
64 context_id: &str,
65 key: &str,
66 ) -> Result<Option<SecretValue>, ContextError> {
67 let entry = self.get_entry(context_id, key)?;
68
69 match entry.get_password() {
70 Ok(password) => {
71 tracing::debug!(
72 context_id = context_id,
73 key = key,
74 "Retrieved secret from keychain"
75 );
76 Ok(Some(Zeroizing::new(password)))
77 }
78 Err(keyring::Error::NoEntry) => Ok(None),
79 Err(e) => {
80 tracing::warn!(
81 context_id = context_id,
82 key = key,
83 error = %e,
84 "Failed to get secret from keychain"
85 );
86 Err(ContextError::SecretProvider(format!(
87 "Failed to get secret '{}' from keychain: {}",
88 key, e
89 )))
90 }
91 }
92 }
93
94 async fn set_secret(
95 &self,
96 context_id: &str,
97 key: &str,
98 value: &str,
99 ) -> Result<(), ContextError> {
100 let entry = self.get_entry(context_id, key)?;
101
102 entry.set_password(value).map_err(|e| {
103 tracing::error!(
104 context_id = context_id,
105 key = key,
106 error = %e,
107 "Failed to set secret in keychain"
108 );
109 ContextError::SecretProvider(format!(
110 "Failed to set secret '{}' in keychain: {}",
111 key, e
112 ))
113 })?;
114
115 tracing::info!(
116 context_id = context_id,
117 key = key,
118 "Stored secret in keychain"
119 );
120
121 Ok(())
122 }
123
124 async fn delete_secret(&self, context_id: &str, key: &str) -> Result<(), ContextError> {
125 let entry = self.get_entry(context_id, key)?;
126
127 match entry.delete_credential() {
128 Ok(()) => {
129 tracing::info!(
130 context_id = context_id,
131 key = key,
132 "Deleted secret from keychain"
133 );
134 Ok(())
135 }
136 Err(keyring::Error::NoEntry) => {
137 Ok(())
139 }
140 Err(e) => {
141 tracing::error!(
142 context_id = context_id,
143 key = key,
144 error = %e,
145 "Failed to delete secret from keychain"
146 );
147 Err(ContextError::SecretProvider(format!(
148 "Failed to delete secret '{}' from keychain: {}",
149 key, e
150 )))
151 }
152 }
153 }
154
155 async fn list_keys(&self, _context_id: &str) -> Result<Vec<String>, ContextError> {
156 tracing::warn!("Listing keys is not supported by the keychain provider");
159 Ok(Vec::new())
160 }
161
162 fn name(&self) -> &'static str {
163 "keychain"
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[tokio::test]
175 #[ignore = "interacts with system keychain"]
176 async fn test_keychain_set_get_delete() {
177 let provider = KeychainProvider::new();
178 let context_id = "test-context";
179 let key = "test-secret-key";
180 let value = "super-secret-value";
181
182 provider.set_secret(context_id, key, value).await.unwrap();
184
185 let retrieved = provider.get_secret(context_id, key).await.unwrap();
187 assert!(retrieved.is_some());
188 assert_eq!(&*retrieved.unwrap(), value);
189
190 provider.delete_secret(context_id, key).await.unwrap();
192
193 let retrieved = provider.get_secret(context_id, key).await.unwrap();
195 assert!(retrieved.is_none());
196 }
197
198 #[tokio::test]
199 async fn test_keychain_get_nonexistent() {
200 let provider = KeychainProvider::new();
201 let result = provider
202 .get_secret("nonexistent-context", "nonexistent-key")
203 .await;
204
205 assert!(result.is_ok());
207 assert!(result.unwrap().is_none());
208 }
209
210 #[test]
211 fn test_key_building() {
212 let provider = KeychainProvider::new();
213 let key = provider.build_key("my-context", "api-key");
214 assert_eq!(key, "skill-engine-context/my-context/api-key");
215
216 let provider_with_prefix = KeychainProvider::with_prefix("custom");
217 let key = provider_with_prefix.build_key("my-context", "api-key");
218 assert_eq!(key, "custom/skill-engine-context/my-context/api-key");
219 }
220}