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}