Skip to content

Commit 3a90a2f

Browse files
authored
feat: add KeyringStore and secure secret backend selection (#8) (#12)
Merged via automated workflow
1 parent 02c168f commit 3a90a2f

File tree

3 files changed

+522
-146
lines changed

3 files changed

+522
-146
lines changed
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
//! OS keyring-backed secret storage implementation.
2+
3+
use async_trait::async_trait;
4+
use keyring::Entry;
5+
6+
use super::{Secret, SecretStore, StoreError};
7+
8+
/// OS keyring-backed secret store.
9+
///
10+
/// This store uses the platform's native keyring service:
11+
/// - macOS: Keychain
12+
/// - Linux: Secret Service API (via libsecret)
13+
/// - Windows: Credential Manager
14+
///
15+
/// # Storage Key Format
16+
///
17+
/// Keys are stored using the format: `{service_name}/{key}`
18+
/// where the service_name is set during construction.
19+
///
20+
/// # Example
21+
///
22+
/// ```rust,ignore
23+
/// use sigilforge_core::store::{KeyringStore, SecretStore, Secret};
24+
///
25+
/// let store = KeyringStore::try_new("sigilforge").unwrap();
26+
/// let secret = Secret::new("my-token");
27+
/// store.set("spotify/personal/access_token", &secret).await.unwrap();
28+
/// ```
29+
pub struct KeyringStore {
30+
service_name: String,
31+
}
32+
33+
impl KeyringStore {
34+
/// Create a new keyring store with the given service name.
35+
///
36+
/// # Panics
37+
///
38+
/// Panics if the keyring backend is not available on this platform.
39+
/// Use [`try_new`](Self::try_new) for a non-panicking version.
40+
pub fn new(service_name: &str) -> Self {
41+
Self::try_new(service_name).expect("keyring backend not available")
42+
}
43+
44+
/// Try to create a new keyring store.
45+
///
46+
/// Returns an error if the keyring backend is not available on this platform.
47+
pub fn try_new(service_name: &str) -> Result<Self, StoreError> {
48+
// Validate that keyring is available by attempting to create a test entry
49+
let test_key = format!("{}/__test__", service_name);
50+
match Entry::new(&test_key, "availability_check") {
51+
Ok(_) => Ok(Self {
52+
service_name: service_name.to_string(),
53+
}),
54+
Err(e) => Err(StoreError::KeyringUnavailable {
55+
message: format!("keyring backend not available: {}", e),
56+
}),
57+
}
58+
}
59+
60+
/// Create a keyring entry for the given key.
61+
fn create_entry(&self, key: &str) -> Result<Entry, StoreError> {
62+
let service = format!("{}/{}", self.service_name, key);
63+
Entry::new(&service, "sigilforge").map_err(|e| StoreError::BackendError {
64+
message: format!("failed to create keyring entry: {}", e),
65+
})
66+
}
67+
}
68+
69+
impl std::fmt::Debug for KeyringStore {
70+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71+
f.debug_struct("KeyringStore")
72+
.field("service_name", &self.service_name)
73+
.finish()
74+
}
75+
}
76+
77+
#[async_trait]
78+
impl SecretStore for KeyringStore {
79+
async fn get(&self, key: &str) -> Result<Option<Secret>, StoreError> {
80+
let entry = self.create_entry(key)?;
81+
82+
match entry.get_password() {
83+
Ok(password) => Ok(Some(Secret::new(password))),
84+
Err(keyring::Error::NoEntry) => Ok(None),
85+
Err(keyring::Error::Ambiguous(_)) => Err(StoreError::BackendError {
86+
message: format!("ambiguous keyring entry for key: {}", key),
87+
}),
88+
Err(keyring::Error::Invalid(msg, _)) => Err(StoreError::BackendError {
89+
message: format!("invalid keyring operation: {}", msg),
90+
}),
91+
Err(keyring::Error::PlatformFailure(e)) => Err(StoreError::BackendError {
92+
message: format!("platform keyring failure: {}", e),
93+
}),
94+
Err(e) => Err(StoreError::BackendError {
95+
message: format!("keyring error: {}", e),
96+
}),
97+
}
98+
}
99+
100+
async fn set(&self, key: &str, secret: &Secret) -> Result<(), StoreError> {
101+
let entry = self.create_entry(key)?;
102+
103+
entry
104+
.set_password(secret.expose())
105+
.map_err(|e| StoreError::BackendError {
106+
message: format!("failed to set keyring password: {}", e),
107+
})
108+
}
109+
110+
async fn delete(&self, key: &str) -> Result<(), StoreError> {
111+
let entry = self.create_entry(key)?;
112+
113+
match entry.delete_credential() {
114+
Ok(()) => Ok(()),
115+
Err(keyring::Error::NoEntry) => Ok(()), // Idempotent delete
116+
Err(e) => Err(StoreError::BackendError {
117+
message: format!("failed to delete keyring entry: {}", e),
118+
}),
119+
}
120+
}
121+
122+
async fn list_keys(&self, prefix: &str) -> Result<Vec<String>, StoreError> {
123+
// The keyring crate doesn't provide a native list operation.
124+
// This is a limitation of most platform keyring APIs.
125+
// For now, we return an error indicating this is unsupported.
126+
//
127+
// Future implementations could maintain a separate index or
128+
// use platform-specific APIs where available.
129+
Err(StoreError::BackendError {
130+
message: format!(
131+
"list_keys not supported by keyring backend (requested prefix: {})",
132+
prefix
133+
),
134+
})
135+
}
136+
}
137+
138+
#[cfg(test)]
139+
mod tests {
140+
use super::*;
141+
142+
// Note: These tests verify the API but don't actually interact with the keyring
143+
// to avoid platform-specific test failures and credential pollution.
144+
145+
#[test]
146+
fn test_keyring_store_creation() {
147+
// This test may fail on platforms without keyring support
148+
// We test both success and failure paths
149+
match KeyringStore::try_new("sigilforge-test") {
150+
Ok(store) => {
151+
assert_eq!(store.service_name, "sigilforge-test");
152+
}
153+
Err(StoreError::KeyringUnavailable { .. }) => {
154+
// Expected on platforms without keyring support
155+
}
156+
Err(e) => {
157+
panic!("unexpected error: {}", e);
158+
}
159+
}
160+
}
161+
162+
#[tokio::test]
163+
async fn test_keyring_store_operations() {
164+
// Only run this test if keyring is available
165+
let store = match KeyringStore::try_new("sigilforge-test-ops") {
166+
Ok(s) => s,
167+
Err(_) => {
168+
// Skip test if keyring unavailable
169+
eprintln!("Skipping test_keyring_store_operations: keyring unavailable");
170+
return;
171+
}
172+
};
173+
174+
// Use a timestamp-based key to avoid conflicts
175+
let test_key = format!("test/{}", std::time::SystemTime::now()
176+
.duration_since(std::time::UNIX_EPOCH)
177+
.unwrap()
178+
.as_nanos());
179+
let secret = Secret::new("test-value");
180+
181+
// Note: On headless Linux systems without a proper keyring daemon (e.g., CI environments),
182+
// the keyring crate may report success on set() but fail to persist data.
183+
// This is a known limitation of the platform keyring backends.
184+
// We test the happy path but accept that it may not work in all environments.
185+
186+
// Test set (should not error)
187+
if let Err(e) = store.set(&test_key, &secret).await {
188+
eprintln!("Keyring set failed ({}), skipping test - keyring backend not fully functional", e);
189+
return;
190+
}
191+
192+
// Test get - may return None if keyring daemon isn't running
193+
match store.get(&test_key).await {
194+
Ok(Some(retrieved)) => {
195+
// Happy path: keyring is working
196+
assert_eq!(retrieved.expose(), "test-value");
197+
198+
// Test delete
199+
store.delete(&test_key).await.unwrap();
200+
let deleted = store.get(&test_key).await.unwrap();
201+
assert!(deleted.is_none());
202+
}
203+
Ok(None) => {
204+
// Keyring backend accepted the set but didn't persist
205+
// This happens on headless systems without keyring daemon
206+
eprintln!("Keyring set succeeded but get returned None - keyring daemon may not be running");
207+
eprintln!("This is expected on headless systems. Skipping remainder of test.");
208+
// Clean up attempt (may also fail)
209+
let _ = store.delete(&test_key).await;
210+
}
211+
Err(e) => {
212+
eprintln!("Keyring get failed: {}. Skipping test.", e);
213+
let _ = store.delete(&test_key).await;
214+
}
215+
}
216+
217+
// Test delete is idempotent (should never error)
218+
store.delete(&test_key).await.unwrap();
219+
}
220+
221+
#[tokio::test]
222+
async fn test_keyring_store_get_nonexistent() {
223+
let store = match KeyringStore::try_new("sigilforge-test-nonexist") {
224+
Ok(s) => s,
225+
Err(_) => return,
226+
};
227+
228+
let result = store.get("nonexistent/key").await.unwrap();
229+
assert!(result.is_none());
230+
}
231+
232+
#[tokio::test]
233+
async fn test_keyring_list_keys_unsupported() {
234+
let store = match KeyringStore::try_new("sigilforge-test-list") {
235+
Ok(s) => s,
236+
Err(_) => return,
237+
};
238+
239+
let result = store.list_keys("sigilforge").await;
240+
assert!(result.is_err());
241+
assert!(matches!(result, Err(StoreError::BackendError { .. })));
242+
}
243+
}

0 commit comments

Comments
 (0)