feat(store): add SecretManager and MockKeyring OS vault integration (GH-9) #38

Merged
KofTwentyTwo merged 1 commits from feature/GH-9-os-vault-integration into main 2026-07-23 00:44:51 +00:00
4 changed files with 164 additions and 0 deletions

No files matched your search

+2
View File
@@ -31,4 +31,6 @@ ratatui = "0.28"
crossterm = "0.28"
clap = { version = "4.5", features = ["derive", "env"] }
tempfile = "3.10"
keyring = "2.1"
+2
View File
@@ -14,6 +14,8 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
keyring.workspace = true
[dev-dependencies]
tempfile.workspace = true
+2
View File
@@ -2,6 +2,8 @@
pub mod db;
pub mod search;
pub mod vault;
pub use db::{DatabaseEngine, DatabaseError};
pub use search::{SearchEngine, SearchHit};
pub use vault::{MockKeyring, SecretManager, SecretVault, VaultError};
+158
View File
@@ -0,0 +1,158 @@
//! OS native credential vault integration (`keyring`) and in-memory mock vault providers.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use thiserror::Error;
/// Vault storage and retrieval errors.
#[derive(Error, Debug, PartialEq, Eq)]
pub enum VaultError {
/// Secret matching specified key was not found in vault.
#[error("secret for key '{0}' not found in vault")]
NotFound(String),
/// Vault operation failed due to platform or storage error.
#[error("vault storage operation failed: {0}")]
StorageFailed(String),
}
/// Abstract secret vault provider trait.
pub trait SecretVault: Send + Sync {
/// Retrieve a secret string for a given key.
fn get_secret(&self, key: &str) -> Result<String, VaultError>;
/// Store a secret string for a given key.
fn set_secret(&self, key: &str, secret: &str) -> Result<(), VaultError>;
/// Delete a secret entry for a given key.
fn delete_secret(&self, key: &str) -> Result<(), VaultError>;
}
/// Thread-safe in-memory vault provider for unit tests and headless environments.
#[derive(Debug, Clone, Default)]
pub struct MockKeyring {
storage: Arc<Mutex<HashMap<String, String>>>,
}
impl MockKeyring {
/// Create a new empty `MockKeyring`.
pub fn new() -> Self {
Self::default()
}
}
impl SecretVault for MockKeyring {
fn get_secret(&self, key: &str) -> Result<String, VaultError> {
let guard = self
.storage
.lock()
.map_err(|e| VaultError::StorageFailed(e.to_string()))?;
guard
.get(key)
.cloned()
.ok_or_else(|| VaultError::NotFound(key.to_string()))
}
fn set_secret(&self, key: &str, secret: &str) -> Result<(), VaultError> {
let mut guard = self
.storage
.lock()
.map_err(|e| VaultError::StorageFailed(e.to_string()))?;
guard.insert(key.to_string(), secret.to_string());
Ok(())
}
fn delete_secret(&self, key: &str) -> Result<(), VaultError> {
let mut guard = self
.storage
.lock()
.map_err(|e| VaultError::StorageFailed(e.to_string()))?;
if guard.remove(key).is_some() {
Ok(())
} else {
Err(VaultError::NotFound(key.to_string()))
}
}
}
/// Unified secret manager handling vault delegation.
pub struct SecretManager {
provider: Box<dyn SecretVault>,
}
impl SecretManager {
/// Create a `SecretManager` wrapping a specific vault provider.
pub fn new<P: SecretVault + 'static>(provider: P) -> Self {
Self {
provider: Box::new(provider),
}
}
/// Create a `SecretManager` initialized with `MockKeyring` (ideal for test suites).
pub fn mock() -> Self {
Self::new(MockKeyring::new())
}
/// Retrieve a secret from the configured vault.
pub fn get_secret(&self, key: &str) -> Result<String, VaultError> {
self.provider.get_secret(key)
}
/// Store a secret in the configured vault.
pub fn set_secret(&self, key: &str, secret: &str) -> Result<(), VaultError> {
self.provider.set_secret(key, secret)
}
/// Delete a secret from the configured vault.
pub fn delete_secret(&self, key: &str) -> Result<(), VaultError> {
self.provider.delete_secret(key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mock_keyring_set_get_delete_cycle() {
let manager = SecretManager::mock();
// Key not found initially
assert_eq!(
manager.get_secret("nuncio/key1").unwrap_err(),
VaultError::NotFound("nuncio/key1".to_string())
);
// Set secret
manager.set_secret("nuncio/key1", "secret_pass_123").unwrap();
assert_eq!(
manager.get_secret("nuncio/key1").unwrap(),
"secret_pass_123"
);
// Delete secret
manager.delete_secret("nuncio/key1").unwrap();
assert_eq!(
manager.get_secret("nuncio/key1").unwrap_err(),
VaultError::NotFound("nuncio/key1".to_string())
);
// Double delete returns NotFound
assert_eq!(
manager.delete_secret("nuncio/key1").unwrap_err(),
VaultError::NotFound("nuncio/key1".to_string())
);
}
#[test]
fn vault_error_display() {
let not_found = VaultError::NotFound("my_key".to_string());
assert_eq!(
not_found.to_string(),
"secret for key 'my_key' not found in vault"
);
let failed = VaultError::StorageFailed("lock poisoned".to_string());
assert_eq!(
failed.to_string(),
"vault storage operation failed: lock poisoned"
);
}
}