use super::error::{ConfigError, Result};
use std::env;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvironmentOverrides {
Enabled,
Disabled,
}
const KEYRING_PREFIX: &str = "keyring:";
#[cfg(feature = "secure-storage")]
const SERVICE_NAME: &str = "redisctl";
#[cfg(feature = "secure-storage")]
const PROBE_ENTRY: &str = "__probe__";
#[cfg(feature = "secure-storage")]
static PROBE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[derive(Debug, Clone)]
pub enum CredentialStorage {
#[cfg(feature = "secure-storage")]
Keyring,
Plaintext,
}
pub struct CredentialStore {
#[cfg(feature = "secure-storage")]
storage: CredentialStorage,
}
impl Default for CredentialStore {
fn default() -> Self {
Self::new()
}
}
impl CredentialStore {
pub fn new() -> Self {
#[cfg(feature = "secure-storage")]
{
if Self::is_keyring_available() {
Self {
storage: CredentialStorage::Keyring,
}
} else {
Self {
storage: CredentialStorage::Plaintext,
}
}
}
#[cfg(not(feature = "secure-storage"))]
{
Self {}
}
}
pub fn plaintext() -> Self {
#[cfg(feature = "secure-storage")]
{
Self {
storage: CredentialStorage::Plaintext,
}
}
#[cfg(not(feature = "secure-storage"))]
{
Self {}
}
}
#[cfg(feature = "secure-storage")]
fn is_keyring_available() -> bool {
match keyring::Entry::new(SERVICE_NAME, PROBE_ENTRY) {
Ok(entry) => !matches!(
entry.get_password(),
Err(keyring::Error::NoStorageAccess(_) | keyring::Error::PlatformFailure(_))
),
Err(_) => false,
}
}
pub fn probe_writable(&self) -> Result<()> {
#[cfg(feature = "secure-storage")]
{
if !matches!(self.storage, CredentialStorage::Keyring) {
return Ok(());
}
const PROBE_VALUE: &str = "redisctl-probe";
let key = format!(
"{PROBE_ENTRY}-{}-{}",
std::process::id(),
PROBE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let entry = keyring::Entry::new(SERVICE_NAME, &key)
.map_err(|e| ConfigError::KeyringError(e.to_string()))?;
entry.set_password(PROBE_VALUE).map_err(|e| {
ConfigError::KeyringError(format!("the keyring rejected a test write: {e}"))
})?;
let read_back = entry.get_password().map_err(|e| {
ConfigError::KeyringError(format!("the keyring did not return a test write: {e}"))
});
let _ = entry.delete_credential();
if read_back? != PROBE_VALUE {
return Err(ConfigError::KeyringError(
"the keyring returned a different value than was written".to_string(),
));
}
}
Ok(())
}
pub fn store_credential(&self, key: &str, value: &str) -> Result<String> {
#[cfg(feature = "secure-storage")]
{
match self.storage {
CredentialStorage::Keyring => {
let entry = keyring::Entry::new(SERVICE_NAME, key)
.map_err(|e| ConfigError::KeyringError(e.to_string()))?;
entry.set_password(value).map_err(|e| {
ConfigError::KeyringError(format!(
"Failed to store credential in keyring: {}",
e
))
})?;
Ok(format!("{}{}", KEYRING_PREFIX, key))
}
CredentialStorage::Plaintext => Ok(value.to_string()),
}
}
#[cfg(not(feature = "secure-storage"))]
{
let _ = key; Ok(value.to_string())
}
}
pub fn get_credential(&self, value: &str, env_var: Option<&str>) -> Result<String> {
match env_var {
Some(env_var) => self.get_credential_with_environment(
value,
&[env_var],
EnvironmentOverrides::Enabled,
),
None => self.get_credential_with_environment(value, &[], EnvironmentOverrides::Enabled),
}
}
pub fn get_credential_with_env_vars(&self, value: &str, env_vars: Vec<&str>) -> Result<String> {
self.get_credential_with_environment(value, &env_vars, EnvironmentOverrides::Enabled)
}
pub fn get_credential_with_environment(
&self,
value: &str,
env_vars: &[&str],
environment_overrides: EnvironmentOverrides,
) -> Result<String> {
if environment_overrides == EnvironmentOverrides::Enabled {
for var in env_vars {
if let Ok(env_value) = env::var(var) {
return Ok(env_value);
}
}
}
if value.starts_with(KEYRING_PREFIX) {
#[cfg(feature = "secure-storage")]
{
let key = value.trim_start_matches(KEYRING_PREFIX);
let entry = keyring::Entry::new(SERVICE_NAME, key)
.map_err(|e| ConfigError::KeyringError(e.to_string()))?;
entry.get_password().map_err(|e| match e {
keyring::Error::NoEntry => ConfigError::KeyringError(format!(
"credential '{key}' is no longer in the OS keyring, so any profile \
referencing it cannot be used until it is stored again. On Linux the \
keyring does not survive a reboot. Store it again with \
`redisctl profile set <name> --type <cloud|enterprise>`, or for a Redis \
Cloud profile sign in again with \
`redisctl --profile <name> cloud auth login`."
)),
other => ConfigError::KeyringError(format!(
"Failed to retrieve credential '{key}' from keyring: {other}"
)),
})
}
#[cfg(not(feature = "secure-storage"))]
{
Err(ConfigError::CredentialError(
"Credential references keyring but secure-storage feature is not enabled"
.to_string(),
))
}
} else {
Ok(value.to_string())
}
}
pub fn delete_credential(&self, key: &str) -> Result<()> {
#[cfg(feature = "secure-storage")]
{
match self.storage {
CredentialStorage::Keyring => {
let entry = keyring::Entry::new(SERVICE_NAME, key)
.map_err(|e| ConfigError::KeyringError(e.to_string()))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(ConfigError::KeyringError(format!(
"Failed to delete credential from keyring: {}",
e
))),
}
}
CredentialStorage::Plaintext => Ok(()), }
}
#[cfg(not(feature = "secure-storage"))]
{
let _ = key; Ok(()) }
}
pub fn is_keyring_reference(value: &str) -> bool {
value.starts_with(KEYRING_PREFIX)
}
pub fn storage_backend(&self) -> &str {
#[cfg(feature = "secure-storage")]
{
match self.storage {
CredentialStorage::Keyring => "keyring",
CredentialStorage::Plaintext => "plaintext",
}
}
#[cfg(not(feature = "secure-storage"))]
{
"plaintext"
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plaintext_storage() {
let store = CredentialStore::new();
let result = store.get_credential("my-api-key", None).unwrap();
assert_eq!(result, "my-api-key");
}
#[test]
fn test_env_var_override() {
unsafe {
env::set_var("TEST_CREDENTIAL", "env-value");
}
let store = CredentialStore::new();
let result = store
.get_credential("config-value", Some("TEST_CREDENTIAL"))
.unwrap();
assert_eq!(result, "env-value");
unsafe {
env::remove_var("TEST_CREDENTIAL");
}
}
#[test]
#[serial_test::serial(credential_alias_env)]
fn test_env_var_alias_override_uses_first_available() {
unsafe {
env::set_var("TEST_CREDENTIAL_ALIAS_2", "alias-value");
}
let store = CredentialStore::new();
let result = store
.get_credential_with_env_vars(
"config-value",
vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
)
.unwrap();
assert_eq!(result, "alias-value");
unsafe {
env::remove_var("TEST_CREDENTIAL_ALIAS_2");
}
}
#[test]
#[serial_test::serial(credential_alias_env)]
fn test_env_var_alias_override_prefers_first_set() {
unsafe {
env::set_var("TEST_CREDENTIAL_ALIAS_1", "preferred-value");
env::set_var("TEST_CREDENTIAL_ALIAS_2", "fallback-value");
}
let store = CredentialStore::new();
let result = store
.get_credential_with_env_vars(
"config-value",
vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
)
.unwrap();
assert_eq!(result, "preferred-value");
unsafe {
env::remove_var("TEST_CREDENTIAL_ALIAS_1");
env::remove_var("TEST_CREDENTIAL_ALIAS_2");
}
}
#[test]
fn test_keyring_reference_detection() {
assert!(CredentialStore::is_keyring_reference("keyring:my-key"));
assert!(!CredentialStore::is_keyring_reference("my-key"));
assert!(!CredentialStore::is_keyring_reference(""));
}
#[cfg(feature = "secure-storage")]
#[test]
#[ignore = "Requires keyring service to be available"]
fn test_keyring_storage() {
let store = CredentialStore::new();
let key = "test-credential";
let value = "test-value";
let reference = store.store_credential(key, value).unwrap();
assert!(reference.starts_with(KEYRING_PREFIX));
let retrieved = store.get_credential(&reference, None).unwrap();
assert_eq!(retrieved, value);
let _ = store.delete_credential(key);
}
#[test]
fn probe_writable_is_a_no_op_for_plaintext() {
assert!(CredentialStore::plaintext().probe_writable().is_ok());
}
#[cfg(feature = "secure-storage")]
#[test]
#[ignore = "Requires keyring service to be available"]
fn probe_writable_accepts_a_working_keyring_repeatedly() {
let store = CredentialStore::new();
store.probe_writable().unwrap();
store.probe_writable().unwrap();
let reference = store.store_credential("probe-neighbour", "kept").unwrap();
assert_eq!(store.get_credential(&reference, None).unwrap(), "kept");
let _ = store.delete_credential("probe-neighbour");
}
}