Skip to main content

zeph_vault/
age.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Age-encrypted vault backend.
5//!
6//! This module provides [`AgeVaultProvider`], the primary secret storage backend, and the
7//! associated [`AgeVaultError`] type. Secrets are stored as a JSON object encrypted with an
8//! x25519 keypair using the [age](https://age-encryption.org) format.
9
10use std::collections::BTreeMap;
11use std::fmt;
12use std::future::Future;
13use std::io::{Read as _, Write as _};
14use std::path::{Path, PathBuf};
15use std::pin::Pin;
16
17use zeroize::Zeroizing;
18
19use crate::VaultProvider;
20use zeph_common::secret::VaultError;
21
22// ---------------------------------------------------------------------------
23// Error type
24// ---------------------------------------------------------------------------
25
26/// Errors that can occur during age vault operations.
27///
28/// Each variant wraps the underlying cause so callers can match on failure type without
29/// parsing error strings.
30///
31/// # Examples
32///
33/// ```
34/// use zeph_vault::AgeVaultError;
35///
36/// let err = AgeVaultError::KeyParse("no identity line found".into());
37/// assert!(err.to_string().contains("failed to parse age identity"));
38/// ```
39#[non_exhaustive]
40#[derive(Debug, thiserror::Error)]
41pub enum AgeVaultError {
42    /// The key file could not be read from disk.
43    #[error("failed to read key file: {0}")]
44    KeyRead(std::io::Error),
45    /// The key file content could not be parsed as an age identity.
46    #[error("failed to parse age identity: {0}")]
47    KeyParse(String),
48    /// The vault file could not be read from disk.
49    #[error("failed to read vault file: {0}")]
50    VaultRead(std::io::Error),
51    /// The age decryption step failed (wrong key, corrupted file, etc.).
52    #[error("age decryption failed: {0}")]
53    Decrypt(age::DecryptError),
54    /// An I/O error occurred while reading plaintext from the age stream.
55    #[error("I/O error during decryption: {0}")]
56    Io(std::io::Error),
57    /// The decrypted bytes could not be parsed as JSON.
58    #[error("invalid JSON in vault: {0}")]
59    Json(serde_json::Error),
60    /// The age encryption step failed.
61    #[error("age encryption failed: {0}")]
62    Encrypt(String),
63    /// The vault file (or its temporary predecessor) could not be written to disk.
64    #[error("failed to write vault file: {0}")]
65    VaultWrite(std::io::Error),
66    /// The key file could not be written to disk.
67    #[error("failed to write key file: {0}")]
68    KeyWrite(std::io::Error),
69}
70
71// ---------------------------------------------------------------------------
72// Provider
73// ---------------------------------------------------------------------------
74
75/// Age-encrypted vault backend.
76///
77/// Secrets are stored as a JSON object (`{"KEY": "value", ...}`) encrypted with an x25519
78/// keypair using the [age](https://age-encryption.org) format. The in-memory secret values
79/// are held in [`zeroize::Zeroizing`] buffers.
80///
81/// # File layout
82///
83/// ```text
84/// <dir>/vault-key.txt   # age identity (private key), Unix mode 0600
85/// <dir>/secrets.age     # age-encrypted JSON object
86/// ```
87///
88/// # Initialising a new vault
89///
90/// Use [`AgeVaultProvider::init_vault`] to generate a fresh keypair and create an empty vault:
91///
92/// ```no_run
93/// use std::path::Path;
94/// use zeph_vault::AgeVaultProvider;
95///
96/// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
97/// // Produces:
98/// //   /etc/zeph/vault-key.txt  (mode 0600)
99/// //   /etc/zeph/secrets.age    (empty encrypted vault)
100/// # Ok::<_, zeph_vault::AgeVaultError>(())
101/// ```
102///
103/// # Atomic writes
104///
105/// [`save`][AgeVaultProvider::save] writes to a `.age.tmp` sibling file first, then renames it
106/// atomically, so a crash during write never leaves the vault in a corrupted state.
107pub struct AgeVaultProvider {
108    pub(crate) secrets: BTreeMap<String, Zeroizing<String>>,
109    pub(crate) key_path: PathBuf,
110    pub(crate) vault_path: PathBuf,
111}
112
113impl fmt::Debug for AgeVaultProvider {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("AgeVaultProvider")
116            .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
117            .field("key_path", &self.key_path)
118            .field("vault_path", &self.vault_path)
119            .finish()
120    }
121}
122
123impl AgeVaultProvider {
124    /// Decrypt an age-encrypted JSON secrets file.
125    ///
126    /// This is an alias for [`load`][Self::load] provided for ergonomic construction.
127    ///
128    /// # Arguments
129    ///
130    /// - `key_path` — path to the age identity (private key) file. Lines starting with `#`
131    ///   and blank lines are ignored; the first non-comment line is parsed as the identity.
132    /// - `vault_path` — path to the age-encrypted JSON file.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
137    ///
138    /// # Examples
139    ///
140    /// ```no_run
141    /// use std::path::Path;
142    /// use zeph_vault::AgeVaultProvider;
143    ///
144    /// let vault = AgeVaultProvider::new(
145    ///     Path::new("/etc/zeph/vault-key.txt"),
146    ///     Path::new("/etc/zeph/secrets.age"),
147    /// )?;
148    /// println!("{} secrets loaded", vault.list_keys().len());
149    /// # Ok::<_, zeph_vault::AgeVaultError>(())
150    /// ```
151    pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
152        Self::load(key_path, vault_path)
153    }
154
155    /// Load vault from disk, storing paths for subsequent write operations.
156    ///
157    /// Reads and decrypts the vault, then retains both paths so that
158    /// [`save`][Self::save] can re-encrypt and persist changes without requiring callers to
159    /// pass paths again.
160    ///
161    /// This method performs blocking I/O on the calling thread. Use [`load_async`][Self::load_async]
162    /// when calling from an async context to avoid stalling the tokio executor.
163    ///
164    /// # Errors
165    ///
166    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, or decryption failure.
167    ///
168    /// # Examples
169    ///
170    /// ```no_run
171    /// use std::path::Path;
172    /// use zeph_vault::AgeVaultProvider;
173    ///
174    /// let vault = AgeVaultProvider::load(
175    ///     Path::new("/etc/zeph/vault-key.txt"),
176    ///     Path::new("/etc/zeph/secrets.age"),
177    /// )?;
178    /// # Ok::<_, zeph_vault::AgeVaultError>(())
179    /// ```
180    #[tracing::instrument(name = "vault.age.load", skip_all, err)]
181    pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
182        let key_str =
183            Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
184        let identity = parse_identity(&key_str)?;
185        let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
186        let secrets = decrypt_secrets(&identity, &ciphertext)?;
187        Ok(Self {
188            secrets,
189            key_path: key_path.to_owned(),
190            vault_path: vault_path.to_owned(),
191        })
192    }
193
194    /// Async variant of [`load`][Self::load] — offloads blocking I/O to a `spawn_blocking` thread.
195    ///
196    /// Use this when calling from an async context to avoid stalling the tokio executor.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`AgeVaultError`] on key/vault read failure, parse error, decryption failure, or
201    /// if the blocking task panics.
202    ///
203    /// # Examples
204    ///
205    /// ```no_run
206    /// use std::path::Path;
207    /// use zeph_vault::AgeVaultProvider;
208    ///
209    /// # async fn example() -> Result<(), zeph_vault::AgeVaultError> {
210    /// let vault = AgeVaultProvider::load_async(
211    ///     Path::new("/etc/zeph/vault-key.txt"),
212    ///     Path::new("/etc/zeph/secrets.age"),
213    /// ).await?;
214    /// # Ok(())
215    /// # }
216    /// ```
217    #[tracing::instrument(name = "vault.age.load_async", skip_all, err)]
218    pub async fn load_async(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
219        let key_path = key_path.to_owned();
220        let vault_path = vault_path.to_owned();
221        tokio::task::spawn_blocking(move || Self::load(&key_path, &vault_path))
222            .await
223            .map_err(|e| {
224                AgeVaultError::Io(std::io::Error::other(format!(
225                    "spawn_blocking panicked: {e}"
226                )))
227            })?
228    }
229
230    /// Serialize and re-encrypt secrets to vault file using atomic write (temp + rename).
231    ///
232    /// Re-reads and re-parses the key file on each call. For CLI one-shot use this is
233    /// acceptable; if used in a long-lived context consider caching the parsed identity.
234    ///
235    /// This method performs blocking I/O on the calling thread. Use [`save_async`][Self::save_async]
236    /// when calling from an async context to avoid stalling the tokio executor.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`AgeVaultError`] on encryption or write failure.
241    ///
242    /// # Examples
243    ///
244    /// ```no_run
245    /// use std::path::Path;
246    /// use zeph_vault::AgeVaultProvider;
247    ///
248    /// let mut vault = AgeVaultProvider::load(
249    ///     Path::new("/etc/zeph/vault-key.txt"),
250    ///     Path::new("/etc/zeph/secrets.age"),
251    /// )?;
252    /// vault.set_secret_mut("MY_TOKEN".into(), "tok_abc123".into());
253    /// vault.save()?;
254    /// # Ok::<_, zeph_vault::AgeVaultError>(())
255    /// ```
256    #[tracing::instrument(name = "vault.age.save", skip_all, err)]
257    pub fn save(&self) -> Result<(), AgeVaultError> {
258        let key_str = Zeroizing::new(
259            std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
260        );
261        let identity = parse_identity(&key_str)?;
262        let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
263        atomic_write(&self.vault_path, &ciphertext)
264    }
265
266    /// Async variant of [`save`][Self::save] — offloads blocking I/O to a `spawn_blocking` thread.
267    ///
268    /// Use this when calling from an async context to avoid stalling the tokio executor.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`AgeVaultError`] on encryption or write failure, or if the blocking task panics.
273    ///
274    /// # Examples
275    ///
276    /// ```no_run
277    /// use std::path::Path;
278    /// use zeph_vault::AgeVaultProvider;
279    ///
280    /// # async fn example() -> Result<(), zeph_vault::AgeVaultError> {
281    /// let mut vault = AgeVaultProvider::load(
282    ///     Path::new("/etc/zeph/vault-key.txt"),
283    ///     Path::new("/etc/zeph/secrets.age"),
284    /// )?;
285    /// vault.set_secret_mut("MY_TOKEN".into(), "tok_abc123".into());
286    /// vault.save_async().await?;
287    /// # Ok(())
288    /// # }
289    /// ```
290    #[tracing::instrument(name = "vault.age.save_async", skip_all, err)]
291    pub async fn save_async(&self) -> Result<(), AgeVaultError> {
292        let key_path = self.key_path.clone();
293        let vault_path = self.vault_path.clone();
294        let secrets = self.secrets.clone();
295        tokio::task::spawn_blocking(move || {
296            let key_str =
297                Zeroizing::new(std::fs::read_to_string(&key_path).map_err(AgeVaultError::KeyRead)?);
298            let identity = parse_identity(&key_str)?;
299            let ciphertext = encrypt_secrets(&identity, &secrets)?;
300            atomic_write(&vault_path, &ciphertext)
301        })
302        .await
303        .map_err(|e| {
304            AgeVaultError::Io(std::io::Error::other(format!(
305                "spawn_blocking panicked: {e}"
306            )))
307        })?
308    }
309
310    /// Insert or update a secret in the in-memory map.
311    ///
312    /// Call [`save`][Self::save] afterwards to persist the change to disk.
313    ///
314    /// # Examples
315    ///
316    /// ```no_run
317    /// use std::path::Path;
318    /// use zeph_vault::AgeVaultProvider;
319    ///
320    /// let mut vault = AgeVaultProvider::load(
321    ///     Path::new("/etc/zeph/vault-key.txt"),
322    ///     Path::new("/etc/zeph/secrets.age"),
323    /// )?;
324    /// vault.set_secret_mut("API_KEY".into(), "sk-...".into());
325    /// vault.save()?;
326    /// # Ok::<_, zeph_vault::AgeVaultError>(())
327    /// ```
328    pub fn set_secret_mut(&mut self, key: String, value: String) {
329        self.secrets.insert(key, Zeroizing::new(value));
330    }
331
332    /// Remove a secret from the in-memory map.
333    ///
334    /// Returns `true` if the key existed and was removed, `false` if it was not present.
335    /// Call [`save`][Self::save] afterwards to persist the removal to disk.
336    ///
337    /// # Examples
338    ///
339    /// ```no_run
340    /// use std::path::Path;
341    /// use zeph_vault::AgeVaultProvider;
342    ///
343    /// let mut vault = AgeVaultProvider::load(
344    ///     Path::new("/etc/zeph/vault-key.txt"),
345    ///     Path::new("/etc/zeph/secrets.age"),
346    /// )?;
347    /// let removed = vault.remove_secret_mut("OLD_KEY");
348    /// if removed {
349    ///     vault.save()?;
350    /// }
351    /// # Ok::<_, zeph_vault::AgeVaultError>(())
352    /// ```
353    pub fn remove_secret_mut(&mut self, key: &str) -> bool {
354        self.secrets.remove(key).is_some()
355    }
356
357    /// Return sorted list of secret keys (no values exposed).
358    ///
359    /// Keys are returned in ascending lexicographic order. Secret values are never included.
360    ///
361    /// # Examples
362    ///
363    /// ```no_run
364    /// use std::path::Path;
365    /// use zeph_vault::AgeVaultProvider;
366    ///
367    /// let vault = AgeVaultProvider::load(
368    ///     Path::new("/etc/zeph/vault-key.txt"),
369    ///     Path::new("/etc/zeph/secrets.age"),
370    /// )?;
371    /// for key in vault.list_keys() {
372    ///     println!("{key}");
373    /// }
374    /// # Ok::<_, zeph_vault::AgeVaultError>(())
375    /// ```
376    #[must_use]
377    pub fn list_keys(&self) -> Vec<&str> {
378        let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
379        keys.sort_unstable();
380        keys
381    }
382
383    /// Look up a secret value by key, returning `None` if not present.
384    ///
385    /// Returns a borrowed `&str` tied to the lifetime of the vault. For async use across await
386    /// points, use [`VaultProvider::get_secret`] instead, which returns an owned `String`.
387    ///
388    /// # Examples
389    ///
390    /// ```no_run
391    /// use std::path::Path;
392    /// use zeph_vault::AgeVaultProvider;
393    ///
394    /// let vault = AgeVaultProvider::load(
395    ///     Path::new("/etc/zeph/vault-key.txt"),
396    ///     Path::new("/etc/zeph/secrets.age"),
397    /// )?;
398    /// match vault.get("ZEPH_OPENAI_API_KEY") {
399    ///     Some(key) => println!("key length: {}", key.len()),
400    ///     None => println!("key not configured"),
401    /// }
402    /// # Ok::<_, zeph_vault::AgeVaultError>(())
403    /// ```
404    #[must_use]
405    pub fn get(&self, key: &str) -> Option<&str> {
406        self.secrets.get(key).map(|v| v.as_str())
407    }
408
409    /// Generate a new x25519 keypair, write the key file (mode 0600), and create an empty
410    /// encrypted vault.
411    ///
412    /// Creates `dir` and all missing parent directories before writing files. Existing files
413    /// are not checked — calling this on an already-initialised directory will overwrite both
414    /// the key and the vault, making the old key irrecoverable.
415    ///
416    /// # Output files
417    ///
418    /// | File | Contents | Unix mode |
419    /// |------|----------|-----------|
420    /// | `<dir>/vault-key.txt` | age identity (private + public key comment) | `0600` |
421    /// | `<dir>/secrets.age`   | age-encrypted empty JSON object `{}` | default |
422    ///
423    /// # Errors
424    ///
425    /// Returns [`AgeVaultError`] on key/vault write failure or encryption failure.
426    ///
427    /// # Examples
428    ///
429    /// ```no_run
430    /// use std::path::Path;
431    /// use zeph_vault::AgeVaultProvider;
432    ///
433    /// AgeVaultProvider::init_vault(Path::new("/etc/zeph"))?;
434    /// // /etc/zeph/vault-key.txt and /etc/zeph/secrets.age are now ready.
435    /// # Ok::<_, zeph_vault::AgeVaultError>(())
436    /// ```
437    pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
438        use age::secrecy::ExposeSecret as _;
439
440        std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
441
442        let identity = age::x25519::Identity::generate();
443        let public_key = identity.to_public();
444
445        let key_content = Zeroizing::new(format!(
446            "# public key: {}\n{}\n",
447            public_key,
448            identity.to_string().expose_secret()
449        ));
450
451        let key_path = dir.join("vault-key.txt");
452        write_private_file(&key_path, key_content.as_bytes())?;
453
454        let vault_path = dir.join("secrets.age");
455        let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
456        let ciphertext = encrypt_secrets(&identity, &empty)?;
457        atomic_write(&vault_path, &ciphertext)?;
458
459        println!("Vault initialized:");
460        println!("  Key:   {}", key_path.display());
461        println!("  Vault: {}", vault_path.display());
462
463        Ok(())
464    }
465}
466
467impl VaultProvider for AgeVaultProvider {
468    fn get_secret(
469        &self,
470        key: &str,
471    ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
472        let result = self.secrets.get(key).map(|v| (**v).clone());
473        Box::pin(async move { Ok(result) })
474    }
475
476    fn list_keys(&self) -> Vec<String> {
477        let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
478        keys.sort_unstable();
479        keys
480    }
481}
482
483// ---------------------------------------------------------------------------
484// Internal helpers
485// ---------------------------------------------------------------------------
486
487pub(crate) fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
488    let key_line = key_str
489        .lines()
490        .find(|l| !l.starts_with('#') && !l.trim().is_empty())
491        .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
492    key_line
493        .trim()
494        .parse()
495        .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
496}
497
498pub(crate) fn decrypt_secrets(
499    identity: &age::x25519::Identity,
500    ciphertext: &[u8],
501) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
502    let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
503    let mut reader = decryptor
504        .decrypt(std::iter::once(identity as &dyn age::Identity))
505        .map_err(AgeVaultError::Decrypt)?;
506    let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
507    reader
508        .read_to_end(&mut plaintext)
509        .map_err(AgeVaultError::Io)?;
510    let raw: BTreeMap<String, String> =
511        serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
512    Ok(raw
513        .into_iter()
514        .map(|(k, v)| (k, Zeroizing::new(v)))
515        .collect())
516}
517
518pub(crate) fn encrypt_secrets(
519    identity: &age::x25519::Identity,
520    secrets: &BTreeMap<String, Zeroizing<String>>,
521) -> Result<Vec<u8>, AgeVaultError> {
522    let recipient = identity.to_public();
523    let encryptor =
524        age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
525            .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
526    let plain: BTreeMap<&str, &str> = secrets
527        .iter()
528        .map(|(k, v)| (k.as_str(), v.as_str()))
529        .collect();
530    let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
531    let mut ciphertext = Vec::with_capacity(json.len() + 64);
532    let mut writer = encryptor
533        .wrap_output(&mut ciphertext)
534        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
535    writer.write_all(&json).map_err(AgeVaultError::Io)?;
536    writer
537        .finish()
538        .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
539    Ok(ciphertext)
540}
541
542pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
543    zeph_common::fs_secure::atomic_write_private(path, data).map_err(AgeVaultError::VaultWrite)
544}
545
546pub(crate) fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
547    zeph_common::fs_secure::write_private(path, data).map_err(AgeVaultError::KeyWrite)
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use tempfile::tempdir;
554
555    fn init_temp_vault(dir: &Path) -> (PathBuf, PathBuf) {
556        AgeVaultProvider::init_vault(dir).expect("init_vault failed");
557        (dir.join("vault-key.txt"), dir.join("secrets.age"))
558    }
559
560    #[test]
561    fn round_trip() {
562        let dir = tempdir().unwrap();
563        let (key_path, vault_path) = init_temp_vault(dir.path());
564
565        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
566        vault.set_secret_mut("KEY".into(), "val".into());
567        vault.save().unwrap();
568
569        let loaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
570        assert_eq!(loaded.get("KEY"), Some("val"));
571    }
572
573    #[test]
574    fn remove_secret() {
575        let dir = tempdir().unwrap();
576        let (key_path, vault_path) = init_temp_vault(dir.path());
577
578        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
579        vault.set_secret_mut("KEY".into(), "val".into());
580
581        assert!(vault.remove_secret_mut("KEY"));
582        assert!(!vault.remove_secret_mut("KEY"));
583        assert_eq!(vault.get("KEY"), None);
584    }
585
586    #[test]
587    fn init_vault_creates_files() {
588        let dir = tempdir().unwrap();
589        AgeVaultProvider::init_vault(dir.path()).unwrap();
590
591        assert!(dir.path().join("vault-key.txt").exists());
592        assert!(dir.path().join("secrets.age").exists());
593    }
594
595    #[test]
596    fn load_missing_vault_errors() {
597        let dir = tempdir().unwrap();
598        let key_path = dir.path().join("vault-key.txt");
599        let vault_path = dir.path().join("secrets.age");
600
601        let result = AgeVaultProvider::load(&key_path, &vault_path);
602        assert!(result.is_err());
603    }
604
605    #[test]
606    #[cfg(unix)]
607    fn key_file_has_restricted_permissions() {
608        use std::os::unix::fs::PermissionsExt as _;
609
610        let dir = tempdir().unwrap();
611        let (key_path, _) = init_temp_vault(dir.path());
612
613        let mode = std::fs::metadata(&key_path).unwrap().permissions().mode() & 0o777;
614        assert_eq!(
615            mode, 0o600,
616            "vault-key.txt must have mode 0600, got {mode:o}"
617        );
618    }
619
620    #[test]
621    fn load_blank_key_returns_key_parse_error() {
622        let dir = tempdir().unwrap();
623        let key_path = dir.path().join("vault-key.txt");
624        let vault_path = dir.path().join("secrets.age");
625
626        // Key file with only comments and blank lines — no valid identity line.
627        std::fs::write(&key_path, "# comment\n\n# another comment\n").unwrap();
628        // Vault file must exist so the error comes from key parsing, not vault read.
629        std::fs::write(&vault_path, b"").unwrap();
630
631        let result = AgeVaultProvider::load(&key_path, &vault_path);
632        assert!(
633            matches!(result, Err(AgeVaultError::KeyParse(_))),
634            "expected KeyParse, got {result:?}",
635        );
636    }
637
638    #[test]
639    fn decrypt_corrupted_ciphertext_returns_decrypt_error() {
640        let dir = tempdir().unwrap();
641        let (key_path, vault_path) = init_temp_vault(dir.path());
642
643        // Overwrite the encrypted vault with random garbage.
644        std::fs::write(&vault_path, b"not valid age ciphertext at all").unwrap();
645
646        let result = AgeVaultProvider::load(&key_path, &vault_path);
647        assert!(
648            matches!(result, Err(AgeVaultError::Decrypt(_))),
649            "expected Decrypt, got {result:?}",
650        );
651    }
652
653    #[test]
654    fn save_leaves_no_tmp_file() {
655        let dir = tempdir().unwrap();
656        let (key_path, vault_path) = init_temp_vault(dir.path());
657
658        let mut vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
659        vault.set_secret_mut("TMP_TEST".into(), "value".into());
660        vault.save().unwrap();
661
662        let tmp_path = vault_path.with_added_extension("tmp");
663        assert!(!tmp_path.exists(), ".age.tmp must not exist after save()");
664        assert!(vault_path.exists(), "secrets.age must exist after save()");
665    }
666}