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 None).
358    listed_only: Vec<String>,
359}
360
361#[cfg(test)]
362impl MockVaultProvider {
363    #[must_use]
364    pub fn new() -> Self {
365        Self::default()
366    }
367
368    #[must_use]
369    pub fn with_secret(mut self, key: &str, value: &str) -> Self {
370        self.secrets.insert(key.to_owned(), value.to_owned());
371        self
372    }
373
374    /// Add a key to list_keys() without a corresponding get_secret() value.
375    #[must_use]
376    pub fn with_listed_key(mut self, key: &str) -> Self {
377        self.listed_only.push(key.to_owned());
378        self
379    }
380}
381
382#[cfg(test)]
383impl VaultProvider for MockVaultProvider {
384    fn get_secret(
385        &self,
386        key: &str,
387    ) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<String>>> + Send + '_>> {
388        let result = self.secrets.get(key).cloned();
389        Box::pin(async move { Ok(result) })
390    }
391
392    fn list_keys(&self) -> Vec<String> {
393        let mut keys: Vec<String> = self
394            .secrets
395            .keys()
396            .cloned()
397            .chain(self.listed_only.iter().cloned())
398            .collect();
399        keys.sort_unstable();
400        keys.dedup();
401        keys
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn secret_expose_returns_inner() {
411        let secret = Secret::new("my-api-key");
412        assert_eq!(secret.expose(), "my-api-key");
413    }
414
415    #[test]
416    fn secret_debug_is_redacted() {
417        let secret = Secret::new("my-api-key");
418        assert_eq!(format!("{secret:?}"), "[REDACTED]");
419    }
420
421    #[test]
422    fn secret_display_is_redacted() {
423        let secret = Secret::new("my-api-key");
424        assert_eq!(format!("{secret}"), "[REDACTED]");
425    }
426
427    #[allow(unsafe_code)]
428    #[tokio::test]
429    async fn env_vault_returns_set_var() {
430        let key = "ZEPH_TEST_VAULT_SECRET_SET";
431        unsafe { std::env::set_var(key, "test-value") };
432        let vault = EnvVaultProvider;
433        let result = vault.get_secret(key).await.unwrap();
434        unsafe { std::env::remove_var(key) };
435        assert_eq!(result.as_deref(), Some("test-value"));
436    }
437
438    #[tokio::test]
439    async fn env_vault_returns_none_for_unset() {
440        let vault = EnvVaultProvider;
441        let result = vault
442            .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345")
443            .await
444            .unwrap();
445        assert!(result.is_none());
446    }
447
448    #[tokio::test]
449    async fn mock_vault_returns_configured_secret() {
450        let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123");
451        let result = vault.get_secret("API_KEY").await.unwrap();
452        assert_eq!(result.as_deref(), Some("secret-123"));
453    }
454
455    #[tokio::test]
456    async fn mock_vault_returns_none_for_missing() {
457        let vault = MockVaultProvider::new();
458        let result = vault.get_secret("MISSING").await.unwrap();
459        assert!(result.is_none());
460    }
461
462    #[test]
463    fn secret_from_string() {
464        let s = Secret::new(String::from("test"));
465        assert_eq!(s.expose(), "test");
466    }
467
468    #[test]
469    fn secret_expose_roundtrip() {
470        let s = Secret::new("test");
471        let owned = s.expose().to_owned();
472        let s2 = Secret::new(owned);
473        assert_eq!(s.expose(), s2.expose());
474    }
475
476    #[test]
477    fn secret_deserialize() {
478        let json = "\"my-secret-value\"";
479        let secret: Secret = serde_json::from_str(json).unwrap();
480        assert_eq!(secret.expose(), "my-secret-value");
481        assert_eq!(format!("{secret:?}"), "[REDACTED]");
482    }
483
484    #[test]
485    fn mock_vault_list_keys_sorted() {
486        let vault = MockVaultProvider::new()
487            .with_secret("B_KEY", "v2")
488            .with_secret("A_KEY", "v1")
489            .with_secret("C_KEY", "v3");
490        let mut keys = vault.list_keys();
491        keys.sort_unstable();
492        assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]);
493    }
494
495    #[test]
496    fn mock_vault_list_keys_empty() {
497        let vault = MockVaultProvider::new();
498        assert!(vault.list_keys().is_empty());
499    }
500
501    #[allow(unsafe_code)]
502    #[test]
503    fn env_vault_list_keys_filters_zeph_secret_prefix() {
504        let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999";
505        unsafe { std::env::set_var(key, "v") };
506        let vault = EnvVaultProvider;
507        let keys = vault.list_keys();
508        assert!(keys.contains(&key.to_owned()));
509        unsafe { std::env::remove_var(key) };
510    }
511}
512
513#[cfg(test)]
514mod age_tests {
515    use std::io::Write as _;
516
517    use age::secrecy::ExposeSecret;
518
519    use super::*;
520
521    fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec<u8> {
522        let recipient = identity.to_public();
523        let encryptor =
524            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
525                .expect("encryptor creation");
526        let mut encrypted = vec![];
527        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output");
528        writer
529            .write_all(json.to_string().as_bytes())
530            .expect("write plaintext");
531        writer.finish().expect("finish encryption");
532        encrypted
533    }
534
535    fn write_temp_files(
536        identity: &age::x25519::Identity,
537        ciphertext: &[u8],
538    ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
539        let dir = tempfile::tempdir().expect("tempdir");
540        let key_path = dir.path().join("key.txt");
541        let vault_path = dir.path().join("secrets.age");
542        std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key");
543        std::fs::write(&vault_path, ciphertext).expect("write vault");
544        (dir, key_path, vault_path)
545    }
546
547    #[tokio::test]
548    async fn age_vault_returns_existing_secret() {
549        let identity = age::x25519::Identity::generate();
550        let json = serde_json::json!({"KEY": "value"});
551        let encrypted = encrypt_json(&identity, &json);
552        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
553
554        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
555        let result = vault.get_secret("KEY").await.unwrap();
556        assert_eq!(result.as_deref(), Some("value"));
557    }
558
559    #[tokio::test]
560    async fn age_vault_returns_none_for_missing() {
561        let identity = age::x25519::Identity::generate();
562        let json = serde_json::json!({"KEY": "value"});
563        let encrypted = encrypt_json(&identity, &json);
564        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
565
566        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
567        let result = vault.get_secret("MISSING").await.unwrap();
568        assert!(result.is_none());
569    }
570
571    #[test]
572    fn age_vault_bad_key_file() {
573        let err = AgeVaultProvider::new(
574            Path::new("/nonexistent/key.txt"),
575            Path::new("/nonexistent/vault.age"),
576        )
577        .unwrap_err();
578        assert!(matches!(err, AgeVaultError::KeyRead(_)));
579    }
580
581    #[test]
582    fn age_vault_bad_key_parse() {
583        let dir = tempfile::tempdir().unwrap();
584        let key_path = dir.path().join("bad-key.txt");
585        std::fs::write(&key_path, "not-a-valid-age-key").unwrap();
586
587        let vault_path = dir.path().join("vault.age");
588        std::fs::write(&vault_path, b"dummy").unwrap();
589
590        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
591        assert!(matches!(err, AgeVaultError::KeyParse(_)));
592    }
593
594    #[test]
595    fn age_vault_bad_vault_file() {
596        let dir = tempfile::tempdir().unwrap();
597        let identity = age::x25519::Identity::generate();
598        let key_path = dir.path().join("key.txt");
599        std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap();
600
601        let err =
602            AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err();
603        assert!(matches!(err, AgeVaultError::VaultRead(_)));
604    }
605
606    #[test]
607    fn age_vault_wrong_key() {
608        let identity = age::x25519::Identity::generate();
609        let wrong_identity = age::x25519::Identity::generate();
610        let json = serde_json::json!({"KEY": "value"});
611        let encrypted = encrypt_json(&identity, &json);
612        let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted);
613
614        let dir2 = tempfile::tempdir().unwrap();
615        let wrong_key_path = dir2.path().join("wrong-key.txt");
616        std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap();
617
618        let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err();
619        assert!(matches!(err, AgeVaultError::Decrypt(_)));
620    }
621
622    #[test]
623    fn age_vault_invalid_json() {
624        let identity = age::x25519::Identity::generate();
625        let recipient = identity.to_public();
626        let encryptor =
627            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
628                .expect("encryptor");
629        let mut encrypted = vec![];
630        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
631        writer.write_all(b"not json").expect("write");
632        writer.finish().expect("finish");
633
634        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
635        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
636        assert!(matches!(err, AgeVaultError::Json(_)));
637    }
638
639    #[tokio::test]
640    async fn age_encrypt_decrypt_resolve_secrets_roundtrip() {
641        let identity = age::x25519::Identity::generate();
642        let json = serde_json::json!({
643            "ZEPH_CLAUDE_API_KEY": "sk-ant-test-123",
644            "ZEPH_TELEGRAM_TOKEN": "tg-token-456"
645        });
646        let encrypted = encrypt_json(&identity, &json);
647        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
648
649        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
650        let mut config =
651            crate::config::Config::load(Path::new("/nonexistent/config.toml")).unwrap();
652        config.resolve_secrets(&vault).await.unwrap();
653
654        assert_eq!(
655            config.secrets.claude_api_key.as_ref().unwrap().expose(),
656            "sk-ant-test-123"
657        );
658        let tg = config.telegram.unwrap();
659        assert_eq!(tg.token.as_deref(), Some("tg-token-456"));
660    }
661
662    #[test]
663    fn age_vault_debug_impl() {
664        let identity = age::x25519::Identity::generate();
665        let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"});
666        let encrypted = encrypt_json(&identity, &json);
667        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
668
669        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
670        let debug = format!("{vault:?}");
671        assert!(debug.contains("AgeVaultProvider"));
672        assert!(debug.contains("[2 secrets]"));
673        assert!(!debug.contains("value1"));
674    }
675
676    #[tokio::test]
677    async fn age_vault_key_file_with_comments() {
678        let identity = age::x25519::Identity::generate();
679        let json = serde_json::json!({"KEY": "value"});
680        let encrypted = encrypt_json(&identity, &json);
681        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
682
683        let key_with_comments = format!(
684            "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n",
685            identity.to_public(),
686            identity.to_string().expose_secret()
687        );
688        std::fs::write(&key_path, &key_with_comments).unwrap();
689
690        let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
691        let result = vault.get_secret("KEY").await.unwrap();
692        assert_eq!(result.as_deref(), Some("value"));
693    }
694
695    #[test]
696    fn age_vault_key_file_only_comments() {
697        let dir = tempfile::tempdir().unwrap();
698        let key_path = dir.path().join("comments-only.txt");
699        std::fs::write(&key_path, "# comment\n# another\n").unwrap();
700        let vault_path = dir.path().join("vault.age");
701        std::fs::write(&vault_path, b"dummy").unwrap();
702
703        let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
704        assert!(matches!(err, AgeVaultError::KeyParse(_)));
705    }
706
707    #[test]
708    fn age_vault_error_display() {
709        let key_err =
710            AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
711        assert!(key_err.to_string().contains("failed to read key file"));
712
713        let parse_err = AgeVaultError::KeyParse("bad key".into());
714        assert!(
715            parse_err
716                .to_string()
717                .contains("failed to parse age identity")
718        );
719
720        let vault_err =
721            AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
722        assert!(vault_err.to_string().contains("failed to read vault file"));
723
724        let enc_err = AgeVaultError::Encrypt("bad".into());
725        assert!(enc_err.to_string().contains("age encryption failed"));
726
727        let write_err = AgeVaultError::VaultWrite(std::io::Error::new(
728            std::io::ErrorKind::PermissionDenied,
729            "test",
730        ));
731        assert!(write_err.to_string().contains("failed to write vault file"));
732    }
733
734    #[test]
735    fn age_vault_set_and_list_keys() {
736        let identity = age::x25519::Identity::generate();
737        let json = serde_json::json!({"A": "1"});
738        let encrypted = encrypt_json(&identity, &json);
739        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
740
741        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
742        vault.set_secret_mut("B".to_owned(), "2".to_owned());
743        vault.set_secret_mut("C".to_owned(), "3".to_owned());
744
745        let keys = vault.list_keys();
746        assert_eq!(keys, vec!["A", "B", "C"]);
747    }
748
749    #[test]
750    fn age_vault_remove_secret() {
751        let identity = age::x25519::Identity::generate();
752        let json = serde_json::json!({"X": "val", "Y": "val2"});
753        let encrypted = encrypt_json(&identity, &json);
754        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
755
756        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
757        assert!(vault.remove_secret_mut("X"));
758        assert!(!vault.remove_secret_mut("NONEXISTENT"));
759        assert_eq!(vault.list_keys(), vec!["Y"]);
760    }
761
762    #[tokio::test]
763    async fn age_vault_save_roundtrip() {
764        let identity = age::x25519::Identity::generate();
765        let json = serde_json::json!({"ORIG": "value"});
766        let encrypted = encrypt_json(&identity, &json);
767        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
768
769        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
770        vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned());
771        vault.save().unwrap();
772
773        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
774        let result = reloaded.get_secret("NEW_KEY").await.unwrap();
775        assert_eq!(result.as_deref(), Some("new_value"));
776
777        let orig = reloaded.get_secret("ORIG").await.unwrap();
778        assert_eq!(orig.as_deref(), Some("value"));
779    }
780
781    #[test]
782    fn age_vault_get_method_returns_str() {
783        let identity = age::x25519::Identity::generate();
784        let json = serde_json::json!({"FOO": "bar"});
785        let encrypted = encrypt_json(&identity, &json);
786        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
787
788        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
789        assert_eq!(vault.get("FOO"), Some("bar"));
790        assert_eq!(vault.get("MISSING"), None);
791    }
792
793    #[test]
794    fn age_vault_empty_secret_value() {
795        let identity = age::x25519::Identity::generate();
796        let json = serde_json::json!({"EMPTY": ""});
797        let encrypted = encrypt_json(&identity, &json);
798        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
799
800        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
801        assert_eq!(vault.get("EMPTY"), Some(""));
802    }
803
804    #[test]
805    fn age_vault_init_vault() {
806        let dir = tempfile::tempdir().unwrap();
807        AgeVaultProvider::init_vault(dir.path()).unwrap();
808
809        let key_path = dir.path().join("vault-key.txt");
810        let vault_path = dir.path().join("secrets.age");
811        assert!(key_path.exists());
812        assert!(vault_path.exists());
813
814        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
815        assert_eq!(vault.list_keys(), Vec::<&str>::new());
816    }
817
818    #[tokio::test]
819    async fn age_vault_keys_sorted_after_roundtrip() {
820        let identity = age::x25519::Identity::generate();
821        // Insert keys intentionally out of lexicographic order.
822        let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"});
823        let encrypted = encrypt_json(&identity, &json);
824        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
825
826        let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
827        let keys = vault.list_keys();
828        assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]);
829    }
830
831    #[test]
832    fn age_vault_save_preserves_key_order() {
833        let identity = age::x25519::Identity::generate();
834        let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"});
835        let encrypted = encrypt_json(&identity, &json);
836        let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
837
838        let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
839        vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned());
840        vault.save().unwrap();
841
842        let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
843        let keys = reloaded.list_keys();
844        assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]);
845    }
846
847    #[test]
848    fn age_vault_decrypt_returns_btreemap_sorted() {
849        let identity = age::x25519::Identity::generate();
850        // Provide keys in reverse order; BTreeMap must sort them on deserialization.
851        let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#;
852        let recipient = identity.to_public();
853        let encryptor =
854            age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
855                .expect("encryptor");
856        let mut encrypted = vec![];
857        let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
858        writer.write_all(json_str.as_bytes()).expect("write");
859        writer.finish().expect("finish");
860
861        let ciphertext = encrypted;
862        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
863        let keys: Vec<&str> = secrets.keys().map(String::as_str).collect();
864        // BTreeMap guarantees lexicographic order regardless of insertion order.
865        assert_eq!(keys, vec!["alpha", "bar", "zoo"]);
866    }
867
868    #[test]
869    fn age_vault_into_iter_consumes_all_entries() {
870        // Regression: drain() was replaced with into_iter(). Verify all entries
871        // are consumed and values are accessible without data loss.
872        let identity = age::x25519::Identity::generate();
873        let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"});
874        let encrypted = encrypt_json(&identity, &json);
875        let ciphertext = encrypted;
876        let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
877
878        let mut pairs: Vec<(String, String)> = secrets
879            .into_iter()
880            .map(|(k, v)| (k, v.as_str().to_owned()))
881            .collect();
882        pairs.sort_by(|a, b| a.0.cmp(&b.0));
883
884        assert_eq!(pairs.len(), 3);
885        assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned()));
886        assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned()));
887        assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned()));
888    }
889
890    use proptest::prelude::*;
891
892    proptest! {
893        #[test]
894        fn secret_value_roundtrip(s in ".*") {
895            let secret = Secret::new(s.clone());
896            assert_eq!(secret.expose(), s.as_str());
897        }
898
899        #[test]
900        fn secret_debug_always_redacted(s in ".*") {
901            let secret = Secret::new(s);
902            assert_eq!(format!("{secret:?}"), "[REDACTED]");
903        }
904
905        #[test]
906        fn secret_display_always_redacted(s in ".*") {
907            let secret = Secret::new(s);
908            assert_eq!(format!("{secret}"), "[REDACTED]");
909        }
910    }
911}