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