1use std::collections::BTreeMap;
5use std::fmt;
6use std::future::Future;
7use std::io::{Read as _, Write as _};
8use std::path::{Path, PathBuf};
9use std::pin::Pin;
10use std::sync::Arc;
11
12use zeroize::Zeroizing;
13
14pub use zeph_common::secret::{Secret, VaultError};
17
18pub trait VaultProvider: Send + Sync {
20 fn get_secret(
25 &self,
26 key: &str,
27 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>>;
28
29 fn list_keys(&self) -> Vec<String> {
31 Vec::new()
32 }
33}
34
35pub struct EnvVaultProvider;
37
38#[derive(Debug, thiserror::Error)]
39pub enum AgeVaultError {
40 #[error("failed to read key file: {0}")]
41 KeyRead(std::io::Error),
42 #[error("failed to parse age identity: {0}")]
43 KeyParse(String),
44 #[error("failed to read vault file: {0}")]
45 VaultRead(std::io::Error),
46 #[error("age decryption failed: {0}")]
47 Decrypt(age::DecryptError),
48 #[error("I/O error during decryption: {0}")]
49 Io(std::io::Error),
50 #[error("invalid JSON in vault: {0}")]
51 Json(serde_json::Error),
52 #[error("age encryption failed: {0}")]
53 Encrypt(String),
54 #[error("failed to write vault file: {0}")]
55 VaultWrite(std::io::Error),
56 #[error("failed to write key file: {0}")]
57 KeyWrite(std::io::Error),
58}
59
60pub struct AgeVaultProvider {
61 secrets: BTreeMap<String, Zeroizing<String>>,
62 key_path: PathBuf,
63 vault_path: PathBuf,
64}
65
66impl fmt::Debug for AgeVaultProvider {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 f.debug_struct("AgeVaultProvider")
69 .field("secrets", &format_args!("[{} secrets]", self.secrets.len()))
70 .field("key_path", &self.key_path)
71 .field("vault_path", &self.vault_path)
72 .finish()
73 }
74}
75
76impl AgeVaultProvider {
77 pub fn new(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
86 Self::load(key_path, vault_path)
87 }
88
89 pub fn load(key_path: &Path, vault_path: &Path) -> Result<Self, AgeVaultError> {
95 let key_str =
96 Zeroizing::new(std::fs::read_to_string(key_path).map_err(AgeVaultError::KeyRead)?);
97 let identity = parse_identity(&key_str)?;
98 let ciphertext = std::fs::read(vault_path).map_err(AgeVaultError::VaultRead)?;
99 let secrets = decrypt_secrets(&identity, &ciphertext)?;
100 Ok(Self {
101 secrets,
102 key_path: key_path.to_owned(),
103 vault_path: vault_path.to_owned(),
104 })
105 }
106
107 pub fn save(&self) -> Result<(), AgeVaultError> {
116 let key_str = Zeroizing::new(
117 std::fs::read_to_string(&self.key_path).map_err(AgeVaultError::KeyRead)?,
118 );
119 let identity = parse_identity(&key_str)?;
120 let ciphertext = encrypt_secrets(&identity, &self.secrets)?;
121 atomic_write(&self.vault_path, &ciphertext)
122 }
123
124 pub fn set_secret_mut(&mut self, key: String, value: String) {
126 self.secrets.insert(key, Zeroizing::new(value));
127 }
128
129 pub fn remove_secret_mut(&mut self, key: &str) -> bool {
131 self.secrets.remove(key).is_some()
132 }
133
134 #[must_use]
136 pub fn list_keys(&self) -> Vec<&str> {
137 let mut keys: Vec<&str> = self.secrets.keys().map(String::as_str).collect();
138 keys.sort_unstable();
139 keys
140 }
141
142 #[must_use]
144 pub fn get(&self, key: &str) -> Option<&str> {
145 self.secrets.get(key).map(|v| v.as_str())
146 }
147
148 pub fn init_vault(dir: &Path) -> Result<(), AgeVaultError> {
158 use age::secrecy::ExposeSecret as _;
159
160 std::fs::create_dir_all(dir).map_err(AgeVaultError::KeyWrite)?;
161
162 let identity = age::x25519::Identity::generate();
163 let public_key = identity.to_public();
164
165 let key_content = Zeroizing::new(format!(
166 "# public key: {}\n{}\n",
167 public_key,
168 identity.to_string().expose_secret()
169 ));
170
171 let key_path = dir.join("vault-key.txt");
172 write_private_file(&key_path, key_content.as_bytes())?;
173
174 let vault_path = dir.join("secrets.age");
175 let empty: BTreeMap<String, Zeroizing<String>> = BTreeMap::new();
176 let ciphertext = encrypt_secrets(&identity, &empty)?;
177 atomic_write(&vault_path, &ciphertext)?;
178
179 println!("Vault initialized:");
180 println!(" Key: {}", key_path.display());
181 println!(" Vault: {}", vault_path.display());
182
183 Ok(())
184 }
185}
186
187#[must_use]
189pub fn default_vault_dir() -> PathBuf {
190 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
191 return PathBuf::from(xdg).join("zeph");
192 }
193 if let Ok(appdata) = std::env::var("APPDATA") {
194 return PathBuf::from(appdata).join("zeph");
195 }
196 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned());
197 PathBuf::from(home).join(".config").join("zeph")
198}
199
200fn parse_identity(key_str: &str) -> Result<age::x25519::Identity, AgeVaultError> {
201 let key_line = key_str
202 .lines()
203 .find(|l| !l.starts_with('#') && !l.trim().is_empty())
204 .ok_or_else(|| AgeVaultError::KeyParse("no identity line found".into()))?;
205 key_line
206 .trim()
207 .parse()
208 .map_err(|e: &str| AgeVaultError::KeyParse(e.to_owned()))
209}
210
211fn decrypt_secrets(
212 identity: &age::x25519::Identity,
213 ciphertext: &[u8],
214) -> Result<BTreeMap<String, Zeroizing<String>>, AgeVaultError> {
215 let decryptor = age::Decryptor::new(ciphertext).map_err(AgeVaultError::Decrypt)?;
216 let mut reader = decryptor
217 .decrypt(std::iter::once(identity as &dyn age::Identity))
218 .map_err(AgeVaultError::Decrypt)?;
219 let mut plaintext = Zeroizing::new(Vec::with_capacity(ciphertext.len()));
220 reader
221 .read_to_end(&mut plaintext)
222 .map_err(AgeVaultError::Io)?;
223 let raw: BTreeMap<String, String> =
224 serde_json::from_slice(&plaintext).map_err(AgeVaultError::Json)?;
225 Ok(raw
226 .into_iter()
227 .map(|(k, v)| (k, Zeroizing::new(v)))
228 .collect())
229}
230
231fn encrypt_secrets(
232 identity: &age::x25519::Identity,
233 secrets: &BTreeMap<String, Zeroizing<String>>,
234) -> Result<Vec<u8>, AgeVaultError> {
235 let recipient = identity.to_public();
236 let encryptor =
237 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
238 .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
239 let plain: BTreeMap<&str, &str> = secrets
240 .iter()
241 .map(|(k, v)| (k.as_str(), v.as_str()))
242 .collect();
243 let json = Zeroizing::new(serde_json::to_vec(&plain).map_err(AgeVaultError::Json)?);
244 let mut ciphertext = Vec::with_capacity(json.len() + 64);
245 let mut writer = encryptor
246 .wrap_output(&mut ciphertext)
247 .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
248 writer.write_all(&json).map_err(AgeVaultError::Io)?;
249 writer
250 .finish()
251 .map_err(|e| AgeVaultError::Encrypt(e.to_string()))?;
252 Ok(ciphertext)
253}
254
255fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
256 let tmp_path = path.with_extension("age.tmp");
257 std::fs::write(&tmp_path, data).map_err(AgeVaultError::VaultWrite)?;
258 std::fs::rename(&tmp_path, path).map_err(AgeVaultError::VaultWrite)
259}
260
261#[cfg(unix)]
262fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
263 use std::os::unix::fs::OpenOptionsExt as _;
264 let mut file = std::fs::OpenOptions::new()
265 .write(true)
266 .create(true)
267 .truncate(true)
268 .mode(0o600)
269 .open(path)
270 .map_err(AgeVaultError::KeyWrite)?;
271 file.write_all(data).map_err(AgeVaultError::KeyWrite)
272}
273
274#[cfg(not(unix))]
277fn write_private_file(path: &Path, data: &[u8]) -> Result<(), AgeVaultError> {
278 std::fs::write(path, data).map_err(AgeVaultError::KeyWrite)
279}
280
281impl VaultProvider for AgeVaultProvider {
282 fn get_secret(
283 &self,
284 key: &str,
285 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
286 let result = self.secrets.get(key).map(|v| (**v).clone());
287 Box::pin(async move { Ok(result) })
288 }
289
290 fn list_keys(&self) -> Vec<String> {
291 let mut keys: Vec<String> = self.secrets.keys().cloned().collect();
292 keys.sort_unstable();
293 keys
294 }
295}
296
297impl VaultProvider for EnvVaultProvider {
298 fn get_secret(
299 &self,
300 key: &str,
301 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
302 let key = key.to_owned();
303 Box::pin(async move { Ok(std::env::var(&key).ok()) })
304 }
305
306 fn list_keys(&self) -> Vec<String> {
307 let mut keys: Vec<String> = std::env::vars()
308 .filter(|(k, _)| k.starts_with("ZEPH_SECRET_"))
309 .map(|(k, _)| k)
310 .collect();
311 keys.sort_unstable();
312 keys
313 }
314}
315
316pub struct ArcAgeVaultProvider(pub Arc<tokio::sync::RwLock<AgeVaultProvider>>);
322
323impl VaultProvider for ArcAgeVaultProvider {
324 fn get_secret(
325 &self,
326 key: &str,
327 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
328 let arc = Arc::clone(&self.0);
329 let key = key.to_owned();
330 Box::pin(async move {
331 let guard = arc.read().await;
332 Ok(guard.get(&key).map(str::to_owned))
333 })
334 }
335
336 fn list_keys(&self) -> Vec<String> {
337 let arc = Arc::clone(&self.0);
340 let guard = tokio::task::block_in_place(|| arc.blocking_read());
341 let mut keys: Vec<String> = guard.list_keys().iter().map(|s| (*s).to_owned()).collect();
342 keys.sort_unstable();
343 keys
344 }
345}
346
347#[cfg(any(test, feature = "mock"))]
349#[derive(Default)]
350pub struct MockVaultProvider {
351 secrets: std::collections::BTreeMap<String, String>,
352 listed_only: Vec<String>,
355}
356
357#[cfg(any(test, feature = "mock"))]
358impl MockVaultProvider {
359 #[must_use]
360 pub fn new() -> Self {
361 Self::default()
362 }
363
364 #[must_use]
365 pub fn with_secret(mut self, key: &str, value: &str) -> Self {
366 self.secrets.insert(key.to_owned(), value.to_owned());
367 self
368 }
369
370 #[must_use]
372 pub fn with_listed_key(mut self, key: &str) -> Self {
373 self.listed_only.push(key.to_owned());
374 self
375 }
376}
377
378#[cfg(any(test, feature = "mock"))]
379impl VaultProvider for MockVaultProvider {
380 fn get_secret(
381 &self,
382 key: &str,
383 ) -> Pin<Box<dyn Future<Output = Result<Option<String>, VaultError>> + Send + '_>> {
384 let result = self.secrets.get(key).cloned();
385 Box::pin(async move { Ok(result) })
386 }
387
388 fn list_keys(&self) -> Vec<String> {
389 let mut keys: Vec<String> = self
390 .secrets
391 .keys()
392 .cloned()
393 .chain(self.listed_only.iter().cloned())
394 .collect();
395 keys.sort_unstable();
396 keys.dedup();
397 keys
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 #![allow(clippy::doc_markdown)]
404
405 use super::*;
406
407 #[test]
408 fn secret_expose_returns_inner() {
409 let secret = Secret::new("my-api-key");
410 assert_eq!(secret.expose(), "my-api-key");
411 }
412
413 #[test]
414 fn secret_debug_is_redacted() {
415 let secret = Secret::new("my-api-key");
416 assert_eq!(format!("{secret:?}"), "[REDACTED]");
417 }
418
419 #[test]
420 fn secret_display_is_redacted() {
421 let secret = Secret::new("my-api-key");
422 assert_eq!(format!("{secret}"), "[REDACTED]");
423 }
424
425 #[allow(unsafe_code)]
426 #[tokio::test]
427 async fn env_vault_returns_set_var() {
428 let key = "ZEPH_TEST_VAULT_SECRET_SET";
429 unsafe { std::env::set_var(key, "test-value") };
430 let vault = EnvVaultProvider;
431 let result = vault.get_secret(key).await.unwrap();
432 unsafe { std::env::remove_var(key) };
433 assert_eq!(result.as_deref(), Some("test-value"));
434 }
435
436 #[tokio::test]
437 async fn env_vault_returns_none_for_unset() {
438 let vault = EnvVaultProvider;
439 let result = vault
440 .get_secret("ZEPH_TEST_VAULT_NONEXISTENT_KEY_12345")
441 .await
442 .unwrap();
443 assert!(result.is_none());
444 }
445
446 #[tokio::test]
447 async fn mock_vault_returns_configured_secret() {
448 let vault = MockVaultProvider::new().with_secret("API_KEY", "secret-123");
449 let result = vault.get_secret("API_KEY").await.unwrap();
450 assert_eq!(result.as_deref(), Some("secret-123"));
451 }
452
453 #[tokio::test]
454 async fn mock_vault_returns_none_for_missing() {
455 let vault = MockVaultProvider::new();
456 let result = vault.get_secret("MISSING").await.unwrap();
457 assert!(result.is_none());
458 }
459
460 #[test]
461 fn secret_from_string() {
462 let s = Secret::new(String::from("test"));
463 assert_eq!(s.expose(), "test");
464 }
465
466 #[test]
467 fn secret_expose_roundtrip() {
468 let s = Secret::new("test");
469 let owned = s.expose().to_owned();
470 let s2 = Secret::new(owned);
471 assert_eq!(s.expose(), s2.expose());
472 }
473
474 #[test]
475 fn secret_deserialize() {
476 let json = "\"my-secret-value\"";
477 let secret: Secret = serde_json::from_str(json).unwrap();
478 assert_eq!(secret.expose(), "my-secret-value");
479 assert_eq!(format!("{secret:?}"), "[REDACTED]");
480 }
481
482 #[test]
483 fn mock_vault_list_keys_sorted() {
484 let vault = MockVaultProvider::new()
485 .with_secret("B_KEY", "v2")
486 .with_secret("A_KEY", "v1")
487 .with_secret("C_KEY", "v3");
488 let mut keys = vault.list_keys();
489 keys.sort_unstable();
490 assert_eq!(keys, vec!["A_KEY", "B_KEY", "C_KEY"]);
491 }
492
493 #[test]
494 fn mock_vault_list_keys_empty() {
495 let vault = MockVaultProvider::new();
496 assert!(vault.list_keys().is_empty());
497 }
498
499 #[allow(unsafe_code)]
500 #[test]
501 fn env_vault_list_keys_filters_zeph_secret_prefix() {
502 let key = "ZEPH_SECRET_TEST_LISTKEYS_UNIQUE_9999";
503 unsafe { std::env::set_var(key, "v") };
504 let vault = EnvVaultProvider;
505 let keys = vault.list_keys();
506 assert!(keys.contains(&key.to_owned()));
507 unsafe { std::env::remove_var(key) };
508 }
509}
510
511#[cfg(test)]
512mod age_tests {
513 use std::io::Write as _;
514
515 use age::secrecy::ExposeSecret;
516
517 use super::*;
518
519 fn encrypt_json(identity: &age::x25519::Identity, json: &serde_json::Value) -> Vec<u8> {
520 let recipient = identity.to_public();
521 let encryptor =
522 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
523 .expect("encryptor creation");
524 let mut encrypted = vec![];
525 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap_output");
526 writer
527 .write_all(json.to_string().as_bytes())
528 .expect("write plaintext");
529 writer.finish().expect("finish encryption");
530 encrypted
531 }
532
533 fn write_temp_files(
534 identity: &age::x25519::Identity,
535 ciphertext: &[u8],
536 ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
537 let dir = tempfile::tempdir().expect("tempdir");
538 let key_path = dir.path().join("key.txt");
539 let vault_path = dir.path().join("secrets.age");
540 std::fs::write(&key_path, identity.to_string().expose_secret()).expect("write key");
541 std::fs::write(&vault_path, ciphertext).expect("write vault");
542 (dir, key_path, vault_path)
543 }
544
545 #[tokio::test]
546 async fn age_vault_returns_existing_secret() {
547 let identity = age::x25519::Identity::generate();
548 let json = serde_json::json!({"KEY": "value"});
549 let encrypted = encrypt_json(&identity, &json);
550 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
551
552 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
553 let result = vault.get_secret("KEY").await.unwrap();
554 assert_eq!(result.as_deref(), Some("value"));
555 }
556
557 #[tokio::test]
558 async fn age_vault_returns_none_for_missing() {
559 let identity = age::x25519::Identity::generate();
560 let json = serde_json::json!({"KEY": "value"});
561 let encrypted = encrypt_json(&identity, &json);
562 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
563
564 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
565 let result = vault.get_secret("MISSING").await.unwrap();
566 assert!(result.is_none());
567 }
568
569 #[test]
570 fn age_vault_bad_key_file() {
571 let err = AgeVaultProvider::new(
572 Path::new("/nonexistent/key.txt"),
573 Path::new("/nonexistent/vault.age"),
574 )
575 .unwrap_err();
576 assert!(matches!(err, AgeVaultError::KeyRead(_)));
577 }
578
579 #[test]
580 fn age_vault_bad_key_parse() {
581 let dir = tempfile::tempdir().unwrap();
582 let key_path = dir.path().join("bad-key.txt");
583 std::fs::write(&key_path, "not-a-valid-age-key").unwrap();
584
585 let vault_path = dir.path().join("vault.age");
586 std::fs::write(&vault_path, b"dummy").unwrap();
587
588 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
589 assert!(matches!(err, AgeVaultError::KeyParse(_)));
590 }
591
592 #[test]
593 fn age_vault_bad_vault_file() {
594 let dir = tempfile::tempdir().unwrap();
595 let identity = age::x25519::Identity::generate();
596 let key_path = dir.path().join("key.txt");
597 std::fs::write(&key_path, identity.to_string().expose_secret()).unwrap();
598
599 let err =
600 AgeVaultProvider::new(&key_path, Path::new("/nonexistent/vault.age")).unwrap_err();
601 assert!(matches!(err, AgeVaultError::VaultRead(_)));
602 }
603
604 #[test]
605 fn age_vault_wrong_key() {
606 let identity = age::x25519::Identity::generate();
607 let wrong_identity = age::x25519::Identity::generate();
608 let json = serde_json::json!({"KEY": "value"});
609 let encrypted = encrypt_json(&identity, &json);
610 let (_dir, _, vault_path) = write_temp_files(&identity, &encrypted);
611
612 let dir2 = tempfile::tempdir().unwrap();
613 let wrong_key_path = dir2.path().join("wrong-key.txt");
614 std::fs::write(&wrong_key_path, wrong_identity.to_string().expose_secret()).unwrap();
615
616 let err = AgeVaultProvider::new(&wrong_key_path, &vault_path).unwrap_err();
617 assert!(matches!(err, AgeVaultError::Decrypt(_)));
618 }
619
620 #[test]
621 fn age_vault_invalid_json() {
622 let identity = age::x25519::Identity::generate();
623 let recipient = identity.to_public();
624 let encryptor =
625 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
626 .expect("encryptor");
627 let mut encrypted = vec![];
628 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
629 writer.write_all(b"not json").expect("write");
630 writer.finish().expect("finish");
631
632 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
633 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
634 assert!(matches!(err, AgeVaultError::Json(_)));
635 }
636
637 #[test]
638 fn age_vault_debug_impl() {
639 let identity = age::x25519::Identity::generate();
640 let json = serde_json::json!({"KEY1": "value1", "KEY2": "value2"});
641 let encrypted = encrypt_json(&identity, &json);
642 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
643
644 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
645 let debug = format!("{vault:?}");
646 assert!(debug.contains("AgeVaultProvider"));
647 assert!(debug.contains("[2 secrets]"));
648 assert!(!debug.contains("value1"));
649 }
650
651 #[tokio::test]
652 async fn age_vault_key_file_with_comments() {
653 let identity = age::x25519::Identity::generate();
654 let json = serde_json::json!({"KEY": "value"});
655 let encrypted = encrypt_json(&identity, &json);
656 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
657
658 let key_with_comments = format!(
659 "# created: 2026-02-11T12:00:00+03:00\n# public key: {}\n{}\n",
660 identity.to_public(),
661 identity.to_string().expose_secret()
662 );
663 std::fs::write(&key_path, &key_with_comments).unwrap();
664
665 let vault = AgeVaultProvider::new(&key_path, &vault_path).unwrap();
666 let result = vault.get_secret("KEY").await.unwrap();
667 assert_eq!(result.as_deref(), Some("value"));
668 }
669
670 #[test]
671 fn age_vault_key_file_only_comments() {
672 let dir = tempfile::tempdir().unwrap();
673 let key_path = dir.path().join("comments-only.txt");
674 std::fs::write(&key_path, "# comment\n# another\n").unwrap();
675 let vault_path = dir.path().join("vault.age");
676 std::fs::write(&vault_path, b"dummy").unwrap();
677
678 let err = AgeVaultProvider::new(&key_path, &vault_path).unwrap_err();
679 assert!(matches!(err, AgeVaultError::KeyParse(_)));
680 }
681
682 #[test]
683 fn age_vault_error_display() {
684 let key_err =
685 AgeVaultError::KeyRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
686 assert!(key_err.to_string().contains("failed to read key file"));
687
688 let parse_err = AgeVaultError::KeyParse("bad key".into());
689 assert!(
690 parse_err
691 .to_string()
692 .contains("failed to parse age identity")
693 );
694
695 let vault_err =
696 AgeVaultError::VaultRead(std::io::Error::new(std::io::ErrorKind::NotFound, "test"));
697 assert!(vault_err.to_string().contains("failed to read vault file"));
698
699 let enc_err = AgeVaultError::Encrypt("bad".into());
700 assert!(enc_err.to_string().contains("age encryption failed"));
701
702 let write_err = AgeVaultError::VaultWrite(std::io::Error::new(
703 std::io::ErrorKind::PermissionDenied,
704 "test",
705 ));
706 assert!(write_err.to_string().contains("failed to write vault file"));
707 }
708
709 #[test]
710 fn age_vault_set_and_list_keys() {
711 let identity = age::x25519::Identity::generate();
712 let json = serde_json::json!({"A": "1"});
713 let encrypted = encrypt_json(&identity, &json);
714 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
715
716 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
717 vault.set_secret_mut("B".to_owned(), "2".to_owned());
718 vault.set_secret_mut("C".to_owned(), "3".to_owned());
719
720 let keys = vault.list_keys();
721 assert_eq!(keys, vec!["A", "B", "C"]);
722 }
723
724 #[test]
725 fn age_vault_remove_secret() {
726 let identity = age::x25519::Identity::generate();
727 let json = serde_json::json!({"X": "val", "Y": "val2"});
728 let encrypted = encrypt_json(&identity, &json);
729 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
730
731 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
732 assert!(vault.remove_secret_mut("X"));
733 assert!(!vault.remove_secret_mut("NONEXISTENT"));
734 assert_eq!(vault.list_keys(), vec!["Y"]);
735 }
736
737 #[tokio::test]
738 async fn age_vault_save_roundtrip() {
739 let identity = age::x25519::Identity::generate();
740 let json = serde_json::json!({"ORIG": "value"});
741 let encrypted = encrypt_json(&identity, &json);
742 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
743
744 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
745 vault.set_secret_mut("NEW_KEY".to_owned(), "new_value".to_owned());
746 vault.save().unwrap();
747
748 let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
749 let result = reloaded.get_secret("NEW_KEY").await.unwrap();
750 assert_eq!(result.as_deref(), Some("new_value"));
751
752 let orig = reloaded.get_secret("ORIG").await.unwrap();
753 assert_eq!(orig.as_deref(), Some("value"));
754 }
755
756 #[test]
757 fn age_vault_get_method_returns_str() {
758 let identity = age::x25519::Identity::generate();
759 let json = serde_json::json!({"FOO": "bar"});
760 let encrypted = encrypt_json(&identity, &json);
761 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
762
763 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
764 assert_eq!(vault.get("FOO"), Some("bar"));
765 assert_eq!(vault.get("MISSING"), None);
766 }
767
768 #[test]
769 fn age_vault_empty_secret_value() {
770 let identity = age::x25519::Identity::generate();
771 let json = serde_json::json!({"EMPTY": ""});
772 let encrypted = encrypt_json(&identity, &json);
773 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
774
775 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
776 assert_eq!(vault.get("EMPTY"), Some(""));
777 }
778
779 #[test]
780 fn age_vault_init_vault() {
781 let dir = tempfile::tempdir().unwrap();
782 AgeVaultProvider::init_vault(dir.path()).unwrap();
783
784 let key_path = dir.path().join("vault-key.txt");
785 let vault_path = dir.path().join("secrets.age");
786 assert!(key_path.exists());
787 assert!(vault_path.exists());
788
789 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
790 assert_eq!(vault.list_keys(), Vec::<&str>::new());
791 }
792
793 #[tokio::test]
794 async fn age_vault_keys_sorted_after_roundtrip() {
795 let identity = age::x25519::Identity::generate();
796 let json = serde_json::json!({"ZEBRA": "z", "APPLE": "a", "MANGO": "m"});
798 let encrypted = encrypt_json(&identity, &json);
799 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
800
801 let vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
802 let keys = vault.list_keys();
803 assert_eq!(keys, vec!["APPLE", "MANGO", "ZEBRA"]);
804 }
805
806 #[test]
807 fn age_vault_save_preserves_key_order() {
808 let identity = age::x25519::Identity::generate();
809 let json = serde_json::json!({"Z_KEY": "z", "A_KEY": "a", "M_KEY": "m"});
810 let encrypted = encrypt_json(&identity, &json);
811 let (_dir, key_path, vault_path) = write_temp_files(&identity, &encrypted);
812
813 let mut vault = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
814 vault.set_secret_mut("B_KEY".to_owned(), "b".to_owned());
815 vault.save().unwrap();
816
817 let reloaded = AgeVaultProvider::load(&key_path, &vault_path).unwrap();
818 let keys = reloaded.list_keys();
819 assert_eq!(keys, vec!["A_KEY", "B_KEY", "M_KEY", "Z_KEY"]);
820 }
821
822 #[test]
823 fn age_vault_decrypt_returns_btreemap_sorted() {
824 let identity = age::x25519::Identity::generate();
825 let json_str = r#"{"zoo":"z","bar":"b","alpha":"a"}"#;
827 let recipient = identity.to_public();
828 let encryptor =
829 age::Encryptor::with_recipients(std::iter::once(&recipient as &dyn age::Recipient))
830 .expect("encryptor");
831 let mut encrypted = vec![];
832 let mut writer = encryptor.wrap_output(&mut encrypted).expect("wrap");
833 writer.write_all(json_str.as_bytes()).expect("write");
834 writer.finish().expect("finish");
835
836 let ciphertext = encrypted;
837 let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
838 let keys: Vec<&str> = secrets.keys().map(String::as_str).collect();
839 assert_eq!(keys, vec!["alpha", "bar", "zoo"]);
841 }
842
843 #[test]
844 fn age_vault_into_iter_consumes_all_entries() {
845 let identity = age::x25519::Identity::generate();
848 let json = serde_json::json!({"K1": "v1", "K2": "v2", "K3": "v3"});
849 let encrypted = encrypt_json(&identity, &json);
850 let ciphertext = encrypted;
851 let secrets = decrypt_secrets(&identity, &ciphertext).unwrap();
852
853 let mut pairs: Vec<(String, String)> = secrets
854 .into_iter()
855 .map(|(k, v)| (k, v.as_str().to_owned()))
856 .collect();
857 pairs.sort_by(|a, b| a.0.cmp(&b.0));
858
859 assert_eq!(pairs.len(), 3);
860 assert_eq!(pairs[0], ("K1".to_owned(), "v1".to_owned()));
861 assert_eq!(pairs[1], ("K2".to_owned(), "v2".to_owned()));
862 assert_eq!(pairs[2], ("K3".to_owned(), "v3".to_owned()));
863 }
864
865 use proptest::prelude::*;
866
867 proptest! {
868 #[test]
869 fn secret_value_roundtrip(s in ".*") {
870 let secret = Secret::new(s.clone());
871 assert_eq!(secret.expose(), s.as_str());
872 }
873
874 #[test]
875 fn secret_debug_always_redacted(s in ".*") {
876 let secret = Secret::new(s);
877 assert_eq!(format!("{secret:?}"), "[REDACTED]");
878 }
879
880 #[test]
881 fn secret_display_always_redacted(s in ".*") {
882 let secret = Secret::new(s);
883 assert_eq!(format!("{secret}"), "[REDACTED]");
884 }
885 }
886}