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