redisctl_core/config/
credential.rs1use super::error::{ConfigError, Result};
10use std::env;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum EnvironmentOverrides {
15 Enabled,
17 Disabled,
19}
20
21const KEYRING_PREFIX: &str = "keyring:";
23
24#[cfg(feature = "secure-storage")]
26const SERVICE_NAME: &str = "redisctl";
27
28#[cfg(feature = "secure-storage")]
31const PROBE_ENTRY: &str = "__probe__";
32
33#[cfg(feature = "secure-storage")]
35static PROBE_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
36
37#[derive(Debug, Clone)]
39pub enum CredentialStorage {
40 #[cfg(feature = "secure-storage")]
42 Keyring,
43 Plaintext,
45}
46
47pub struct CredentialStore {
49 #[cfg(feature = "secure-storage")]
50 storage: CredentialStorage,
51}
52
53impl Default for CredentialStore {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl CredentialStore {
60 pub fn new() -> Self {
62 #[cfg(feature = "secure-storage")]
63 {
64 if Self::is_keyring_available() {
66 Self {
67 storage: CredentialStorage::Keyring,
68 }
69 } else {
70 Self {
71 storage: CredentialStorage::Plaintext,
72 }
73 }
74 }
75 #[cfg(not(feature = "secure-storage"))]
76 {
77 Self {}
78 }
79 }
80
81 pub fn plaintext() -> Self {
84 #[cfg(feature = "secure-storage")]
85 {
86 Self {
87 storage: CredentialStorage::Plaintext,
88 }
89 }
90 #[cfg(not(feature = "secure-storage"))]
91 {
92 Self {}
93 }
94 }
95
96 #[cfg(feature = "secure-storage")]
107 fn is_keyring_available() -> bool {
108 match keyring::Entry::new(SERVICE_NAME, PROBE_ENTRY) {
109 Ok(entry) => !matches!(
110 entry.get_password(),
111 Err(keyring::Error::NoStorageAccess(_) | keyring::Error::PlatformFailure(_))
112 ),
113 Err(_) => false,
114 }
115 }
116
117 pub fn probe_writable(&self) -> Result<()> {
132 #[cfg(feature = "secure-storage")]
133 {
134 if !matches!(self.storage, CredentialStorage::Keyring) {
135 return Ok(());
136 }
137 const PROBE_VALUE: &str = "redisctl-probe";
138
139 let key = format!(
143 "{PROBE_ENTRY}-{}-{}",
144 std::process::id(),
145 PROBE_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
146 );
147 let entry = keyring::Entry::new(SERVICE_NAME, &key)
148 .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
149 entry.set_password(PROBE_VALUE).map_err(|e| {
150 ConfigError::KeyringError(format!("the keyring rejected a test write: {e}"))
151 })?;
152 let read_back = entry.get_password().map_err(|e| {
153 ConfigError::KeyringError(format!("the keyring did not return a test write: {e}"))
154 });
155 let _ = entry.delete_credential();
157 if read_back? != PROBE_VALUE {
158 return Err(ConfigError::KeyringError(
159 "the keyring returned a different value than was written".to_string(),
160 ));
161 }
162 }
163 Ok(())
164 }
165
166 pub fn store_credential(&self, key: &str, value: &str) -> Result<String> {
168 #[cfg(feature = "secure-storage")]
169 {
170 match self.storage {
171 CredentialStorage::Keyring => {
172 let entry = keyring::Entry::new(SERVICE_NAME, key)
173 .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
174 entry.set_password(value).map_err(|e| {
175 ConfigError::KeyringError(format!(
176 "Failed to store credential in keyring: {}",
177 e
178 ))
179 })?;
180 Ok(format!("{}{}", KEYRING_PREFIX, key))
182 }
183 CredentialStorage::Plaintext => Ok(value.to_string()),
184 }
185 }
186 #[cfg(not(feature = "secure-storage"))]
187 {
188 let _ = key; Ok(value.to_string())
191 }
192 }
193
194 pub fn get_credential(&self, value: &str, env_var: Option<&str>) -> Result<String> {
201 match env_var {
202 Some(env_var) => self.get_credential_with_environment(
203 value,
204 &[env_var],
205 EnvironmentOverrides::Enabled,
206 ),
207 None => self.get_credential_with_environment(value, &[], EnvironmentOverrides::Enabled),
208 }
209 }
210
211 pub fn get_credential_with_env_vars(&self, value: &str, env_vars: Vec<&str>) -> Result<String> {
215 self.get_credential_with_environment(value, &env_vars, EnvironmentOverrides::Enabled)
216 }
217
218 pub fn get_credential_with_environment(
224 &self,
225 value: &str,
226 env_vars: &[&str],
227 environment_overrides: EnvironmentOverrides,
228 ) -> Result<String> {
229 if environment_overrides == EnvironmentOverrides::Enabled {
230 for var in env_vars {
231 if let Ok(env_value) = env::var(var) {
232 return Ok(env_value);
233 }
234 }
235 }
236
237 if value.starts_with(KEYRING_PREFIX) {
239 #[cfg(feature = "secure-storage")]
240 {
241 let key = value.trim_start_matches(KEYRING_PREFIX);
242 let entry = keyring::Entry::new(SERVICE_NAME, key)
243 .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
244 entry.get_password().map_err(|e| match e {
245 keyring::Error::NoEntry => ConfigError::KeyringError(format!(
250 "credential '{key}' is no longer in the OS keyring, so any profile \
251 referencing it cannot be used until it is stored again. On Linux the \
252 keyring does not survive a reboot. Store it again with \
253 `redisctl profile set <name> --type <cloud|enterprise>`, or for a Redis \
254 Cloud profile sign in again with \
255 `redisctl --profile <name> cloud auth login`."
256 )),
257 other => ConfigError::KeyringError(format!(
258 "Failed to retrieve credential '{key}' from keyring: {other}"
259 )),
260 })
261 }
262 #[cfg(not(feature = "secure-storage"))]
263 {
264 Err(ConfigError::CredentialError(
265 "Credential references keyring but secure-storage feature is not enabled"
266 .to_string(),
267 ))
268 }
269 } else {
270 Ok(value.to_string())
272 }
273 }
274
275 pub fn delete_credential(&self, key: &str) -> Result<()> {
277 #[cfg(feature = "secure-storage")]
278 {
279 match self.storage {
280 CredentialStorage::Keyring => {
281 let entry = keyring::Entry::new(SERVICE_NAME, key)
282 .map_err(|e| ConfigError::KeyringError(e.to_string()))?;
283 match entry.delete_credential() {
284 Ok(()) => Ok(()),
285 Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(ConfigError::KeyringError(format!(
287 "Failed to delete credential from keyring: {}",
288 e
289 ))),
290 }
291 }
292 CredentialStorage::Plaintext => Ok(()), }
294 }
295 #[cfg(not(feature = "secure-storage"))]
296 {
297 let _ = key; Ok(()) }
300 }
301
302 pub fn is_keyring_reference(value: &str) -> bool {
304 value.starts_with(KEYRING_PREFIX)
305 }
306
307 pub fn storage_backend(&self) -> &str {
309 #[cfg(feature = "secure-storage")]
310 {
311 match self.storage {
312 CredentialStorage::Keyring => "keyring",
313 CredentialStorage::Plaintext => "plaintext",
314 }
315 }
316 #[cfg(not(feature = "secure-storage"))]
317 {
318 "plaintext"
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_plaintext_storage() {
329 let store = CredentialStore::new();
330
331 let result = store.get_credential("my-api-key", None).unwrap();
333 assert_eq!(result, "my-api-key");
334 }
335
336 #[test]
337 fn test_env_var_override() {
338 unsafe {
339 env::set_var("TEST_CREDENTIAL", "env-value");
340 }
341
342 let store = CredentialStore::new();
343 let result = store
344 .get_credential("config-value", Some("TEST_CREDENTIAL"))
345 .unwrap();
346 assert_eq!(result, "env-value");
347
348 unsafe {
349 env::remove_var("TEST_CREDENTIAL");
350 }
351 }
352
353 #[test]
354 #[serial_test::serial(credential_alias_env)]
355 fn test_env_var_alias_override_uses_first_available() {
356 unsafe {
357 env::set_var("TEST_CREDENTIAL_ALIAS_2", "alias-value");
358 }
359
360 let store = CredentialStore::new();
361 let result = store
362 .get_credential_with_env_vars(
363 "config-value",
364 vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
365 )
366 .unwrap();
367 assert_eq!(result, "alias-value");
368
369 unsafe {
370 env::remove_var("TEST_CREDENTIAL_ALIAS_2");
371 }
372 }
373
374 #[test]
375 #[serial_test::serial(credential_alias_env)]
376 fn test_env_var_alias_override_prefers_first_set() {
377 unsafe {
378 env::set_var("TEST_CREDENTIAL_ALIAS_1", "preferred-value");
379 env::set_var("TEST_CREDENTIAL_ALIAS_2", "fallback-value");
380 }
381
382 let store = CredentialStore::new();
383 let result = store
384 .get_credential_with_env_vars(
385 "config-value",
386 vec!["TEST_CREDENTIAL_ALIAS_1", "TEST_CREDENTIAL_ALIAS_2"],
387 )
388 .unwrap();
389 assert_eq!(result, "preferred-value");
390
391 unsafe {
392 env::remove_var("TEST_CREDENTIAL_ALIAS_1");
393 env::remove_var("TEST_CREDENTIAL_ALIAS_2");
394 }
395 }
396
397 #[test]
398 fn test_keyring_reference_detection() {
399 assert!(CredentialStore::is_keyring_reference("keyring:my-key"));
400 assert!(!CredentialStore::is_keyring_reference("my-key"));
401 assert!(!CredentialStore::is_keyring_reference(""));
402 }
403
404 #[cfg(feature = "secure-storage")]
405 #[test]
406 #[ignore = "Requires keyring service to be available"]
407 fn test_keyring_storage() {
408 let store = CredentialStore::new();
409
410 let key = "test-credential";
412 let value = "test-value";
413 let reference = store.store_credential(key, value).unwrap();
414
415 assert!(reference.starts_with(KEYRING_PREFIX));
417
418 let retrieved = store.get_credential(&reference, None).unwrap();
420 assert_eq!(retrieved, value);
421
422 let _ = store.delete_credential(key);
424 }
425
426 #[test]
428 fn probe_writable_is_a_no_op_for_plaintext() {
429 assert!(CredentialStore::plaintext().probe_writable().is_ok());
430 }
431
432 #[cfg(feature = "secure-storage")]
439 #[test]
440 #[ignore = "Requires keyring service to be available"]
441 fn probe_writable_accepts_a_working_keyring_repeatedly() {
442 let store = CredentialStore::new();
443 store.probe_writable().unwrap();
444 store.probe_writable().unwrap();
445
446 let reference = store.store_credential("probe-neighbour", "kept").unwrap();
448 assert_eq!(store.get_credential(&reference, None).unwrap(), "kept");
449 let _ = store.delete_credential("probe-neighbour");
450 }
451}