#[cfg(feature = "encrypted-file")]
mod encrypted;
#[cfg(feature = "keychain")]
mod keychain;
mod memory;
mod types;
#[cfg(feature = "encrypted-file")]
pub use encrypted::EncryptedFileBackend;
#[cfg(feature = "keychain")]
pub use keychain::KeychainBackend;
pub use memory::MemoryBackend;
pub use types::{SecretBackupPolicy, SecretPasswordSource, SecretStorage};
use crate::error::Result;
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
pub trait CredentialBackend: Send + Sync {
fn store(&self, key: &str, value: &str) -> Result<()>;
fn get(&self, key: &str) -> Result<Option<String>>;
fn remove(&self, key: &str) -> Result<()>;
fn exists(&self, key: &str) -> Result<bool> {
Ok(self.get(key)?.is_some())
}
fn list_keys(&self) -> Result<Vec<String>>;
fn backend_name(&self) -> &'static str;
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
#[derive(Clone)]
pub struct CredentialManager {
primary: Arc<dyn CredentialBackend>,
fallback: Option<Arc<dyn CredentialBackend>>,
is_primary_failed: Arc<AtomicBool>,
service_name: String,
#[cfg(feature = "profiles")]
profile_context: Option<String>,
volatile: Arc<MemoryBackend>,
}
#[cfg(any(feature = "keychain", feature = "encrypted-file"))]
impl CredentialManager {
#[cfg(feature = "keychain")]
pub fn new(service_name: impl Into<String>) -> Self {
let service = service_name.into();
Self {
primary: Arc::new(KeychainBackend::new(service.clone())),
fallback: None,
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: service,
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
}
}
#[cfg(not(feature = "keychain"))]
pub fn new(service_name: impl Into<String>) -> Self {
let service = service_name.into();
Self {
primary: Arc::new(MemoryBackend::new()),
fallback: None,
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: service,
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
}
}
#[cfg(all(feature = "keychain", feature = "encrypted-file"))]
pub fn with_fallback(
service_name: impl Into<String>,
fallback_path: std::path::PathBuf,
password_source: &SecretPasswordSource,
) -> Self {
let service = service_name.into();
let fallback = EncryptedFileBackend::with_source(fallback_path, password_source).ok();
Self {
primary: Arc::new(KeychainBackend::new(service.clone())),
fallback: fallback.map(|f| Arc::new(f) as Arc<dyn CredentialBackend>),
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: service,
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
}
}
pub fn memory_only(service_name: impl Into<String>) -> Self {
Self {
primary: Arc::new(MemoryBackend::new()),
fallback: None,
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: service_name.into(),
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
}
}
pub fn with_backend(
service_name: impl Into<String>,
backend: Arc<dyn CredentialBackend>,
) -> Self {
Self {
primary: backend,
fallback: None,
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: service_name.into(),
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
}
}
#[cfg(feature = "profiles")]
#[must_use]
pub fn with_profile_context(&self, profile_name: &str) -> Self {
Self {
primary: self.primary.clone(),
fallback: self.fallback.clone(),
is_primary_failed: self.is_primary_failed.clone(),
service_name: self.service_name.clone(),
profile_context: Some(profile_name.to_string()),
volatile: self.volatile.clone(),
}
}
#[must_use]
pub fn is_primary_failed(&self) -> bool {
self.is_primary_failed.load(Ordering::Relaxed)
}
#[must_use]
pub fn is_volatile_active(&self) -> bool {
self.volatile.list_keys().is_ok_and(|keys| !keys.is_empty())
}
pub fn store(&self, key: &str, value: &str) -> Result<()> {
self.store_with_profile(key, value, None)
}
pub fn store_with_profile(&self, key: &str, value: &str, profile: Option<&str>) -> Result<()> {
let full_key = self.make_key_with_profile(key, profile);
if !self.is_primary_failed.load(Ordering::Relaxed) {
match self.primary.store(&full_key, value) {
Ok(()) => {
log::debug!(
"Stored credential '{key}' in {}",
self.primary.backend_name()
);
return Ok(());
}
Err(e) => {
log::error!("=== PRIMARY BACKEND FAILED FOR {}: {:?}", key, e);
self.is_primary_failed.store(true, Ordering::Relaxed);
}
}
}
if let Some(ref fallback) = self.fallback {
match fallback.store(&full_key, value) {
Ok(()) => {
log::debug!("Stored credential '{key}' in persistent fallback");
return Ok(());
}
Err(e) => {
log::error!(
"Persistent fallback failed for '{key}': {e}. Falling back to VOLATILE memory."
);
}
}
}
log::warn!("Using volatile fallback for '{key}' - secret will NOT persist across restarts");
self.volatile.store(&full_key, value)
}
pub fn get(&self, key: &str) -> Result<Option<String>> {
self.get_with_profile(key, None)
}
pub fn get_with_profile(&self, key: &str, profile: Option<&str>) -> Result<Option<String>> {
let full_key = self.make_key_with_profile(key, profile);
if !self.is_primary_failed.load(Ordering::Relaxed) {
match self.primary.get(&full_key) {
Ok(val) => return Ok(val),
Err(e) => {
log::error!("=== PRIMARY BACKEND FAILED FOR {}: {:?}", key, e);
self.is_primary_failed.store(true, Ordering::Relaxed);
}
}
}
if let Some(ref fallback) = self.fallback {
match fallback.get(&full_key) {
Ok(val) => return Ok(val),
Err(e) => {
log::error!(
"Persistent fallback failed for '{key}': {e}. Trying VOLATILE memory."
);
}
}
}
self.volatile.get(&full_key)
}
pub fn remove(&self, key: &str) -> Result<()> {
self.remove_with_profile(key, None)
}
pub fn remove_with_profile(&self, key: &str, profile: Option<&str>) -> Result<()> {
let full_key = self.make_key_with_profile(key, profile);
let _ = self.primary.remove(&full_key);
if let Some(ref fallback) = self.fallback {
let _ = fallback.remove(&full_key);
}
let _ = self.volatile.remove(&full_key);
Ok(())
}
#[must_use]
pub fn exists(&self, key: &str) -> bool {
let full_key = self.make_key(key);
if self.primary.exists(&full_key).unwrap_or(false) {
return true;
}
if let Some(ref fallback) = self.fallback {
return fallback.exists(&full_key).unwrap_or(false);
}
false
}
pub fn clear(&self) -> Result<()> {
#[cfg(feature = "profiles")]
let prefix = if let Some(profile_ctx) = &self.profile_context {
format!("{}:profiles:{}:", self.service_name, profile_ctx)
} else {
format!("{}:", self.service_name)
};
#[cfg(not(feature = "profiles"))]
let prefix = format!("{}:", self.service_name);
let mut keys_to_remove = std::collections::HashSet::new();
if let Ok(keys) = self.primary.list_keys() {
for key in keys {
if key.starts_with(&prefix) {
keys_to_remove.insert(key);
}
}
}
if let Some(ref fallback) = self.fallback
&& let Ok(keys) = fallback.list_keys()
{
for key in keys {
if key.starts_with(&prefix) {
keys_to_remove.insert(key);
}
}
}
if let Ok(keys) = self.volatile.list_keys() {
for key in keys {
if key.starts_with(&prefix) {
keys_to_remove.insert(key);
}
}
}
for full_key in keys_to_remove {
let _ = self.primary.remove(&full_key);
if let Some(ref fallback) = self.fallback {
let _ = fallback.remove(&full_key);
}
let _ = self.volatile.remove(&full_key);
}
Ok(())
}
#[must_use]
pub fn service_name(&self) -> &str {
&self.service_name
}
#[must_use]
pub fn backend_name(&self) -> &'static str {
self.primary.backend_name()
}
fn make_key(&self, key: &str) -> String {
self.make_key_with_profile(key, None)
}
fn make_key_with_profile(&self, key: &str, profile: Option<&str>) -> String {
#[cfg(feature = "profiles")]
{
if let Some(profile) = profile {
return format!("{}:profiles:{}:{}", self.service_name, profile, key);
}
if let Some(ref profile_ctx) = self.profile_context {
return format!("{}:profiles:{}:{}", self.service_name, profile_ctx, key);
}
}
#[cfg(not(feature = "profiles"))]
let _ = profile;
format!("{}:{}", self.service_name, key)
}
}
#[cfg(all(test, any(feature = "keychain", feature = "encrypted-file")))]
mod tests {
use super::*;
use crate::Error;
struct FailingBackend;
impl CredentialBackend for FailingBackend {
fn store(&self, _key: &str, _value: &str) -> Result<()> {
Err(crate::error::Error::Credential(
"primary store failed".to_string(),
))
}
fn get(&self, _key: &str) -> Result<Option<String>> {
Err(crate::error::Error::Credential(
"primary get failed".to_string(),
))
}
fn remove(&self, _key: &str) -> Result<()> {
Err(crate::error::Error::Credential(
"primary remove failed".to_string(),
))
}
fn list_keys(&self) -> Result<Vec<String>> {
Ok(Vec::new())
}
fn backend_name(&self) -> &'static str {
"failing"
}
}
#[test]
fn test_memory_backend_crud() {
let manager = CredentialManager::memory_only("test-app");
manager.store("api_key", "secret123").unwrap();
assert!(manager.exists("api_key"));
let value = manager.get("api_key").unwrap();
assert_eq!(value, Some("secret123".to_string()));
manager.remove("api_key").unwrap();
assert!(!manager.exists("api_key"));
}
#[test]
fn test_credential_not_found() {
let manager = CredentialManager::memory_only("test-app");
let value = manager.get("nonexistent").unwrap();
assert_eq!(value, None);
}
#[test]
fn test_fallback_used_when_primary_backend_fails() {
let manager = CredentialManager {
primary: std::sync::Arc::new(FailingBackend),
fallback: Some(std::sync::Arc::new(MemoryBackend::new())),
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: "test-app".to_string(),
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
};
manager.remove("api_key").unwrap();
assert_eq!(manager.get("api_key").unwrap(), None);
}
#[test]
fn test_sticky_fallback_is_remembered() {
let manager = CredentialManager {
primary: std::sync::Arc::new(FailingBackend),
fallback: Some(std::sync::Arc::new(MemoryBackend::new())),
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: "test-app".to_string(),
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
};
assert!(!manager.is_primary_failed.load(Ordering::Relaxed));
manager.store("key1", "val1").unwrap();
assert!(manager.is_primary_failed.load(Ordering::Relaxed));
manager.get("key1").unwrap();
assert!(manager.is_primary_failed.load(Ordering::Relaxed));
}
#[cfg(feature = "profiles")]
#[test]
fn test_clear_with_profile_context_is_scoped() {
let manager = CredentialManager::memory_only("test-app");
manager
.store_with_profile("api_key", "default-secret", Some("default"))
.unwrap();
manager
.store_with_profile("api_key", "work-secret", Some("work"))
.unwrap();
manager.store("global_key", "global-secret").unwrap();
let work_manager = manager.with_profile_context("work");
work_manager.clear().unwrap();
assert_eq!(
manager.get_with_profile("api_key", Some("work")).unwrap(),
None
);
assert_eq!(
manager
.get_with_profile("api_key", Some("default"))
.unwrap(),
Some("default-secret".to_string())
);
assert_eq!(
manager.get("global_key").unwrap(),
Some("global-secret".to_string())
);
}
#[cfg(feature = "profiles")]
#[test]
fn test_clear_without_profile_context_removes_all_scopes() {
let manager = CredentialManager::memory_only("test-app");
manager
.store_with_profile("api_key", "default-secret", Some("default"))
.unwrap();
manager
.store_with_profile("api_key", "work-secret", Some("work"))
.unwrap();
manager.store("global_key", "global-secret").unwrap();
manager.clear().unwrap();
assert_eq!(
manager
.get_with_profile("api_key", Some("default"))
.unwrap(),
None
);
assert_eq!(
manager.get_with_profile("api_key", Some("work")).unwrap(),
None
);
assert_eq!(manager.get("global_key").unwrap(), None);
}
#[cfg(feature = "profiles")]
#[test]
fn test_profile_context_isolation_under_concurrency() {
use std::sync::Arc;
use std::thread;
let manager = Arc::new(CredentialManager::memory_only("test-app"));
let work_manager = Arc::new(manager.with_profile_context("work"));
let default_manager = Arc::new(manager.with_profile_context("default"));
let work = {
let work_manager = Arc::clone(&work_manager);
thread::spawn(move || {
for _ in 0..100 {
work_manager.store("api_key", "work-secret").unwrap();
}
})
};
let default = {
let default_manager = Arc::clone(&default_manager);
thread::spawn(move || {
for _ in 0..100 {
default_manager.store("api_key", "default-secret").unwrap();
}
})
};
work.join().unwrap();
default.join().unwrap();
assert_eq!(
work_manager.get("api_key").unwrap(),
Some("work-secret".to_string())
);
assert_eq!(
default_manager.get("api_key").unwrap(),
Some("default-secret".to_string())
);
assert_eq!(manager.get("api_key").unwrap(), None);
}
#[test]
fn test_ultimate_memory_fallback() {
struct FailingBackend;
impl CredentialBackend for FailingBackend {
fn store(&self, _: &str, _: &str) -> Result<()> {
Err(Error::Credential(
"Catastrophic persistent failure".to_string(),
))
}
fn get(&self, _: &str) -> Result<Option<String>> {
Err(Error::Credential(
"Catastrophic persistent failure".to_string(),
))
}
fn remove(&self, _: &str) -> Result<()> {
Err(Error::Credential(
"Catastrophic persistent failure".to_string(),
))
}
fn list_keys(&self) -> Result<Vec<String>> {
Err(Error::Credential(
"Catastrophic persistent failure".to_string(),
))
}
fn backend_name(&self) -> &'static str {
"failing"
}
}
let manager = CredentialManager {
primary: Arc::new(FailingBackend),
fallback: Some(Arc::new(FailingBackend)),
is_primary_failed: Arc::new(AtomicBool::new(false)),
service_name: "test-app".to_string(),
#[cfg(feature = "profiles")]
profile_context: None,
volatile: Arc::new(MemoryBackend::new()),
};
manager.store("api_key", "top-secret").unwrap();
assert_eq!(
manager.get("api_key").unwrap(),
Some("top-secret".to_string())
);
manager.remove("api_key").unwrap();
assert_eq!(manager.get("api_key").unwrap(), None);
}
}