Skip to main content

zeph_core/
vault.rs

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