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