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    let tmp_path = path.with_extension("age.tmp");
573    std::fs::write(&tmp_path, data).map_err(AgeVaultError::VaultWrite)?;
574    std::fs::rename(&tmp_path, path).map_err(AgeVaultError::VaultWrite)
575}
576
577#[cfg(unix)]
578fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
579    use std::os::unix::fs::OpenOptionsExt as _;
580    let mut file = std::fs::OpenOptions::new()
581        .write(true)
582        .create(true)
583        .truncate(true)
584        .mode(0o600)
585        .open(path)
586        .map_err(AgeVaultError::KeyWrite)?;
587    file.write_all(data).map_err(AgeVaultError::KeyWrite)
588}
589
590// TODO: Windows does not enforce file permissions via mode bits; the key file is created
591// without access control restrictions. Consider using Windows ACLs in a follow-up.
592#[cfg(not(unix))]
593fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
594    std::fs::write(path, data).map_err(AgeVaultError::KeyWrite)
595}
596
597impl VaultProvider for AgeVaultProvider {
598    fn get_secret(
599        &self,
600        key: &str,
601    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
602        let result = self.secrets.get(key).map(|v| (**v).clone());
603        Box::pin(async move { Ok(result) })
604    }
605
606    fn list_keys(&self) -> Vec<String> {
607        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
608        keys.sort_unstable();
609        keys
610    }
611}
612
613impl VaultProvider for EnvVaultProvider {
614    fn get_secret(
615        &self,
616        key: &str,
617    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
618        let key = key.to_owned();
619        Box::pin(async move { Ok(std::env::var(&key).ok()) })
620    }
621
622    fn list_keys(&self) -> Vec<String> {
623        let mut keys: Vec<String> = std::env::vars()
624            .filter(|(k, _)| k.starts_with("ZEPH_SECRET_"))
625            .map(|(k, _)| k)
626            .collect();
627        keys.sort_unstable();
628        keys
629    }
630}
631
632/// [`VaultProvider`] wrapper around `Arc<RwLock<AgeVaultProvider>>`.
633///
634/// Allows the age vault `Arc` to be stored as `Box<dyn VaultProvider>` while the
635/// underlying `Arc<RwLock<AgeVaultProvider>>` is separately held for OAuth credential
636/// persistence via `VaultCredentialStore`.
637///
638/// # Examples
639///
640/// ```no_run
641/// use std::sync::Arc;
642/// use tokio::sync::RwLock;
643/// use zeph_vault::{AgeVaultProvider, ArcAgeVaultProvider, VaultProvider};
644/// use std::path::Path;
645///
646/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
647/// let age = AgeVaultProvider::new(
648///     Path::new("/etc/zeph/vault-key.txt"),
649///     Path::new("/etc/zeph/secrets.age"),
650/// )?;
651/// let shared = Arc::new(RwLock::new(age));
652/// let provider: Box<dyn VaultProvider> = Box::new(ArcAgeVaultProvider(Arc::clone(&shared)));
653///
654/// // Both `provider` and `shared` are usable concurrently.
655/// let value = provider.get_secret("MY_KEY").await?;
656/// # Ok(())
657/// # }
658/// ```
659pub struct ArcAgeVaultProvider(pub Arc<tokio::sync::RwLock<AgeVaultProvider>>);
660
661impl VaultProvider for ArcAgeVaultProvider {
662    fn get_secret(
663        &self,
664        key: &str,
665    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
666        let arc = Arc::clone(&self.0);
667        let key = key.to_owned();
668        Box::pin(async move {
669            let guard = arc.read().await;
670            Ok(guard.get(&key).map(str::to_owned))
671        })
672    }
673
674    fn list_keys(&self) -> Vec<String> {
675        // block_in_place is required because list_keys is a sync trait method that may be called
676        // from within a tokio async context (e.g. resolve_secrets). blocking_read() panics there.
677        let arc = Arc::clone(&self.0);
678        let guard = tokio::task::block_in_place(|| arc.blocking_read());
679        let mut keys: Vec<String> = guard.list_keys().iter().map(|s| (*s).to_owned()).collect();
680        keys.sort_unstable();
681        keys
682    }
683}
684
685/// In-memory vault backend for tests and mocking.
686///
687/// Available when the `mock` feature is enabled or in `#[cfg(test)]` contexts.
688///
689/// Secrets are stored in a plain `BTreeMap`. An additional `listed_only` list allows tests
690/// to simulate keys that appear in [`list_keys`][VaultProvider::list_keys] but for which
691/// [`get_secret`][VaultProvider::get_secret] returns `None` (e.g. to test missing-key
692/// handling in callers that enumerate keys before fetching).
693///
694/// # Examples
695///
696/// ```no_run
697/// use zeph_vault::{MockVaultProvider, VaultProvider as _};
698///
699/// # #[tokio::main]
700/// # async fn example() {
701/// let vault = MockVaultProvider::new()
702///     .with_secret("API_KEY", "sk-test-123")
703///     .with_listed_key("GHOST_KEY");
704///
705/// let val = vault.get_secret("API_KEY").await.unwrap();
706/// assert_eq!(val.as_deref(), Some("sk-test-123"));
707///
708/// // GHOST_KEY appears in list_keys() but get_secret returns None
709/// assert!(vault.list_keys().contains(&"GHOST_KEY".to_owned()));
710/// let ghost = vault.get_secret("GHOST_KEY").await.unwrap();
711/// assert!(ghost.is_none());
712/// # }
713/// ```
714#[cfg(any(test, feature = "mock"))]
715#[derive(Default)]
716pub struct MockVaultProvider {
717    secrets: std::collections::BTreeMap<String, String>,
718    /// Keys returned by `list_keys()` but absent from secrets (simulates `get_secret` returning
719    /// `None`).
720    listed_only: Vec<String>,
721}
722
723#[cfg(any(test, feature = "mock"))]
724impl MockVaultProvider {
725    /// Create a new empty mock vault.
726    ///
727    /// # Examples
728    ///
729    /// ```
730    /// use zeph_vault::{MockVaultProvider, VaultProvider as _};
731    ///
732    /// let vault = MockVaultProvider::new();
733    /// assert!(vault.list_keys().is_empty());
734    /// ```
735    #[must_use]
736    pub fn new() -> Self {
737        Self::default()
738    }
739
740    /// Add a secret key-value pair to the mock vault.
741    ///
742    /// Follows the builder pattern so calls can be chained.
743    ///
744    /// # Examples
745    ///
746    /// ```
747    /// use zeph_vault::{MockVaultProvider, VaultProvider as _};
748    ///
749    /// let vault = MockVaultProvider::new()
750    ///     .with_secret("A", "alpha")
751    ///     .with_secret("B", "beta");
752    /// assert!(vault.list_keys().contains(&"A".to_owned()));
753    /// assert!(vault.list_keys().contains(&"B".to_owned()));
754    /// ```
755    #[must_use]
756    pub fn with_secret(mut self, key: &str, value: &str) -> Self {
757        self.secrets.insert(key.to_owned(), value.to_owned());
758        self
759    }
760
761    /// Add a key to `list_keys()` without a corresponding `get_secret()` value.
762    ///
763    /// Useful for testing callers that enumerate keys before fetching values — allows
764    /// simulation of race conditions or partially-visible key sets.
765    ///
766    /// # Examples
767    ///
768    /// ```
769    /// use zeph_vault::{MockVaultProvider, VaultProvider as _};
770    ///
771    /// let vault = MockVaultProvider::new().with_listed_key("PHANTOM");
772    /// // PHANTOM is enumerable but has no stored value.
773    /// assert!(vault.list_keys().contains(&"PHANTOM".to_owned()));
774    /// ```
775    #[must_use]
776    pub fn with_listed_key(mut self, key: &str) -> Self {
777        self.listed_only.push(key.to_owned());
778        self
779    }
780}
781
782#[cfg(any(test, feature = "mock"))]
783impl VaultProvider for MockVaultProvider {
784    fn get_secret(
785        &self,
786        key: &str,
787    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
788        let result = self.secrets.get(key).cloned();
789        Box::pin(async move { Ok(result) })
790    }
791
792    fn list_keys(&self) -> Vec<String> {
793        let mut keys: Vec<String> = self
794            .secrets
795            .keys()
796            .cloned()
797            .chain(self.listed_only.iter().cloned())
798            .collect();
799        keys.sort_unstable();
800        keys.dedup();
801        keys
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    #![allow(clippy::doc_markdown)]
808
809    use super::*;
810
811    #[test]
812    fn secret_expose_returns_inner() {
813        let secret = Secret::new("my-api-key");
814        assert_eq!(secret.expose(), "my-api-key");
815    }
816
817    #[test]
818    fn secret_debug_is_redacted() {
819        let secret = Secret::new("my-api-key");
820        assert_eq!(format!("{secret:?}"), "[REDACTED]");
821    }
822
823    #[test]
824    fn secret_display_is_redacted() {
825        let secret = Secret::new("my-api-key");
826        assert_eq!(format!("{secret}"), "[REDACTED]");
827    }
828
829    #[allow(unsafe_code)]
830    #[tokio::test]
831    async fn env_vault_returns_set_var() {
832        let key = "ZEPH_TEST_VAULT_SECRET_SET";
833        unsafe { std::env::set_var(key, "test-value") };
834        let vault = EnvVaultProvider;
835        let result = vault.get_secret(key).await.unwrap();
836        unsafe { std::env::remove_var(key) };
837        assert_eq!(result.as_deref(), Some("test-value"));
838    }
839
840    #[tokio::test]
841    async fn env_vault_returns_none_for_unset() {
842        let vault = EnvVaultProvider;
843        let result = vault
844            .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345")
845            .await
846            .unwrap();
847        assert!(result.is_none());
848    }
849
850    #[tokio::test]
851    async fn mock_vault_returns_configured_secret() {
852        let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123");
853        let result = vault.get_secret("API_KEY").await.unwrap();
854        assert_eq!(result.as_deref(), Some("secret-123"));
855    }
856
857    #[tokio::test]
858    async fn mock_vault_returns_none_for_missing() {
859        let vault = MockVaultProvider::new();
860        let result = vault.get_secret("MISSING").await.unwrap();
861        assert!(result.is_none());
862    }
863
864    #[test]
865    fn secret_from_string() {
866        let s = Secret::new(String::from("test"));
867        assert_eq!(s.expose(), "test");
868    }
869
870    #[test]
871    fn secret_expose_roundtrip() {
872        let s = Secret::new("test");
873        let owned = s.expose().to_owned();
874        let s2 = Secret::new(owned);
875        assert_eq!(s.expose(), s2.expose());
876    }
877
878    #[test]
879    fn secret_deserialize() {
880        let json = "\"my-secret-value\"";
881        let secret: Secret = serde_json::from_str(json).unwrap();
882        assert_eq!(secret.expose(), "my-secret-value");
883        assert_eq!(format!("{secret:?}"), "[REDACTED]");
884    }
885
886    #[test]
887    fn mock_vault_list_keys_sorted() {
888        let vault = MockVaultProvider::new()
889            .with_secret("B_KEY", "v2")
890            .with_secret("A_KEY", "v1")
891            .with_secret("C_KEY", "v3");
892        let mut keys = vault.list_keys();
893        keys.sort_unstable();
894        assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]);
895    }
896
897    #[test]
898    fn mock_vault_list_keys_empty() {
899        let vault = MockVaultProvider::new();
900        assert!(vault.list_keys().is_empty());
901    }
902
903    #[allow(unsafe_code)]
904    #[test]
905    fn env_vault_list_keys_filters_zeph_secret_prefix() {
906        let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999";
907        unsafe { std::env::set_var(key, "v") };
908        let vault = EnvVaultProvider;
909        let keys = vault.list_keys();
910        assert!(keys.contains(&key.to_owned()));
911        unsafe { std::env::remove_var(key) };
912    }
913}
914
915#[cfg(test)]
916mod age_tests {
917    use std::io::Write as _;
918
919    use age::secrecy::ExposeSecret;
920
921    use super::*;
922
923    fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec<u8> {
924        let recipient = identity.to_public();
925        let encryptor =
926            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
927                .expect("encryptor creation");
928        let mut encrypted = vec![];
929        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output");
930        writer
931            .write_all(json.to_string().as_bytes())
932            .expect("write plaintext");
933        writer.finish().expect("finish encryption");
934        encrypted
935    }
936
937    fn write_temp_files(
938        identity: &age::x25519::Identity,
939        ciphertext: &[u8],
940    ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
941        let dir = tempfile::tempdir().expect("tempdir");
942        let key_path = dir.path().join("key.txt");
943        let vault_path = dir.path().join("secrets.age");
944        std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key");
945        std::fs::write(&vault_path, ciphertext).expect("write vault");
946        (dir, key_path, vault_path)
947    }
948
949    #[tokio::test]
950    async fn age_vault_returns_existing_secret() {
951        let identity = age::x25519::Identity::generate();
952        let json = serde_json::json!({"KEY": "value"});
953        let encrypted = encrypt_json(&identity, &json);
954        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
955
956        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
957        let result = vault.get_secret("KEY").await.unwrap();
958        assert_eq!(result.as_deref(), Some("value"));
959    }
960
961    #[tokio::test]
962    async fn age_vault_returns_none_for_missing() {
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("MISSING").await.unwrap();
970        assert!(result.is_none());
971    }
972
973    #[test]
974    fn age_vault_bad_key_file() {
975        let err = AgeVaultProvider::new(
976            Path::new("/nonexistent/key.txt"),
977            Path::new("/nonexistent/vault.age"),
978        )
979        .unwrap_err();
980        assert!(matches!(err, AgeVaultError::KeyRead(_)));
981    }
982
983    #[test]
984    fn age_vault_bad_key_parse() {
985        let dir = tempfile::tempdir().unwrap();
986        let key_path = dir.path().join("bad-key.txt");
987        std::fs::write(&key_path, "not-a-valid-age-key").unwrap();
988
989        let vault_path = dir.path().join("vault.age");
990        std::fs::write(&vault_path, b"dummy").unwrap();
991
992        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
993        assert!(matches!(err, AgeVaultError::KeyParse(_)));
994    }
995
996    #[test]
997    fn age_vault_bad_vault_file() {
998        let dir = tempfile::tempdir().unwrap();
999        let identity = age::x25519::Identity::generate();
1000        let key_path = dir.path().join("key.txt");
1001        std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap();
1002
1003        let err =
1004            AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err();
1005        assert!(matches!(err, AgeVaultError::VaultRead(_)));
1006    }
1007
1008    #[test]
1009    fn age_vault_wrong_key() {
1010        let identity = age::x25519::Identity::generate();
1011        let wrong_identity = age::x25519::Identity::generate();
1012        let json = serde_json::json!({"KEY": "value"});
1013        let encrypted = encrypt_json(&identity, &json);
1014        let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted);
1015
1016        let dir2 = tempfile::tempdir().unwrap();
1017        let wrong_key_path = dir2.path().join("wrong-key.txt");
1018        std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap();
1019
1020        let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err();
1021        assert!(matches!(err, AgeVaultError::Decrypt(_)));
1022    }
1023
1024    #[test]
1025    fn age_vault_invalid_json() {
1026        let identity = age::x25519::Identity::generate();
1027        let recipient = identity.to_public();
1028        let encryptor =
1029            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1030                .expect("encryptor");
1031        let mut encrypted = vec![];
1032        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1033        writer.write_all(b"not json").expect("write");
1034        writer.finish().expect("finish");
1035
1036        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1037        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1038        assert!(matches!(err, AgeVaultError::Json(_)));
1039    }
1040
1041    #[test]
1042    fn age_vault_debug_impl() {
1043        let identity = age::x25519::Identity::generate();
1044        let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"});
1045        let encrypted = encrypt_json(&identity, &json);
1046        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1047
1048        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1049        let debug = format!("{vault:?}");
1050        assert!(debug.contains("AgeVaultProvider"));
1051        assert!(debug.contains("[2 secrets]"));
1052        assert!(!debug.contains("value1"));
1053    }
1054
1055    #[tokio::test]
1056    async fn age_vault_key_file_with_comments() {
1057        let identity = age::x25519::Identity::generate();
1058        let json = serde_json::json!({"KEY": "value"});
1059        let encrypted = encrypt_json(&identity, &json);
1060        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1061
1062        let key_with_comments = format!(
1063            "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n",
1064            identity.to_public(),
1065            identity.to_string().expose_secret()
1066        );
1067        std::fs::write(&key_path, &key_with_comments).unwrap();
1068
1069        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
1070        let result = vault.get_secret("KEY").await.unwrap();
1071        assert_eq!(result.as_deref(), Some("value"));
1072    }
1073
1074    #[test]
1075    fn age_vault_key_file_only_comments() {
1076        let dir = tempfile::tempdir().unwrap();
1077        let key_path = dir.path().join("comments-only.txt");
1078        std::fs::write(&key_path, "# comment\n# another\n").unwrap();
1079        let vault_path = dir.path().join("vault.age");
1080        std::fs::write(&vault_path, b"dummy").unwrap();
1081
1082        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
1083        assert!(matches!(err, AgeVaultError::KeyParse(_)));
1084    }
1085
1086    #[test]
1087    fn age_vault_error_display() {
1088        let key_err =
1089            AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1090        assert!(key_err.to_string().contains("failed to read key file"));
1091
1092        let parse_err = AgeVaultError::KeyParse("bad key".into());
1093        assert!(
1094            parse_err
1095                .to_string()
1096                .contains("failed to parse age identity")
1097        );
1098
1099        let vault_err =
1100            AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
1101        assert!(vault_err.to_string().contains("failed to read vault file"));
1102
1103        let enc_err = AgeVaultError::Encrypt("bad".into());
1104        assert!(enc_err.to_string().contains("age encryption failed"));
1105
1106        let write_err = AgeVaultError::VaultWrite(std::io::Error::new(
1107            std::io::ErrorKind::PermissionDenied,
1108            "test",
1109        ));
1110        assert!(write_err.to_string().contains("failed to write vault file"));
1111    }
1112
1113    #[test]
1114    fn age_vault_set_and_list_keys() {
1115        let identity = age::x25519::Identity::generate();
1116        let json = serde_json::json!({"A": "1"});
1117        let encrypted = encrypt_json(&identity, &json);
1118        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1119
1120        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1121        vault.set_secret_mut("B".to_owned(), "2".to_owned());
1122        vault.set_secret_mut("C".to_owned(), "3".to_owned());
1123
1124        let keys = vault.list_keys();
1125        assert_eq!(keys, vec!["A", "B", "C"]);
1126    }
1127
1128    #[test]
1129    fn age_vault_remove_secret() {
1130        let identity = age::x25519::Identity::generate();
1131        let json = serde_json::json!({"X": "val", "Y": "val2"});
1132        let encrypted = encrypt_json(&identity, &json);
1133        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1134
1135        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1136        assert!(vault.remove_secret_mut("X"));
1137        assert!(!vault.remove_secret_mut("NONEXISTENT"));
1138        assert_eq!(vault.list_keys(), vec!["Y"]);
1139    }
1140
1141    #[tokio::test]
1142    async fn age_vault_save_roundtrip() {
1143        let identity = age::x25519::Identity::generate();
1144        let json = serde_json::json!({"ORIG": "value"});
1145        let encrypted = encrypt_json(&identity, &json);
1146        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1147
1148        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1149        vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned());
1150        vault.save().unwrap();
1151
1152        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1153        let result = reloaded.get_secret("NEW_KEY").await.unwrap();
1154        assert_eq!(result.as_deref(), Some("new_value"));
1155
1156        let orig = reloaded.get_secret("ORIG").await.unwrap();
1157        assert_eq!(orig.as_deref(), Some("value"));
1158    }
1159
1160    #[test]
1161    fn age_vault_get_method_returns_str() {
1162        let identity = age::x25519::Identity::generate();
1163        let json = serde_json::json!({"FOO": "bar"});
1164        let encrypted = encrypt_json(&identity, &json);
1165        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1166
1167        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1168        assert_eq!(vault.get("FOO"), Some("bar"));
1169        assert_eq!(vault.get("MISSING"), None);
1170    }
1171
1172    #[test]
1173    fn age_vault_empty_secret_value() {
1174        let identity = age::x25519::Identity::generate();
1175        let json = serde_json::json!({"EMPTY": ""});
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("EMPTY"), Some(""));
1181    }
1182
1183    #[test]
1184    fn age_vault_init_vault() {
1185        let dir = tempfile::tempdir().unwrap();
1186        AgeVaultProvider::init_vault(dir.path()).unwrap();
1187
1188        let key_path = dir.path().join("vault-key.txt");
1189        let vault_path = dir.path().join("secrets.age");
1190        assert!(key_path.exists());
1191        assert!(vault_path.exists());
1192
1193        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1194        assert_eq!(vault.list_keys(), Vec::<&str>::new());
1195    }
1196
1197    #[tokio::test]
1198    async fn age_vault_keys_sorted_after_roundtrip() {
1199        let identity = age::x25519::Identity::generate();
1200        // Insert keys intentionally out of lexicographic order.
1201        let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"});
1202        let encrypted = encrypt_json(&identity, &json);
1203        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1204
1205        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1206        let keys = vault.list_keys();
1207        assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]);
1208    }
1209
1210    #[test]
1211    fn age_vault_save_preserves_key_order() {
1212        let identity = age::x25519::Identity::generate();
1213        let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"});
1214        let encrypted = encrypt_json(&identity, &json);
1215        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
1216
1217        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1218        vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned());
1219        vault.save().unwrap();
1220
1221        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
1222        let keys = reloaded.list_keys();
1223        assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]);
1224    }
1225
1226    #[test]
1227    fn age_vault_decrypt_returns_btreemap_sorted() {
1228        let identity = age::x25519::Identity::generate();
1229        // Provide keys in reverse order; BTreeMap must sort them on deserialization.
1230        let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#;
1231        let recipient = identity.to_public();
1232        let encryptor =
1233            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
1234                .expect("encryptor");
1235        let mut encrypted = vec![];
1236        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
1237        writer.write_all(json_str.as_bytes()).expect("write");
1238        writer.finish().expect("finish");
1239
1240        let ciphertext = encrypted;
1241        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1242        let keys: Vec<&str> = secrets.keys().map(String::as_str).collect();
1243        // BTreeMap guarantees lexicographic order regardless of insertion order.
1244        assert_eq!(keys, vec!["alpha", "bar", "zoo"]);
1245    }
1246
1247    #[test]
1248    fn age_vault_into_iter_consumes_all_entries() {
1249        // Regression: drain() was replaced with into_iter(). Verify all entries
1250        // are consumed and values are accessible without data loss.
1251        let identity = age::x25519::Identity::generate();
1252        let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"});
1253        let encrypted = encrypt_json(&identity, &json);
1254        let ciphertext = encrypted;
1255        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
1256
1257        let mut pairs: Vec<(String, String)> = secrets
1258            .into_iter()
1259            .map(|(k, v)| (k, v.as_str().to_owned()))
1260            .collect();
1261        pairs.sort_by(|a, b| a.0.cmp(&b.0));
1262
1263        assert_eq!(pairs.len(), 3);
1264        assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned()));
1265        assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned()));
1266        assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned()));
1267    }
1268
1269    use proptest::prelude::*;
1270
1271    proptest! {
1272        #[test]
1273        fn secret_value_roundtrip(s in ".*") {
1274            let secret = Secret::new(s.clone());
1275            assert_eq!(secret.expose(), s.as_str());
1276        }
1277
1278        #[test]
1279        fn secret_debug_always_redacted(s in ".*") {
1280            let secret = Secret::new(s);
1281            assert_eq!(format!("{secret:?}"), "[REDACTED]");
1282        }
1283
1284        #[test]
1285        fn secret_display_always_redacted(s in ".*") {
1286            let secret = Secret::new(s);
1287            assert_eq!(format!("{secret}"), "[REDACTED]");
1288        }
1289    }
1290}