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