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
4use std::collections::BTreeMap;
5use std::fmt;
6use std::future::Future;
7use std::io::{Read as _, Write as _};
8use std::path::{Path, PathBuf};
9use std::pin::Pin;
10use std::sync::Arc;
11
12use zeroize::Zeroizing;
13
14// Secret and VaultError live in zeph-common (Layer 0) so that zeph-config (Layer 1)
15// can reference them without creating a circular dependency.
16pub use zeph_common::secret::{Secret, VaultError};
17
18/// Pluggable secret retrieval backend.
19pub trait VaultProvider: Send + Sync {
20    /// Retrieve a secret by key.
21    ///
22    /// Returns `Ok(None)` when the key does not exist. Returns `Err(VaultError)` on
23    /// backend failures (I/O, decryption, network, etc.).
24    fn get_secret(
25        &self,
26        key: &str,
27    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>>;
28
29    /// Return all known secret keys. Used for scanning `ZEPH_SECRET_*` prefixes.
30    fn list_keys(&self) -> Vec<String> {
31        Vec::new()
32    }
33}
34
35/// MVP vault backend that reads secrets from environment variables.
36pub struct EnvVaultProvider;
37
38#[derive(Debug, thiserror::Error)]
39pub enum AgeVaultError {
40    #[error("failed to read key file: {0}")]
41    KeyRead(std::io::Error),
42    #[error("failed to parse age identity: {0}")]
43    KeyParse(String),
44    #[error("failed to read vault file: {0}")]
45    VaultRead(std::io::Error),
46    #[error("age decryption failed: {0}")]
47    Decrypt(age::DecryptError),
48    #[error("I/O error during decryption: {0}")]
49    Io(std::io::Error),
50    #[error("invalid JSON in vault: {0}")]
51    Json(serde_json::Error),
52    #[error("age encryption failed: {0}")]
53    Encrypt(String),
54    #[error("failed to write vault file: {0}")]
55    VaultWrite(std::io::Error),
56    #[error("failed to write key file: {0}")]
57    KeyWrite(std::io::Error),
58}
59
60pub struct AgeVaultProvider {
61    secrets: BTreeMap<String, Zeroizing<String>>,
62    key_path: PathBuf,
63    vault_path: PathBuf,
64}
65
66impl fmt::Debug for AgeVaultProvider {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_struct("AgeVaultProvider")
69            .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
70            .field("key_path", &self.key_path)
71            .field("vault_path", &self.vault_path)
72            .finish()
73    }
74}
75
76impl AgeVaultProvider {
77    /// Decrypt an age-encrypted JSON secrets file.
78    ///
79    /// `key_path` — path to the age identity (private key) file.
80    /// `vault_path` — path to the age-encrypted JSON file.
81    ///
82    /// # Errors
83    ///
84    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
85    pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
86        Self::load(key_path, vault_path)
87    }
88
89    /// Load vault from disk, storing paths for subsequent write operations.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
94    pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
95        let key_str =
96            Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
97        let identity = parse_identity(&key_str)?;
98        let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
99        let secrets = decrypt_secrets(&identity, &ciphertext)?;
100        Ok(Self {
101            secrets,
102            key_path: key_path.to_owned(),
103            vault_path: vault_path.to_owned(),
104        })
105    }
106
107    /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename).
108    ///
109    /// # Errors
110    ///
111    /// Returns [`AgeVaultError`] on encryption or write failure.
112    ///
113    /// Note: re-reads and re-parses the key file on each call. For CLI one-shot use this
114    /// is acceptable; if used in a long-lived context consider caching the parsed identity.
115    pub fn save(&self) -> Result<(), AgeVaultError> {
116        let key_str = Zeroizing::new(
117            std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
118        );
119        let identity = parse_identity(&key_str)?;
120        let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
121        atomic_write(&self.vault_path, &ciphertext)
122    }
123
124    /// Insert or update a secret in the in-memory map.
125    pub fn set_secret_mut(&mut self, key: String, value: String) {
126        self.secrets.insert(key, Zeroizing::new(value));
127    }
128
129    /// Remove a secret from the in-memory map. Returns `true` if the key existed.
130    pub fn remove_secret_mut(&mut self, key: &str) -> bool {
131        self.secrets.remove(key).is_some()
132    }
133
134    /// Return sorted list of secret keys (no values exposed).
135    #[must_use]
136    pub fn list_keys(&self) -> Vec<&str> {
137        let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
138        keys.sort_unstable();
139        keys
140    }
141
142    /// Look up a secret value by key, returning `None` if not present.
143    #[must_use]
144    pub fn get(&self, key: &str) -> Option<&str> {
145        self.secrets.get(key).map(|v| v.as_str())
146    }
147
148    /// Generate a new x25519 keypair, write key file (mode 0600), and create an empty encrypted vault.
149    ///
150    /// Outputs:
151    /// - `<dir>/vault-key.txt` — age identity (private + public key comment)
152    /// - `<dir>/secrets.age`  — age-encrypted empty JSON object
153    ///
154    /// # Errors
155    ///
156    /// Returns [`AgeVaultError`] on key/vault write failure or encryption failure.
157    pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
158        use age::secrecy::ExposeSecret as _;
159
160        std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
161
162        let identity = age::x25519::Identity::generate();
163        let public_key = identity.to_public();
164
165        let key_content = Zeroizing::new(format!(
166            "# public key: {}\n{}\n",
167            public_key,
168            identity.to_string().expose_secret()
169        ));
170
171        let key_path = dir.join("vault-key.txt");
172        write_private_file(&key_path, key_content.as_bytes())?;
173
174        let vault_path = dir.join("secrets.age");
175        let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
176        let ciphertext = encrypt_secrets(&identity, &empty)?;
177        atomic_write(&vault_path, &ciphertext)?;
178
179        println!("Vault initialized:");
180        println!("  Key:   {}", key_path.display());
181        println!("  Vault: {}", vault_path.display());
182
183        Ok(())
184    }
185}
186
187/// Default vault directory: `$XDG_CONFIG_HOME/zeph`, `$APPDATA/zeph`, or `~/.config/zeph`.
188#[must_use]
189pub fn default_vault_dir() -> PathBuf {
190    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
191        return PathBuf::from(xdg).join("zeph");
192    }
193    if let Ok(appdata) = std::env::var("APPDATA") {
194        return PathBuf::from(appdata).join("zeph");
195    }
196    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned());
197    PathBuf::from(home).join(".config").join("zeph")
198}
199
200fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
201    let key_line = key_str
202        .lines()
203        .find(|l| !l.starts_with('#') && !l.trim().is_empty())
204        .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
205    key_line
206        .trim()
207        .parse()
208        .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
209}
210
211fn decrypt_secrets(
212    identity: &age::x25519::Identity,
213    ciphertext: &[u8],
214) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
215    let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
216    let mut reader = decryptor
217        .decrypt(std::iter::once(identity as &dyn age::Identity))
218        .map_err(AgeVaultError::Decrypt)?;
219    let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
220    reader
221        .read_to_end(&mut plaintext)
222        .map_err(AgeVaultError::Io)?;
223    let raw: BTreeMap<String, String> =
224        serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
225    Ok(raw
226        .into_iter()
227        .map(|(k, v)| (k, Zeroizing::new(v)))
228        .collect())
229}
230
231fn encrypt_secrets(
232    identity: &age::x25519::Identity,
233    secrets: &BTreeMap<String, Zeroizing<String>>,
234) -> Result<Vec<u8>, AgeVaultError> {
235    let recipient = identity.to_public();
236    let encryptor =
237        age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
238            .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
239    let plain: BTreeMap<&str, &str> = secrets
240        .iter()
241        .map(|(k, v)| (k.as_str(), v.as_str()))
242        .collect();
243    let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
244    let mut ciphertext = Vec::with_capacity(json.len() + 64);
245    let mut writer = encryptor
246        .wrap_output(&mut ciphertext)
247        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
248    writer.write_all(&json).map_err(AgeVaultError::Io)?;
249    writer
250        .finish()
251        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
252    Ok(ciphertext)
253}
254
255fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
256    let tmp_path = path.with_extension("age.tmp");
257    std::fs::write(&tmp_path, data).map_err(AgeVaultError::VaultWrite)?;
258    std::fs::rename(&tmp_path, path).map_err(AgeVaultError::VaultWrite)
259}
260
261#[cfg(unix)]
262fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
263    use std::os::unix::fs::OpenOptionsExt as _;
264    let mut file = std::fs::OpenOptions::new()
265        .write(true)
266        .create(true)
267        .truncate(true)
268        .mode(0o600)
269        .open(path)
270        .map_err(AgeVaultError::KeyWrite)?;
271    file.write_all(data).map_err(AgeVaultError::KeyWrite)
272}
273
274// TODO: Windows does not enforce file permissions via mode bits; the key file is created
275// without access control restrictions. Consider using Windows ACLs in a follow-up.
276#[cfg(not(unix))]
277fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
278    std::fs::write(path, data).map_err(AgeVaultError::KeyWrite)
279}
280
281impl VaultProvider for AgeVaultProvider {
282    fn get_secret(
283        &self,
284        key: &str,
285    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
286        let result = self.secrets.get(key).map(|v| (**v).clone());
287        Box::pin(async move { Ok(result) })
288    }
289
290    fn list_keys(&self) -> Vec<String> {
291        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
292        keys.sort_unstable();
293        keys
294    }
295}
296
297impl VaultProvider for EnvVaultProvider {
298    fn get_secret(
299        &self,
300        key: &str,
301    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
302        let key = key.to_owned();
303        Box::pin(async move { Ok(std::env::var(&key).ok()) })
304    }
305
306    fn list_keys(&self) -> Vec<String> {
307        let mut keys: Vec<String> = std::env::vars()
308            .filter(|(k, _)| k.starts_with("ZEPH_SECRET_"))
309            .map(|(k, _)| k)
310            .collect();
311        keys.sort_unstable();
312        keys
313    }
314}
315
316/// `VaultProvider` wrapper around `Arc<RwLock<AgeVaultProvider>>`.
317///
318/// Allows the age vault `Arc` to be stored as `Box<dyn VaultProvider>` while the
319/// underlying `Arc<RwLock<AgeVaultProvider>>` is separately held for OAuth credential
320/// persistence via `VaultCredentialStore`.
321pub struct ArcAgeVaultProvider(pub Arc<tokio::sync::RwLock<AgeVaultProvider>>);
322
323impl VaultProvider for ArcAgeVaultProvider {
324    fn get_secret(
325        &self,
326        key: &str,
327    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
328        let arc = Arc::clone(&self.0);
329        let key = key.to_owned();
330        Box::pin(async move {
331            let guard = arc.read().await;
332            Ok(guard.get(&key).map(str::to_owned))
333        })
334    }
335
336    fn list_keys(&self) -> Vec<String> {
337        // block_in_place is required because list_keys is a sync trait method that may be called
338        // from within a tokio async context (e.g. resolve_secrets). blocking_read() panics there.
339        let arc = Arc::clone(&self.0);
340        let guard = tokio::task::block_in_place(|| arc.blocking_read());
341        let mut keys: Vec<String> = guard.list_keys().iter().map(|s| (*s).to_owned()).collect();
342        keys.sort_unstable();
343        keys
344    }
345}
346
347/// Test helper with BTreeMap-based secret storage.
348#[cfg(any(test, feature = "mock"))]
349#[derive(Default)]
350pub struct MockVaultProvider {
351    secrets: std::collections::BTreeMap<String, String>,
352    /// Keys returned by `list_keys()` but absent from secrets (simulates `get_secret` returning
353    /// `None`).
354    listed_only: Vec<String>,
355}
356
357#[cfg(any(test, feature = "mock"))]
358impl MockVaultProvider {
359    #[must_use]
360    pub fn new() -> Self {
361        Self::default()
362    }
363
364    #[must_use]
365    pub fn with_secret(mut self, key: &str, value: &str) -> Self {
366        self.secrets.insert(key.to_owned(), value.to_owned());
367        self
368    }
369
370    /// Add a key to `list_keys()` without a corresponding `get_secret()` value.
371    #[must_use]
372    pub fn with_listed_key(mut self, key: &str) -> Self {
373        self.listed_only.push(key.to_owned());
374        self
375    }
376}
377
378#[cfg(any(test, feature = "mock"))]
379impl VaultProvider for MockVaultProvider {
380    fn get_secret(
381        &self,
382        key: &str,
383    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
384        let result = self.secrets.get(key).cloned();
385        Box::pin(async move { Ok(result) })
386    }
387
388    fn list_keys(&self) -> Vec<String> {
389        let mut keys: Vec<String> = self
390            .secrets
391            .keys()
392            .cloned()
393            .chain(self.listed_only.iter().cloned())
394            .collect();
395        keys.sort_unstable();
396        keys.dedup();
397        keys
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    #![allow(clippy::doc_markdown)]
404
405    use super::*;
406
407    #[test]
408    fn secret_expose_returns_inner() {
409        let secret = Secret::new("my-api-key");
410        assert_eq!(secret.expose(), "my-api-key");
411    }
412
413    #[test]
414    fn secret_debug_is_redacted() {
415        let secret = Secret::new("my-api-key");
416        assert_eq!(format!("{secret:?}"), "[REDACTED]");
417    }
418
419    #[test]
420    fn secret_display_is_redacted() {
421        let secret = Secret::new("my-api-key");
422        assert_eq!(format!("{secret}"), "[REDACTED]");
423    }
424
425    #[allow(unsafe_code)]
426    #[tokio::test]
427    async fn env_vault_returns_set_var() {
428        let key = "ZEPH_TEST_VAULT_SECRET_SET";
429        unsafe { std::env::set_var(key, "test-value") };
430        let vault = EnvVaultProvider;
431        let result = vault.get_secret(key).await.unwrap();
432        unsafe { std::env::remove_var(key) };
433        assert_eq!(result.as_deref(), Some("test-value"));
434    }
435
436    #[tokio::test]
437    async fn env_vault_returns_none_for_unset() {
438        let vault = EnvVaultProvider;
439        let result = vault
440            .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345")
441            .await
442            .unwrap();
443        assert!(result.is_none());
444    }
445
446    #[tokio::test]
447    async fn mock_vault_returns_configured_secret() {
448        let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123");
449        let result = vault.get_secret("API_KEY").await.unwrap();
450        assert_eq!(result.as_deref(), Some("secret-123"));
451    }
452
453    #[tokio::test]
454    async fn mock_vault_returns_none_for_missing() {
455        let vault = MockVaultProvider::new();
456        let result = vault.get_secret("MISSING").await.unwrap();
457        assert!(result.is_none());
458    }
459
460    #[test]
461    fn secret_from_string() {
462        let s = Secret::new(String::from("test"));
463        assert_eq!(s.expose(), "test");
464    }
465
466    #[test]
467    fn secret_expose_roundtrip() {
468        let s = Secret::new("test");
469        let owned = s.expose().to_owned();
470        let s2 = Secret::new(owned);
471        assert_eq!(s.expose(), s2.expose());
472    }
473
474    #[test]
475    fn secret_deserialize() {
476        let json = "\"my-secret-value\"";
477        let secret: Secret = serde_json::from_str(json).unwrap();
478        assert_eq!(secret.expose(), "my-secret-value");
479        assert_eq!(format!("{secret:?}"), "[REDACTED]");
480    }
481
482    #[test]
483    fn mock_vault_list_keys_sorted() {
484        let vault = MockVaultProvider::new()
485            .with_secret("B_KEY", "v2")
486            .with_secret("A_KEY", "v1")
487            .with_secret("C_KEY", "v3");
488        let mut keys = vault.list_keys();
489        keys.sort_unstable();
490        assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]);
491    }
492
493    #[test]
494    fn mock_vault_list_keys_empty() {
495        let vault = MockVaultProvider::new();
496        assert!(vault.list_keys().is_empty());
497    }
498
499    #[allow(unsafe_code)]
500    #[test]
501    fn env_vault_list_keys_filters_zeph_secret_prefix() {
502        let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999";
503        unsafe { std::env::set_var(key, "v") };
504        let vault = EnvVaultProvider;
505        let keys = vault.list_keys();
506        assert!(keys.contains(&key.to_owned()));
507        unsafe { std::env::remove_var(key) };
508    }
509}
510
511#[cfg(test)]
512mod age_tests {
513    use std::io::Write as _;
514
515    use age::secrecy::ExposeSecret;
516
517    use super::*;
518
519    fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec<u8> {
520        let recipient = identity.to_public();
521        let encryptor =
522            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
523                .expect("encryptor creation");
524        let mut encrypted = vec![];
525        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output");
526        writer
527            .write_all(json.to_string().as_bytes())
528            .expect("write plaintext");
529        writer.finish().expect("finish encryption");
530        encrypted
531    }
532
533    fn write_temp_files(
534        identity: &age::x25519::Identity,
535        ciphertext: &[u8],
536    ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
537        let dir = tempfile::tempdir().expect("tempdir");
538        let key_path = dir.path().join("key.txt");
539        let vault_path = dir.path().join("secrets.age");
540        std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key");
541        std::fs::write(&vault_path, ciphertext).expect("write vault");
542        (dir, key_path, vault_path)
543    }
544
545    #[tokio::test]
546    async fn age_vault_returns_existing_secret() {
547        let identity = age::x25519::Identity::generate();
548        let json = serde_json::json!({"KEY": "value"});
549        let encrypted = encrypt_json(&identity, &json);
550        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
551
552        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
553        let result = vault.get_secret("KEY").await.unwrap();
554        assert_eq!(result.as_deref(), Some("value"));
555    }
556
557    #[tokio::test]
558    async fn age_vault_returns_none_for_missing() {
559        let identity = age::x25519::Identity::generate();
560        let json = serde_json::json!({"KEY": "value"});
561        let encrypted = encrypt_json(&identity, &json);
562        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
563
564        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
565        let result = vault.get_secret("MISSING").await.unwrap();
566        assert!(result.is_none());
567    }
568
569    #[test]
570    fn age_vault_bad_key_file() {
571        let err = AgeVaultProvider::new(
572            Path::new("/nonexistent/key.txt"),
573            Path::new("/nonexistent/vault.age"),
574        )
575        .unwrap_err();
576        assert!(matches!(err, AgeVaultError::KeyRead(_)));
577    }
578
579    #[test]
580    fn age_vault_bad_key_parse() {
581        let dir = tempfile::tempdir().unwrap();
582        let key_path = dir.path().join("bad-key.txt");
583        std::fs::write(&key_path, "not-a-valid-age-key").unwrap();
584
585        let vault_path = dir.path().join("vault.age");
586        std::fs::write(&vault_path, b"dummy").unwrap();
587
588        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
589        assert!(matches!(err, AgeVaultError::KeyParse(_)));
590    }
591
592    #[test]
593    fn age_vault_bad_vault_file() {
594        let dir = tempfile::tempdir().unwrap();
595        let identity = age::x25519::Identity::generate();
596        let key_path = dir.path().join("key.txt");
597        std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap();
598
599        let err =
600            AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err();
601        assert!(matches!(err, AgeVaultError::VaultRead(_)));
602    }
603
604    #[test]
605    fn age_vault_wrong_key() {
606        let identity = age::x25519::Identity::generate();
607        let wrong_identity = age::x25519::Identity::generate();
608        let json = serde_json::json!({"KEY": "value"});
609        let encrypted = encrypt_json(&identity, &json);
610        let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted);
611
612        let dir2 = tempfile::tempdir().unwrap();
613        let wrong_key_path = dir2.path().join("wrong-key.txt");
614        std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap();
615
616        let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err();
617        assert!(matches!(err, AgeVaultError::Decrypt(_)));
618    }
619
620    #[test]
621    fn age_vault_invalid_json() {
622        let identity = age::x25519::Identity::generate();
623        let recipient = identity.to_public();
624        let encryptor =
625            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
626                .expect("encryptor");
627        let mut encrypted = vec![];
628        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
629        writer.write_all(b"not json").expect("write");
630        writer.finish().expect("finish");
631
632        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
633        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
634        assert!(matches!(err, AgeVaultError::Json(_)));
635    }
636
637    #[test]
638    fn age_vault_debug_impl() {
639        let identity = age::x25519::Identity::generate();
640        let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"});
641        let encrypted = encrypt_json(&identity, &json);
642        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
643
644        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
645        let debug = format!("{vault:?}");
646        assert!(debug.contains("AgeVaultProvider"));
647        assert!(debug.contains("[2 secrets]"));
648        assert!(!debug.contains("value1"));
649    }
650
651    #[tokio::test]
652    async fn age_vault_key_file_with_comments() {
653        let identity = age::x25519::Identity::generate();
654        let json = serde_json::json!({"KEY": "value"});
655        let encrypted = encrypt_json(&identity, &json);
656        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
657
658        let key_with_comments = format!(
659            "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n",
660            identity.to_public(),
661            identity.to_string().expose_secret()
662        );
663        std::fs::write(&key_path, &key_with_comments).unwrap();
664
665        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
666        let result = vault.get_secret("KEY").await.unwrap();
667        assert_eq!(result.as_deref(), Some("value"));
668    }
669
670    #[test]
671    fn age_vault_key_file_only_comments() {
672        let dir = tempfile::tempdir().unwrap();
673        let key_path = dir.path().join("comments-only.txt");
674        std::fs::write(&key_path, "# comment\n# another\n").unwrap();
675        let vault_path = dir.path().join("vault.age");
676        std::fs::write(&vault_path, b"dummy").unwrap();
677
678        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
679        assert!(matches!(err, AgeVaultError::KeyParse(_)));
680    }
681
682    #[test]
683    fn age_vault_error_display() {
684        let key_err =
685            AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
686        assert!(key_err.to_string().contains("failed to read key file"));
687
688        let parse_err = AgeVaultError::KeyParse("bad key".into());
689        assert!(
690            parse_err
691                .to_string()
692                .contains("failed to parse age identity")
693        );
694
695        let vault_err =
696            AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
697        assert!(vault_err.to_string().contains("failed to read vault file"));
698
699        let enc_err = AgeVaultError::Encrypt("bad".into());
700        assert!(enc_err.to_string().contains("age encryption failed"));
701
702        let write_err = AgeVaultError::VaultWrite(std::io::Error::new(
703            std::io::ErrorKind::PermissionDenied,
704            "test",
705        ));
706        assert!(write_err.to_string().contains("failed to write vault file"));
707    }
708
709    #[test]
710    fn age_vault_set_and_list_keys() {
711        let identity = age::x25519::Identity::generate();
712        let json = serde_json::json!({"A": "1"});
713        let encrypted = encrypt_json(&identity, &json);
714        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
715
716        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
717        vault.set_secret_mut("B".to_owned(), "2".to_owned());
718        vault.set_secret_mut("C".to_owned(), "3".to_owned());
719
720        let keys = vault.list_keys();
721        assert_eq!(keys, vec!["A", "B", "C"]);
722    }
723
724    #[test]
725    fn age_vault_remove_secret() {
726        let identity = age::x25519::Identity::generate();
727        let json = serde_json::json!({"X": "val", "Y": "val2"});
728        let encrypted = encrypt_json(&identity, &json);
729        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
730
731        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
732        assert!(vault.remove_secret_mut("X"));
733        assert!(!vault.remove_secret_mut("NONEXISTENT"));
734        assert_eq!(vault.list_keys(), vec!["Y"]);
735    }
736
737    #[tokio::test]
738    async fn age_vault_save_roundtrip() {
739        let identity = age::x25519::Identity::generate();
740        let json = serde_json::json!({"ORIG": "value"});
741        let encrypted = encrypt_json(&identity, &json);
742        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
743
744        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
745        vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned());
746        vault.save().unwrap();
747
748        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
749        let result = reloaded.get_secret("NEW_KEY").await.unwrap();
750        assert_eq!(result.as_deref(), Some("new_value"));
751
752        let orig = reloaded.get_secret("ORIG").await.unwrap();
753        assert_eq!(orig.as_deref(), Some("value"));
754    }
755
756    #[test]
757    fn age_vault_get_method_returns_str() {
758        let identity = age::x25519::Identity::generate();
759        let json = serde_json::json!({"FOO": "bar"});
760        let encrypted = encrypt_json(&identity, &json);
761        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
762
763        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
764        assert_eq!(vault.get("FOO"), Some("bar"));
765        assert_eq!(vault.get("MISSING"), None);
766    }
767
768    #[test]
769    fn age_vault_empty_secret_value() {
770        let identity = age::x25519::Identity::generate();
771        let json = serde_json::json!({"EMPTY": ""});
772        let encrypted = encrypt_json(&identity, &json);
773        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
774
775        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
776        assert_eq!(vault.get("EMPTY"), Some(""));
777    }
778
779    #[test]
780    fn age_vault_init_vault() {
781        let dir = tempfile::tempdir().unwrap();
782        AgeVaultProvider::init_vault(dir.path()).unwrap();
783
784        let key_path = dir.path().join("vault-key.txt");
785        let vault_path = dir.path().join("secrets.age");
786        assert!(key_path.exists());
787        assert!(vault_path.exists());
788
789        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
790        assert_eq!(vault.list_keys(), Vec::<&str>::new());
791    }
792
793    #[tokio::test]
794    async fn age_vault_keys_sorted_after_roundtrip() {
795        let identity = age::x25519::Identity::generate();
796        // Insert keys intentionally out of lexicographic order.
797        let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"});
798        let encrypted = encrypt_json(&identity, &json);
799        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
800
801        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
802        let keys = vault.list_keys();
803        assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]);
804    }
805
806    #[test]
807    fn age_vault_save_preserves_key_order() {
808        let identity = age::x25519::Identity::generate();
809        let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"});
810        let encrypted = encrypt_json(&identity, &json);
811        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
812
813        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
814        vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned());
815        vault.save().unwrap();
816
817        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
818        let keys = reloaded.list_keys();
819        assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]);
820    }
821
822    #[test]
823    fn age_vault_decrypt_returns_btreemap_sorted() {
824        let identity = age::x25519::Identity::generate();
825        // Provide keys in reverse order; BTreeMap must sort them on deserialization.
826        let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#;
827        let recipient = identity.to_public();
828        let encryptor =
829            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
830                .expect("encryptor");
831        let mut encrypted = vec![];
832        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
833        writer.write_all(json_str.as_bytes()).expect("write");
834        writer.finish().expect("finish");
835
836        let ciphertext = encrypted;
837        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
838        let keys: Vec<&str> = secrets.keys().map(String::as_str).collect();
839        // BTreeMap guarantees lexicographic order regardless of insertion order.
840        assert_eq!(keys, vec!["alpha", "bar", "zoo"]);
841    }
842
843    #[test]
844    fn age_vault_into_iter_consumes_all_entries() {
845        // Regression: drain() was replaced with into_iter(). Verify all entries
846        // are consumed and values are accessible without data loss.
847        let identity = age::x25519::Identity::generate();
848        let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"});
849        let encrypted = encrypt_json(&identity, &json);
850        let ciphertext = encrypted;
851        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
852
853        let mut pairs: Vec<(String, String)> = secrets
854            .into_iter()
855            .map(|(k, v)| (k, v.as_str().to_owned()))
856            .collect();
857        pairs.sort_by(|a, b| a.0.cmp(&b.0));
858
859        assert_eq!(pairs.len(), 3);
860        assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned()));
861        assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned()));
862        assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned()));
863    }
864
865    use proptest::prelude::*;
866
867    proptest! {
868        #[test]
869        fn secret_value_roundtrip(s in ".*") {
870            let secret = Secret::new(s.clone());
871            assert_eq!(secret.expose(), s.as_str());
872        }
873
874        #[test]
875        fn secret_debug_always_redacted(s in ".*") {
876            let secret = Secret::new(s);
877            assert_eq!(format!("{secret:?}"), "[REDACTED]");
878        }
879
880        #[test]
881        fn secret_display_always_redacted(s in ".*") {
882            let secret = Secret::new(s);
883            assert_eq!(format!("{secret}"), "[REDACTED]");
884        }
885    }
886}