use std::error::Error;
use std::fmt;
use serde_json::Value;
use crate::constants::{ENV_PASS, ENV_USER};
use crate::error::RevenantError;
use super::secret::Secret;
use super::storage::{KEY_PASSWORD, KEY_USERNAME};
use super::{ConfigStore, SessionCredentials};
pub(super) const KEYRING_SERVICE: &str = "revenant";
#[derive(Debug)]
pub(super) struct SecretStoreError {
message: String,
}
impl SecretStoreError {
fn new(message: impl Into<String>) -> Self {
SecretStoreError {
message: message.into(),
}
}
}
impl fmt::Display for SecretStoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl Error for SecretStoreError {}
pub(super) trait SecretStore: fmt::Debug + Send + Sync {
fn get(&self, account: &str) -> Result<Option<String>, SecretStoreError>;
fn set(&self, account: &str, secret: &str) -> Result<(), SecretStoreError>;
fn delete(&self, account: &str) -> Result<(), SecretStoreError>;
fn backend_name(&self) -> String;
fn is_secure(&self) -> bool;
}
#[derive(Debug)]
pub(super) struct KeyringStore {
service: String,
}
impl KeyringStore {
pub(super) fn new(service: impl Into<String>) -> Self {
KeyringStore {
service: service.into(),
}
}
fn entry(&self, account: &str) -> Result<keyring::Entry, SecretStoreError> {
keyring::Entry::new(&self.service, account)
.map_err(|e| SecretStoreError::new(e.to_string()))
}
}
impl SecretStore for KeyringStore {
fn get(&self, account: &str) -> Result<Option<String>, SecretStoreError> {
match self.entry(account)?.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(SecretStoreError::new(e.to_string())),
}
}
fn set(&self, account: &str, secret: &str) -> Result<(), SecretStoreError> {
self.entry(account)?
.set_password(secret)
.map_err(|e| SecretStoreError::new(e.to_string()))
}
fn delete(&self, account: &str) -> Result<(), SecretStoreError> {
match self.entry(account)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(SecretStoreError::new(e.to_string())),
}
}
fn backend_name(&self) -> String {
if cfg!(target_os = "macos") {
"macOS Keychain".to_owned()
} else if cfg!(target_os = "windows") {
"Windows Credential Manager".to_owned()
} else if cfg!(target_os = "linux") {
"Linux Secret Service".to_owned()
} else {
"system keychain".to_owned()
}
}
fn is_secure(&self) -> bool {
true
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedCredentials {
pub username: Option<String>,
pub password: Option<Secret>,
}
impl ResolvedCredentials {
#[must_use]
pub fn is_complete(&self) -> bool {
let has_user = self.username.as_deref().is_some_and(|u| !u.is_empty());
let has_pass = self.password.as_ref().is_some_and(|p| !p.is_empty());
has_user && has_pass
}
}
fn raw_nonempty(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
map.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
impl ConfigStore {
#[must_use]
pub fn is_keyring_available(&self) -> bool {
self.secrets.is_secure()
}
#[must_use]
pub fn credential_storage_info(&self) -> String {
if self.secrets.is_secure() {
self.secrets.backend_name()
} else {
format!("{} (plaintext)", self.storage.file().display())
}
}
#[must_use]
pub fn saved_username(&self) -> Option<String> {
self.storage.load_typed().username.filter(|u| !u.is_empty())
}
pub fn set_session_credentials(
&self,
username: impl Into<String>,
password: impl Into<Secret>,
) {
let mut guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = Some(SessionCredentials {
username: username.into(),
password: password.into(),
});
}
pub fn clear_session_credentials(&self) {
let mut guard = self
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = None;
}
fn migrate_plaintext_password(&self) {
if !self.secrets.is_secure() {
return;
}
let raw = self.storage.load_raw();
let Some(username) = raw_nonempty(&raw, KEY_USERNAME) else {
return;
};
if raw_nonempty(&raw, KEY_PASSWORD).is_none() {
return; }
match self.secrets.get(&username) {
Ok(Some(keychain_password)) if !keychain_password.is_empty() => {
let mut raw = self.storage.load_raw();
if raw.remove(KEY_PASSWORD).is_some() {
match self.storage.save(&raw) {
Ok(()) => {
log::info!("Migrated: removed plaintext password from config file");
}
Err(e) => log::warn!("Failed to remove plaintext password: {e}"),
}
}
}
_ => {}
}
}
#[must_use]
pub fn get_credentials(&self) -> ResolvedCredentials {
self.migrate_plaintext_password();
let config = self.storage.load_typed();
let Some(username) = config.username.filter(|u| !u.is_empty()) else {
return ResolvedCredentials::default();
};
if self.secrets.is_secure() {
match self.secrets.get(&username) {
Ok(Some(password)) if !password.is_empty() => {
return ResolvedCredentials {
username: Some(username),
password: Some(Secret::new(password)),
};
}
Ok(_) => {} Err(e) => log::debug!("Keyring read failed, trying config file: {e}"),
}
}
let password = config.password.filter(|p| !p.is_empty());
ResolvedCredentials {
username: Some(username),
password,
}
}
pub fn save_credentials(&self, username: &str, password: &str) -> Result<bool, RevenantError> {
let mut raw = self.storage.load_raw_for_update()?;
let old_username = raw
.get(KEY_USERNAME)
.and_then(Value::as_str)
.map(str::to_owned);
raw.insert(KEY_USERNAME.to_owned(), Value::String(username.to_owned()));
let stored_in_keychain = if self.secrets.is_secure() {
match self.secrets.set(username, password) {
Ok(()) => {
raw.remove(KEY_PASSWORD);
true
}
Err(e) => {
log::warn!("Keyring save failed, using config file: {e}");
raw.insert(KEY_PASSWORD.to_owned(), Value::String(password.to_owned()));
false
}
}
} else {
log::warn!(
"Keyring unavailable. Password will be saved in plaintext ({}).",
self.storage.file().display()
);
raw.insert(KEY_PASSWORD.to_owned(), Value::String(password.to_owned()));
false
};
self.storage.save(&raw)?;
if let Some(old) = &old_username {
if old != username {
if let Err(e) = self.secrets.delete(old) {
log::debug!("Keyring delete of old entry failed: {e}");
}
}
}
Ok(stored_in_keychain)
}
pub fn clear_credentials(&self) -> Result<(), RevenantError> {
let mut raw = self.storage.load_raw_for_update()?;
let username = raw
.get(KEY_USERNAME)
.and_then(Value::as_str)
.map(str::to_owned);
log::info!(
"Clearing credentials: has_username={}, password_in_config={}, secure={}",
username.is_some(),
raw.contains_key(KEY_PASSWORD),
self.secrets.is_secure()
);
if let Some(username) = &username {
if let Err(e) = self.secrets.delete(username) {
log::debug!("Keyring delete failed: {e}");
}
}
raw.remove(KEY_USERNAME);
raw.remove(KEY_PASSWORD);
self.storage.save(&raw)
}
#[must_use]
pub fn resolve_credentials(&self) -> ResolvedCredentials {
let session = self.session_snapshot();
let mut username = self.env_nonempty(ENV_USER).or_else(|| {
session
.as_ref()
.map(|s| s.username.clone())
.filter(|u| !u.is_empty())
});
let mut password = self.env_nonempty(ENV_PASS).map(Secret::new).or_else(|| {
session
.as_ref()
.map(|s| s.password.clone())
.filter(|p| !p.is_empty())
});
if username.is_none() || password.is_none() {
let saved = self.get_credentials();
username = username.or(saved.username);
password = password.or(saved.password);
}
ResolvedCredentials { username, password }
}
}
#[cfg(test)]
pub(super) use mock::MockSecretStore;
#[cfg(test)]
mod mock {
use super::{SecretStore, SecretStoreError};
use std::collections::HashMap;
use std::fmt;
use std::sync::Mutex;
pub(in crate::config) struct MockSecretStore {
entries: Mutex<HashMap<String, String>>,
secure: bool,
fail_ops: bool,
}
impl MockSecretStore {
pub(in crate::config) fn working() -> Self {
MockSecretStore {
entries: Mutex::new(HashMap::new()),
secure: true,
fail_ops: false,
}
}
pub(in crate::config) fn failing() -> Self {
MockSecretStore {
entries: Mutex::new(HashMap::new()),
secure: true,
fail_ops: true,
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, String>> {
self.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
impl fmt::Debug for MockSecretStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MockSecretStore")
.field("secure", &self.secure)
.field("fail_ops", &self.fail_ops)
.field("entries", &self.lock().len())
.finish()
}
}
impl SecretStore for MockSecretStore {
fn get(&self, account: &str) -> Result<Option<String>, SecretStoreError> {
if self.fail_ops {
return Err(SecretStoreError::new("mock get failure"));
}
Ok(self.lock().get(account).cloned())
}
fn set(&self, account: &str, secret: &str) -> Result<(), SecretStoreError> {
if self.fail_ops {
return Err(SecretStoreError::new("mock set failure"));
}
self.lock().insert(account.to_owned(), secret.to_owned());
Ok(())
}
fn delete(&self, account: &str) -> Result<(), SecretStoreError> {
if self.fail_ops {
return Err(SecretStoreError::new("mock delete failure"));
}
self.lock().remove(account);
Ok(())
}
fn backend_name(&self) -> String {
"Mock Keychain".to_owned()
}
fn is_secure(&self) -> bool {
self.secure
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{test_store, test_store_with_env};
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn save_uses_keychain_and_hides_password_from_file() {
let (dir, store) = test_store();
let secure = store.save_credentials("alice", "pw-123").expect("save");
assert!(secure, "working keychain should store securely");
let text = std::fs::read_to_string(dir.path().join("config.json")).unwrap();
assert!(text.contains("alice"));
assert!(!text.contains("pw-123"), "plaintext leaked: {text}");
let ResolvedCredentials {
username: user,
password: pass,
} = store.get_credentials();
assert_eq!(user.as_deref(), Some("alice"));
assert_eq!(
pass.map(|p| p.expose().to_owned()),
Some("pw-123".to_owned())
);
}
#[test]
fn save_falls_back_to_plaintext_when_keychain_fails() {
let dir = tempfile::tempdir().unwrap();
let store = ConfigStore::with_parts(
dir.path().to_path_buf(),
Box::new(super::mock::MockSecretStore::failing()),
HashMap::new(),
);
let secure = store.save_credentials("bob", "pw-xyz").expect("save");
assert!(!secure, "failing keychain must fall back to plaintext");
let text = std::fs::read_to_string(dir.path().join("config.json")).unwrap();
assert!(
text.contains("pw-xyz"),
"plaintext password expected in file"
);
let ResolvedCredentials {
username: user,
password: pass,
} = store.get_credentials();
assert_eq!(user.as_deref(), Some("bob"));
assert_eq!(
pass.map(|p| p.expose().to_owned()),
Some("pw-xyz".to_owned())
);
}
#[test]
fn changing_username_removes_old_keychain_entry() {
let (_dir, store) = test_store();
store.save_credentials("old", "pw1").unwrap();
store.save_credentials("new", "pw2").unwrap();
let ResolvedCredentials {
username: user,
password: pass,
} = store.get_credentials();
assert_eq!(user.as_deref(), Some("new"));
assert_eq!(pass.map(|p| p.expose().to_owned()), Some("pw2".to_owned()));
}
#[test]
fn clear_removes_username_and_password() {
let (_dir, store) = test_store();
store.save_credentials("alice", "pw").unwrap();
store.clear_credentials().unwrap();
assert_eq!(store.get_credentials(), ResolvedCredentials::default());
assert_eq!(store.saved_username(), None);
}
#[test]
fn migrate_removes_plaintext_once_keychain_has_it() {
let (dir, store) = test_store();
let path = dir.path().join("config.json");
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(&path, r#"{"username":"alice","password":"legacy-pw"}"#).unwrap();
store.save_credentials("alice", "legacy-pw").unwrap(); let mut raw = store.storage.load_raw();
raw.insert(
KEY_PASSWORD.to_owned(),
Value::String("legacy-pw".to_owned()),
);
store.storage.save(&raw).unwrap();
let _ = store.get_credentials(); let text = std::fs::read_to_string(&path).unwrap();
assert!(
!text.contains("legacy-pw"),
"plaintext should be migrated away: {text}"
);
}
#[test]
fn resolve_precedence_env_over_session_over_saved() {
let (_dir, store) =
test_store_with_env(&[("REVENANT_USER", "env-user"), ("REVENANT_PASS", "env-pass")]);
store.set_session_credentials("session-user", "session-pass");
store.save_credentials("saved-user", "saved-pass").unwrap();
let resolved = store.resolve_credentials();
assert_eq!(resolved.username.as_deref(), Some("env-user"));
assert_eq!(
resolved.password.as_ref().map(Secret::expose),
Some("env-pass")
);
assert!(resolved.is_complete());
}
#[test]
fn resolve_merges_partial_sources() {
let (_dir, store) = test_store_with_env(&[("REVENANT_USER", "env-user")]);
store.save_credentials("saved-user", "saved-pass").unwrap();
let resolved = store.resolve_credentials();
assert_eq!(resolved.username.as_deref(), Some("env-user"));
assert_eq!(
resolved.password.as_ref().map(Secret::expose),
Some("saved-pass")
);
}
#[test]
fn resolve_session_fills_when_no_env() {
let (_dir, store) = test_store();
store.set_session_credentials("session-user", "session-pass");
let resolved = store.resolve_credentials();
assert_eq!(resolved.username.as_deref(), Some("session-user"));
assert_eq!(
resolved.password.as_ref().map(Secret::expose),
Some("session-pass")
);
}
#[test]
fn storage_info_reflects_backend() {
let (_dir, store) = test_store();
assert_eq!(store.credential_storage_info(), "Mock Keychain");
assert!(store.is_keyring_available());
let dir = tempfile::tempdir().unwrap();
let insecure = ConfigStore::with_parts(
dir.path().to_path_buf(),
Box::new(NonSecureStore),
HashMap::new(),
);
assert!(insecure.credential_storage_info().contains("(plaintext)"));
assert!(!insecure.is_keyring_available());
let _ = PathBuf::new();
}
#[test]
#[ignore = "touches the real OS keychain; run explicitly with --ignored"]
fn keyring_store_roundtrips_against_real_backend() {
let store = KeyringStore::new("revenant-test-phase3");
let account = "phase3-throwaway";
let _ = store.delete(account);
assert_eq!(store.get(account).expect("get"), None);
store.set(account, "secret-value-123").expect("set");
assert_eq!(
store.get(account).expect("get").as_deref(),
Some("secret-value-123")
);
store.delete(account).expect("delete");
assert_eq!(store.get(account).expect("get"), None);
assert!(store.is_secure());
}
#[derive(Debug)]
struct NonSecureStore;
impl SecretStore for NonSecureStore {
fn get(&self, _account: &str) -> Result<Option<String>, SecretStoreError> {
Ok(None)
}
fn set(&self, _account: &str, _secret: &str) -> Result<(), SecretStoreError> {
Ok(())
}
fn delete(&self, _account: &str) -> Result<(), SecretStoreError> {
Ok(())
}
fn backend_name(&self) -> String {
"none".to_owned()
}
fn is_secure(&self) -> bool {
false
}
}
}