Skip to main content

oxirs/config/
secrets.rs

1//! Secret management for sensitive configuration data
2//!
3//! Provides secure storage and retrieval of sensitive information like
4//! API keys, passwords, and authentication tokens.
5
6use crate::cli::error::{CliError, CliResult};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs::{self, File};
10use std::io::{Read, Write};
11use std::os::unix::fs::PermissionsExt;
12use std::path::PathBuf;
13
14/// Secret manager for handling sensitive data
15pub struct SecretManager {
16    /// Secrets directory
17    secrets_dir: PathBuf,
18    /// In-memory cache of decrypted secrets
19    cache: HashMap<String, SecretValue>,
20    /// Encryption key (derived from master password or system keyring)
21    key: Option<Vec<u8>>,
22}
23
24/// A secret value with metadata
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SecretValue {
27    /// The actual secret value (encrypted when stored)
28    pub value: String,
29    /// Description of the secret
30    pub description: Option<String>,
31    /// Creation timestamp
32    pub created_at: chrono::DateTime<chrono::Utc>,
33    /// Last modified timestamp
34    pub updated_at: chrono::DateTime<chrono::Utc>,
35    /// Expiration time (if any)
36    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
37}
38
39/// Secret storage backend
40#[derive(Debug, Clone, Copy)]
41pub enum SecretBackend {
42    /// File-based storage (encrypted)
43    File,
44    /// System keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service)
45    SystemKeyring,
46    /// Environment variables
47    Environment,
48    /// HashiCorp Vault
49    Vault,
50}
51
52impl SecretManager {
53    /// Create a new secret manager
54    pub fn new(_backend: SecretBackend) -> CliResult<Self> {
55        let secrets_dir = Self::get_secrets_dir()?;
56        Self::with_dir(secrets_dir)
57    }
58
59    /// Create a new secret manager with a specific directory
60    ///
61    /// This is useful for testing and custom configurations where the
62    /// default directory is not suitable.
63    pub fn with_dir(secrets_dir: PathBuf) -> CliResult<Self> {
64        // Ensure secrets directory exists with restricted permissions
65        if !secrets_dir.exists() {
66            fs::create_dir_all(&secrets_dir).map_err(|e| {
67                CliError::config_error(format!("Cannot create secrets directory: {e}"))
68            })?;
69
70            // Set restrictive permissions (700 - owner only)
71            #[cfg(unix)]
72            {
73                let metadata = fs::metadata(&secrets_dir)?;
74                let mut permissions = metadata.permissions();
75                permissions.set_mode(0o700);
76                fs::set_permissions(&secrets_dir, permissions)?;
77            }
78        }
79
80        Ok(Self {
81            secrets_dir,
82            cache: HashMap::new(),
83            key: None,
84        })
85    }
86
87    /// Get the secrets directory
88    fn get_secrets_dir() -> CliResult<PathBuf> {
89        // Check environment variable first
90        if let Ok(dir) = std::env::var("OXIRS_SECRETS_DIR") {
91            return Ok(PathBuf::from(dir));
92        }
93
94        // Use platform-specific secure directory
95        #[cfg(target_os = "macos")]
96        let base_dir = dirs::home_dir().map(|h| h.join("Library/Application Support"));
97
98        #[cfg(target_os = "linux")]
99        let base_dir = dirs::config_dir();
100
101        #[cfg(target_os = "windows")]
102        let base_dir = dirs::data_local_dir();
103
104        base_dir
105            .map(|p| p.join("oxirs/secrets"))
106            .ok_or_else(|| CliError::config_error("Cannot determine secrets directory"))
107    }
108
109    /// Initialize with master password
110    pub fn unlock(&mut self, password: &str) -> CliResult<()> {
111        // Derive encryption key from password using a key derivation function
112        self.key = Some(self.derive_key(password)?);
113
114        // Try to decrypt a test secret to verify the password
115        if self.secrets_dir.join(".test").exists() {
116            self.get_secret(".test")?;
117        }
118
119        Ok(())
120    }
121
122    /// Check if the secret manager is unlocked
123    pub fn is_unlocked(&self) -> bool {
124        self.key.is_some()
125    }
126
127    /// Store a secret
128    pub fn set_secret(
129        &mut self,
130        name: &str,
131        value: &str,
132        description: Option<String>,
133    ) -> CliResult<()> {
134        if !self.is_unlocked() {
135            return Err(CliError::config_error("Secret manager is locked"));
136        }
137
138        let secret = SecretValue {
139            value: value.to_string(),
140            description,
141            created_at: chrono::Utc::now(),
142            updated_at: chrono::Utc::now(),
143            expires_at: None,
144        };
145
146        // Encrypt and store
147        self.store_encrypted_secret(name, &secret)?;
148
149        // Update cache
150        self.cache.insert(name.to_string(), secret);
151
152        Ok(())
153    }
154
155    /// Retrieve a secret
156    pub fn get_secret(&mut self, name: &str) -> CliResult<String> {
157        // Check cache first
158        if let Some(secret) = self.cache.get(name) {
159            // Check expiration
160            if let Some(expires) = secret.expires_at {
161                if expires < chrono::Utc::now() {
162                    self.cache.remove(name);
163                    return Err(CliError::config_error("Secret has expired"));
164                }
165            }
166            return Ok(secret.value.clone());
167        }
168
169        // Try environment variable
170        let env_name = format!("OXIRS_SECRET_{}", name.to_uppercase().replace('-', "_"));
171        if let Ok(value) = std::env::var(&env_name) {
172            return Ok(value);
173        }
174
175        // Load from encrypted storage
176        if !self.is_unlocked() {
177            return Err(CliError::config_error("Secret manager is locked"));
178        }
179
180        let secret = self.load_encrypted_secret(name)?;
181
182        // Check expiration
183        if let Some(expires) = secret.expires_at {
184            if expires < chrono::Utc::now() {
185                return Err(CliError::config_error("Secret has expired"));
186            }
187        }
188
189        let value = secret.value.clone();
190        self.cache.insert(name.to_string(), secret);
191
192        Ok(value)
193    }
194
195    /// Delete a secret
196    pub fn delete_secret(&mut self, name: &str) -> CliResult<()> {
197        self.cache.remove(name);
198
199        let path = self.secrets_dir.join(format!("{name}.secret"));
200        if path.exists() {
201            fs::remove_file(path)
202                .map_err(|e| CliError::config_error(format!("Cannot delete secret: {e}")))?;
203        }
204
205        Ok(())
206    }
207
208    /// List all secrets (names only, not values)
209    pub fn list_secrets(&self) -> CliResult<Vec<SecretInfo>> {
210        let mut secrets = Vec::new();
211
212        if self.secrets_dir.exists() {
213            for entry in fs::read_dir(&self.secrets_dir)? {
214                let entry = entry?;
215                let path = entry.path();
216
217                if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
218                    if path.extension().and_then(|e| e.to_str()) == Some("secret")
219                        && name != ".test"
220                    {
221                        // Try to load metadata without decrypting value
222                        if let Ok(metadata) = self.load_secret_metadata(name) {
223                            secrets.push(metadata);
224                        }
225                    }
226                }
227            }
228        }
229
230        // Also list environment variable secrets
231        for (key, _) in std::env::vars() {
232            if key.starts_with("OXIRS_SECRET_") {
233                let name = key
234                    .strip_prefix("OXIRS_SECRET_")
235                    .expect("prefix should match after starts_with check")
236                    .to_lowercase()
237                    .replace('_', "-");
238
239                secrets.push(SecretInfo {
240                    name,
241                    description: Some("Environment variable".to_string()),
242                    created_at: None,
243                    expires_at: None,
244                    source: SecretSource::Environment,
245                });
246            }
247        }
248
249        secrets.sort_by(|a, b| a.name.cmp(&b.name));
250        Ok(secrets)
251    }
252
253    /// Derive encryption key from password
254    ///
255    /// Uses PBKDF2-HMAC-SHA256 with 100,000 iterations and a fixed salt. These
256    /// parameters (algorithm, iteration count, salt, and 32-byte output length)
257    /// are kept identical to the previous `ring::pbkdf2` implementation so that
258    /// keys derived before the OxiCrypto migration still decrypt existing
259    /// on-disk secrets.
260    fn derive_key(&self, password: &str) -> CliResult<Vec<u8>> {
261        let salt = b"oxirs-secret-salt"; // In production, use a random salt stored separately
262        let mut key = vec![0u8; 32];
263
264        oxicrypto_kdf::pbkdf2_sha256(password.as_bytes(), salt, 100_000, &mut key)
265            .map_err(|e| CliError::config_error(format!("Key derivation failed: {e}")))?;
266
267        Ok(key)
268    }
269
270    /// Encrypt and store a secret
271    fn store_encrypted_secret(&self, name: &str, secret: &SecretValue) -> CliResult<()> {
272        use oxicrypto_core::Aead;
273
274        let key = self
275            .key
276            .as_ref()
277            .ok_or_else(|| CliError::config_error("No encryption key available"))?;
278
279        // Serialize secret
280        let plaintext = serde_json::to_vec(secret)
281            .map_err(|e| CliError::config_error(format!("Cannot serialize secret: {e}")))?;
282
283        // Encrypt using AES-256-GCM (OxiCrypto, Pure Rust).
284        //
285        // On-disk layout is unchanged from the previous `ring` implementation:
286        // `seal_to_vec` returns `ciphertext || tag` (16-byte GCM tag appended),
287        // a fixed all-zero 12-byte nonce is used, and the AAD is empty.
288        let nonce = [0u8; 12]; // In production, use random nonce
289        let ciphertext = oxicrypto_aead::Aes256Gcm
290            .seal_to_vec(key, &nonce, &[], &plaintext)
291            .map_err(|_| CliError::config_error("Encryption failed"))?;
292
293        // Write to file
294        let path = self.secrets_dir.join(format!("{name}.secret"));
295        let mut file = File::create(&path)
296            .map_err(|e| CliError::config_error(format!("Cannot create secret file: {e}")))?;
297
298        // Set restrictive permissions
299        #[cfg(unix)]
300        {
301            let metadata = file.metadata()?;
302            let mut permissions = metadata.permissions();
303            permissions.set_mode(0o600);
304            file.set_permissions(permissions)?;
305        }
306
307        file.write_all(&ciphertext)
308            .map_err(|e| CliError::config_error(format!("Cannot write secret: {e}")))?;
309
310        Ok(())
311    }
312
313    /// Load and decrypt a secret
314    fn load_encrypted_secret(&self, name: &str) -> CliResult<SecretValue> {
315        use oxicrypto_core::Aead;
316
317        let key = self
318            .key
319            .as_ref()
320            .ok_or_else(|| CliError::config_error("No decryption key available"))?;
321
322        // Read encrypted data
323        let path = self.secrets_dir.join(format!("{name}.secret"));
324        let mut file = File::open(&path)
325            .map_err(|_| CliError::config_error(format!("Secret '{name}' not found")))?;
326
327        let mut ciphertext = Vec::new();
328        file.read_to_end(&mut ciphertext)
329            .map_err(|e| CliError::config_error(format!("Cannot read secret: {e}")))?;
330
331        // Decrypt using AES-256-GCM (OxiCrypto, Pure Rust).
332        //
333        // The file holds `ciphertext || tag`; `open_to_vec` verifies the
334        // appended 16-byte GCM tag and returns the plaintext. The fixed all-zero
335        // 12-byte nonce and empty AAD mirror `store_encrypted_secret`.
336        let nonce = [0u8; 12];
337        let plaintext = oxicrypto_aead::Aes256Gcm
338            .open_to_vec(key, &nonce, &[], &ciphertext)
339            .map_err(|_| CliError::config_error("Decryption failed - wrong password?"))?;
340
341        // Deserialize secret
342        serde_json::from_slice(&plaintext)
343            .map_err(|e| CliError::config_error(format!("Cannot deserialize secret: {e}")))
344    }
345
346    /// Load secret metadata without decrypting the value
347    fn load_secret_metadata(&self, name: &str) -> CliResult<SecretInfo> {
348        // For now, return basic info
349        // In a real implementation, we'd store metadata separately
350        Ok(SecretInfo {
351            name: name.to_string(),
352            description: None,
353            created_at: None,
354            expires_at: None,
355            source: SecretSource::File,
356        })
357    }
358}
359
360/// Information about a secret (without the actual value)
361#[derive(Debug, Clone)]
362pub struct SecretInfo {
363    pub name: String,
364    pub description: Option<String>,
365    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
366    pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
367    pub source: SecretSource,
368}
369
370/// Source of a secret
371#[derive(Debug, Clone, Copy)]
372pub enum SecretSource {
373    File,
374    Environment,
375    SystemKeyring,
376}
377
378/// Integration with system keyring.
379///
380/// Requires the `system-keyring` Cargo feature:
381/// ```text
382/// cargo build --features system-keyring
383/// ```
384///
385/// The underlying [`keyring`](https://docs.rs/keyring) crate delegates to the
386/// platform's native credential store:
387/// - **macOS** — Security framework (Keychain)
388/// - **Windows** — Credential Manager
389/// - **Linux** — Secret Service API (e.g. GNOME Keyring / KWallet)
390pub mod keyring {
391    use super::*;
392
393    /// Store a secret in the system keyring.
394    ///
395    /// On macOS this uses the Keychain Services API via `apple-native-keyring-store`.
396    /// The default credential store is initialised lazily on first call.
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if the `system-keyring` feature is not enabled or if
401    /// the OS credential store rejects the operation.
402    #[cfg(feature = "system-keyring")]
403    pub fn store_in_keyring(service: &str, name: &str, value: &str) -> CliResult<()> {
404        init_default_store()?;
405        let entry = keyring_core::Entry::new(service, name).map_err(|e| {
406            CliError::config_error(format!(
407                "System keyring error creating entry '{name}' for service '{service}': {e}"
408            ))
409        })?;
410        entry.set_password(value).map_err(|e| {
411            CliError::config_error(format!(
412                "System keyring error storing secret '{name}' for service '{service}': {e}"
413            ))
414        })
415    }
416
417    #[cfg(not(feature = "system-keyring"))]
418    pub fn store_in_keyring(_service: &str, _name: &str, _value: &str) -> CliResult<()> {
419        Err(CliError::config_error(
420            "System keyring requires the 'system-keyring' feature flag. \
421             Rebuild with: cargo build --features system-keyring",
422        ))
423    }
424
425    /// Retrieve a secret from the system keyring.
426    ///
427    /// # Errors
428    ///
429    /// Returns an error if the `system-keyring` feature is not enabled, if the
430    /// secret is not found, or if the OS credential store returns an error.
431    #[cfg(feature = "system-keyring")]
432    pub fn get_from_keyring(service: &str, name: &str) -> CliResult<String> {
433        init_default_store()?;
434        let entry = keyring_core::Entry::new(service, name).map_err(|e| {
435            CliError::config_error(format!(
436                "System keyring error creating entry '{name}' for service '{service}': {e}"
437            ))
438        })?;
439        entry.get_password().map_err(|e| {
440            CliError::config_error(format!(
441                "System keyring error retrieving secret '{name}' for service '{service}': {e}"
442            ))
443        })
444    }
445
446    #[cfg(not(feature = "system-keyring"))]
447    pub fn get_from_keyring(_service: &str, _name: &str) -> CliResult<String> {
448        Err(CliError::config_error(
449            "System keyring requires the 'system-keyring' feature flag. \
450             Rebuild with: cargo build --features system-keyring",
451        ))
452    }
453
454    /// Initialise the default keyring-core credential store.
455    ///
456    /// Uses the macOS Keychain on macOS and the `keyring-core` sample store
457    /// (in-memory, non-persistent) on other platforms for testing purposes.
458    ///
459    /// Safe to call multiple times — the store is only set once.
460    #[cfg(feature = "system-keyring")]
461    fn init_default_store() -> CliResult<()> {
462        use std::sync::{Mutex, Once};
463        static INIT: Once = Once::new();
464        static INIT_ERR: Mutex<Option<String>> = Mutex::new(None);
465
466        INIT.call_once(|| {
467            let result: CliResult<()> = (|| {
468                #[cfg(target_os = "macos")]
469                {
470                    use apple_native_keyring_store::keychain::Store;
471                    let store = Store::new_with_configuration(&std::collections::HashMap::new())
472                        .map_err(|e| {
473                            CliError::config_error(format!(
474                                "Failed to initialise macOS Keychain store: {e}"
475                            ))
476                        })?;
477                    keyring_core::set_default_store(store);
478                }
479                #[cfg(not(target_os = "macos"))]
480                {
481                    // Fall back to the in-memory sample store on non-macOS platforms
482                    // (useful for CI/testing; not persistent across processes)
483                    use keyring_core::sample::Store;
484                    let store =
485                        Store::new_with_configuration(&std::collections::HashMap::from([(
486                            "persist", "true",
487                        )]))
488                        .map_err(|e| {
489                            CliError::config_error(format!(
490                                "Failed to initialise keyring sample store: {e}"
491                            ))
492                        })?;
493                    keyring_core::set_default_store(store);
494                }
495                Ok(())
496            })();
497            if let Err(e) = result {
498                if let Ok(mut guard) = INIT_ERR.lock() {
499                    *guard = Some(e.to_string());
500                }
501            }
502        });
503
504        // Surface any initialisation error that was captured inside call_once
505        match INIT_ERR.lock() {
506            Ok(guard) => match guard.as_ref() {
507                Some(msg) => Err(CliError::config_error(format!(
508                    "System keyring initialisation failed: {msg}"
509                ))),
510                None => Ok(()),
511            },
512            Err(_) => Err(CliError::config_error(
513                "Internal error: keyring init mutex poisoned",
514            )),
515        }
516    }
517}
518
519/// Secure credential helpers
520pub mod credentials {
521    use super::*;
522
523    /// SPARQL endpoint credentials
524    #[derive(Debug, Clone, Serialize, Deserialize)]
525    pub struct EndpointCredentials {
526        pub url: String,
527        pub username: Option<String>,
528        pub password: Option<String>,
529        pub auth_type: AuthType,
530    }
531
532    #[derive(Debug, Clone, Serialize, Deserialize)]
533    pub enum AuthType {
534        None,
535        Basic,
536        Bearer,
537        OAuth2,
538    }
539
540    /// Get credentials for a SPARQL endpoint
541    pub fn get_endpoint_credentials(
542        manager: &mut SecretManager,
543        url: &str,
544    ) -> CliResult<EndpointCredentials> {
545        let secret_name = format!("endpoint_{}", url.replace(['/', ':'], "_"));
546
547        if let Ok(creds_json) = manager.get_secret(&secret_name) {
548            serde_json::from_str(&creds_json)
549                .map_err(|e| CliError::config_error(format!("Invalid credentials format: {e}")))
550        } else {
551            Ok(EndpointCredentials {
552                url: url.to_string(),
553                username: None,
554                password: None,
555                auth_type: AuthType::None,
556            })
557        }
558    }
559
560    /// Store credentials for a SPARQL endpoint
561    pub fn store_endpoint_credentials(
562        manager: &mut SecretManager,
563        creds: &EndpointCredentials,
564    ) -> CliResult<()> {
565        let secret_name = format!("endpoint_{}", creds.url.replace(['/', ':'], "_"));
566        let creds_json = serde_json::to_string(creds)
567            .map_err(|e| CliError::config_error(format!("Cannot serialize credentials: {e}")))?;
568
569        manager.set_secret(
570            &secret_name,
571            &creds_json,
572            Some(format!("Credentials for {}", creds.url)),
573        )
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use tempfile::tempdir;
581
582    #[test]
583    fn test_secret_manager_creation() {
584        let dir = tempdir().expect("failed to create temp dir");
585        let manager = SecretManager::with_dir(dir.path().to_path_buf())
586            .expect("failed to create SecretManager");
587        assert!(!manager.is_unlocked());
588    }
589
590    #[test]
591    fn test_secret_storage_and_retrieval() {
592        let dir = tempdir().expect("failed to create temp dir");
593        let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
594            .expect("failed to create SecretManager");
595        manager.unlock("test-password").expect("failed to unlock");
596
597        // Store a secret
598        manager
599            .set_secret("test-key", "test-value", Some("Test secret".to_string()))
600            .expect("failed to set secret");
601
602        // Retrieve it
603        let value = manager
604            .get_secret("test-key")
605            .expect("failed to get secret");
606        assert_eq!(value, "test-value");
607
608        // List secrets
609        let secrets = manager.list_secrets().expect("failed to list secrets");
610        assert!(secrets.iter().any(|s| s.name == "test-key"));
611    }
612
613    #[test]
614    fn test_pbkdf2_derive_is_deterministic_and_correct_length() {
615        // The migrated PBKDF2-SHA256 derivation must be stable for a given
616        // password (same salt/iterations) and produce a 32-byte key, matching
617        // the legacy `ring::pbkdf2` parameters so old secrets still decrypt.
618        let dir = tempdir().expect("failed to create temp dir");
619        let manager = SecretManager::with_dir(dir.path().to_path_buf())
620            .expect("failed to create SecretManager");
621
622        let key_a = manager
623            .derive_key("correct horse battery staple")
624            .expect("derive_key failed");
625        let key_b = manager
626            .derive_key("correct horse battery staple")
627            .expect("derive_key failed");
628        let key_other = manager
629            .derive_key("a different password")
630            .expect("derive_key failed");
631
632        assert_eq!(key_a.len(), 32, "derived key must be 32 bytes for AES-256");
633        assert_eq!(key_a, key_b, "PBKDF2 derivation must be deterministic");
634        assert_ne!(
635            key_a, key_other,
636            "different passwords must derive different keys"
637        );
638
639        // Cross-check against the leaf crate directly with identical parameters.
640        let mut expected = vec![0u8; 32];
641        oxicrypto_kdf::pbkdf2_sha256(
642            b"correct horse battery staple",
643            b"oxirs-secret-salt",
644            100_000,
645            &mut expected,
646        )
647        .expect("pbkdf2_sha256 failed");
648        assert_eq!(
649            key_a, expected,
650            "derive_key must match raw pbkdf2_sha256 output"
651        );
652    }
653
654    #[test]
655    fn test_encrypt_decrypt_round_trip_on_disk() {
656        // Prove the migrated AES-256-GCM seal/open paths produce and consume the
657        // same on-disk format (nonce-implicit, ciphertext || 16-byte tag).
658        let dir = tempdir().expect("failed to create temp dir");
659        let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
660            .expect("failed to create SecretManager");
661        manager
662            .unlock("round-trip-password")
663            .expect("unlock failed");
664
665        manager
666            .set_secret(
667                "db-password",
668                "s3cr3t-value-with-unicode-\u{1F600}",
669                Some("integration secret".to_string()),
670            )
671            .expect("set_secret failed");
672
673        // The encrypted file must contain at least the GCM tag (16 bytes) more
674        // than the empty payload and must not contain the plaintext.
675        let secret_path = dir.path().join("db-password.secret");
676        let raw = std::fs::read(&secret_path).expect("failed to read secret file");
677        assert!(
678            raw.len() >= 16,
679            "ciphertext must include the 16-byte GCM tag"
680        );
681        assert!(
682            !raw.windows(7).any(|w| w == b"s3cr3t-"),
683            "plaintext must not appear in the encrypted file"
684        );
685
686        // Drop the in-memory cache to force a real decrypt-from-disk on read.
687        manager.cache.clear();
688        let value = manager
689            .get_secret("db-password")
690            .expect("get_secret failed");
691        assert_eq!(value, "s3cr3t-value-with-unicode-\u{1F600}");
692    }
693
694    #[test]
695    fn test_decrypt_with_wrong_password_fails() {
696        // A secret written under one password must fail to decrypt under
697        // another (GCM tag verification rejects the tampered/derived key).
698        let dir = tempdir().expect("failed to create temp dir");
699
700        {
701            let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
702                .expect("failed to create SecretManager");
703            manager.unlock("first-password").expect("unlock failed");
704            manager
705                .set_secret("token", "abc123", None)
706                .expect("set_secret failed");
707        }
708
709        let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
710            .expect("failed to create SecretManager");
711        manager.unlock("wrong-password").expect("unlock failed");
712        let result = manager.get_secret("token");
713        assert!(
714            result.is_err(),
715            "decryption with the wrong password must fail"
716        );
717    }
718
719    #[test]
720    fn test_environment_secret() {
721        // Note: This test uses unsafe env::set_var/remove_var because it specifically
722        // tests the environment variable fallback behavior. This is safe in test mode
723        // as cargo test runs tests in separate threads with proper synchronization.
724        // SAFETY: Tests are run with --test-threads=1 by default or isolated by cargo.
725        unsafe { std::env::set_var("OXIRS_SECRET_API_KEY", "secret-api-key") };
726
727        let dir = tempdir().expect("failed to create temp dir");
728        let mut manager = SecretManager::with_dir(dir.path().to_path_buf())
729            .expect("failed to create SecretManager");
730        let value = manager.get_secret("api-key").expect("failed to get secret");
731        assert_eq!(value, "secret-api-key");
732
733        // SAFETY: Cleaning up test environment
734        unsafe { std::env::remove_var("OXIRS_SECRET_API_KEY") };
735    }
736
737    // ------------------------------------------------------------------
738    // System keyring tests
739    // ------------------------------------------------------------------
740
741    /// Without the `system-keyring` feature the functions must return a
742    /// descriptive error mentioning the flag to enable.
743    #[cfg(not(feature = "system-keyring"))]
744    #[test]
745    fn test_keyring_store_requires_feature_flag() {
746        let result = keyring::store_in_keyring("oxirs-test", "test-key", "test-value");
747        assert!(result.is_err());
748        let msg = result.unwrap_err().to_string();
749        assert!(
750            msg.contains("system-keyring"),
751            "error should mention the feature flag, got: {msg}"
752        );
753    }
754
755    #[cfg(not(feature = "system-keyring"))]
756    #[test]
757    fn test_keyring_get_requires_feature_flag() {
758        let result = keyring::get_from_keyring("oxirs-test", "test-key");
759        assert!(result.is_err());
760        let msg = result.unwrap_err().to_string();
761        assert!(
762            msg.contains("system-keyring"),
763            "error should mention the feature flag, got: {msg}"
764        );
765    }
766
767    /// With the `system-keyring` feature a round-trip store → retrieve must
768    /// succeed. The test deletes the credential afterwards to leave the OS
769    /// keyring clean.
770    #[cfg(feature = "system-keyring")]
771    #[test]
772    fn test_keyring_round_trip() {
773        let service = "oxirs-test-service";
774        let key = "oxirs-test-round-trip-key";
775        let value = "super-secret-value-42";
776
777        keyring::store_in_keyring(service, key, value).expect("keyring store failed");
778
779        let retrieved = keyring::get_from_keyring(service, key).expect("keyring retrieve failed");
780        assert_eq!(retrieved, value, "retrieved value must equal stored value");
781
782        // Clean up: delete the test credential from the OS keyring
783        if let Ok(entry) = keyring_core::Entry::new(service, key) {
784            let _ = entry.delete_credential();
785        }
786    }
787}