Skip to main content

redisctl_core/config/
credential.rs

1//! Credential storage abstraction with optional keyring support
2//!
3//! This module provides a unified interface for storing and retrieving credentials,
4//! with support for:
5//! - OS keyring (when feature enabled)
6//! - Plaintext storage (fallback)
7//! - Environment variable override
8
9use super::error::{ConfigError, Result};
10use std::env;
11
12/// Prefix that indicates a value should be retrieved from the keyring
13const KEYRING_PREFIX: &str = "keyring:";
14
15/// Service name for keyring entries
16#[cfg(feature = "secure-storage")]
17const SERVICE_NAME: &str = "redisctl";
18
19/// Storage backend for credentials
20#[derive(Debug, Clone)]
21#[allow(dead_code)]
22pub enum CredentialStorage {
23    /// Store in OS keyring
24    #[cfg(feature = "secure-storage")]
25    Keyring,
26    /// Store as plaintext
27    Plaintext,
28}
29
30/// Credential store abstraction
31pub struct CredentialStore {
32    #[allow(dead_code)]
33    storage: CredentialStorage,
34}
35
36impl Default for CredentialStore {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42impl CredentialStore {
43    /// Create a new credential store with automatic backend selection
44    pub fn new() -> Self {
45        #[cfg(feature = "secure-storage")]
46        {
47            // Try to use keyring if available
48            if Self::is_keyring_available() {
49                Self {
50                    storage: CredentialStorage::Keyring,
51                }
52            } else {
53                Self {
54                    storage: CredentialStorage::Plaintext,
55                }
56            }
57        }
58        #[cfg(not(feature = "secure-storage"))]
59        {
60            Self {
61                storage: CredentialStorage::Plaintext,
62            }
63        }
64    }
65
66    /// Check if keyring is available on this system
67    #[cfg(feature = "secure-storage")]
68    fn is_keyring_available() -> bool {
69        // Try to create a test entry to see if keyring works
70        match keyring::Entry::new(SERVICE_NAME, "__test__") {
71            Ok(entry) => {
72                // Try to get a non-existent password (should fail gracefully)
73                let _ = entry.get_password();
74                true
75            }
76            Err(_) => false,
77        }
78    }
79
80    /// Store a credential value
81    #[allow(dead_code)]
82    pub fn store_credential(&self, key: &str, value: &str) -> Result<String> {
83        #[cfg(feature = "secure-storage")]
84        {
85            match self.storage {
86                CredentialStorage::Keyring => {
87                    let entry = keyring::Entry::new(SERVICE_NAME, key)
88                        .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
89                    entry.set_password(value).map_err(|e| {
90                        ConfigError::KeyringError(format!(
91                            "Failed to store credential in keyring: {}",
92                            e
93                        ))
94                    })?;
95                    // Return the reference string that will be stored in config
96                    Ok(format!("{}{}", KEYRING_PREFIX, key))
97                }
98                CredentialStorage::Plaintext => Ok(value.to_string()),
99            }
100        }
101        #[cfg(not(feature = "secure-storage"))]
102        {
103            // Without secure-storage feature, always use plaintext
104            let _ = key; // Not used without secure-storage
105            Ok(value.to_string())
106        }
107    }
108
109    /// Retrieve a credential value
110    ///
111    /// Resolution order:
112    /// 1. Check environment variables in order (if env vars provided)
113    /// 2. If value starts with "keyring:", retrieve from keyring
114    /// 3. Otherwise, return the value as-is (plaintext)
115    pub fn get_credential(&self, value: &str, env_var: Option<&str>) -> Result<String> {
116        self.get_credential_with_env_vars(value, env_var.into_iter().collect())
117    }
118
119    /// Retrieve a credential value with support for multiple environment variable aliases.
120    ///
121    /// Environment variables are checked in order, and the first set value wins.
122    pub fn get_credential_with_env_vars(&self, value: &str, env_vars: Vec<&str>) -> Result<String> {
123        for var in env_vars {
124            if let Ok(env_value) = env::var(var) {
125                return Ok(env_value);
126            }
127        }
128
129        // Check if this is a keyring reference
130        if value.starts_with(KEYRING_PREFIX) {
131            #[cfg(feature = "secure-storage")]
132            {
133                let key = value.trim_start_matches(KEYRING_PREFIX);
134                let entry = keyring::Entry::new(SERVICE_NAME, key)
135                    .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
136                entry.get_password().map_err(|e| {
137                    ConfigError::KeyringError(format!(
138                        "Failed to retrieve credential '{}' from keyring: {}",
139                        key, e
140                    ))
141                })
142            }
143            #[cfg(not(feature = "secure-storage"))]
144            {
145                Err(ConfigError::CredentialError(
146                    "Credential references keyring but secure-storage feature is not enabled"
147                        .to_string(),
148                ))
149            }
150        } else {
151            // Plain text value
152            Ok(value.to_string())
153        }
154    }
155
156    /// Delete a credential from storage
157    #[allow(dead_code)]
158    pub fn delete_credential(&self, key: &str) -> Result<()> {
159        #[cfg(feature = "secure-storage")]
160        {
161            match self.storage {
162                CredentialStorage::Keyring => {
163                    let entry = keyring::Entry::new(SERVICE_NAME, key)
164                        .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
165                    match entry.delete_credential() {
166                        Ok(()) => Ok(()),
167                        Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
168                        Err(e) => Err(ConfigError::KeyringError(format!(
169                            "Failed to delete credential from keyring: {}",
170                            e
171                        ))),
172                    }
173                }
174                CredentialStorage::Plaintext => Ok(()), // Nothing to delete for plaintext
175            }
176        }
177        #[cfg(not(feature = "secure-storage"))]
178        {
179            let _ = key; // Not used without secure-storage
180            Ok(()) // Nothing to delete for plaintext
181        }
182    }
183
184    /// Check if a value is a keyring reference
185    #[allow(dead_code)]
186    pub fn is_keyring_reference(value: &str) -> bool {
187        value.starts_with(KEYRING_PREFIX)
188    }
189
190    /// Get the current storage backend
191    #[allow(dead_code)]
192    pub fn storage_backend(&self) -> &str {
193        #[cfg(feature = "secure-storage")]
194        {
195            match self.storage {
196                CredentialStorage::Keyring => "keyring",
197                CredentialStorage::Plaintext => "plaintext",
198            }
199        }
200        #[cfg(not(feature = "secure-storage"))]
201        {
202            "plaintext"
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_plaintext_storage() {
213        let store = CredentialStore::new();
214
215        // Plaintext values should be returned as-is
216        let result = store.get_credential("my-api-key", None).unwrap();
217        assert_eq!(result, "my-api-key");
218    }
219
220    #[test]
221    fn test_env_var_override() {
222        unsafe {
223            env::set_var("TEST_CREDENTIAL", "env-value");
224        }
225
226        let store = CredentialStore::new();
227        let result = store
228            .get_credential("config-value", Some("TEST_CREDENTIAL"))
229            .unwrap();
230        assert_eq!(result, "env-value");
231
232        unsafe {
233            env::remove_var("TEST_CREDENTIAL");
234        }
235    }
236
237    #[test]
238    #[serial_test::serial(credential_alias_env)]
239    fn test_env_var_alias_override_uses_first_available() {
240        unsafe {
241            env::set_var("TEST_CREDENTIAL_ALIAS_2", "alias-value");
242        }
243
244        let store = CredentialStore::new();
245        let result = store
246            .get_credential_with_env_vars(
247                "config-value",
248                vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
249            )
250            .unwrap();
251        assert_eq!(result, "alias-value");
252
253        unsafe {
254            env::remove_var("TEST_CREDENTIAL_ALIAS_2");
255        }
256    }
257
258    #[test]
259    #[serial_test::serial(credential_alias_env)]
260    fn test_env_var_alias_override_prefers_first_set() {
261        unsafe {
262            env::set_var("TEST_CREDENTIAL_ALIAS_1", "preferred-value");
263            env::set_var("TEST_CREDENTIAL_ALIAS_2", "fallback-value");
264        }
265
266        let store = CredentialStore::new();
267        let result = store
268            .get_credential_with_env_vars(
269                "config-value",
270                vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
271            )
272            .unwrap();
273        assert_eq!(result, "preferred-value");
274
275        unsafe {
276            env::remove_var("TEST_CREDENTIAL_ALIAS_1");
277            env::remove_var("TEST_CREDENTIAL_ALIAS_2");
278        }
279    }
280
281    #[test]
282    fn test_keyring_reference_detection() {
283        assert!(CredentialStore::is_keyring_reference("keyring:my-key"));
284        assert!(!CredentialStore::is_keyring_reference("my-key"));
285        assert!(!CredentialStore::is_keyring_reference(""));
286    }
287
288    #[cfg(feature = "secure-storage")]
289    #[test]
290    #[ignore = "Requires keyring service to be available"]
291    fn test_keyring_storage() {
292        let store = CredentialStore::new();
293
294        // Store a credential
295        let key = "test-credential";
296        let value = "test-value";
297        let reference = store.store_credential(key, value).unwrap();
298
299        // Should return a keyring reference
300        assert!(reference.starts_with(KEYRING_PREFIX));
301
302        // Retrieve it back
303        let retrieved = store.get_credential(&reference, None).unwrap();
304        assert_eq!(retrieved, value);
305
306        // Clean up
307        let _ = store.delete_credential(key);
308    }
309}