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