diff --git a/Cargo.toml b/Cargo.toml index fcaf98e..ca919cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,4 +31,6 @@ ratatui = "0.28" crossterm = "0.28" clap = { version = "4.5", features = ["derive", "env"] } tempfile = "3.10" +keyring = "2.1" + diff --git a/crates/nuncio-store/Cargo.toml b/crates/nuncio-store/Cargo.toml index 7be2b88..1de639e 100644 --- a/crates/nuncio-store/Cargo.toml +++ b/crates/nuncio-store/Cargo.toml @@ -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 diff --git a/crates/nuncio-store/src/lib.rs b/crates/nuncio-store/src/lib.rs index 15c75dc..4898ada 100644 --- a/crates/nuncio-store/src/lib.rs +++ b/crates/nuncio-store/src/lib.rs @@ -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}; diff --git a/crates/nuncio-store/src/vault.rs b/crates/nuncio-store/src/vault.rs new file mode 100644 index 0000000..e7f04b3 --- /dev/null +++ b/crates/nuncio-store/src/vault.rs @@ -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; + /// 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>>, +} + +impl MockKeyring { + /// Create a new empty `MockKeyring`. + pub fn new() -> Self { + Self::default() + } +} + +impl SecretVault for MockKeyring { + fn get_secret(&self, key: &str) -> Result { + 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, +} + +impl SecretManager { + /// Create a `SecretManager` wrapping a specific vault provider. + pub fn new(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 { + 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" + ); + } +}