redisctl_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 crate::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 variable (if env_var 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        // First check environment variable if provided
117        if let Some(var) = env_var
118            && let Ok(env_value) = env::var(var)
119        {
120            return Ok(env_value);
121        }
122
123        // Check if this is a keyring reference
124        if value.starts_with(KEYRING_PREFIX) {
125            #[cfg(feature = "secure-storage")]
126            {
127                let key = value.trim_start_matches(KEYRING_PREFIX);
128                let entry = keyring::Entry::new(SERVICE_NAME, key)
129                    .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
130                entry.get_password().map_err(|e| {
131                    ConfigError::KeyringError(format!(
132                        "Failed to retrieve credential '{}' from keyring: {}",
133                        key, e
134                    ))
135                })
136            }
137            #[cfg(not(feature = "secure-storage"))]
138            {
139                Err(ConfigError::CredentialError(
140                    "Credential references keyring but secure-storage feature is not enabled"
141                        .to_string(),
142                ))
143            }
144        } else {
145            // Plain text value
146            Ok(value.to_string())
147        }
148    }
149
150    /// Delete a credential from storage
151    #[allow(dead_code)]
152    pub fn delete_credential(&self, key: &str) -> Result<()> {
153        #[cfg(feature = "secure-storage")]
154        {
155            match self.storage {
156                CredentialStorage::Keyring => {
157                    let entry = keyring::Entry::new(SERVICE_NAME, key)
158                        .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
159                    match entry.delete_credential() {
160                        Ok(()) => Ok(()),
161                        Err(keyring::Error::NoEntry) => Ok(()), // Already deleted
162                        Err(e) => Err(ConfigError::KeyringError(format!(
163                            "Failed to delete credential from keyring: {}",
164                            e
165                        ))),
166                    }
167                }
168                CredentialStorage::Plaintext => Ok(()), // Nothing to delete for plaintext
169            }
170        }
171        #[cfg(not(feature = "secure-storage"))]
172        {
173            let _ = key; // Not used without secure-storage
174            Ok(()) // Nothing to delete for plaintext
175        }
176    }
177
178    /// Check if a value is a keyring reference
179    #[allow(dead_code)]
180    pub fn is_keyring_reference(value: &str) -> bool {
181        value.starts_with(KEYRING_PREFIX)
182    }
183
184    /// Get the current storage backend
185    #[allow(dead_code)]
186    pub fn storage_backend(&self) -> &str {
187        #[cfg(feature = "secure-storage")]
188        {
189            match self.storage {
190                CredentialStorage::Keyring => "keyring",
191                CredentialStorage::Plaintext => "plaintext",
192            }
193        }
194        #[cfg(not(feature = "secure-storage"))]
195        {
196            "plaintext"
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_plaintext_storage() {
207        let store = CredentialStore::new();
208
209        // Plaintext values should be returned as-is
210        let result = store.get_credential("my-api-key", None).unwrap();
211        assert_eq!(result, "my-api-key");
212    }
213
214    #[test]
215    fn test_env_var_override() {
216        unsafe {
217            env::set_var("TEST_CREDENTIAL", "env-value");
218        }
219
220        let store = CredentialStore::new();
221        let result = store
222            .get_credential("config-value", Some("TEST_CREDENTIAL"))
223            .unwrap();
224        assert_eq!(result, "env-value");
225
226        unsafe {
227            env::remove_var("TEST_CREDENTIAL");
228        }
229    }
230
231    #[test]
232    fn test_keyring_reference_detection() {
233        assert!(CredentialStore::is_keyring_reference("keyring:my-key"));
234        assert!(!CredentialStore::is_keyring_reference("my-key"));
235        assert!(!CredentialStore::is_keyring_reference(""));
236    }
237
238    #[cfg(feature = "secure-storage")]
239    #[test]
240    #[ignore = "Requires keyring service to be available"]
241    fn test_keyring_storage() {
242        let store = CredentialStore::new();
243
244        // Store a credential
245        let key = "test-credential";
246        let value = "test-value";
247        let reference = store.store_credential(key, value).unwrap();
248
249        // Should return a keyring reference
250        assert!(reference.starts_with(KEYRING_PREFIX));
251
252        // Retrieve it back
253        let retrieved = store.get_credential(&reference, None).unwrap();
254        assert_eq!(retrieved, value);
255
256        // Clean up
257        let _ = store.delete_credential(key);
258    }
259}