Skip to main content

dotenvage/
manager.rs

1//! Secret manager implementation for encryption and decryption using age.
2//!
3//! This module provides the core [`SecretManager`] type for encrypting and
4//! decrypting sensitive values using the
5//! [age encryption tool](https://age-encryption.org/).
6//!
7//! It also provides types for managing key storage across user-level and
8//! system-level credential stores, enabling daemon processes to access
9//! encryption keys without embedded secrets.
10
11use std::io::{
12    Read,
13    Write,
14};
15use std::path::{
16    Path,
17    PathBuf,
18};
19
20use age::secrecy::ExposeSecret;
21use age::x25519;
22use base64::Engine as _;
23
24use crate::error::{
25    SecretsError,
26    SecretsResult,
27};
28
29/// Target credential store for key operations.
30///
31/// Controls where keys are saved and loaded from. Used with
32/// [`SecretManager::generate_and_save`] and related methods.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum KeyStoreTarget {
35    /// User-level OS credential store:
36    /// - macOS: Login Keychain
37    /// - Linux: kernel keyutils
38    /// - Windows: Credential Manager
39    OsKeychain,
40    /// System-level store for daemon processes:
41    /// - macOS: System Keychain (`/Library/Keychains/System.keychain`)
42    /// - Linux: `/etc/dotenvage/<key-name>.key`
43    /// - Windows: `%ProgramData%\dotenvage\<key-name>.key`
44    ///
45    /// Requires elevated privileges (sudo/admin) to write.
46    SystemStore,
47    /// Key file on disk at the XDG-compliant path.
48    File,
49    /// Both user-level OS keychain and file.
50    OsKeychainAndFile,
51}
52
53/// Describes where a key was saved.
54#[derive(Debug, Clone)]
55pub enum KeyLocation {
56    /// Saved to the user-level OS keychain.
57    OsKeychain {
58        /// The service name used for the keychain entry.
59        service: String,
60        /// The account name used for the keychain entry.
61        account: String,
62    },
63    /// Saved to the macOS System Keychain.
64    SystemKeychain {
65        /// The service name used for the keychain entry.
66        service: String,
67        /// The account name used for the keychain entry.
68        account: String,
69    },
70    /// Saved to a system-level protected file (Linux/Windows).
71    SystemFile(PathBuf),
72    /// Saved to a user-level key file.
73    UserFile(PathBuf),
74}
75
76/// Options for key generation via [`SecretManager::generate_and_save`].
77#[derive(Debug, Clone)]
78pub struct KeyGenOptions {
79    /// Where to save the generated key.
80    pub target: KeyStoreTarget,
81    /// Explicit key name (overrides `AGE_KEY_NAME` and `.env`
82    /// file discovery). Example: `"ekg/wwkg"`.
83    pub key_name: Option<String>,
84    /// Explicit file path (overrides XDG path derivation).
85    /// Only used when target includes [`KeyStoreTarget::File`].
86    pub file_path: Option<PathBuf>,
87    /// Overwrite existing key if present.
88    pub force: bool,
89}
90
91/// Result of a key generation operation.
92pub struct KeyGenResult {
93    /// The manager holding the generated key.
94    pub manager: SecretManager,
95    /// Where the key was persisted.
96    pub locations: Vec<KeyLocation>,
97    /// Public key string (`age1...`).
98    pub public_key: String,
99}
100
101impl std::fmt::Debug for KeyGenResult {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("KeyGenResult")
104            .field("locations", &self.locations)
105            .field("public_key", &self.public_key)
106            .finish_non_exhaustive()
107    }
108}
109
110/// Manages encryption and decryption of secrets using age/X25519.
111///
112/// `SecretManager` provides a simple interface for encrypting and decrypting
113/// sensitive values. It uses the age encryption format with X25519 keys.
114///
115/// Encrypted values are stored in the compact format: `ENC[AGE:b64:...]`
116///
117/// # Examples
118///
119/// ```rust
120/// use dotenvage::SecretManager;
121///
122/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
123/// // Generate a new key
124/// let manager = SecretManager::generate()?;
125///
126/// // Encrypt a value
127/// let encrypted = manager.encrypt_value("my-secret-token")?;
128/// assert!(SecretManager::is_encrypted(&encrypted));
129///
130/// // Decrypt it back
131/// let decrypted = manager.decrypt_value(&encrypted)?;
132/// assert_eq!(decrypted, "my-secret-token");
133/// # Ok(())
134/// # }
135/// ```
136#[derive(Clone)]
137pub struct SecretManager {
138    identity: x25519::Identity,
139}
140
141trait KeyBackend {
142    fn load_identity_string(&self) -> SecretsResult<Option<String>>;
143    fn save_identity_string(&self, _identity: &str) -> SecretsResult<()> {
144        Err(SecretsError::KeySaveFailed(
145            "save operation not implemented for this backend".to_string(),
146        ))
147    }
148}
149
150struct FileKeyBackend {
151    path: PathBuf,
152}
153
154impl FileKeyBackend {
155    fn new(path: PathBuf) -> Self {
156        Self { path }
157    }
158}
159
160impl KeyBackend for FileKeyBackend {
161    fn load_identity_string(&self) -> SecretsResult<Option<String>> {
162        if !self.path.exists() {
163            return Ok(None);
164        }
165
166        let key_data = std::fs::read_to_string(&self.path).map_err(|e| {
167            SecretsError::KeyLoadFailed(format!("read {}: {}", self.path.display(), e))
168        })?;
169        Ok(Some(key_data))
170    }
171
172    fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
173        if let Some(parent) = self.path.parent() {
174            std::fs::create_dir_all(parent).map_err(|e| {
175                SecretsError::KeySaveFailed(format!("create dir {}: {}", parent.display(), e))
176            })?;
177        }
178
179        std::fs::write(&self.path, identity.as_bytes()).map_err(|e| {
180            SecretsError::KeySaveFailed(format!("write {}: {}", self.path.display(), e))
181        })?;
182
183        #[cfg(unix)]
184        {
185            use std::os::unix::fs::PermissionsExt;
186            let mut perms = std::fs::metadata(&self.path)
187                .map_err(|e| {
188                    SecretsError::KeySaveFailed(format!("metadata {}: {}", self.path.display(), e))
189                })?
190                .permissions();
191            perms.set_mode(0o600);
192            std::fs::set_permissions(&self.path, perms).map_err(|e| {
193                SecretsError::KeySaveFailed(format!("chmod {}: {}", self.path.display(), e))
194            })?;
195        }
196
197        Ok(())
198    }
199}
200
201struct OsKeychainBackend {
202    service: String,
203    account: String,
204}
205
206impl OsKeychainBackend {
207    fn new(service: String, account: String) -> Self {
208        Self { service, account }
209    }
210}
211
212impl KeyBackend for OsKeychainBackend {
213    fn load_identity_string(&self) -> SecretsResult<Option<String>> {
214        load_from_os_keychain(&self.service, &self.account)
215    }
216
217    fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
218        save_to_os_keychain(&self.service, &self.account, identity)
219    }
220}
221
222fn normalize_key_data(data: &str) -> Option<String> {
223    let trimmed = data.trim();
224    if trimmed.is_empty() {
225        return None;
226    }
227    Some(trimmed.to_string())
228}
229
230#[cfg(feature = "os-keychain")]
231fn load_from_os_keychain(service: &str, account: &str) -> SecretsResult<Option<String>> {
232    let entry = match keyring::Entry::new(service, account) {
233        Ok(e) => e,
234        Err(_) => return Ok(None),
235    };
236    match entry.get_password() {
237        Ok(password) => Ok(normalize_key_data(&password)),
238        Err(keyring::Error::NoEntry) => Ok(None),
239        Err(keyring::Error::PlatformFailure(_)) => Ok(None),
240        Err(e) => Err(SecretsError::KeyLoadFailed(format!(
241            "OS keychain read failed (service='{}', account='{}'): {}",
242            service, account, e
243        ))),
244    }
245}
246
247#[cfg(not(feature = "os-keychain"))]
248fn load_from_os_keychain(_service: &str, _account: &str) -> SecretsResult<Option<String>> {
249    Ok(None)
250}
251
252#[cfg(feature = "os-keychain")]
253fn save_to_os_keychain(service: &str, account: &str, identity: &str) -> SecretsResult<()> {
254    let entry = keyring::Entry::new(service, account).map_err(|e| {
255        SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
256    })?;
257    entry.set_password(identity).map_err(|e| {
258        SecretsError::KeySaveFailed(format!(
259            "failed to save to OS keychain (service='{}', account='{}'): {}",
260            service, account, e
261        ))
262    })
263}
264
265#[cfg(not(feature = "os-keychain"))]
266fn save_to_os_keychain(_service: &str, _account: &str, _identity: &str) -> SecretsResult<()> {
267    Err(SecretsError::KeySaveFailed(
268        "OS keychain support not compiled (enable 'os-keychain' feature)".to_string(),
269    ))
270}
271
272#[cfg(feature = "os-keychain")]
273fn delete_from_os_keychain(service: &str, account: &str) -> SecretsResult<()> {
274    let entry = keyring::Entry::new(service, account).map_err(|e| {
275        SecretsError::KeySaveFailed(format!("failed to create keychain entry: {}", e))
276    })?;
277    match entry.delete_credential() {
278        Ok(()) => Ok(()),
279        Err(keyring::Error::NoEntry) => Ok(()),
280        Err(e) => Err(SecretsError::KeySaveFailed(format!(
281            "failed to delete from OS keychain (service='{}', account='{}'): {}",
282            service, account, e
283        ))),
284    }
285}
286
287// ── System store backend ─────────────────────────────────────
288
289struct SystemStoreBackend {
290    key_name: String,
291}
292
293impl SystemStoreBackend {
294    fn new(key_name: String) -> Self {
295        Self { key_name }
296    }
297
298    #[allow(dead_code)]
299    fn path(&self) -> PathBuf {
300        system_store_path_for(&self.key_name)
301    }
302}
303
304impl KeyBackend for SystemStoreBackend {
305    fn load_identity_string(&self) -> SecretsResult<Option<String>> {
306        load_from_system_store_impl(&self.key_name)
307    }
308
309    fn save_identity_string(&self, identity: &str) -> SecretsResult<()> {
310        save_to_system_store_impl(&self.key_name, identity)
311    }
312}
313
314/// Returns the system store path for a given key name.
315///
316/// - macOS: `/Library/Keychains/System.keychain` (no file path; uses the System
317///   Keychain API)
318/// - Linux: `/etc/dotenvage/<key-name>.key`
319/// - Windows: `%ProgramData%\dotenvage\<key-name>.key`
320fn system_store_path_for(_key_name: &str) -> PathBuf {
321    #[cfg(target_os = "linux")]
322    {
323        PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
324    }
325
326    #[cfg(target_os = "windows")]
327    {
328        let base = std::env::var("ProgramData").unwrap_or_else(|_| r"C:\ProgramData".to_string());
329        PathBuf::from(base)
330            .join("dotenvage")
331            .join(format!("{}.key", _key_name))
332    }
333
334    #[cfg(target_os = "macos")]
335    {
336        // macOS uses System Keychain, not a file path.
337        // Return a sentinel path for display purposes.
338        PathBuf::from("/Library/Keychains/System.keychain")
339    }
340
341    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
342    {
343        PathBuf::from("/etc/dotenvage").join(format!("{}.key", _key_name))
344    }
345}
346
347fn load_from_system_store_impl(key_name: &str) -> SecretsResult<Option<String>> {
348    #[cfg(target_os = "macos")]
349    {
350        load_from_macos_system_keychain(key_name)
351    }
352
353    #[cfg(not(target_os = "macos"))]
354    {
355        let path = system_store_path_for(key_name);
356        if !path.exists() {
357            return Ok(None);
358        }
359        let data = std::fs::read_to_string(&path)
360            .map_err(|e| SecretsError::KeyLoadFailed(format!("read {}: {}", path.display(), e)))?;
361        Ok(normalize_key_data(&data))
362    }
363}
364
365fn save_to_system_store_impl(key_name: &str, identity: &str) -> SecretsResult<()> {
366    #[cfg(target_os = "macos")]
367    {
368        save_to_macos_system_keychain(key_name, identity)
369    }
370
371    #[cfg(not(target_os = "macos"))]
372    {
373        let path = system_store_path_for(key_name);
374        if let Some(parent) = path.parent() {
375            std::fs::create_dir_all(parent).map_err(|e| {
376                if e.kind() == std::io::ErrorKind::PermissionDenied {
377                    return SecretsError::InsufficientPrivileges(format!(
378                        "cannot create {}: {} (try with sudo/admin)",
379                        parent.display(),
380                        e
381                    ));
382                }
383                SecretsError::KeySaveFailed(format!("create dir {}: {}", parent.display(), e))
384            })?;
385        }
386        std::fs::write(&path, identity.as_bytes()).map_err(|e| {
387            if e.kind() == std::io::ErrorKind::PermissionDenied {
388                return SecretsError::InsufficientPrivileges(format!(
389                    "cannot write {}: {} (try with sudo/admin)",
390                    path.display(),
391                    e
392                ));
393            }
394            SecretsError::KeySaveFailed(format!("write {}: {}", path.display(), e))
395        })?;
396
397        #[cfg(unix)]
398        {
399            use std::os::unix::fs::PermissionsExt;
400            let mut perms = std::fs::metadata(&path)
401                .map_err(|e| {
402                    SecretsError::KeySaveFailed(format!("metadata {}: {}", path.display(), e))
403                })?
404                .permissions();
405            perms.set_mode(0o600);
406            std::fs::set_permissions(&path, perms).map_err(|e| {
407                SecretsError::KeySaveFailed(format!("chmod {}: {}", path.display(), e))
408            })?;
409        }
410
411        Ok(())
412    }
413}
414
415/// Resolve the home directory for a given username.
416fn resolve_user_home(username: &str) -> SecretsResult<PathBuf> {
417    #[cfg(unix)]
418    {
419        use nix::unistd::User;
420
421        let user = User::from_name(username).map_err(|e| {
422            SecretsError::KeyLoadFailed(format!("failed to look up user '{}': {}", username, e))
423        })?;
424        match user {
425            Some(u) => Ok(u.dir),
426            None => Err(SecretsError::KeyLoadFailed(format!(
427                "user '{}' not found",
428                username
429            ))),
430        }
431    }
432
433    #[cfg(windows)]
434    {
435        // On Windows, user profiles are at C:\Users\<username>.
436        let drive = std::env::var("SystemDrive").unwrap_or_else(|_| "C:".to_string());
437        Ok(PathBuf::from(drive).join("Users").join(username))
438    }
439
440    #[cfg(not(any(unix, windows)))]
441    {
442        let _ = username;
443        Err(SecretsError::KeyLoadFailed(
444            "resolve_user_home not supported on this platform".to_string(),
445        ))
446    }
447}
448
449#[cfg(target_os = "macos")]
450fn load_from_macos_system_keychain(key_name: &str) -> SecretsResult<Option<String>> {
451    use security_framework::os::macos::keychain::SecKeychain;
452
453    let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
454        .map_err(|e| SecretsError::KeyLoadFailed(format!("cannot open System Keychain: {}", e)))?;
455
456    let service = SecretManager::keychain_service_name();
457    match keychain.find_generic_password(&service, key_name) {
458        Ok((password, _item)) => {
459            let data = String::from_utf8(password.as_ref().to_vec()).map_err(|e| {
460                SecretsError::KeyLoadFailed(format!("invalid keychain data: {}", e))
461            })?;
462            Ok(normalize_key_data(&data))
463        }
464        // errSecItemNotFound = -25300
465        Err(e) if e.code() == -25300 => Ok(None),
466        Err(_) => Ok(None), // Keychain inaccessible (locked, permissions)
467    }
468}
469
470#[cfg(target_os = "macos")]
471fn save_to_macos_system_keychain(key_name: &str, identity: &str) -> SecretsResult<()> {
472    use security_framework::os::macos::keychain::SecKeychain;
473
474    let keychain = SecKeychain::open("/Library/Keychains/System.keychain")
475        .map_err(|e| SecretsError::KeySaveFailed(format!("cannot open System Keychain: {}", e)))?;
476
477    let service = SecretManager::keychain_service_name();
478    keychain
479        .set_generic_password(&service, key_name, identity.as_bytes())
480        .map_err(|e| {
481            let msg = e.to_string();
482            if msg.contains("Authorization") || msg.contains("permission") || e.code() == -25293 {
483                // errSecAuthFailed = -25293
484                return SecretsError::InsufficientPrivileges(format!(
485                    "cannot write to System Keychain (try with sudo): {}",
486                    msg
487                ));
488            }
489            SecretsError::KeySaveFailed(format!(
490                "failed to save to macOS System Keychain \
491                 (service='{}', account='{}'): {}",
492                service, key_name, msg
493            ))
494        })
495}
496
497impl SecretManager {
498    /// Creates a new `SecretManager` by loading the key from standard
499    /// locations.
500    ///
501    /// # Key Loading Order
502    ///
503    /// 0. **Auto-discover** `AGE_KEY_NAME` from `.env` or `.env.local` files
504    ///    (looks for `AGE_KEY_NAME` or `*_AGE_KEY_NAME`)
505    /// 1. `DOTENVAGE_AGE_KEY` environment variable (full identity string)
506    /// 2. `AGE_KEY` environment variable (for compatibility)
507    /// 3. `EKG_AGE_KEY` environment variable (for EKG project compatibility)
508    /// 4. OS keychain entry using:
509    ///    - Service: `DOTENVAGE_KEYCHAIN_SERVICE` or `dotenvage`
510    ///    - Account: `AGE_KEY_NAME` or `{CARGO_PKG_NAME}/dotenvage`
511    /// 5. Key file at path determined by `AGE_KEY_NAME` (e.g.,
512    ///    `~/.local/state/ekg/myproject.key` if `AGE_KEY_NAME=ekg/myproject`)
513    /// 6. Default key file: `~/.local/state/{CARGO_PKG_NAME}/dotenvage.key`
514    ///
515    /// # Errors
516    ///
517    /// Returns an error if no key can be found or if the key is invalid.
518    ///
519    /// # Examples
520    ///
521    /// ```rust,no_run
522    /// use dotenvage::SecretManager;
523    ///
524    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
525    /// let manager = SecretManager::new()?;
526    /// # Ok(())
527    /// # }
528    /// ```
529    pub fn new() -> SecretsResult<Self> {
530        Self::load_key()
531    }
532
533    /// Generates a new random identity.
534    ///
535    /// Use this when creating a new encryption key. You'll typically want to
536    /// save this key using [`save_key`](Self::save_key) or
537    /// [`save_key_to_default`](Self::save_key_to_default).
538    ///
539    /// # Errors
540    ///
541    /// This function always succeeds and returns `Ok`.
542    ///
543    /// # Examples
544    ///
545    /// ```rust
546    /// use dotenvage::SecretManager;
547    ///
548    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
549    /// let manager = SecretManager::generate()?;
550    /// println!("Public key: {}", manager.public_key_string());
551    /// # Ok(())
552    /// # }
553    /// ```
554    pub fn generate() -> SecretsResult<Self> {
555        Ok(Self {
556            identity: x25519::Identity::generate(),
557        })
558    }
559
560    /// Creates a `SecretManager` from an existing identity.
561    ///
562    /// Use this when you have an age X25519 identity that you want to use
563    /// directly.
564    pub fn from_identity(identity: x25519::Identity) -> Self {
565        Self { identity }
566    }
567
568    /// Gets the public key (recipient) corresponding to this identity.
569    ///
570    /// The public key can be shared with others who want to encrypt values
571    /// that only you can decrypt.
572    pub fn public_key(&self) -> x25519::Recipient {
573        self.identity.to_public()
574    }
575
576    /// Gets the public key as a string in age format (starts with `age1`).
577    ///
578    /// # Examples
579    ///
580    /// ```rust
581    /// use dotenvage::SecretManager;
582    ///
583    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
584    /// let manager = SecretManager::generate()?;
585    /// let public_key = manager.public_key_string();
586    /// assert!(public_key.starts_with("age1"));
587    /// # Ok(())
588    /// # }
589    /// ```
590    pub fn public_key_string(&self) -> String {
591        self.public_key().to_string()
592    }
593
594    /// Encrypts a plaintext value and wraps it in the format
595    /// `ENC[AGE:b64:...]`.
596    ///
597    /// The encrypted value can be safely stored in `.env` files and version
598    /// control.
599    ///
600    /// # Errors
601    ///
602    /// Returns an error if encryption fails.
603    ///
604    /// # Examples
605    ///
606    /// ```rust
607    /// use dotenvage::SecretManager;
608    ///
609    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
610    /// let manager = SecretManager::generate()?;
611    /// let encrypted = manager.encrypt_value("sk_live_abc123")?;
612    /// assert!(encrypted.starts_with("ENC[AGE:b64:"));
613    /// # Ok(())
614    /// # }
615    /// ```
616    pub fn encrypt_value(&self, plaintext: &str) -> SecretsResult<String> {
617        let recipient = self.public_key();
618        let recipients: Vec<&dyn age::Recipient> = vec![&recipient];
619        let encryptor = age::Encryptor::with_recipients(recipients.into_iter())
620            .map_err(|e: age::EncryptError| SecretsError::EncryptionFailed(e.to_string()))?;
621
622        let mut encrypted = Vec::new();
623        let mut writer = encryptor
624            .wrap_output(&mut encrypted)
625            .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
626        writer
627            .write_all(plaintext.as_bytes())
628            .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
629        writer
630            .finish()
631            .map_err(|e: std::io::Error| SecretsError::EncryptionFailed(e.to_string()))?;
632
633        let b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted);
634        Ok(format!("ENC[AGE:b64:{}]", b64))
635    }
636
637    /// Decrypts a value if it's encrypted; otherwise returns it unchanged.
638    ///
639    /// This method automatically detects whether a value is encrypted by
640    /// checking for the `ENC[AGE:b64:...]` prefix or the legacy armor
641    /// format. If the value is not encrypted, it's returned as-is.
642    ///
643    /// # Supported Formats
644    ///
645    /// - Compact: `ENC[AGE:b64:...]` (recommended)
646    /// - Legacy: `-----BEGIN AGE ENCRYPTED FILE-----`
647    ///
648    /// # Errors
649    ///
650    /// Returns an error if the value is encrypted but decryption fails
651    /// (e.g., wrong key, corrupted data).
652    ///
653    /// # Examples
654    ///
655    /// ```rust
656    /// use dotenvage::SecretManager;
657    ///
658    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
659    /// let manager = SecretManager::generate()?;
660    ///
661    /// // Decrypt an encrypted value
662    /// let encrypted = manager.encrypt_value("secret")?;
663    /// let decrypted = manager.decrypt_value(&encrypted)?;
664    /// assert_eq!(decrypted, "secret");
665    ///
666    /// // Pass through unencrypted values
667    /// let plain = manager.decrypt_value("not-encrypted")?;
668    /// assert_eq!(plain, "not-encrypted");
669    /// # Ok(())
670    /// # }
671    /// ```
672    pub fn decrypt_value(&self, value: &str) -> SecretsResult<String> {
673        let trimmed = value.trim();
674
675        // Compact format: ENC[AGE:b64:...]
676        if let Some(inner) = trimmed
677            .strip_prefix("ENC[AGE:b64:")
678            .and_then(|s| s.strip_suffix(']'))
679        {
680            let encrypted = base64::engine::general_purpose::STANDARD
681                .decode(inner)
682                .map_err(|e| SecretsError::DecryptionFailed(format!("invalid base64: {}", e)))?;
683
684            let decryptor = age::Decryptor::new(&encrypted[..])
685                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
686            let identities: Vec<&dyn age::Identity> = vec![&self.identity];
687            let mut reader = decryptor
688                .decrypt(identities.into_iter())
689                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
690
691            let mut decrypted = Vec::new();
692            reader
693                .read_to_end(&mut decrypted)
694                .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
695            return String::from_utf8(decrypted)
696                .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
697        }
698
699        // Legacy armor format
700        if trimmed.starts_with("-----BEGIN AGE ENCRYPTED FILE-----") {
701            let armor_reader = age::armor::ArmoredReader::new(trimmed.as_bytes());
702            let decryptor = age::Decryptor::new(armor_reader)
703                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
704            let identities: Vec<&dyn age::Identity> = vec![&self.identity];
705            let mut reader = decryptor
706                .decrypt(identities.into_iter())
707                .map_err(|e: age::DecryptError| SecretsError::DecryptionFailed(e.to_string()))?;
708
709            let mut decrypted = Vec::new();
710            reader
711                .read_to_end(&mut decrypted)
712                .map_err(|e: std::io::Error| SecretsError::DecryptionFailed(e.to_string()))?;
713            return String::from_utf8(decrypted)
714                .map_err(|e| SecretsError::DecryptionFailed(e.to_string()));
715        }
716
717        Ok(value.to_string())
718    }
719
720    /// Checks if a value is in a recognized encrypted format.
721    ///
722    /// Returns `true` if the value starts with `ENC[AGE:b64:` or the legacy
723    /// age armor format.
724    ///
725    /// # Examples
726    ///
727    /// ```rust
728    /// use dotenvage::SecretManager;
729    ///
730    /// assert!(SecretManager::is_encrypted(
731    ///     "ENC[AGE:b64:YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+...]"
732    /// ));
733    /// assert!(!SecretManager::is_encrypted("plaintext"));
734    /// ```
735    pub fn is_encrypted(value: &str) -> bool {
736        let t = value.trim();
737        t.starts_with("ENC[AGE:b64:") || t.starts_with("-----BEGIN AGE ENCRYPTED FILE-----")
738    }
739
740    /// Saves the private identity to a file with restricted permissions.
741    ///
742    /// On Unix systems, the file permissions are set to `0o600` (readable and
743    /// writable only by the owner).
744    ///
745    /// # Errors
746    ///
747    /// Returns an error if the file cannot be created or written.
748    ///
749    /// # Examples
750    ///
751    /// ```rust,no_run
752    /// use dotenvage::SecretManager;
753    ///
754    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
755    /// let manager = SecretManager::generate()?;
756    /// manager.save_key("my-key.txt")?;
757    /// # Ok(())
758    /// # }
759    /// ```
760    pub fn save_key(&self, path: impl AsRef<Path>) -> SecretsResult<()> {
761        let backend = FileKeyBackend::new(path.as_ref().to_path_buf());
762        backend.save_identity_string(&self.identity_string())
763    }
764
765    /// Saves the key to the default path and returns that path.
766    ///
767    /// The default path is typically `~/.local/state/dotenvage/dotenvage.key`
768    /// on Unix systems.
769    ///
770    /// # Errors
771    ///
772    /// Returns an error if the file cannot be created or written.
773    ///
774    /// # Examples
775    ///
776    /// ```rust,no_run
777    /// use dotenvage::SecretManager;
778    ///
779    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
780    /// let manager = SecretManager::generate()?;
781    /// let path = manager.save_key_to_default()?;
782    /// println!("Key saved to: {}", path.display());
783    /// # Ok(())
784    /// # }
785    /// ```
786    pub fn save_key_to_default(&self) -> SecretsResult<PathBuf> {
787        let p = Self::default_key_path();
788        self.save_key(&p)?;
789        Ok(p)
790    }
791
792    /// Saves the private key to the OS keychain.
793    ///
794    /// Uses:
795    /// - Service: `DOTENVAGE_KEYCHAIN_SERVICE` or `dotenvage`
796    /// - Account: `AGE_KEY_NAME` or `{CARGO_PKG_NAME}/dotenvage`
797    ///
798    /// Returns the `(service, account)` pair used.
799    ///
800    /// # Errors
801    ///
802    /// Returns an error if the key cannot be saved to the OS keychain.
803    pub fn save_key_to_os_keychain(&self) -> SecretsResult<(String, String)> {
804        let service = Self::keychain_service_name();
805        let account = Self::key_name_from_env_or_default();
806        let backend = OsKeychainBackend::new(service.clone(), account.clone());
807        backend.save_identity_string(&self.identity_string())?;
808        Ok((service, account))
809    }
810
811    /// Saves this key to the system-level credential store.
812    ///
813    /// - **macOS**: System Keychain (`/Library/Keychains/System.keychain`)
814    /// - **Linux**: `/etc/dotenvage/<key-name>.key`
815    /// - **Windows**: `%ProgramData%\dotenvage\<key-name>.key`
816    ///
817    /// Requires elevated privileges (sudo/admin).
818    ///
819    /// # Errors
820    ///
821    /// Returns [`SecretsError::InsufficientPrivileges`] if the process
822    /// lacks write access to the system store.
823    pub fn save_key_to_system_store(&self) -> SecretsResult<KeyLocation> {
824        let key_name = Self::key_name_from_env_or_default();
825        self.save_key_to_system_store_as(&key_name)
826    }
827
828    /// Saves this key to the system-level store with an explicit
829    /// key name.
830    ///
831    /// # Errors
832    ///
833    /// Returns [`SecretsError::InsufficientPrivileges`] if the process
834    /// lacks write access to the system store.
835    pub fn save_key_to_system_store_as(&self, key_name: &str) -> SecretsResult<KeyLocation> {
836        let backend = SystemStoreBackend::new(key_name.to_string());
837        backend.save_identity_string(&self.identity_string())?;
838
839        #[cfg(target_os = "macos")]
840        {
841            let service = Self::keychain_service_name();
842            Ok(KeyLocation::SystemKeychain {
843                service,
844                account: key_name.to_string(),
845            })
846        }
847
848        #[cfg(not(target_os = "macos"))]
849        {
850            Ok(KeyLocation::SystemFile(backend.path()))
851        }
852    }
853
854    /// Generates a new key and saves it to the specified store(s).
855    ///
856    /// This is the programmatic equivalent of
857    /// `dotenvage keygen --store <target>`.
858    ///
859    /// # Errors
860    ///
861    /// Returns an error if key generation or saving fails, or if
862    /// a key already exists and `force` is not set.
863    ///
864    /// # Examples
865    ///
866    /// ```rust,no_run
867    /// use dotenvage::{
868    ///     KeyGenOptions,
869    ///     KeyStoreTarget,
870    ///     SecretManager,
871    /// };
872    ///
873    /// let result = SecretManager::generate_and_save(KeyGenOptions {
874    ///     target: KeyStoreTarget::OsKeychain,
875    ///     key_name: Some("ekg/wwkg".into()),
876    ///     file_path: None,
877    ///     force: false,
878    /// })?;
879    /// println!("Public key: {}", result.public_key);
880    /// # Ok::<(), Box<dyn std::error::Error>>(())
881    /// ```
882    pub fn generate_and_save(options: KeyGenOptions) -> SecretsResult<KeyGenResult> {
883        // If key_name is provided, set it in the environment so all
884        // downstream path resolution uses it.
885        if let Some(ref name) = options.key_name {
886            unsafe {
887                std::env::set_var("AGE_KEY_NAME", name);
888            }
889        } else {
890            Self::discover_age_key_name_from_env_files()?;
891        }
892
893        let manager = Self::generate()?;
894        let mut locations = Vec::new();
895
896        match options.target {
897            KeyStoreTarget::File => {
898                let path = options
899                    .file_path
900                    .unwrap_or_else(Self::key_path_from_env_or_default);
901                if path.exists() && !options.force {
902                    return Err(SecretsError::KeyAlreadyExists(format!(
903                        "key file at {}",
904                        path.display()
905                    )));
906                }
907                manager.save_key(&path)?;
908                locations.push(KeyLocation::UserFile(path));
909            }
910            KeyStoreTarget::OsKeychain => {
911                let (service, account) = manager.save_key_to_os_keychain()?;
912                locations.push(KeyLocation::OsKeychain { service, account });
913            }
914            KeyStoreTarget::SystemStore => {
915                let key_name = Self::key_name_from_env_or_default();
916                let loc = manager.save_key_to_system_store_as(&key_name)?;
917                locations.push(loc);
918            }
919            KeyStoreTarget::OsKeychainAndFile => {
920                let (service, account) = manager.save_key_to_os_keychain()?;
921                locations.push(KeyLocation::OsKeychain { service, account });
922                let path = options
923                    .file_path
924                    .unwrap_or_else(Self::key_path_from_env_or_default);
925                if path.exists() && !options.force {
926                    return Err(SecretsError::KeyAlreadyExists(format!(
927                        "key file at {}",
928                        path.display()
929                    )));
930                }
931                manager.save_key(&path)?;
932                locations.push(KeyLocation::UserFile(path));
933            }
934        }
935
936        let public_key = manager.public_key_string();
937        Ok(KeyGenResult {
938            manager,
939            locations,
940            public_key,
941        })
942    }
943
944    /// Loads the key specifically from the system-level store.
945    ///
946    /// Unlike [`new`](Self::new) which tries the full discovery
947    /// chain, this only checks the system store.
948    ///
949    /// # Errors
950    ///
951    /// Returns an error if no key is found in the system store.
952    pub fn load_from_system_store() -> SecretsResult<Self> {
953        Self::discover_age_key_name_from_env_files()?;
954        let key_name = Self::key_name_from_env_or_default();
955        let backend = SystemStoreBackend::new(key_name.clone());
956        match backend.load_identity_string()? {
957            Some(data) => Self::load_from_string(&data),
958            None => Err(SecretsError::KeyLoadFailed(format!(
959                "no key found in system store for '{}'",
960                key_name
961            ))),
962        }
963    }
964
965    /// Loads a key from another user's file store.
966    ///
967    /// Resolves the key file path for `~<username>/.local/state/...`
968    /// based on the current `AGE_KEY_NAME`. This is intended for use
969    /// during `sudo` operations where the invoking user's key needs
970    /// to be read by the elevated process.
971    ///
972    /// # Errors
973    ///
974    /// Returns an error if the user's home directory cannot be
975    /// resolved or no key file is found.
976    pub fn load_from_user(username: &str) -> SecretsResult<Self> {
977        Self::discover_age_key_name_from_env_files()?;
978        let key_name = Self::key_name_from_env_or_default();
979
980        let home = resolve_user_home(username)?;
981        let key_path = home
982            .join(".local/state")
983            .join(&key_name)
984            .with_extension("key");
985
986        let backend = FileKeyBackend::new(key_path.clone());
987        match backend.load_identity_string()? {
988            Some(data) => Self::load_from_string(&data),
989            None => Err(SecretsError::KeyLoadFailed(format!(
990                "no key file for user '{}' at {}",
991                username,
992                key_path.display()
993            ))),
994        }
995    }
996
997    /// Checks whether a key exists in the OS user keychain.
998    pub fn key_exists_in_os_keychain() -> bool {
999        let _ = Self::discover_age_key_name_from_env_files();
1000        let key_name = Self::key_name_from_env_or_default();
1001        let service = Self::keychain_service_name();
1002        let backend = OsKeychainBackend::new(service, key_name);
1003        matches!(backend.load_identity_string(), Ok(Some(_)))
1004    }
1005
1006    /// Checks whether a key exists in the system-level store.
1007    pub fn key_exists_in_system_store() -> bool {
1008        let _ = Self::discover_age_key_name_from_env_files();
1009        let key_name = Self::key_name_from_env_or_default();
1010        let backend = SystemStoreBackend::new(key_name);
1011        matches!(backend.load_identity_string(), Ok(Some(_)))
1012    }
1013
1014    /// Deletes the key from the OS user keychain.
1015    ///
1016    /// # Errors
1017    ///
1018    /// Returns an error if the deletion fails. Does not error if
1019    /// no key exists.
1020    #[cfg(feature = "os-keychain")]
1021    pub fn delete_from_os_keychain() -> SecretsResult<()> {
1022        let _ = Self::discover_age_key_name_from_env_files();
1023        let key_name = Self::key_name_from_env_or_default();
1024        let service = Self::keychain_service_name();
1025        delete_from_os_keychain(&service, &key_name)
1026    }
1027
1028    /// Returns the system store path for the current platform
1029    /// and key name.
1030    ///
1031    /// - **macOS**: `/Library/Keychains/System.keychain`
1032    /// - **Linux**: `/etc/dotenvage/<key-name>.key`
1033    /// - **Windows**: `%ProgramData%\dotenvage\<key-name>.key`
1034    pub fn system_store_path() -> PathBuf {
1035        let _ = Self::discover_age_key_name_from_env_files();
1036        let key_name = Self::key_name_from_env_or_default();
1037        system_store_path_for(&key_name)
1038    }
1039
1040    /// Loads the identity from standard locations.
1041    ///
1042    /// This is called internally by [`new`](Self::new).
1043    ///
1044    /// ## Key Loading Priority
1045    ///
1046    /// 0. Read .env files to discover `AGE_KEY_NAME` (or `*_AGE_KEY_NAME`) for
1047    ///    project-specific keys
1048    /// 1. `DOTENVAGE_AGE_KEY` env var (full identity string)
1049    /// 2. `AGE_KEY` env var (full identity string)
1050    /// 3. `EKG_AGE_KEY` env var (for EKG project compatibility)
1051    /// 4. OS user keychain (via `keyring` crate)
1052    /// 5. System-level store (macOS System Keychain, or
1053    ///    `/etc/dotenvage/<key>.key` on Linux,
1054    ///    `%ProgramData%\dotenvage\<key>.key` on Windows)
1055    /// 6. Key file at path determined by `AGE_KEY_NAME` from .env or
1056    ///    environment
1057    /// 7. Default key file: `~/.local/state/{CARGO_PKG_NAME or
1058    ///    "dotenvage"}/dotenvage.key`
1059    ///
1060    /// # Errors
1061    ///
1062    /// Returns an error if no key can be found in any of the standard
1063    /// locations or if the key file/string is invalid.
1064    pub fn load_key() -> SecretsResult<Self> {
1065        // FIRST: Try to discover AGE_KEY_NAME from .env files before
1066        // doing anything else. This allows project-specific key
1067        // discovery from .env configuration.
1068        Self::discover_age_key_name_from_env_files()?;
1069
1070        if let Ok(data) = std::env::var("DOTENVAGE_AGE_KEY") {
1071            return Self::load_from_string(&data);
1072        }
1073        if let Ok(data) = std::env::var("AGE_KEY") {
1074            return Self::load_from_string(&data);
1075        }
1076        if let Ok(data) = std::env::var("EKG_AGE_KEY") {
1077            return Self::load_from_string(&data);
1078        }
1079
1080        // Step 4: OS user keychain
1081        let key_name = Self::key_name_from_env_or_default();
1082        let keychain_service = Self::keychain_service_name();
1083        let os_keychain_backend = OsKeychainBackend::new(keychain_service, key_name.clone());
1084        if let Some(data) = os_keychain_backend.load_identity_string()? {
1085            return Self::load_from_string(&data);
1086        }
1087
1088        // Step 5: System-level store
1089        let system_backend = SystemStoreBackend::new(key_name);
1090        if let Some(data) = system_backend.load_identity_string()? {
1091            return Self::load_from_string(&data);
1092        }
1093
1094        // Step 6-7: File-based key
1095        let key_path = Self::key_path_from_env_or_default();
1096        let file_backend = FileKeyBackend::new(key_path.clone());
1097        if let Some(data) = file_backend.load_identity_string()? {
1098            return Self::load_from_string(&data);
1099        }
1100        Err(SecretsError::KeyLoadFailed(format!(
1101            "no key found (env vars, OS keychain, system store, \
1102             or key file at {})",
1103            key_path.display()
1104        )))
1105    }
1106
1107    /// Attempts to discover AGE_KEY_NAME from .env files in the current
1108    /// directory.
1109    ///
1110    /// This reads .env files (without decryption) to find AGE_KEY_NAME or
1111    /// *_AGE_KEY_NAME variables and sets them in the environment so they
1112    /// can be used for key path resolution.
1113    ///
1114    /// Priority order for .env files:
1115    /// 1. .env.local
1116    /// 2. .env
1117    ///
1118    /// # Errors
1119    ///
1120    /// Returns an error if an AGE key name variable (e.g., `EKG_AGE_KEY_NAME`)
1121    /// is found but encrypted. AGE key name variables must be plaintext because
1122    /// they are needed for key discovery, which happens before decryption.
1123    pub fn discover_age_key_name_from_env_files() -> SecretsResult<()> {
1124        // Skip if AGE_KEY_NAME is already set in environment
1125        if std::env::var("AGE_KEY_NAME").is_ok() {
1126            return Ok(());
1127        }
1128
1129        // Try to read .env.local first, then .env
1130        let env_files = [".env.local", ".env"];
1131
1132        for env_file in &env_files {
1133            match Self::find_age_key_name_in_file(env_file)? {
1134                Some(key_name) => {
1135                    unsafe {
1136                        std::env::set_var("AGE_KEY_NAME", key_name);
1137                    }
1138                    return Ok(());
1139                }
1140                None => continue,
1141            }
1142        }
1143
1144        Ok(())
1145    }
1146
1147    /// Searches a single .env file for AGE_KEY_NAME or *_AGE_KEY_NAME
1148    /// variables.
1149    ///
1150    /// Returns `Some(plaintext_value)` if a plaintext AGE key name variable
1151    /// is found, `None` if no AGE key name variable is found, or an error if
1152    /// an encrypted AGE key name variable is found.
1153    ///
1154    /// **Important**: AGE key name variables (e.g., `EKG_AGE_KEY_NAME`) must
1155    /// be plaintext because they are needed for key discovery, which happens
1156    /// before decryption is possible. If an encrypted AGE key name variable
1157    /// is found, this function returns an error.
1158    ///
1159    /// # Errors
1160    ///
1161    /// Returns an error if an AGE key name variable is found but encrypted.
1162    /// The error message includes the variable name and file path to help
1163    /// identify and fix the issue.
1164    fn find_age_key_name_in_file(file_path: &str) -> SecretsResult<Option<String>> {
1165        let content = std::fs::read_to_string(file_path).ok();
1166
1167        let Some(content) = content else {
1168            return Ok(None);
1169        };
1170
1171        for line in content.lines() {
1172            let line = line.trim();
1173
1174            // Skip comments and empty lines
1175            if line.is_empty() || line.starts_with('#') {
1176                continue;
1177            }
1178
1179            // Look for KEY_NAME=value patterns
1180            let Some((key, value)) = line.split_once('=') else {
1181                continue;
1182            };
1183            let key = key.trim();
1184            let value = value.trim().trim_matches('"').trim_matches('\'');
1185
1186            // Check for AGE_KEY_NAME or *_AGE_KEY_NAME pattern
1187            if (key == "AGE_KEY_NAME" || key.ends_with("_AGE_KEY_NAME")) && !value.is_empty() {
1188                // AGE key name variables must be plaintext - they are needed for key discovery
1189                if Self::is_encrypted(value) {
1190                    return Err(SecretsError::KeyLoadFailed(format!(
1191                        "found encrypted AGE key name variable '{}' in {}: \
1192                         AGE key name variables (e.g., EKG_AGE_KEY_NAME, AGE_KEY_NAME) must be \
1193                         plaintext because they are used to discover the encryption key. \
1194                         Please decrypt this variable or remove it from your .env file.",
1195                        key, file_path
1196                    )));
1197                }
1198                return Ok(Some(value.to_string()));
1199            }
1200        }
1201
1202        Ok(None)
1203    }
1204
1205    fn load_from_string(data: &str) -> SecretsResult<Self> {
1206        let identity = data
1207            .parse::<x25519::Identity>()
1208            .map_err(|e| SecretsError::KeyLoadFailed(format!("parse key: {}", e)))?;
1209        Ok(Self { identity })
1210    }
1211
1212    /// Returns the raw identity string (`AGE-SECRET-KEY-1...`).
1213    ///
1214    /// Use this when you need to embed the key in a service definition
1215    /// for environments where keychain access is unavailable (e.g.,
1216    /// containers). Handle the returned string carefully — it is the
1217    /// private key in plaintext.
1218    pub fn identity_string(&self) -> String {
1219        self.identity.to_string().expose_secret().to_string()
1220    }
1221
1222    fn key_name_from_env_or_default() -> String {
1223        std::env::var("AGE_KEY_NAME")
1224            .ok()
1225            .filter(|s| !s.trim().is_empty())
1226            .unwrap_or_else(|| {
1227                // Default to CARGO_PKG_NAME/dotenvage for project-specific keys
1228                format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1229            })
1230    }
1231
1232    fn keychain_service_name() -> String {
1233        std::env::var("DOTENVAGE_KEYCHAIN_SERVICE")
1234            .ok()
1235            .filter(|s| !s.trim().is_empty())
1236            .unwrap_or_else(|| "dotenvage".to_string())
1237    }
1238
1239    /// Returns the key path based on AGE_KEY_NAME or project default.
1240    ///
1241    /// ## Priority:
1242    /// 1. If `AGE_KEY_NAME` is set in environment (e.g., from .env), use it
1243    /// 2. Otherwise default to `{CARGO_PKG_NAME}/dotenvage`
1244    ///
1245    /// ## Path Construction:
1246    /// - XDG-compliant: `$XDG_STATE_HOME/{name}.key`
1247    /// - Fallback: `~/.local/state/{name}.key`
1248    ///
1249    /// ## Examples
1250    ///
1251    /// With `AGE_KEY_NAME=myapp/production` in .env:
1252    /// - Returns: `~/.local/state/myapp/production.key`
1253    ///
1254    /// Without AGE_KEY_NAME (default for "ekg-backend" crate):
1255    /// - Returns: `~/.local/state/ekg-backend/dotenvage.key`
1256    pub fn key_path_from_env_or_default() -> PathBuf {
1257        let key_name = Self::key_name_from_env_or_default();
1258
1259        // Construct XDG-compliant path
1260        Self::xdg_base_dir_for(&key_name)
1261            .unwrap_or_else(|| PathBuf::from(".").join(&key_name))
1262            .with_extension("key")
1263    }
1264
1265    /// Returns the default key path (for backward compatibility).
1266    ///
1267    /// Prefer using `key_path_from_env_or_default()` which respects
1268    /// AGE_KEY_NAME.
1269    ///
1270    /// # Examples
1271    ///
1272    /// ```rust
1273    /// use dotenvage::SecretManager;
1274    ///
1275    /// let path = SecretManager::default_key_path();
1276    /// println!("Default key path: {}", path.display());
1277    /// ```
1278    pub fn default_key_path() -> PathBuf {
1279        Self::xdg_base_dir_for("dotenvage")
1280            .unwrap_or_else(|| PathBuf::from(".").join("dotenvage"))
1281            .join("dotenvage.key")
1282    }
1283
1284    fn xdg_base_dir_for(name: &str) -> Option<PathBuf> {
1285        if let Ok(p) = std::env::var("XDG_STATE_HOME")
1286            && !p.is_empty()
1287        {
1288            return Some(PathBuf::from(p).join(name));
1289        }
1290        if let Ok(p) = std::env::var("XDG_CONFIG_HOME")
1291            && !p.is_empty()
1292        {
1293            return Some(PathBuf::from(p).join(name));
1294        }
1295        if let Ok(home) = std::env::var("HOME") {
1296            let home_path = PathBuf::from(home);
1297            let state_dir = home_path.join(".local/state").join(name);
1298            // Prefer state dir unless config dir already exists
1299            if state_dir.exists() || !home_path.join(".config").join(name).exists() {
1300                return Some(state_dir);
1301            }
1302            return Some(home_path.join(".config").join(name));
1303        }
1304        None
1305    }
1306}
1307
1308#[cfg(test)]
1309mod tests {
1310    use serial_test::serial;
1311
1312    use super::*;
1313
1314    #[test]
1315    fn test_encrypt_decrypt_roundtrip() {
1316        let manager = SecretManager::generate().expect("failed to generate manager");
1317        let plaintext = "sk_live_abc123";
1318        let encrypted = manager.encrypt_value(plaintext).expect("encryption failed");
1319        assert!(SecretManager::is_encrypted(&encrypted));
1320        let decrypted = manager
1321            .decrypt_value(&encrypted)
1322            .expect("decryption failed");
1323        assert_eq!(plaintext, decrypted);
1324    }
1325
1326    #[test]
1327    fn test_decrypt_unencrypted_value() {
1328        let manager = SecretManager::generate().expect("failed to generate manager");
1329        let plaintext = "not_encrypted";
1330        let result = manager
1331            .decrypt_value(plaintext)
1332            .expect("decrypt should pass through");
1333        assert_eq!(plaintext, result);
1334    }
1335
1336    #[test]
1337    #[serial]
1338    fn test_key_path_from_env_or_default_with_age_key_name() {
1339        // This test must clear ALL env vars that affect key path discovery
1340        let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1341        let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1342        let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1343
1344        // Test with AGE_KEY_NAME set
1345        unsafe {
1346            std::env::remove_var("XDG_CONFIG_HOME"); // Clear any XDG_CONFIG_HOME
1347            std::env::set_var("AGE_KEY_NAME", "myproject/myapp");
1348            std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1349        }
1350
1351        let path = SecretManager::key_path_from_env_or_default();
1352        assert_eq!(
1353            path,
1354            std::path::PathBuf::from("/tmp/xdg-state/myproject/myapp.key")
1355        );
1356
1357        // Restore env
1358        unsafe {
1359            std::env::remove_var("AGE_KEY_NAME");
1360            std::env::remove_var("XDG_STATE_HOME");
1361            if let Some(val) = orig_age_key_name {
1362                std::env::set_var("AGE_KEY_NAME", val);
1363            }
1364            if let Some(val) = orig_xdg_state {
1365                std::env::set_var("XDG_STATE_HOME", val);
1366            }
1367            if let Some(val) = orig_xdg_config {
1368                std::env::set_var("XDG_CONFIG_HOME", val);
1369            }
1370        }
1371    }
1372
1373    #[test]
1374    #[serial]
1375    fn test_key_path_from_env_or_default_without_age_key_name() {
1376        // Save original env
1377        let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1378        let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1379        let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1380
1381        // Test without AGE_KEY_NAME - should default to CARGO_PKG_NAME/dotenvage
1382        unsafe {
1383            std::env::remove_var("AGE_KEY_NAME");
1384            std::env::remove_var("XDG_CONFIG_HOME"); // Clear any XDG_CONFIG_HOME
1385            std::env::set_var("XDG_STATE_HOME", "/tmp/xdg-state");
1386        }
1387
1388        let path = SecretManager::key_path_from_env_or_default();
1389        let expected = format!("/tmp/xdg-state/{}/dotenvage.key", env!("CARGO_PKG_NAME"));
1390        assert_eq!(path, std::path::PathBuf::from(expected));
1391
1392        // Restore env
1393        unsafe {
1394            std::env::remove_var("XDG_STATE_HOME");
1395            if let Some(val) = orig_age_key_name {
1396                std::env::set_var("AGE_KEY_NAME", val);
1397            }
1398            if let Some(val) = orig_xdg_state {
1399                std::env::set_var("XDG_STATE_HOME", val);
1400            }
1401            if let Some(val) = orig_xdg_config {
1402                std::env::set_var("XDG_CONFIG_HOME", val);
1403            }
1404        }
1405    }
1406
1407    #[test]
1408    #[serial]
1409    fn test_key_name_from_env_or_default() {
1410        let orig_age_key_name = std::env::var("AGE_KEY_NAME").ok();
1411
1412        unsafe {
1413            std::env::set_var("AGE_KEY_NAME", "myproject/prod");
1414        }
1415        assert_eq!(
1416            SecretManager::key_name_from_env_or_default(),
1417            "myproject/prod"
1418        );
1419
1420        unsafe {
1421            std::env::set_var("AGE_KEY_NAME", "   ");
1422        }
1423        assert_eq!(
1424            SecretManager::key_name_from_env_or_default(),
1425            format!("{}/dotenvage", env!("CARGO_PKG_NAME"))
1426        );
1427
1428        unsafe {
1429            if let Some(val) = orig_age_key_name {
1430                std::env::set_var("AGE_KEY_NAME", val);
1431            } else {
1432                std::env::remove_var("AGE_KEY_NAME");
1433            }
1434        }
1435    }
1436
1437    #[test]
1438    #[serial]
1439    fn test_keychain_service_name() {
1440        let orig = std::env::var("DOTENVAGE_KEYCHAIN_SERVICE").ok();
1441
1442        unsafe {
1443            std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", "team-secrets");
1444        }
1445        assert_eq!(SecretManager::keychain_service_name(), "team-secrets");
1446
1447        unsafe {
1448            std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", "   ");
1449        }
1450        assert_eq!(SecretManager::keychain_service_name(), "dotenvage");
1451
1452        unsafe {
1453            if let Some(val) = orig {
1454                std::env::set_var("DOTENVAGE_KEYCHAIN_SERVICE", val);
1455            } else {
1456                std::env::remove_var("DOTENVAGE_KEYCHAIN_SERVICE");
1457            }
1458        }
1459    }
1460
1461    #[test]
1462    #[serial]
1463    fn test_xdg_base_dir_for() {
1464        // Save original env
1465        let orig_xdg_state = std::env::var("XDG_STATE_HOME").ok();
1466        let orig_xdg_config = std::env::var("XDG_CONFIG_HOME").ok();
1467        let orig_home = std::env::var("HOME").ok();
1468
1469        // Test with XDG_STATE_HOME
1470        unsafe {
1471            std::env::set_var("XDG_STATE_HOME", "/custom/state");
1472        }
1473        let path = SecretManager::xdg_base_dir_for("test");
1474        assert_eq!(path, Some(std::path::PathBuf::from("/custom/state/test")));
1475
1476        // Test with HOME fallback
1477        unsafe {
1478            std::env::remove_var("XDG_STATE_HOME");
1479            std::env::remove_var("XDG_CONFIG_HOME");
1480            std::env::set_var("HOME", "/home/user");
1481        }
1482        let path = SecretManager::xdg_base_dir_for("test");
1483        assert_eq!(
1484            path,
1485            Some(std::path::PathBuf::from("/home/user/.local/state/test"))
1486        );
1487
1488        // Restore env
1489        unsafe {
1490            if let Some(val) = orig_xdg_state {
1491                std::env::set_var("XDG_STATE_HOME", val);
1492            } else {
1493                std::env::remove_var("XDG_STATE_HOME");
1494            }
1495            if let Some(val) = orig_xdg_config {
1496                std::env::set_var("XDG_CONFIG_HOME", val);
1497            } else {
1498                std::env::remove_var("XDG_CONFIG_HOME");
1499            }
1500            if let Some(val) = orig_home {
1501                std::env::set_var("HOME", val);
1502            } else {
1503                std::env::remove_var("HOME");
1504            }
1505        }
1506    }
1507}