Skip to main content

zeph_vault/
lib.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Secret storage for Zeph with pluggable backends and age encryption.
5//!
6//! This crate provides:
7//!
8//! - [`VaultProvider`] — an async trait for secret retrieval, implemented by all backends.
9//! - [`AgeVaultProvider`] — primary backend that stores secrets as an age-encrypted JSON file.
10//! - [`EnvVaultProvider`] — development/testing backend that reads secrets from environment
11//!   variables prefixed with `ZEPH_SECRET_`.
12//! - [`ArcAgeVaultProvider`] — thin `Arc<RwLock<AgeVaultProvider>>` wrapper that implements
13//!   [`VaultProvider`] so the age vault can be stored as a trait object while still being
14//!   accessible for mutable operations (e.g. OAuth credential persistence).
15//! - `MockVaultProvider` — in-memory backend available under the `mock` feature flag and in
16//!   `#[cfg(test)]` contexts.
17//!
18//! [`Secret`] and [`VaultError`] live in `zeph-common` (layer 0) and are re-exported here so
19//! callers only need to depend on `zeph-vault`.
20//!
21//! # Security model
22//!
23//! - Secrets are stored as a JSON object encrypted with [age](https://age-encryption.org) using
24//!   an x25519 keypair. Only the holder of the private key file can decrypt the vault.
25//! - In-memory secret values are kept in [`zeroize::Zeroizing`] buffers, which overwrite the
26//!   memory on drop.
27//! - The key file is created with Unix permission `0600` (owner-read/write only). On non-Unix
28//!   platforms the file is created without access control restrictions.
29//! - Vault writes are atomic: a temporary file is written first, then renamed, so a crash during
30//!   write never corrupts the existing vault.
31//!
32//! # Vault file layout
33//!
34//! ```text
35//! ~/.config/zeph/
36//! ├── vault-key.txt   # age identity (private key), mode 0600
37//! └── secrets.age     # age-encrypted JSON: {"KEY": "value", ...}
38//! ```
39//!
40//! # Quick start
41//!
42//! ```no_run
43//! use std::path::Path;
44//! use zeph_vault::{AgeVaultProvider, VaultProvider as _};
45//!
46//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
47//! let vault = AgeVaultProvider::new(
48//!     Path::new("/etc/zeph/vault-key.txt"),
49//!     Path::new("/etc/zeph/secrets.age"),
50//! )?;
51//!
52//! // Synchronous access via the direct getter
53//! if let Some(key) = vault.get("ZEPH_OPENAI_API_KEY") {
54//!     println!("key length: {}", key.len());
55//! }
56//! # Ok(())
57//! # }
58//! ```
59
60use std::collections::BTreeMap;
61use std::fmt;
62use std::future::Future;
63use std::io::{Read as _, Write as _};
64use std::path::{Path, PathBuf};
65use std::pin::Pin;
66use std::sync::Arc;
67
68use zeroize::Zeroizing;
69
70// Secret and VaultError live in zeph-common (Layer 0) so that zeph-config (Layer 1)
71// can reference them without creating a circular dependency.
72pub use zeph_common::secret::{Secret, VaultError};
73
74/// Pluggable secret retrieval backend.
75///
76/// Implement this trait to integrate a custom secret store (e.g. `HashiCorp` Vault, `AWS` Secrets
77/// Manager, `1Password`). The crate ships three implementations out of the box:
78/// [`AgeVaultProvider`], [`EnvVaultProvider`], and [`ArcAgeVaultProvider`].
79///
80/// # Implementing
81///
82/// ```
83/// use std::pin::Pin;
84/// use std::future::Future;
85/// use zeph_vault::{VaultProvider, VaultError};
86///
87/// struct ConstantVault(&'static str);
88///
89/// impl VaultProvider for ConstantVault {
90///     fn get_secret(
91///         &self,
92///         key: &str,
93///     ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
94///         let value = if key == "MY_KEY" { Some(self.0.to_owned()) } else { None };
95///         Box::pin(async move { Ok(value) })
96///     }
97/// }
98/// ```
99pub trait VaultProvider: Send + Sync {
100    /// Retrieve a secret by key.
101    ///
102    /// Returns `Ok(None)` when the key does not exist. Returns `Err(VaultError)` on
103    /// backend failures (I/O, decryption, network, etc.).
104    fn get_secret(
105        &self,
106        key: &str,
107    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>>;
108
109    /// Return all known secret keys.
110    ///
111    /// Used internally for scanning `ZEPH_SECRET_*` prefixes and for the `vault list` CLI
112    /// subcommand. The default implementation returns an empty `Vec`; override it when the
113    /// backend supports key enumeration.
114    fn list_keys(&self) -> Vec<String> {
115        Vec::new()
116    }
117}
118
119/// Vault backend that reads secrets from environment variables.
120///
121/// This backend is designed for quick local development and CI environments where injecting
122/// environment variables is convenient. In production, prefer [`AgeVaultProvider`].
123///
124/// [`get_secret`][VaultProvider::get_secret] reads any environment variable by name.
125/// [`list_keys`][VaultProvider::list_keys] returns only variables whose names start with
126/// `ZEPH_SECRET_`, preventing accidental exposure of unrelated process environment.
127///
128/// # Examples
129///
130/// ```no_run
131/// use zeph_vault::{EnvVaultProvider, VaultProvider as _};
132///
133/// # async fn example() {
134/// let vault = EnvVaultProvider;
135/// // Returns None for variables that are not set.
136/// let result = vault.get_secret("ZEPH_TEST_NONEXISTENT_99999").await.unwrap();
137/// assert!(result.is_none());
138/// # }
139/// ```
140pub struct EnvVaultProvider;
141
142/// Errors that can occur during age vault operations.
143///
144/// Each variant wraps the underlying cause so callers can match on failure type without
145/// parsing error strings.
146///
147/// # Examples
148///
149/// ```
150/// use zeph_vault::AgeVaultError;
151///
152/// let err = AgeVaultError::KeyParse("no identity line found".into());
153/// assert!(err.to_string().contains("failed to parse age identity"));
154/// ```
155#[derive(Debug, thiserror::Error)]
156pub enum AgeVaultError {
157    /// The key file could not be read from disk.
158    #[error("failed to read key file: {0}")]
159    KeyRead(std::io::Error),
160    /// The key file content could not be parsed as an age identity.
161    #[error("failed to parse age identity: {0}")]
162    KeyParse(String),
163    /// The vault file could not be read from disk.
164    #[error("failed to read vault file: {0}")]
165    VaultRead(std::io::Error),
166    /// The age decryption step failed (wrong key, corrupted file, etc.).
167    #[error("age decryption failed: {0}")]
168    Decrypt(age::DecryptError),
169    /// An I/O error occurred while reading plaintext from the age stream.
170    #[error("I/O error during decryption: {0}")]
171    Io(std::io::Error),
172    /// The decrypted bytes could not be parsed as JSON.
173    #[error("invalid JSON in vault: {0}")]
174    Json(serde_json::Error),
175    /// The age encryption step failed.
176    #[error("age encryption failed: {0}")]
177    Encrypt(String),
178    /// The vault file (or its temporary predecessor) could not be written to disk.
179    #[error("failed to write vault file: {0}")]
180    VaultWrite(std::io::Error),
181    /// The key file could not be written to disk.
182    #[error("failed to write key file: {0}")]
183    KeyWrite(std::io::Error),
184}
185
186/// Age-encrypted vault backend.
187///
188/// Secrets are stored as a JSON object (`{"KEY": "value", ...}`) encrypted with an x25519
189/// keypair using the [age](https://age-encryption.org) format. The in-memory secret values
190/// are held in [`zeroize::Zeroizing`] buffers.
191///
192/// # File layout
193///
194/// ```text
195/// <dir>/vault-key.txt   # age identity (private key), Unix mode 0600
196/// <dir>/secrets.age     # age-encrypted JSON object
197/// ```
198///
199/// # Initialising a new vault
200///
201/// Use [`AgeVaultProvider::init_vault`] to generate a fresh keypair and create an empty vault:
202///
203/// ```no_run
204/// use std::path::Path;
205/// use zeph_vault::AgeVaultProvider;
206///
207/// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
208/// // Produces:
209/// //   /etc/zeph/vault-key.txt  (mode 0600)
210/// //   /etc/zeph/secrets.age    (empty encrypted vault)
211/// # Ok::<_, zeph_vault::AgeVaultError>(())
212/// ```
213///
214/// # Atomic writes
215///
216/// [`save`][AgeVaultProvider::save] writes to a `.age.tmp` sibling file first, then renames it
217/// atomically, so a crash during write never leaves the vault in a corrupted state.
218pub struct AgeVaultProvider {
219    secrets: BTreeMap<String, Zeroizing<String>>,
220    key_path: PathBuf,
221    vault_path: PathBuf,
222}
223
224impl fmt::Debug for AgeVaultProvider {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        f.debug_struct("AgeVaultProvider")
227            .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
228            .field("key_path", &self.key_path)
229            .field("vault_path", &self.vault_path)
230            .finish()
231    }
232}
233
234impl AgeVaultProvider {
235    /// Decrypt an age-encrypted JSON secrets file.
236    ///
237    /// This is an alias for [`load`][Self::load] provided for ergonomic construction.
238    ///
239    /// # Arguments
240    ///
241    /// - `key_path` — path to the age identity (private key) file. Lines starting with `#`
242    ///   and blank lines are ignored; the first non-comment line is parsed as the identity.
243    /// - `vault_path` — path to the age-encrypted JSON file.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
248    ///
249    /// # Examples
250    ///
251    /// ```no_run
252    /// use std::path::Path;
253    /// use zeph_vault::AgeVaultProvider;
254    ///
255    /// let vault = AgeVaultProvider::new(
256    ///     Path::new("/etc/zeph/vault-key.txt"),
257    ///     Path::new("/etc/zeph/secrets.age"),
258    /// )?;
259    /// println!("{} secrets loaded", vault.list_keys().len());
260    /// # Ok::<_, zeph_vault::AgeVaultError>(())
261    /// ```
262    pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
263        Self::load(key_path, vault_path)
264    }
265
266    /// Load vault from disk, storing paths for subsequent write operations.
267    ///
268    /// Reads and decrypts the vault, then retains both paths so that
269    /// [`save`][Self::save] can re-encrypt and persist changes without requiring callers to
270    /// pass paths again.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
275    ///
276    /// # Examples
277    ///
278    /// ```no_run
279    /// use std::path::Path;
280    /// use zeph_vault::AgeVaultProvider;
281    ///
282    /// let vault = AgeVaultProvider::load(
283    ///     Path::new("/etc/zeph/vault-key.txt"),
284    ///     Path::new("/etc/zeph/secrets.age"),
285    /// )?;
286    /// # Ok::<_, zeph_vault::AgeVaultError>(())
287    /// ```
288    pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
289        let key_str =
290            Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
291        let identity = parse_identity(&key_str)?;
292        let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
293        let secrets = decrypt_secrets(&identity, &ciphertext)?;
294        Ok(Self {
295            secrets,
296            key_path: key_path.to_owned(),
297            vault_path: vault_path.to_owned(),
298        })
299    }
300
301    /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename).
302    ///
303    /// Re-reads and re-parses the key file on each call. For CLI one-shot use this is
304    /// acceptable; if used in a long-lived context consider caching the parsed identity.
305    ///
306    /// # Errors
307    ///
308    /// Returns [`AgeVaultError`] on encryption or write failure.
309    ///
310    /// # Examples
311    ///
312    /// ```no_run
313    /// use std::path::Path;
314    /// use zeph_vault::AgeVaultProvider;
315    ///
316    /// let mut vault = AgeVaultProvider::load(
317    ///     Path::new("/etc/zeph/vault-key.txt"),
318    ///     Path::new("/etc/zeph/secrets.age"),
319    /// )?;
320    /// vault.set_secret_mut("MY_TOKEN".into(), "tok_abc123".into());
321    /// vault.save()?;
322    /// # Ok::<_, zeph_vault::AgeVaultError>(())
323    /// ```
324    pub fn save(&self) -> Result<(), AgeVaultError> {
325        let key_str = Zeroizing::new(
326            std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
327        );
328        let identity = parse_identity(&key_str)?;
329        let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
330        atomic_write(&self.vault_path, &ciphertext)
331    }
332
333    /// Insert or update a secret in the in-memory map.
334    ///
335    /// Call [`save`][Self::save] afterwards to persist the change to disk.
336    ///
337    /// # Examples
338    ///
339    /// ```no_run
340    /// use std::path::Path;
341    /// use zeph_vault::AgeVaultProvider;
342    ///
343    /// let mut vault = AgeVaultProvider::load(
344    ///     Path::new("/etc/zeph/vault-key.txt"),
345    ///     Path::new("/etc/zeph/secrets.age"),
346    /// )?;
347    /// vault.set_secret_mut("API_KEY".into(), "sk-...".into());
348    /// vault.save()?;
349    /// # Ok::<_, zeph_vault::AgeVaultError>(())
350    /// ```
351    pub fn set_secret_mut(&mut self, key: String, value: String) {
352        self.secrets.insert(key, Zeroizing::new(value));
353    }
354
355    /// Remove a secret from the in-memory map.
356    ///
357    /// Returns `true` if the key existed and was removed, `false` if it was not present.
358    /// Call [`save`][Self::save] afterwards to persist the removal to disk.
359    ///
360    /// # Examples
361    ///
362    /// ```no_run
363    /// use std::path::Path;
364    /// use zeph_vault::AgeVaultProvider;
365    ///
366    /// let mut vault = AgeVaultProvider::load(
367    ///     Path::new("/etc/zeph/vault-key.txt"),
368    ///     Path::new("/etc/zeph/secrets.age"),
369    /// )?;
370    /// let removed = vault.remove_secret_mut("OLD_KEY");
371    /// if removed {
372    ///     vault.save()?;
373    /// }
374    /// # Ok::<_, zeph_vault::AgeVaultError>(())
375    /// ```
376    pub fn remove_secret_mut(&mut self, key: &str) -> bool {
377        self.secrets.remove(key).is_some()
378    }
379
380    /// Return sorted list of secret keys (no values exposed).
381    ///
382    /// Keys are returned in ascending lexicographic order. Secret values are never included.
383    ///
384    /// # Examples
385    ///
386    /// ```no_run
387    /// use std::path::Path;
388    /// use zeph_vault::AgeVaultProvider;
389    ///
390    /// let vault = AgeVaultProvider::load(
391    ///     Path::new("/etc/zeph/vault-key.txt"),
392    ///     Path::new("/etc/zeph/secrets.age"),
393    /// )?;
394    /// for key in vault.list_keys() {
395    ///     println!("{key}");
396    /// }
397    /// # Ok::<_, zeph_vault::AgeVaultError>(())
398    /// ```
399    #[must_use]
400    pub fn list_keys(&self) -> Vec<&str> {
401        let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
402        keys.sort_unstable();
403        keys
404    }
405
406    /// Look up a secret value by key, returning `None` if not present.
407    ///
408    /// Returns a borrowed `&str` tied to the lifetime of the vault. For async use across await
409    /// points, use [`VaultProvider::get_secret`] instead, which returns an owned `String`.
410    ///
411    /// # Examples
412    ///
413    /// ```no_run
414    /// use std::path::Path;
415    /// use zeph_vault::AgeVaultProvider;
416    ///
417    /// let vault = AgeVaultProvider::load(
418    ///     Path::new("/etc/zeph/vault-key.txt"),
419    ///     Path::new("/etc/zeph/secrets.age"),
420    /// )?;
421    /// match vault.get("ZEPH_OPENAI_API_KEY") {
422    ///     Some(key) => println!("key length: {}", key.len()),
423    ///     None => println!("key not configured"),
424    /// }
425    /// # Ok::<_, zeph_vault::AgeVaultError>(())
426    /// ```
427    #[must_use]
428    pub fn get(&self, key: &str) -> Option<&str> {
429        self.secrets.get(key).map(|v| v.as_str())
430    }
431
432    /// Generate a new x25519 keypair, write the key file (mode 0600), and create an empty
433    /// encrypted vault.
434    ///
435    /// Creates `dir` and all missing parent directories before writing files. Existing files
436    /// are not checked — calling this on an already-initialised directory will overwrite both
437    /// the key and the vault, making the old key irrecoverable.
438    ///
439    /// # Output files
440    ///
441    /// | File | Contents | Unix mode |
442    /// |------|----------|-----------|
443    /// | `<dir>/vault-key.txt` | age identity (private + public key comment) | `0600` |
444    /// | `<dir>/secrets.age`   | age-encrypted empty JSON object `{}` | default |
445    ///
446    /// # Errors
447    ///
448    /// Returns [`AgeVaultError`] on key/vault write failure or encryption failure.
449    ///
450    /// # Examples
451    ///
452    /// ```no_run
453    /// use std::path::Path;
454    /// use zeph_vault::AgeVaultProvider;
455    ///
456    /// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
457    /// // /etc/zeph/vault-key.txt and /etc/zeph/secrets.age are now ready.
458    /// # Ok::<_, zeph_vault::AgeVaultError>(())
459    /// ```
460    pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
461        use age::secrecy::ExposeSecret as _;
462
463        std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
464
465        let identity = age::x25519::Identity::generate();
466        let public_key = identity.to_public();
467
468        let key_content = Zeroizing::new(format!(
469            "# public key: {}\n{}\n",
470            public_key,
471            identity.to_string().expose_secret()
472        ));
473
474        let key_path = dir.join("vault-key.txt");
475        write_private_file(&key_path, key_content.as_bytes())?;
476
477        let vault_path = dir.join("secrets.age");
478        let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
479        let ciphertext = encrypt_secrets(&identity, &empty)?;
480        atomic_write(&vault_path, &ciphertext)?;
481
482        println!("Vault initialized:");
483        println!("  Key:   {}", key_path.display());
484        println!("  Vault: {}", vault_path.display());
485
486        Ok(())
487    }
488}
489
490/// Return the default vault directory for the current platform.
491///
492/// Resolution order:
493/// 1. `$XDG_CONFIG_HOME/zeph` (Linux / BSD)
494/// 2. `$APPDATA/zeph` (Windows)
495/// 3. `$HOME/.config/zeph` (macOS fallback and others)
496///
497/// # Examples
498///
499/// ```
500/// let dir = zeph_vault::default_vault_dir();
501/// // Ends with "zeph" on all platforms.
502/// assert!(dir.ends_with("zeph"));
503/// ```
504#[must_use]
505pub fn default_vault_dir() -> PathBuf {
506    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
507        return PathBuf::from(xdg).join("zeph");
508    }
509    if let Ok(appdata) = std::env::var("APPDATA") {
510        return PathBuf::from(appdata).join("zeph");
511    }
512    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned());
513    PathBuf::from(home).join(".config").join("zeph")
514}
515
516fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
517    let key_line = key_str
518        .lines()
519        .find(|l| !l.starts_with('#') && !l.trim().is_empty())
520        .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
521    key_line
522        .trim()
523        .parse()
524        .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
525}
526
527fn decrypt_secrets(
528    identity: &age::x25519::Identity,
529    ciphertext: &[u8],
530) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
531    let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
532    let mut reader = decryptor
533        .decrypt(std::iter::once(identity as &dyn age::Identity))
534        .map_err(AgeVaultError::Decrypt)?;
535    let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
536    reader
537        .read_to_end(&mut plaintext)
538        .map_err(AgeVaultError::Io)?;
539    let raw: BTreeMap<String, String> =
540        serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
541    Ok(raw
542        .into_iter()
543        .map(|(k, v)| (k, Zeroizing::new(v)))
544        .collect())
545}
546
547fn encrypt_secrets(
548    identity: &age::x25519::Identity,
549    secrets: &BTreeMap<String, Zeroizing<String>>,
550) -> Result<Vec<u8>, AgeVaultError> {
551    let recipient = identity.to_public();
552    let encryptor =
553        age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
554            .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
555    let plain: BTreeMap<&str, &str> = secrets
556        .iter()
557        .map(|(k, v)| (k.as_str(), v.as_str()))
558        .collect();
559    let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
560    let mut ciphertext = Vec::with_capacity(json.len() + 64);
561    let mut writer = encryptor
562        .wrap_output(&mut ciphertext)
563        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
564    writer.write_all(&json).map_err(AgeVaultError::Io)?;
565    writer
566        .finish()
567        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
568    Ok(ciphertext)
569}
570
571fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
572    zeph_common::fs_secure::atomic_write_private(path, data).map_err(AgeVaultError::VaultWrite)
573}
574
575fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
576    zeph_common::fs_secure::write_private(path, data).map_err(AgeVaultError::KeyWrite)
577}
578
579impl VaultProvider for AgeVaultProvider {
580    fn get_secret(
581        &self,
582        key: &str,
583    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
584        let result = self.secrets.get(key).map(|v| (**v).clone());
585        Box::pin(async move { Ok(result) })
586    }
587
588    fn list_keys(&self) -> Vec<String> {
589        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
590        keys.sort_unstable();
591        keys
592    }
593}
594
595impl VaultProvider for EnvVaultProvider {
596    fn get_secret(
597        &self,
598        key: &str,
599    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
600        let key = key.to_owned();
601        Box::pin(async move { Ok(std::env::var(&key).ok()) })
602    }
603
604    fn list_keys(&self) -> Vec<String> {
605        let mut keys: Vec<String> = std::env::vars()
606            .filter(|(k, _)| k.starts_with("ZEPH_SECRET_"))
607            .map(|(k, _)| k)
608            .collect();
609        keys.sort_unstable();
610        keys
611    }
612}
613
614/// [`VaultProvider`] wrapper around `Arc<RwLock<AgeVaultProvider>>`.
615///
616/// Allows the age vault `Arc` to be stored as `Box<dyn VaultProvider>` while the
617/// underlying `Arc<RwLock<AgeVaultProvider>>` is separately held for OAuth credential
618/// persistence via `VaultCredentialStore`.
619///
620/// # Examples
621///
622/// ```no_run
623/// use std::sync::Arc;
624/// use tokio::sync::RwLock;
625/// use zeph_vault::{AgeVaultProvider, ArcAgeVaultProvider, VaultProvider};
626/// use std::path::Path;
627///
628/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
629/// let age = AgeVaultProvider::new(
630///     Path::new("/etc/zeph/vault-key.txt"),
631///     Path::new("/etc/zeph/secrets.age"),
632/// )?;
633/// let shared = Arc::new(RwLock::new(age));
634/// let provider: Box<dyn VaultProvider> = Box::new(ArcAgeVaultProvider(Arc::clone(&shared)));
635///
636/// // Both `provider` and `shared` are usable concurrently.
637/// let value = provider.get_secret("MY_KEY").await?;
638/// # Ok(())
639/// # }
640/// ```
641pub struct ArcAgeVaultProvider(pub Arc<tokio::sync::RwLock<AgeVaultProvider>>);
642
643impl VaultProvider for ArcAgeVaultProvider {
644    fn get_secret(
645        &self,
646        key: &str,
647    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
648        let arc = Arc::clone(&self.0);
649        let key = key.to_owned();
650        Box::pin(async move {
651            let guard = arc.read().await;
652            Ok(guard.get(&key).map(str::to_owned))
653        })
654    }
655
656    fn list_keys(&self) -> Vec<String> {
657        // block_in_place is required because list_keys is a sync trait method that may be called
658        // from within a tokio async context (e.g. resolve_secrets). blocking_read() panics there.
659        let arc = Arc::clone(&self.0);
660        let guard = tokio::task::block_in_place(|| arc.blocking_read());
661        let mut keys: Vec<String> = guard.list_keys().iter().map(|s| (*s).to_owned()).collect();
662        keys.sort_unstable();
663        keys
664    }
665}
666
667/// In-memory vault backend for tests and mocking.
668///
669/// Available when the `mock` feature is enabled or in `#[cfg(test)]` contexts.
670///
671/// Secrets are stored in a plain `BTreeMap`. An additional `listed_only` list allows tests
672/// to simulate keys that appear in [`list_keys`][VaultProvider::list_keys] but for which
673/// [`get_secret`][VaultProvider::get_secret] returns `None` (e.g. to test missing-key
674/// handling in callers that enumerate keys before fetching).
675///
676/// # Examples
677///
678/// ```no_run
679/// use zeph_vault::{MockVaultProvider, VaultProvider as _};
680///
681/// # #[tokio::main]
682/// # async fn example() {
683/// let vault = MockVaultProvider::new()
684///     .with_secret("API_KEY", "sk-test-123")
685///     .with_listed_key("GHOST_KEY");
686///
687/// let val = vault.get_secret("API_KEY").await.unwrap();
688/// assert_eq!(val.as_deref(), Some("sk-test-123"));
689///
690/// // GHOST_KEY appears in list_keys() but get_secret returns None
691/// assert!(vault.list_keys().contains(&"GHOST_KEY".to_owned()));
692/// let ghost = vault.get_secret("GHOST_KEY").await.unwrap();
693/// assert!(ghost.is_none());
694/// # }
695/// ```
696#[cfg(any(test, feature = "mock"))]
697#[derive(Default)]
698pub struct MockVaultProvider {
699    secrets: std::collections::BTreeMap<String, String>,
700    /// Keys returned by `list_keys()` but absent from secrets (simulates `get_secret` returning
701    /// `None`).
702    listed_only: Vec<String>,
703}
704
705#[cfg(any(test, feature = "mock"))]
706impl MockVaultProvider {
707    /// Create a new empty mock vault.
708    ///
709    /// # Examples
710    ///
711    /// ```
712    /// use zeph_vault::{MockVaultProvider, VaultProvider as _};
713    ///
714    /// let vault = MockVaultProvider::new();
715    /// assert!(vault.list_keys().is_empty());
716    /// ```
717    #[must_use]
718    pub fn new() -> Self {
719        Self::default()
720    }
721
722    /// Add a secret key-value pair to the mock vault.
723    ///
724    /// Follows the builder pattern so calls can be chained.
725    ///
726    /// # Examples
727    ///
728    /// ```
729    /// use zeph_vault::{MockVaultProvider, VaultProvider as _};
730    ///
731    /// let vault = MockVaultProvider::new()
732    ///     .with_secret("A", "alpha")
733    ///     .with_secret("B", "beta");
734    /// assert!(vault.list_keys().contains(&"A".to_owned()));
735    /// assert!(vault.list_keys().contains(&"B".to_owned()));
736    /// ```
737    #[must_use]
738    pub fn with_secret(mut self, key: &str, value: &str) -> Self {
739        self.secrets.insert(key.to_owned(), value.to_owned());
740        self
741    }
742
743    /// Add a key to `list_keys()` without a corresponding `get_secret()` value.
744    ///
745    /// Useful for testing callers that enumerate keys before fetching values — allows
746    /// simulation of race conditions or partially-visible key sets.
747    ///
748    /// # Examples
749    ///
750    /// ```
751    /// use zeph_vault::{MockVaultProvider, VaultProvider as _};
752    ///
753    /// let vault = MockVaultProvider::new().with_listed_key("PHANTOM");
754    /// // PHANTOM is enumerable but has no stored value.
755    /// assert!(vault.list_keys().contains(&"PHANTOM".to_owned()));
756    /// ```
757    #[must_use]
758    pub fn with_listed_key(mut self, key: &str) -> Self {
759        self.listed_only.push(key.to_owned());
760        self
761    }
762}
763
764#[cfg(any(test, feature = "mock"))]
765impl VaultProvider for MockVaultProvider {
766    fn get_secret(
767        &self,
768        key: &str,
769    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
770        let result = self.secrets.get(key).cloned();
771        Box::pin(async move { Ok(result) })
772    }
773
774    fn list_keys(&self) -> Vec<String> {
775        let mut keys: Vec<String> = self
776            .secrets
777            .keys()
778            .cloned()
779            .chain(self.listed_only.iter().cloned())
780            .collect();
781        keys.sort_unstable();
782        keys.dedup();
783        keys
784    }
785}
786
787#[cfg(test)]
788mod tests {
789    #![allow(clippy::doc_markdown)]
790
791    use super::*;
792
793    #[test]
794    fn atomic_write_uses_age_tmp_suffix() {
795        let dir = tempfile::tempdir().unwrap();
796        let path = dir.path().join("vault.age");
797        atomic_write(&path, b"data").unwrap();
798        assert!(path.exists());
799        let tmp = path.with_added_extension("tmp");
800        assert_eq!(tmp.file_name().unwrap(), "vault.age.tmp");
801    }
802
803    #[cfg(unix)]
804    #[test]
805    fn init_vault_sets_0600_on_both_files() {
806        use std::os::unix::fs::PermissionsExt as _;
807        let dir = tempfile::tempdir().unwrap();
808        AgeVaultProvider::init_vault(dir.path()).unwrap();
809        let key_mode = std::fs::metadata(dir.path().join("vault-key.txt"))
810            .unwrap()
811            .permissions()
812            .mode()
813            & 0o777;
814        let vault_mode = std::fs::metadata(dir.path().join("secrets.age"))
815            .unwrap()
816            .permissions()
817            .mode()
818            & 0o777;
819        assert_eq!(key_mode, 0o600, "vault-key.txt must be 0o600");
820        assert_eq!(vault_mode, 0o600, "secrets.age must be 0o600");
821    }
822
823    #[test]
824    fn secret_expose_returns_inner() {
825        let secret = Secret::new("my-api-key");
826        assert_eq!(secret.expose(), "my-api-key");
827    }
828
829    #[test]
830    fn secret_debug_is_redacted() {
831        let secret = Secret::new("my-api-key");
832        assert_eq!(format!("{secret:?}"), "[REDACTED]");
833    }
834
835    #[test]
836    fn secret_display_is_redacted() {
837        let secret = Secret::new("my-api-key");
838        assert_eq!(format!("{secret}"), "[REDACTED]");
839    }
840
841    #[allow(unsafe_code)]
842    #[tokio::test]
843    async fn env_vault_returns_set_var() {
844        let key = "ZEPH_TEST_VAULT_SECRET_SET";
845        unsafe { std::env::set_var(key, "test-value") };
846        let vault = EnvVaultProvider;
847        let result = vault.get_secret(key).await.unwrap();
848        unsafe { std::env::remove_var(key) };
849        assert_eq!(result.as_deref(), Some("test-value"));
850    }
851
852    #[tokio::test]
853    async fn env_vault_returns_none_for_unset() {
854        let vault = EnvVaultProvider;
855        let result = vault
856            .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345")
857            .await
858            .unwrap();
859        assert!(result.is_none());
860    }
861
862    #[tokio::test]
863    async fn mock_vault_returns_configured_secret() {
864        let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123");
865        let result = vault.get_secret("API_KEY").await.unwrap();
866        assert_eq!(result.as_deref(), Some("secret-123"));
867    }
868
869    #[tokio::test]
870    async fn mock_vault_returns_none_for_missing() {
871        let vault = MockVaultProvider::new();
872        let result = vault.get_secret("MISSING").await.unwrap();
873        assert!(result.is_none());
874    }
875
876    #[test]
877    fn secret_from_string() {
878        let s = Secret::new(String::from("test"));
879        assert_eq!(s.expose(), "test");
880    }
881
882    #[test]
883    fn secret_expose_roundtrip() {
884        let s = Secret::new("test");
885        let owned = s.expose().to_owned();
886        let s2 = Secret::new(owned);
887        assert_eq!(s.expose(), s2.expose());
888    }
889
890    #[test]
891    fn secret_deserialize() {
892        let json = "\"my-secret-value\"";
893        let secret: Secret = serde_json::from_str(json).unwrap();
894        assert_eq!(secret.expose(), "my-secret-value");
895        assert_eq!(format!("{secret:?}"), "[REDACTED]");
896    }
897
898    #[test]
899    fn mock_vault_list_keys_sorted() {
900        let vault = MockVaultProvider::new()
901            .with_secret("B_KEY", "v2")
902            .with_secret("A_KEY", "v1")
903            .with_secret("C_KEY", "v3");
904        let mut keys = vault.list_keys();
905        keys.sort_unstable();
906        assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]);
907    }
908
909    #[test]
910    fn mock_vault_list_keys_empty() {
911        let vault = MockVaultProvider::new();
912        assert!(vault.list_keys().is_empty());
913    }
914
915    #[allow(unsafe_code)]
916    #[test]
917    fn env_vault_list_keys_filters_zeph_secret_prefix() {
918        let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999";
919        unsafe { std::env::set_var(key, "v") };
920        let vault = EnvVaultProvider;
921        let keys = vault.list_keys();
922        assert!(keys.contains(&key.to_owned()));
923        unsafe { std::env::remove_var(key) };
924    }
925}
926
927#[cfg(test)]
928mod age_tests {
929    use std::io::Write as _;
930
931    use age::secrecy::ExposeSecret;
932
933    use super::*;
934
935    fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec<u8> {
936        let recipient = identity.to_public();
937        let encryptor =
938            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
939                .expect("encryptor creation");
940        let mut encrypted = vec![];
941        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output");
942        writer
943            .write_all(json.to_string().as_bytes())
944            .expect("write plaintext");
945        writer.finish().expect("finish encryption");
946        encrypted
947    }
948
949    fn write_temp_files(
950        identity: &age::x25519::Identity,
951        ciphertext: &[u8],
952    ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
953        let dir = tempfile::tempdir().expect("tempdir");
954        let key_path = dir.path().join("key.txt");
955        let vault_path = dir.path().join("secrets.age");
956        std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key");
957        std::fs::write(&vault_path, ciphertext).expect("write vault");
958        (dir, key_path, vault_path)
959    }
960
961    #[tokio::test]
962    async fn age_vault_returns_existing_secret() {
963        let identity = age::x25519::Identity::generate();
964        let json = serde_json::json!({"KEY": "value"});
965        let encrypted = encrypt_json(&identity, &json);
966        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
967
968        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
969        let result = vault.get_secret("KEY").await.unwrap();
970        assert_eq!(result.as_deref(), Some("value"));
971    }
972
973    #[tokio::test]
974    async fn age_vault_returns_none_for_missing() {
975        let identity = age::x25519::Identity::generate();
976        let json = serde_json::json!({"KEY": "value"});
977        let encrypted = encrypt_json(&identity, &json);
978        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
979
980        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
981        let result = vault.get_secret("MISSING").await.unwrap();
982        assert!(result.is_none());
983    }
984
985    #[test]
986    fn age_vault_bad_key_file() {
987        let err = AgeVaultProvider::new(
988            Path::new("/nonexistent/key.txt"),
989            Path::new("/nonexistent/vault.age"),
990        )
991        .unwrap_err();
992        assert!(matches!(err, AgeVaultError::KeyRead(_)));
993    }
994
995    #[test]
996    fn age_vault_bad_key_parse() {
997        let dir = tempfile::tempdir().unwrap();
998        let key_path = dir.path().join("bad-key.txt");
999        std::fs::write(&key_path, "not-a-valid-age-key").unwrap();
1000
1001        let vault_path = dir.path().join("vault.age");
1002        std::fs::write(&vault_path, b"dummy").unwrap();
1003
1004        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1005        assert!(matches!(err, AgeVaultError::KeyParse(_)));
1006    }
1007
1008    #[test]
1009    fn age_vault_bad_vault_file() {
1010        let dir = tempfile::tempdir().unwrap();
1011        let identity = age::x25519::Identity::generate();
1012        let key_path = dir.path().join("key.txt");
1013        std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap();
1014
1015        let err =
1016            AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err();
1017        assert!(matches!(err, AgeVaultError::VaultRead(_)));
1018    }
1019
1020    #[test]
1021    fn age_vault_wrong_key() {
1022        let identity = age::x25519::Identity::generate();
1023        let wrong_identity = age::x25519::Identity::generate();
1024        let json = serde_json::json!({"KEY": "value"});
1025        let encrypted = encrypt_json(&identity, &json);
1026        let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted);
1027
1028        let dir2 = tempfile::tempdir().unwrap();
1029        let wrong_key_path = dir2.path().join("wrong-key.txt");
1030        std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap();
1031
1032        let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err();
1033        assert!(matches!(err, AgeVaultError::Decrypt(_)));
1034    }
1035
1036    #[test]
1037    fn age_vault_invalid_json() {
1038        let identity = age::x25519::Identity::generate();
1039        let recipient = identity.to_public();
1040        let encryptor =
1041            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1042                .expect("encryptor");
1043        let mut encrypted = vec![];
1044        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1045        writer.write_all(b"not json").expect("write");
1046        writer.finish().expect("finish");
1047
1048        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1049        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1050        assert!(matches!(err, AgeVaultError::Json(_)));
1051    }
1052
1053    #[test]
1054    fn age_vault_debug_impl() {
1055        let identity = age::x25519::Identity::generate();
1056        let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"});
1057        let encrypted = encrypt_json(&identity, &json);
1058        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1059
1060        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1061        let debug = format!("{vault:?}");
1062        assert!(debug.contains("AgeVaultProvider"));
1063        assert!(debug.contains("[2 secrets]"));
1064        assert!(!debug.contains("value1"));
1065    }
1066
1067    #[tokio::test]
1068    async fn age_vault_key_file_with_comments() {
1069        let identity = age::x25519::Identity::generate();
1070        let json = serde_json::json!({"KEY": "value"});
1071        let encrypted = encrypt_json(&identity, &json);
1072        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1073
1074        let key_with_comments = format!(
1075            "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n",
1076            identity.to_public(),
1077            identity.to_string().expose_secret()
1078        );
1079        std::fs::write(&key_path, &key_with_comments).unwrap();
1080
1081        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1082        let result = vault.get_secret("KEY").await.unwrap();
1083        assert_eq!(result.as_deref(), Some("value"));
1084    }
1085
1086    #[test]
1087    fn age_vault_key_file_only_comments() {
1088        let dir = tempfile::tempdir().unwrap();
1089        let key_path = dir.path().join("comments-only.txt");
1090        std::fs::write(&key_path, "# comment\n# another\n").unwrap();
1091        let vault_path = dir.path().join("vault.age");
1092        std::fs::write(&vault_path, b"dummy").unwrap();
1093
1094        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1095        assert!(matches!(err, AgeVaultError::KeyParse(_)));
1096    }
1097
1098    #[test]
1099    fn age_vault_error_display() {
1100        let key_err =
1101            AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1102        assert!(key_err.to_string().contains("failed to read key file"));
1103
1104        let parse_err = AgeVaultError::KeyParse("bad key".into());
1105        assert!(
1106            parse_err
1107                .to_string()
1108                .contains("failed to parse age identity")
1109        );
1110
1111        let vault_err =
1112            AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1113        assert!(vault_err.to_string().contains("failed to read vault file"));
1114
1115        let enc_err = AgeVaultError::Encrypt("bad".into());
1116        assert!(enc_err.to_string().contains("age encryption failed"));
1117
1118        let write_err = AgeVaultError::VaultWrite(std::io::Error::new(
1119            std::io::ErrorKind::PermissionDenied,
1120            "test",
1121        ));
1122        assert!(write_err.to_string().contains("failed to write vault file"));
1123    }
1124
1125    #[test]
1126    fn age_vault_set_and_list_keys() {
1127        let identity = age::x25519::Identity::generate();
1128        let json = serde_json::json!({"A": "1"});
1129        let encrypted = encrypt_json(&identity, &json);
1130        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1131
1132        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1133        vault.set_secret_mut("B".to_owned(), "2".to_owned());
1134        vault.set_secret_mut("C".to_owned(), "3".to_owned());
1135
1136        let keys = vault.list_keys();
1137        assert_eq!(keys, vec!["A", "B", "C"]);
1138    }
1139
1140    #[test]
1141    fn age_vault_remove_secret() {
1142        let identity = age::x25519::Identity::generate();
1143        let json = serde_json::json!({"X": "val", "Y": "val2"});
1144        let encrypted = encrypt_json(&identity, &json);
1145        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1146
1147        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1148        assert!(vault.remove_secret_mut("X"));
1149        assert!(!vault.remove_secret_mut("NONEXISTENT"));
1150        assert_eq!(vault.list_keys(), vec!["Y"]);
1151    }
1152
1153    #[tokio::test]
1154    async fn age_vault_save_roundtrip() {
1155        let identity = age::x25519::Identity::generate();
1156        let json = serde_json::json!({"ORIG": "value"});
1157        let encrypted = encrypt_json(&identity, &json);
1158        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1159
1160        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1161        vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned());
1162        vault.save().unwrap();
1163
1164        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1165        let result = reloaded.get_secret("NEW_KEY").await.unwrap();
1166        assert_eq!(result.as_deref(), Some("new_value"));
1167
1168        let orig = reloaded.get_secret("ORIG").await.unwrap();
1169        assert_eq!(orig.as_deref(), Some("value"));
1170    }
1171
1172    #[test]
1173    fn age_vault_get_method_returns_str() {
1174        let identity = age::x25519::Identity::generate();
1175        let json = serde_json::json!({"FOO": "bar"});
1176        let encrypted = encrypt_json(&identity, &json);
1177        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1178
1179        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1180        assert_eq!(vault.get("FOO"), Some("bar"));
1181        assert_eq!(vault.get("MISSING"), None);
1182    }
1183
1184    #[test]
1185    fn age_vault_empty_secret_value() {
1186        let identity = age::x25519::Identity::generate();
1187        let json = serde_json::json!({"EMPTY": ""});
1188        let encrypted = encrypt_json(&identity, &json);
1189        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1190
1191        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1192        assert_eq!(vault.get("EMPTY"), Some(""));
1193    }
1194
1195    #[test]
1196    fn age_vault_init_vault() {
1197        let dir = tempfile::tempdir().unwrap();
1198        AgeVaultProvider::init_vault(dir.path()).unwrap();
1199
1200        let key_path = dir.path().join("vault-key.txt");
1201        let vault_path = dir.path().join("secrets.age");
1202        assert!(key_path.exists());
1203        assert!(vault_path.exists());
1204
1205        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1206        assert_eq!(vault.list_keys(), Vec::<&str>::new());
1207    }
1208
1209    #[tokio::test]
1210    async fn age_vault_keys_sorted_after_roundtrip() {
1211        let identity = age::x25519::Identity::generate();
1212        // Insert keys intentionally out of lexicographic order.
1213        let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"});
1214        let encrypted = encrypt_json(&identity, &json);
1215        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1216
1217        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1218        let keys = vault.list_keys();
1219        assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]);
1220    }
1221
1222    #[test]
1223    fn age_vault_save_preserves_key_order() {
1224        let identity = age::x25519::Identity::generate();
1225        let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"});
1226        let encrypted = encrypt_json(&identity, &json);
1227        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1228
1229        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1230        vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned());
1231        vault.save().unwrap();
1232
1233        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1234        let keys = reloaded.list_keys();
1235        assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]);
1236    }
1237
1238    #[test]
1239    fn age_vault_decrypt_returns_btreemap_sorted() {
1240        let identity = age::x25519::Identity::generate();
1241        // Provide keys in reverse order; BTreeMap must sort them on deserialization.
1242        let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#;
1243        let recipient = identity.to_public();
1244        let encryptor =
1245            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1246                .expect("encryptor");
1247        let mut encrypted = vec![];
1248        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1249        writer.write_all(json_str.as_bytes()).expect("write");
1250        writer.finish().expect("finish");
1251
1252        let ciphertext = encrypted;
1253        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1254        let keys: Vec<&str> = secrets.keys().map(String::as_str).collect();
1255        // BTreeMap guarantees lexicographic order regardless of insertion order.
1256        assert_eq!(keys, vec!["alpha", "bar", "zoo"]);
1257    }
1258
1259    #[test]
1260    fn age_vault_into_iter_consumes_all_entries() {
1261        // Regression: drain() was replaced with into_iter(). Verify all entries
1262        // are consumed and values are accessible without data loss.
1263        let identity = age::x25519::Identity::generate();
1264        let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"});
1265        let encrypted = encrypt_json(&identity, &json);
1266        let ciphertext = encrypted;
1267        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1268
1269        let mut pairs: Vec<(String, String)> = secrets
1270            .into_iter()
1271            .map(|(k, v)| (k, v.as_str().to_owned()))
1272            .collect();
1273        pairs.sort_by(|a, b| a.0.cmp(&b.0));
1274
1275        assert_eq!(pairs.len(), 3);
1276        assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned()));
1277        assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned()));
1278        assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned()));
1279    }
1280
1281    use proptest::prelude::*;
1282
1283    proptest! {
1284        #[test]
1285        fn secret_value_roundtrip(s in ".*") {
1286            let secret = Secret::new(s.clone());
1287            assert_eq!(secret.expose(), s.as_str());
1288        }
1289
1290        #[test]
1291        fn secret_debug_always_redacted(s in ".*") {
1292            let secret = Secret::new(s);
1293            assert_eq!(format!("{secret:?}"), "[REDACTED]");
1294        }
1295
1296        #[test]
1297        fn secret_display_always_redacted(s in ".*") {
1298            let secret = Secret::new(s);
1299            assert_eq!(format!("{secret}"), "[REDACTED]");
1300        }
1301    }
1302}