1use std::collections::HashMap;
13use std::fs::{File, OpenOptions};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use zeroize::{Zeroize, Zeroizing};
22
23use tracing::{instrument, warn};
24
25use crate::crypto::{
26 self, CipherKind, KeyPurpose, KeySchedule, VaultKey, VAULT_KDF_M_COST, VAULT_KDF_P_COST,
27 VAULT_KDF_T_COST,
28};
29use crate::errors::{SafeError, SafeResult};
30use crate::rbac::RbacProfile;
31use crate::snapshot;
32
33const VAULT_SCHEMA: &str = "tsafe/vault/v1";
34const VAULT_KDF_ALGORITHM: &str = "argon2id";
35pub(crate) const VAULT_CHALLENGE_PLAINTEXT: &[u8] = b"tsafe-vault-challenge-v1";
37
38const KDF_M_COST_MIN: u32 = 8_192; const KDF_M_COST_MAX: u32 = 131_072; const KDF_T_COST_MIN: u32 = 1;
42const KDF_T_COST_MAX: u32 = 20;
43const KDF_P_COST_MIN: u32 = 1;
44const KDF_P_COST_MAX: u32 = 16;
45
46pub fn validate_secret_key(key: &str) -> SafeResult<()> {
58 if key.is_empty() {
59 return Err(SafeError::InvalidVault {
60 reason: "secret key must not be empty".into(),
61 });
62 }
63 if key.len() > 256 {
64 return Err(SafeError::InvalidVault {
65 reason: format!("secret key too long ({} chars, max 256)", key.len()),
66 });
67 }
68 let mut chars = key.chars().peekable();
69 let first = chars.next().unwrap();
70 if !(first.is_ascii_alphabetic() || first == '_') {
71 return Err(SafeError::InvalidVault {
72 reason: format!("secret key '{key}' must start with a letter or underscore"),
73 });
74 }
75 let is_sep = |c: char| c == '.' || c == '-' || c == '/';
76 let mut prev = first;
77 for c in chars {
78 if !(c.is_ascii_alphanumeric() || c == '_' || is_sep(c)) {
79 return Err(SafeError::InvalidVault {
80 reason: format!("secret key '{key}' contains invalid character '{c}' — use letters, digits, _, -, ., /"),
81 });
82 }
83 if is_sep(c) && is_sep(prev) {
84 return Err(SafeError::InvalidVault {
85 reason: format!("secret key '{key}' has consecutive separators"),
86 });
87 }
88 prev = c;
89 }
90 if is_sep(prev) {
91 return Err(SafeError::InvalidVault {
92 reason: format!("secret key '{key}' must not end with a separator"),
93 });
94 }
95 Ok(())
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct KdfParams {
102 pub algorithm: String,
103 pub m_cost: u32,
104 pub t_cost: u32,
105 pub p_cost: u32,
106 pub salt: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct HistoryEntry {
112 pub nonce: String,
113 pub ciphertext: String,
114 pub updated_at: DateTime<Utc>,
115}
116
117pub const DEFAULT_HISTORY_KEEP: usize = 5;
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct SecretEntry {
123 pub nonce: String,
124 pub ciphertext: String,
125 pub created_at: DateTime<Utc>,
126 pub updated_at: DateTime<Utc>,
127 #[serde(default)]
128 pub tags: HashMap<String, String>,
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub history: Vec<HistoryEntry>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct VaultChallenge {
137 pub nonce: String,
138 pub ciphertext: String,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct VaultFile {
144 #[serde(rename = "_schema")]
145 pub schema: String,
146 pub kdf: KdfParams,
147 pub cipher: String,
148 pub vault_challenge: VaultChallenge,
149 pub created_at: DateTime<Utc>,
150 pub updated_at: DateTime<Utc>,
151 pub secrets: HashMap<String, SecretEntry>,
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub age_recipients: Vec<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub wrapped_dek: Option<String>,
158}
159
160pub struct Vault {
167 path: PathBuf,
168 pub(crate) file: VaultFile,
169 key: VaultKey,
170 cipher: CipherKind,
171 key_schedule: KeySchedule,
172 access_profile: RbacProfile,
173 _lock: Option<LockGuard>,
175}
176
177struct LockGuard {
178 path: PathBuf,
179 contents: String,
180 _file: File,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184struct LockFileContents {
185 version: u8,
186 id: String,
187 pid: u32,
188 created_at: DateTime<Utc>,
189}
190
191impl LockFileContents {
192 fn new() -> Self {
193 Self {
194 version: 1,
195 id: Uuid::new_v4().to_string(),
196 pid: std::process::id(),
197 created_at: Utc::now(),
198 }
199 }
200}
201
202fn acquire_lock(path: &Path) -> SafeResult<LockGuard> {
207 let lock_path = lock_path_for(path);
208 if let Some(parent) = lock_path.parent() {
209 std::fs::create_dir_all(parent)?;
210 }
211 for _ in 0..2 {
212 let contents = serde_json::to_string(&LockFileContents::new())?;
213 match OpenOptions::new()
214 .write(true)
215 .create_new(true)
216 .open(&lock_path)
217 {
218 Ok(mut file) => {
219 file.write_all(contents.as_bytes())?;
220 file.flush()?;
221 return Ok(LockGuard {
222 path: lock_path.clone(),
223 contents,
224 _file: file,
225 });
226 }
227 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
228 if try_recover_dead_lock(&lock_path)? {
229 continue;
230 }
231 return Err(lock_held_error(&lock_path));
232 }
233 Err(e) => return Err(SafeError::Io(e)),
234 }
235 }
236 Err(lock_held_error(&lock_path))
237}
238
239fn lock_path_for(vault_path: &Path) -> PathBuf {
240 vault_path.with_extension("vault.lock")
241}
242
243fn lock_held_error(lock_path: &Path) -> SafeError {
244 SafeError::InvalidVault {
245 reason: format!(
246 "vault is locked by another process: {}",
247 lock_path.display()
248 ),
249 }
250}
251
252fn try_recover_dead_lock(lock_path: &Path) -> SafeResult<bool> {
253 let original_contents = match std::fs::read_to_string(lock_path) {
254 Ok(contents) => contents,
255 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
256 Err(e) => return Err(SafeError::Io(e)),
257 };
258 let lock: LockFileContents = match serde_json::from_str(&original_contents) {
259 Ok(lock) => lock,
260 Err(_) => return Ok(false),
261 };
262 if process_is_running(lock.pid) {
263 return Ok(false);
264 }
265
266 let current_contents = match std::fs::read_to_string(lock_path) {
267 Ok(contents) => contents,
268 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
269 Err(e) => return Err(SafeError::Io(e)),
270 };
271 if current_contents != original_contents {
272 return Ok(false);
273 }
274
275 match std::fs::remove_file(lock_path) {
276 Ok(()) => Ok(true),
277 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
278 Err(e) => Err(SafeError::Io(e)),
279 }
280}
281
282#[cfg(unix)]
283fn process_is_running(pid: u32) -> bool {
284 let Ok(pid) = i32::try_from(pid) else {
285 return false;
286 };
287 let rc = unsafe { libc::kill(pid, 0) };
294 if rc == 0 {
295 true
296 } else {
297 std::io::Error::last_os_error()
298 .raw_os_error()
299 .map(|code| code != libc::ESRCH)
300 .unwrap_or(true)
301 }
302}
303
304#[cfg(windows)]
305fn process_is_running(pid: u32) -> bool {
306 use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
307 use windows_sys::Win32::System::Threading::{
308 GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
309 };
310
311 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
317 if handle.is_null() {
318 let code = std::io::Error::last_os_error()
319 .raw_os_error()
320 .unwrap_or_default();
321 return code != windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER as i32;
322 }
323
324 let mut exit_code = 0u32;
325 let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
332 unsafe {
336 CloseHandle(handle);
337 }
338 ok != 0 && exit_code == STILL_ACTIVE as u32
339}
340
341#[cfg(not(any(unix, windows)))]
342fn process_is_running(_pid: u32) -> bool {
343 true
345}
346
347impl Drop for LockGuard {
348 fn drop(&mut self) {
349 let should_remove = std::fs::read_to_string(&self.path)
350 .map(|contents| contents == self.contents)
351 .unwrap_or(false);
352 if should_remove {
353 let _ = std::fs::remove_file(&self.path);
354 }
355 }
356}
357
358impl Vault {
359 #[instrument(skip(password, path))]
363 pub fn create(path: &Path, password: &[u8]) -> SafeResult<Self> {
364 Self::create_with_access_profile(path, password, RbacProfile::ReadWrite)
365 }
366
367 #[instrument(skip(password, path))]
372 pub fn create_with_access_profile(
373 path: &Path,
374 password: &[u8],
375 access_profile: RbacProfile,
376 ) -> SafeResult<Self> {
377 access_profile.ensure_write_allowed()?;
378 if path.exists() {
379 return Err(SafeError::VaultAlreadyExists {
380 path: path.display().to_string(),
381 });
382 }
383 let salt = crypto::random_salt();
384 let key = crypto::derive_key(
385 password,
386 &salt,
387 VAULT_KDF_M_COST,
388 VAULT_KDF_T_COST,
389 VAULT_KDF_P_COST,
390 )?;
391 let now = Utc::now();
392 let cipher = crypto::default_vault_cipher();
393 let key_schedule = KeySchedule::HkdfSha256V1;
394
395 let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
396 &key,
397 key_schedule,
398 KeyPurpose::VaultChallenge,
399 cipher,
400 VAULT_CHALLENGE_PLAINTEXT,
401 )?;
402 let file = VaultFile {
403 schema: VAULT_SCHEMA.to_string(),
404 kdf: KdfParams {
405 algorithm: "argon2id".to_string(),
406 m_cost: VAULT_KDF_M_COST,
407 t_cost: VAULT_KDF_T_COST,
408 p_cost: VAULT_KDF_P_COST,
409 salt: crypto::encode_b64(&salt),
410 },
411 cipher: cipher.as_str().to_string(),
412 vault_challenge: VaultChallenge {
413 nonce: crypto::encode_b64(&ch_nonce),
414 ciphertext: crypto::encode_b64(&ch_ct),
415 },
416 created_at: now,
417 updated_at: now,
418 secrets: HashMap::new(),
419 age_recipients: Vec::new(),
420 wrapped_dek: None,
421 };
422 let lock = acquire_lock(path)?;
423 let vault = Self {
424 path: path.to_path_buf(),
425 file,
426 key,
427 cipher,
428 key_schedule,
429 access_profile,
430 _lock: Some(lock),
431 };
432 vault.save()?;
433 Ok(vault)
434 }
435
436 #[instrument(skip(password, path))]
439 pub fn open(path: &Path, password: &[u8]) -> SafeResult<Self> {
440 Self::open_with_access_profile(path, password, RbacProfile::ReadWrite)
441 }
442
443 #[instrument(skip(password, path))]
448 pub fn open_read_only(path: &Path, password: &[u8]) -> SafeResult<Self> {
449 Self::open_with_access_profile(path, password, RbacProfile::ReadOnly)
450 }
451
452 #[instrument(skip(password, path))]
454 pub fn open_with_access_profile(
455 path: &Path,
456 password: &[u8],
457 access_profile: RbacProfile,
458 ) -> SafeResult<Self> {
459 let lock = acquire_lock(path)?;
460 if !path.exists() {
462 if access_profile.allows_write() {
463 if let Some(profile) = Self::profile_name_from_path(path) {
464 let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
465 SafeError::VaultNotFound {
466 path: path.display().to_string(),
467 }
468 })?;
469 warn!(
470 snapshot = %snap.display(),
471 "vault file was missing — restored from snapshot"
472 );
473 } else {
474 return Err(SafeError::VaultNotFound {
475 path: path.display().to_string(),
476 });
477 }
478 } else {
479 return Err(SafeError::VaultNotFound {
480 path: path.display().to_string(),
481 });
482 }
483 }
484
485 let json = match std::fs::read_to_string(path) {
487 Ok(s) => s,
488 Err(e) => {
489 if !access_profile.allows_write() {
490 return Err(SafeError::Io(e));
491 }
492 let profile = Self::profile_name_from_path(path).ok_or(SafeError::Io(e))?;
493 let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
494 SafeError::VaultCorrupted {
495 reason: "io error and no usable snapshot found".into(),
496 }
497 })?;
498 warn!(
499 snapshot = %snap.display(),
500 "vault file was unreadable — restored from snapshot"
501 );
502 std::fs::read_to_string(path)?
503 }
504 };
505
506 let file: VaultFile = match serde_json::from_str(&json) {
507 Ok(f) => f,
508 Err(_) => {
509 if !access_profile.allows_write() {
510 return Err(SafeError::VaultCorrupted {
511 reason: "vault JSON is invalid".into(),
512 });
513 }
514 let profile = Self::profile_name_from_path(path).ok_or_else(|| {
515 SafeError::VaultCorrupted {
516 reason: "vault JSON is invalid and profile name could not be inferred"
517 .into(),
518 }
519 })?;
520 let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
521 SafeError::VaultCorrupted {
522 reason: "vault JSON is invalid and no usable snapshot was found".into(),
523 }
524 })?;
525 warn!(snapshot = %snap.display(), "vault JSON was corrupt — restored from snapshot");
526 let recovered = std::fs::read_to_string(path)?;
527 serde_json::from_str(&recovered).map_err(|e| SafeError::VaultCorrupted {
528 reason: format!("snapshot also failed to parse: {e}"),
529 })?
530 }
531 };
532
533 if file.schema != VAULT_SCHEMA {
534 return Err(SafeError::InvalidVault {
535 reason: format!("unknown schema: {}", file.schema),
536 });
537 }
538 let cipher = crypto::parse_cipher_kind(&file.cipher)?;
539 if file.kdf.algorithm != VAULT_KDF_ALGORITHM {
540 return Err(SafeError::InvalidVault {
541 reason: format!(
542 "unsupported KDF algorithm: '{}' (expected '{VAULT_KDF_ALGORITHM}')",
543 file.kdf.algorithm
544 ),
545 });
546 }
547 if file.kdf.m_cost < KDF_M_COST_MIN || file.kdf.m_cost > KDF_M_COST_MAX {
549 return Err(SafeError::InvalidVault {
550 reason: format!(
551 "KDF m_cost {} is outside allowed range [{KDF_M_COST_MIN}, {KDF_M_COST_MAX}]",
552 file.kdf.m_cost
553 ),
554 });
555 }
556 if file.kdf.t_cost < KDF_T_COST_MIN || file.kdf.t_cost > KDF_T_COST_MAX {
557 return Err(SafeError::InvalidVault {
558 reason: format!(
559 "KDF t_cost {} is outside allowed range [{KDF_T_COST_MIN}, {KDF_T_COST_MAX}]",
560 file.kdf.t_cost
561 ),
562 });
563 }
564 if file.kdf.p_cost < KDF_P_COST_MIN || file.kdf.p_cost > KDF_P_COST_MAX {
565 return Err(SafeError::InvalidVault {
566 reason: format!(
567 "KDF p_cost {} is outside allowed range [{KDF_P_COST_MIN}, {KDF_P_COST_MAX}]",
568 file.kdf.p_cost
569 ),
570 });
571 }
572 let salt = crypto::decode_b64(&file.kdf.salt)?;
573 let key = crypto::derive_key(
574 password,
575 &salt,
576 file.kdf.m_cost,
577 file.kdf.t_cost,
578 file.kdf.p_cost,
579 )?;
580
581 let ch_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
583 let ch_ct = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
584 let key_schedule = crypto::detect_key_schedule(
585 &key,
586 KeyPurpose::VaultChallenge,
587 cipher,
588 &ch_nonce,
589 &ch_ct,
590 VAULT_CHALLENGE_PLAINTEXT,
591 )?;
592 Ok(Self {
593 path: path.to_path_buf(),
594 file,
595 key,
596 cipher,
597 key_schedule,
598 access_profile,
599 _lock: Some(lock),
600 })
601 }
602
603 pub fn open_with_key(path: &Path, key: crypto::VaultKey) -> SafeResult<Self> {
606 Self::open_with_key_with_access_profile(path, key, RbacProfile::ReadWrite)
607 }
608
609 pub fn open_with_key_read_only(path: &Path, key: crypto::VaultKey) -> SafeResult<Self> {
611 Self::open_with_key_with_access_profile(path, key, RbacProfile::ReadOnly)
612 }
613
614 pub fn open_with_key_with_access_profile(
616 path: &Path,
617 key: crypto::VaultKey,
618 access_profile: RbacProfile,
619 ) -> SafeResult<Self> {
620 let lock = acquire_lock(path)?;
621 if !path.exists() {
622 return Err(SafeError::VaultNotFound {
623 path: path.display().to_string(),
624 });
625 }
626 let json = std::fs::read_to_string(path)?;
627 let file: VaultFile =
628 serde_json::from_str(&json).map_err(|e| SafeError::VaultCorrupted {
629 reason: format!("vault JSON parse error: {e}"),
630 })?;
631 let allowed_schema = matches!(file.schema.as_str(), "tsafe/vault/v1" | "tsafe/vault/v2");
632 if !allowed_schema {
633 return Err(SafeError::InvalidVault {
634 reason: format!("unknown schema: {}", file.schema),
635 });
636 }
637 let cipher = crypto::parse_cipher_kind(&file.cipher)?;
638 let ch_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
640 let ch_ct = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
641 let key_schedule = crypto::detect_key_schedule(
642 &key,
643 KeyPurpose::VaultChallenge,
644 cipher,
645 &ch_nonce,
646 &ch_ct,
647 VAULT_CHALLENGE_PLAINTEXT,
648 )?;
649 Ok(Self {
650 path: path.to_path_buf(),
651 file,
652 key,
653 cipher,
654 key_schedule,
655 access_profile,
656 _lock: Some(lock),
657 })
658 }
659
660 pub fn is_team_vault(path: &Path) -> bool {
663 std::fs::read_to_string(path)
664 .ok()
665 .and_then(|json| serde_json::from_str::<VaultFile>(&json).ok())
666 .map(|f| !f.age_recipients.is_empty() && f.wrapped_dek.is_some())
667 .unwrap_or(false)
668 }
669
670 #[instrument(skip(self), fields(secrets = self.file.secrets.len()))]
675 pub fn save(&self) -> SafeResult<()> {
676 self.ensure_write_allowed()?;
677 if let Some(parent) = self.path.parent() {
678 std::fs::create_dir_all(parent)?;
679 }
680 if self.path.exists() {
682 if let Some(profile) = self.profile_name() {
683 let _ = snapshot::take(&self.path, &profile, snapshot::DEFAULT_SNAPSHOT_KEEP);
684 }
685 }
686 let json = serde_json::to_string_pretty(&self.file)?;
687 let tmp = self.path.with_extension("vault.tmp");
688 std::fs::write(&tmp, &json)?;
689 std::fs::rename(&tmp, &self.path)?;
690 Ok(())
691 }
692
693 #[instrument(skip(self, value, tags, key))]
698 pub fn set(&mut self, key: &str, value: &str, tags: HashMap<String, String>) -> SafeResult<()> {
699 self.ensure_write_allowed()?;
700 validate_secret_key(key)?;
701 let (nonce, ct) = crypto::encrypt_with_key_schedule(
702 &self.key,
703 self.key_schedule,
704 KeyPurpose::SecretData,
705 self.cipher,
706 value.as_bytes(),
707 )?;
708 let now = Utc::now();
709 let (created_at, history, tags) = match self.file.secrets.get(key) {
710 Some(existing) => {
711 let mut h = existing.history.clone();
712 h.push(HistoryEntry {
713 nonce: existing.nonce.clone(),
714 ciphertext: existing.ciphertext.clone(),
715 updated_at: existing.updated_at,
716 });
717 if h.len() > DEFAULT_HISTORY_KEEP {
718 h.drain(..h.len() - DEFAULT_HISTORY_KEEP);
719 }
720 let merged_tags = if tags.is_empty() {
721 existing.tags.clone()
722 } else {
723 tags
724 };
725 (existing.created_at, h, merged_tags)
726 }
727 None => (now, Vec::new(), tags),
728 };
729 self.file.secrets.insert(
730 key.to_string(),
731 SecretEntry {
732 nonce: crypto::encode_b64(&nonce),
733 ciphertext: crypto::encode_b64(&ct),
734 created_at,
735 updated_at: now,
736 tags,
737 history,
738 },
739 );
740 self.file.updated_at = now;
741 self.save()
742 }
743
744 #[instrument(skip(self, key))]
747 pub fn get(&self, key: &str) -> SafeResult<Zeroizing<String>> {
748 let entry = self
749 .file
750 .secrets
751 .get(key)
752 .ok_or_else(|| SafeError::SecretNotFound {
753 key: key.to_string(),
754 })?;
755 let nonce = crypto::decode_b64(&entry.nonce)?;
756 let ct = crypto::decode_b64(&entry.ciphertext)?;
757 let pt = crypto::decrypt_with_key_schedule(
758 &self.key,
759 self.key_schedule,
760 KeyPurpose::SecretData,
761 self.cipher,
762 &nonce,
763 &ct,
764 )?;
765 match String::from_utf8(pt) {
768 Ok(s) => Ok(Zeroizing::new(s)),
769 Err(e) => {
770 let mut bytes = e.into_bytes();
771 bytes.zeroize();
772 Err(SafeError::InvalidVault {
773 reason: "secret is not valid UTF-8".into(),
774 })
775 }
776 }
777 }
778
779 pub fn delete(&mut self, key: &str) -> SafeResult<()> {
781 self.ensure_write_allowed()?;
782 if !self.file.secrets.contains_key(key) {
783 return Err(SafeError::SecretNotFound {
784 key: key.to_string(),
785 });
786 }
787 self.file.secrets.remove(key);
788 self.file.updated_at = Utc::now();
789 self.save()
790 }
791
792 pub fn rename_key(&mut self, old_key: &str, new_key: &str, overwrite: bool) -> SafeResult<()> {
798 self.ensure_write_allowed()?;
799 validate_secret_key(new_key)?;
800 if !self.file.secrets.contains_key(old_key) {
801 return Err(SafeError::SecretNotFound {
802 key: old_key.to_string(),
803 });
804 }
805 if !overwrite && self.file.secrets.contains_key(new_key) {
806 return Err(SafeError::SecretAlreadyExists {
807 key: new_key.to_string(),
808 });
809 }
810 let entry = self.file.secrets.remove(old_key).unwrap();
811 self.file.secrets.insert(new_key.to_string(), entry);
812 self.file.updated_at = Utc::now();
813 self.save()
814 }
815
816 pub fn list(&self) -> Vec<&str> {
818 let mut keys: Vec<&str> = self.file.secrets.keys().map(String::as_str).collect();
819 keys.sort_unstable();
820 keys
821 }
822
823 pub fn export_all(&self) -> SafeResult<HashMap<String, String>> {
827 self.list()
828 .iter()
829 .map(|k| {
830 let val = self.get(k)?;
831 Ok((k.to_string(), (*val).clone()))
833 })
834 .collect()
835 }
836
837 pub fn get_version(&self, key: &str, version: usize) -> SafeResult<Zeroizing<String>> {
842 if version == 0 {
843 return self.get(key);
844 }
845 let entry = self
846 .file
847 .secrets
848 .get(key)
849 .ok_or_else(|| SafeError::SecretNotFound {
850 key: key.to_string(),
851 })?;
852 let hist_idx =
853 entry
854 .history
855 .len()
856 .checked_sub(version)
857 .ok_or_else(|| SafeError::InvalidVault {
858 reason: format!(
859 "version {version} does not exist for '{key}' (max {})",
860 entry.history.len()
861 ),
862 })?;
863 let h = &entry.history[hist_idx];
864 let nonce = crypto::decode_b64(&h.nonce)?;
865 let ct = crypto::decode_b64(&h.ciphertext)?;
866 let pt = crypto::decrypt_with_key_schedule(
867 &self.key,
868 self.key_schedule,
869 KeyPurpose::SecretData,
870 self.cipher,
871 &nonce,
872 &ct,
873 )?;
874 match String::from_utf8(pt) {
875 Ok(s) => Ok(Zeroizing::new(s)),
876 Err(e) => {
877 let mut bytes = e.into_bytes();
878 bytes.zeroize();
879 Err(SafeError::InvalidVault {
880 reason: "secret is not valid UTF-8".into(),
881 })
882 }
883 }
884 }
885
886 pub fn history(&self, key: &str) -> SafeResult<Vec<(usize, DateTime<Utc>)>> {
889 let entry = self
890 .file
891 .secrets
892 .get(key)
893 .ok_or_else(|| SafeError::SecretNotFound {
894 key: key.to_string(),
895 })?;
896 let mut versions = vec![(0usize, entry.updated_at)];
897 for (i, h) in entry.history.iter().rev().enumerate() {
898 versions.push((i + 1, h.updated_at));
899 }
900 Ok(versions)
901 }
902
903 pub fn revert_to_version(&mut self, key: &str, version: usize) -> SafeResult<()> {
909 self.ensure_write_allowed()?;
910 if version == 0 {
911 return Ok(());
913 }
914 let target_value = self.get_version(key, version)?;
916 let now = Utc::now();
917 let entry = self
918 .file
919 .secrets
920 .get_mut(key)
921 .ok_or_else(|| SafeError::SecretNotFound {
922 key: key.to_string(),
923 })?;
924 let current_nonce = entry.nonce.clone();
926 let current_ciphertext = entry.ciphertext.clone();
927 let current_updated_at = entry.updated_at;
928 entry.history.push(HistoryEntry {
929 nonce: current_nonce,
930 ciphertext: current_ciphertext,
931 updated_at: current_updated_at,
932 });
933 if entry.history.len() > DEFAULT_HISTORY_KEEP {
934 entry
935 .history
936 .drain(..entry.history.len() - DEFAULT_HISTORY_KEEP);
937 }
938 let _ = entry; let (nonce, ct) = crypto::encrypt_with_key_schedule(
942 &self.key,
943 self.key_schedule,
944 KeyPurpose::SecretData,
945 self.cipher,
946 target_value.as_bytes(),
947 )?;
948 let entry = self.file.secrets.get_mut(key).unwrap();
949 entry.nonce = crypto::encode_b64(&nonce);
950 entry.ciphertext = crypto::encode_b64(&ct);
951 entry.updated_at = now;
952 self.file.updated_at = now;
953 self.save()
954 }
955
956 pub fn prune_history(&mut self, key: &str, keep_n: usize) -> SafeResult<()> {
960 self.ensure_write_allowed()?;
961 let entry = self
962 .file
963 .secrets
964 .get_mut(key)
965 .ok_or_else(|| SafeError::SecretNotFound {
966 key: key.to_string(),
967 })?;
968 if entry.history.len() > keep_n {
969 entry.history.drain(..entry.history.len() - keep_n);
970 }
971 self.file.updated_at = Utc::now();
972 self.save()
973 }
974
975 #[instrument(skip(self, new_password), fields(secret_count = self.file.secrets.len()))]
980 pub fn rotate(&mut self, new_password: &[u8]) -> SafeResult<()> {
981 self.ensure_write_allowed()?;
982 let all = self.export_all()?;
984 let meta: HashMap<String, _> = self
985 .file
986 .secrets
987 .iter()
988 .map(|(k, e)| (k.clone(), (e.tags.clone(), e.created_at, e.history.clone())))
989 .collect();
990
991 let mut history_plaintext: HashMap<String, Vec<(String, DateTime<Utc>)>> = HashMap::new();
993 for (key, entry) in &self.file.secrets {
994 let mut pts = Vec::new();
995 for h in &entry.history {
996 let nonce = crypto::decode_b64(&h.nonce)?;
997 let ct = crypto::decode_b64(&h.ciphertext)?;
998 let pt = crypto::decrypt_with_key_schedule(
999 &self.key,
1000 self.key_schedule,
1001 KeyPurpose::SecretData,
1002 self.cipher,
1003 &nonce,
1004 &ct,
1005 )?;
1006 let s = String::from_utf8(pt).map_err(|_| SafeError::InvalidVault {
1007 reason: "history entry is not valid UTF-8".into(),
1008 })?;
1009 pts.push((s, h.updated_at));
1010 }
1011 history_plaintext.insert(key.clone(), pts);
1012 }
1013
1014 let new_salt = crypto::random_salt();
1015 let new_key = crypto::derive_key(
1016 new_password,
1017 &new_salt,
1018 VAULT_KDF_M_COST,
1019 VAULT_KDF_T_COST,
1020 VAULT_KDF_P_COST,
1021 )?;
1022 let new_cipher = crypto::default_vault_cipher();
1023 let new_key_schedule = KeySchedule::HkdfSha256V1;
1024
1025 let now = Utc::now();
1026 let mut new_secrets = HashMap::with_capacity(all.len());
1027 for (key, value) in &all {
1028 let (nonce, ct) = crypto::encrypt_with_key_schedule(
1029 &new_key,
1030 new_key_schedule,
1031 KeyPurpose::SecretData,
1032 new_cipher,
1033 value.as_bytes(),
1034 )?;
1035 let (ref tags, created_at, _) = meta[key];
1036
1037 let mut new_history = Vec::new();
1039 if let Some(pts) = history_plaintext.get(key) {
1040 for (pt, updated_at) in pts {
1041 let (hn, hct) = crypto::encrypt_with_key_schedule(
1042 &new_key,
1043 new_key_schedule,
1044 KeyPurpose::SecretData,
1045 new_cipher,
1046 pt.as_bytes(),
1047 )?;
1048 new_history.push(HistoryEntry {
1049 nonce: crypto::encode_b64(&hn),
1050 ciphertext: crypto::encode_b64(&hct),
1051 updated_at: *updated_at,
1052 });
1053 }
1054 }
1055
1056 new_secrets.insert(
1057 key.clone(),
1058 SecretEntry {
1059 nonce: crypto::encode_b64(&nonce),
1060 ciphertext: crypto::encode_b64(&ct),
1061 created_at,
1062 updated_at: now,
1063 tags: tags.clone(),
1064 history: new_history,
1065 },
1066 );
1067 }
1068
1069 let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
1070 &new_key,
1071 new_key_schedule,
1072 KeyPurpose::VaultChallenge,
1073 new_cipher,
1074 VAULT_CHALLENGE_PLAINTEXT,
1075 )?;
1076 self.file.kdf = KdfParams {
1077 algorithm: "argon2id".to_string(),
1078 m_cost: VAULT_KDF_M_COST,
1079 t_cost: VAULT_KDF_T_COST,
1080 p_cost: VAULT_KDF_P_COST,
1081 salt: crypto::encode_b64(&new_salt),
1082 };
1083 self.file.vault_challenge = VaultChallenge {
1084 nonce: crypto::encode_b64(&ch_nonce),
1085 ciphertext: crypto::encode_b64(&ch_ct),
1086 };
1087 self.file.cipher = new_cipher.as_str().to_string();
1088 self.file.secrets = new_secrets;
1089 self.file.updated_at = now;
1090 self.key = new_key;
1091 self.cipher = new_cipher;
1092 self.key_schedule = new_key_schedule;
1093 self.save()
1094 }
1095
1096 pub fn path(&self) -> &Path {
1099 &self.path
1100 }
1101 pub fn secret_count(&self) -> usize {
1102 self.file.secrets.len()
1103 }
1104
1105 pub fn access_profile(&self) -> RbacProfile {
1106 self.access_profile
1107 }
1108
1109 pub fn with_access_profile(mut self, access_profile: RbacProfile) -> Self {
1111 self.access_profile = access_profile;
1112 self
1113 }
1114
1115 pub fn file(&self) -> &VaultFile {
1118 &self.file
1119 }
1120
1121 pub fn ensure_write_allowed(&self) -> SafeResult<()> {
1122 self.access_profile.ensure_write_allowed()
1123 }
1124
1125 fn profile_name(&self) -> Option<String> {
1127 Self::profile_name_from_path(&self.path)
1128 }
1129
1130 fn profile_name_from_path(path: &Path) -> Option<String> {
1131 path.file_stem()
1132 .and_then(|s| s.to_str())
1133 .map(|s| s.to_string())
1134 }
1135}
1136
1137pub fn parse_rotation_days(policy: &str) -> Option<i64> {
1141 let s = policy.trim();
1142 s.strip_suffix('d')
1143 .and_then(|prefix| prefix.parse::<i64>().ok())
1144 .filter(|&d| d > 0)
1145}
1146
1147pub fn rotation_due(file: &VaultFile) -> Vec<(String, i64, String)> {
1150 let now = Utc::now();
1151 let mut due = Vec::new();
1152 for (key, entry) in &file.secrets {
1153 if let Some(policy) = entry.tags.get("rotate_policy") {
1154 if let Some(days) = parse_rotation_days(policy) {
1155 let age = (now - entry.updated_at).num_days();
1156 if age >= days {
1157 due.push((key.clone(), age - days, policy.clone()));
1158 }
1159 }
1160 }
1161 }
1162 due.sort_by(|a, b| a.0.cmp(&b.0));
1163 due
1164}
1165
1166#[cfg(test)]
1169mod tests {
1170 use super::*;
1171 use proptest::prelude::*;
1172 use tempfile::tempdir;
1173
1174 fn pw() -> &'static [u8] {
1175 b"test-master-password"
1176 }
1177
1178 #[test]
1179 fn create_and_reopen() {
1180 let dir = tempdir().unwrap();
1181 let path = dir.path().join("v.vault");
1182 let mut v = Vault::create(&path, pw()).unwrap();
1183 v.set("K", "val", HashMap::new()).unwrap();
1184 drop(v);
1185 let v2 = Vault::open(&path, pw()).unwrap();
1186 assert_eq!(&*v2.get("K").unwrap(), "val");
1187 }
1188
1189 #[test]
1190 fn read_only_open_blocks_save_and_mutation_paths() {
1191 let dir = tempdir().unwrap();
1192 let path = dir.path().join("v.vault");
1193 let mut writable = Vault::create(&path, pw()).unwrap();
1194 writable.set("K", "value", HashMap::new()).unwrap();
1195 drop(writable);
1196
1197 let mut vault = Vault::open_read_only(&path, pw()).unwrap();
1198 assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
1199 assert_eq!(&*vault.get("K").unwrap(), "value");
1200
1201 for result in [
1202 vault.save(),
1203 vault.set("NEW", "value", HashMap::new()),
1204 vault.delete("K"),
1205 vault.rename_key("K", "RENAMED", false),
1206 vault.rotate(b"new-password"),
1207 ] {
1208 match result {
1209 Err(SafeError::InvalidVault { reason }) => {
1210 assert!(reason.contains("read_only"));
1211 }
1212 other => panic!("expected read-only write denial, got {other:?}"),
1213 }
1214 }
1215 }
1216
1217 #[test]
1218 fn read_only_open_does_not_restore_missing_snapshot() {
1219 let dir = tempdir().unwrap();
1220 let profile_dir = dir.path().join("profiles").join("default");
1221 std::fs::create_dir_all(&profile_dir).unwrap();
1222 let path = profile_dir.join("vault.vault");
1223 let snapshots = dir.path().join("snapshots").join("default");
1224 std::fs::create_dir_all(&snapshots).unwrap();
1225 std::fs::write(
1226 snapshots.join("default-20260407-0000000000000.0000.snap"),
1227 "{}",
1228 )
1229 .unwrap();
1230
1231 match Vault::open_read_only(&path, pw()) {
1232 Err(SafeError::VaultNotFound { .. }) => {}
1233 Ok(_) => panic!("expected read-only open to refuse snapshot restore"),
1234 Err(other) => panic!("expected VaultNotFound, got {other:?}"),
1235 }
1236 assert!(!path.exists(), "read-only open must not restore snapshots");
1237 }
1238
1239 fn root_key_from_file(file: &VaultFile, password: &[u8]) -> VaultKey {
1240 let salt = crypto::decode_b64(&file.kdf.salt).unwrap();
1241 crypto::derive_key(
1242 password,
1243 &salt,
1244 file.kdf.m_cost,
1245 file.kdf.t_cost,
1246 file.kdf.p_cost,
1247 )
1248 .unwrap()
1249 }
1250
1251 fn legacy_vault_file(password: &[u8], value: &str) -> VaultFile {
1252 let salt = crypto::random_salt();
1253 let key = crypto::derive_key(
1254 password,
1255 &salt,
1256 VAULT_KDF_M_COST,
1257 VAULT_KDF_T_COST,
1258 VAULT_KDF_P_COST,
1259 )
1260 .unwrap();
1261 let now = Utc::now();
1262 let (ch_nonce, ch_ct) = crypto::encrypt(&key, VAULT_CHALLENGE_PLAINTEXT).unwrap();
1263 let (nonce, ciphertext) = crypto::encrypt(&key, value.as_bytes()).unwrap();
1264 let mut secrets = HashMap::new();
1265 secrets.insert(
1266 "LEGACY".into(),
1267 SecretEntry {
1268 nonce: crypto::encode_b64(&nonce),
1269 ciphertext: crypto::encode_b64(&ciphertext),
1270 created_at: now,
1271 updated_at: now,
1272 tags: HashMap::new(),
1273 history: Vec::new(),
1274 },
1275 );
1276 VaultFile {
1277 schema: VAULT_SCHEMA.to_string(),
1278 kdf: KdfParams {
1279 algorithm: VAULT_KDF_ALGORITHM.to_string(),
1280 m_cost: VAULT_KDF_M_COST,
1281 t_cost: VAULT_KDF_T_COST,
1282 p_cost: VAULT_KDF_P_COST,
1283 salt: crypto::encode_b64(&salt),
1284 },
1285 cipher: CipherKind::XChaCha20Poly1305.as_str().to_string(),
1286 vault_challenge: VaultChallenge {
1287 nonce: crypto::encode_b64(&ch_nonce),
1288 ciphertext: crypto::encode_b64(&ch_ct),
1289 },
1290 created_at: now,
1291 updated_at: now,
1292 secrets,
1293 age_recipients: Vec::new(),
1294 wrapped_dek: None,
1295 }
1296 }
1297
1298 #[test]
1299 fn second_open_fails_while_lock_is_held() {
1300 let dir = tempdir().unwrap();
1301 let path = dir.path().join("v.vault");
1302 let _v = Vault::create(&path, pw()).unwrap();
1303
1304 match Vault::open(&path, pw()) {
1305 Err(SafeError::InvalidVault { reason }) => {
1306 assert!(reason.contains("vault is locked by another process"));
1307 }
1308 Ok(_) => panic!("expected lock error, got open vault"),
1309 Err(other) => panic!("expected lock error, got {other:?}"),
1310 }
1311 }
1312
1313 #[test]
1314 fn wrong_password_fails() {
1315 let dir = tempdir().unwrap();
1316 let path = dir.path().join("v.vault");
1317 let mut v = Vault::create(&path, pw()).unwrap();
1318 v.set("K", "v", HashMap::new()).unwrap();
1319 drop(v);
1320 assert!(matches!(
1321 Vault::open(&path, b"wrong"),
1322 Err(SafeError::DecryptionFailed)
1323 ));
1324 }
1325
1326 #[test]
1327 fn empty_vault_wrong_password_fails() {
1328 let dir = tempdir().unwrap();
1330 let path = dir.path().join("v.vault");
1331 Vault::create(&path, pw()).unwrap();
1332 assert!(Vault::open(&path, b"wrong").is_err());
1333 }
1334
1335 #[test]
1337 fn validate_secret_key_rejects_non_ascii() {
1338 assert!(validate_secret_key("café_KEY").is_err());
1339 assert!(validate_secret_key("emoji_🔑").is_err());
1340 assert!(validate_secret_key("K_日本").is_err());
1341 }
1342
1343 #[test]
1345 fn set_get_roundtrip_unicode_secret_value() {
1346 let dir = tempdir().unwrap();
1347 let path = dir.path().join("v.vault");
1348 let mut v = Vault::create(&path, pw()).unwrap();
1349 let val = "snowman☃café日本語";
1350 v.set("UNICODE_VAL", val, HashMap::new()).unwrap();
1351 assert_eq!(&*v.get("UNICODE_VAL").unwrap(), val);
1352 }
1353
1354 #[test]
1356 fn empty_master_password_vault_roundtrip() {
1357 let dir = tempdir().unwrap();
1358 let path = dir.path().join("v.vault");
1359 let mut v = Vault::create(&path, b"").unwrap();
1360 v.set("K", "v", HashMap::new()).unwrap();
1361 drop(v);
1362 let v2 = Vault::open(&path, b"").unwrap();
1363 assert_eq!(&*v2.get("K").unwrap(), "v");
1364 }
1365
1366 #[test]
1367 fn create_twice_fails() {
1368 let dir = tempdir().unwrap();
1369 let path = dir.path().join("v.vault");
1370 Vault::create(&path, pw()).unwrap();
1371 assert!(matches!(
1372 Vault::create(&path, pw()),
1373 Err(SafeError::VaultAlreadyExists { .. })
1374 ));
1375 }
1376
1377 #[test]
1378 fn set_get_delete_roundtrip() {
1379 let dir = tempdir().unwrap();
1380 let path = dir.path().join("v.vault");
1381 let mut v = Vault::create(&path, pw()).unwrap();
1382 v.set("DB_PASS", "s3cr3t", HashMap::new()).unwrap();
1383 assert_eq!(&*v.get("DB_PASS").unwrap(), "s3cr3t");
1384 v.delete("DB_PASS").unwrap();
1385 assert!(matches!(
1386 v.get("DB_PASS"),
1387 Err(SafeError::SecretNotFound { .. })
1388 ));
1389 }
1390
1391 #[test]
1392 fn list_is_sorted() {
1393 let dir = tempdir().unwrap();
1394 let path = dir.path().join("v.vault");
1395 let mut v = Vault::create(&path, pw()).unwrap();
1396 v.set("ZZZ", "z", HashMap::new()).unwrap();
1397 v.set("AAA", "a", HashMap::new()).unwrap();
1398 v.set("MMM", "m", HashMap::new()).unwrap();
1399 assert_eq!(v.list(), vec!["AAA", "MMM", "ZZZ"]);
1400 }
1401
1402 #[test]
1403 fn export_all_decrypts_all() {
1404 let dir = tempdir().unwrap();
1405 let path = dir.path().join("v.vault");
1406 let mut v = Vault::create(&path, pw()).unwrap();
1407 v.set("A", "alpha", HashMap::new()).unwrap();
1408 v.set("B", "beta", HashMap::new()).unwrap();
1409 let all = v.export_all().unwrap();
1410 assert_eq!(all["A"], "alpha");
1411 assert_eq!(all["B"], "beta");
1412 }
1413
1414 #[test]
1415 fn rotate_re_encrypts_under_new_password() {
1416 let dir = tempdir().unwrap();
1417 let path = dir.path().join("v.vault");
1418 let mut v = Vault::create(&path, pw()).unwrap();
1419 v.set("SECRET", "value", HashMap::new()).unwrap();
1420 v.rotate(b"new-password").unwrap();
1421 drop(v);
1422 assert!(Vault::open(&path, pw()).is_err());
1423 let v2 = Vault::open(&path, b"new-password").unwrap();
1424 assert_eq!(&*v2.get("SECRET").unwrap(), "value");
1425 }
1426
1427 #[test]
1428 fn new_vault_uses_hkdf_scoped_keys_for_challenge_and_secret_data() {
1429 let dir = tempdir().unwrap();
1430 let path = dir.path().join("v.vault");
1431 let mut vault = Vault::create(&path, pw()).unwrap();
1432 vault.set("SECRET", "value", HashMap::new()).unwrap();
1433
1434 let root_key = root_key_from_file(vault.file(), pw());
1435 let challenge_nonce = crypto::decode_b64(&vault.file.vault_challenge.nonce).unwrap();
1436 let challenge_ct = crypto::decode_b64(&vault.file.vault_challenge.ciphertext).unwrap();
1437 assert!(matches!(
1438 crypto::decrypt_for_cipher(vault.cipher, &root_key, &challenge_nonce, &challenge_ct),
1439 Err(SafeError::DecryptionFailed)
1440 ));
1441 assert_eq!(
1442 crypto::decrypt_with_key_schedule(
1443 &root_key,
1444 KeySchedule::HkdfSha256V1,
1445 KeyPurpose::VaultChallenge,
1446 vault.cipher,
1447 &challenge_nonce,
1448 &challenge_ct
1449 )
1450 .unwrap(),
1451 VAULT_CHALLENGE_PLAINTEXT
1452 );
1453
1454 let entry = &vault.file().secrets["SECRET"];
1455 let secret_nonce = crypto::decode_b64(&entry.nonce).unwrap();
1456 let secret_ct = crypto::decode_b64(&entry.ciphertext).unwrap();
1457 assert!(matches!(
1458 crypto::decrypt_for_cipher(vault.cipher, &root_key, &secret_nonce, &secret_ct),
1459 Err(SafeError::DecryptionFailed)
1460 ));
1461 assert_eq!(
1462 crypto::decrypt_with_key_schedule(
1463 &root_key,
1464 KeySchedule::HkdfSha256V1,
1465 KeyPurpose::SecretData,
1466 vault.cipher,
1467 &secret_nonce,
1468 &secret_ct
1469 )
1470 .unwrap(),
1471 b"value"
1472 );
1473 }
1474
1475 #[test]
1476 fn open_legacy_vault_detects_legacy_schedule_and_keeps_writes_consistent() {
1477 let dir = tempdir().unwrap();
1478 let path = dir.path().join("legacy.vault");
1479 let file = legacy_vault_file(pw(), "legacy-value");
1480 std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
1481
1482 let mut vault = Vault::open(&path, pw()).unwrap();
1483 assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
1484 assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
1485 assert_eq!(&*vault.get("LEGACY").unwrap(), "legacy-value");
1486
1487 vault
1488 .set("NEW_SECRET", "new-value", HashMap::new())
1489 .unwrap();
1490 let root_key = root_key_from_file(vault.file(), pw());
1491 let entry = &vault.file().secrets["NEW_SECRET"];
1492 let nonce = crypto::decode_b64(&entry.nonce).unwrap();
1493 let ciphertext = crypto::decode_b64(&entry.ciphertext).unwrap();
1494 assert_eq!(
1495 crypto::decrypt_for_cipher(vault.cipher, &root_key, &nonce, &ciphertext).unwrap(),
1496 b"new-value"
1497 );
1498 }
1499
1500 #[test]
1501 fn rotating_legacy_vault_migrates_it_to_hkdf_schedule() {
1502 let dir = tempdir().unwrap();
1503 let path = dir.path().join("legacy.vault");
1504 let file = legacy_vault_file(pw(), "legacy-value");
1505 std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
1506
1507 let mut vault = Vault::open(&path, pw()).unwrap();
1508 assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
1509 assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
1510 vault.rotate(b"new-password").unwrap();
1511 assert_eq!(vault.key_schedule, KeySchedule::HkdfSha256V1);
1512 assert_eq!(vault.cipher, crypto::default_vault_cipher());
1513 drop(vault);
1514
1515 let reopened = Vault::open(&path, b"new-password").unwrap();
1516 assert_eq!(reopened.key_schedule, KeySchedule::HkdfSha256V1);
1517 assert_eq!(reopened.cipher, crypto::default_vault_cipher());
1518 assert_eq!(&*reopened.get("LEGACY").unwrap(), "legacy-value");
1519
1520 let root_key = root_key_from_file(reopened.file(), b"new-password");
1521 let challenge_nonce = crypto::decode_b64(&reopened.file.vault_challenge.nonce).unwrap();
1522 let challenge_ct = crypto::decode_b64(&reopened.file.vault_challenge.ciphertext).unwrap();
1523 assert!(matches!(
1524 crypto::decrypt_for_cipher(reopened.cipher, &root_key, &challenge_nonce, &challenge_ct),
1525 Err(SafeError::DecryptionFailed)
1526 ));
1527 assert_eq!(
1528 crypto::decrypt_with_key_schedule(
1529 &root_key,
1530 KeySchedule::HkdfSha256V1,
1531 KeyPurpose::VaultChallenge,
1532 reopened.cipher,
1533 &challenge_nonce,
1534 &challenge_ct
1535 )
1536 .unwrap(),
1537 VAULT_CHALLENGE_PLAINTEXT
1538 );
1539 }
1540
1541 #[cfg(feature = "fips")]
1542 #[test]
1543 fn fips_build_creates_aes256gcm_vaults() {
1544 let dir = tempdir().unwrap();
1545 let path = dir.path().join("v.vault");
1546 let mut vault = Vault::create(&path, pw()).unwrap();
1547 vault.set("SECRET", "value", HashMap::new()).unwrap();
1548 assert_eq!(vault.cipher, CipherKind::Aes256Gcm);
1549 assert_eq!(vault.file.cipher, CipherKind::Aes256Gcm.as_str());
1550 }
1551
1552 #[test]
1553 fn set_preserves_created_at_on_update() {
1554 let dir = tempdir().unwrap();
1555 let path = dir.path().join("v.vault");
1556 let mut v = Vault::create(&path, pw()).unwrap();
1557 v.set("K", "v1", HashMap::new()).unwrap();
1558 let created = v.file.secrets["K"].created_at;
1559 v.set("K", "v2", HashMap::new()).unwrap();
1560 assert_eq!(v.file.secrets["K"].created_at, created);
1561 assert_ne!(v.file.secrets["K"].updated_at, created); }
1563
1564 #[test]
1565 fn delete_missing_key_returns_error() {
1566 let dir = tempdir().unwrap();
1567 let path = dir.path().join("v.vault");
1568 let mut v = Vault::create(&path, pw()).unwrap();
1569 assert!(matches!(
1570 v.delete("NOPE"),
1571 Err(SafeError::SecretNotFound { .. })
1572 ));
1573 }
1574
1575 #[test]
1576 fn key_validation_allows_dot_and_hyphen_namespaces() {
1577 for key in &[
1579 "github.com.token",
1580 "db-prod.PASSWORD",
1581 "_under.score-mix",
1582 "A.b-c.D",
1583 ] {
1584 assert!(validate_secret_key(key).is_ok(), "expected ok for '{key}'");
1585 }
1586 }
1587
1588 #[test]
1589 fn key_validation_rejects_invalid_forms() {
1590 let bad = [
1591 "", "123abc", "-starts-bad", ".starts-bad", "ends.", "ends-", "double..dot", "double--dash", "dot.-dash", "has space", ];
1602 for key in &bad {
1603 assert!(
1604 validate_secret_key(key).is_err(),
1605 "expected error for '{key}'"
1606 );
1607 }
1608 }
1609
1610 #[test]
1613 fn set_builds_history() {
1614 let dir = tempdir().unwrap();
1615 let path = dir.path().join("v.vault");
1616 let mut v = Vault::create(&path, pw()).unwrap();
1617 v.set("K", "v1", HashMap::new()).unwrap();
1618 v.set("K", "v2", HashMap::new()).unwrap();
1619 v.set("K", "v3", HashMap::new()).unwrap();
1620 assert_eq!(v.file.secrets["K"].history.len(), 2);
1621 assert_eq!(&*v.get("K").unwrap(), "v3");
1622 }
1623
1624 #[test]
1625 fn get_version_returns_previous_values() {
1626 let dir = tempdir().unwrap();
1627 let path = dir.path().join("v.vault");
1628 let mut v = Vault::create(&path, pw()).unwrap();
1629 v.set("K", "v1", HashMap::new()).unwrap();
1630 v.set("K", "v2", HashMap::new()).unwrap();
1631 v.set("K", "v3", HashMap::new()).unwrap();
1632 assert_eq!(&*v.get_version("K", 0).unwrap(), "v3");
1633 assert_eq!(&*v.get_version("K", 1).unwrap(), "v2");
1634 assert_eq!(&*v.get_version("K", 2).unwrap(), "v1");
1635 }
1636
1637 #[test]
1638 fn get_version_out_of_range_errors() {
1639 let dir = tempdir().unwrap();
1640 let path = dir.path().join("v.vault");
1641 let mut v = Vault::create(&path, pw()).unwrap();
1642 v.set("K", "v1", HashMap::new()).unwrap();
1643 assert!(v.get_version("K", 1).is_err());
1644 }
1645
1646 #[test]
1647 fn history_capped_at_default() {
1648 let dir = tempdir().unwrap();
1649 let path = dir.path().join("v.vault");
1650 let mut v = Vault::create(&path, pw()).unwrap();
1651 for i in 0..10 {
1652 v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
1653 }
1654 assert_eq!(v.file.secrets["K"].history.len(), DEFAULT_HISTORY_KEEP);
1655 assert_eq!(&*v.get_version("K", 1).unwrap(), "v8");
1657 }
1658
1659 #[test]
1660 fn history_metadata_lists_versions() {
1661 let dir = tempdir().unwrap();
1662 let path = dir.path().join("v.vault");
1663 let mut v = Vault::create(&path, pw()).unwrap();
1664 v.set("K", "v1", HashMap::new()).unwrap();
1665 v.set("K", "v2", HashMap::new()).unwrap();
1666 let versions = v.history("K").unwrap();
1667 assert_eq!(versions.len(), 2); assert_eq!(versions[0].0, 0); assert_eq!(versions[1].0, 1); }
1671
1672 #[test]
1673 fn rotate_preserves_history() {
1674 let dir = tempdir().unwrap();
1675 let path = dir.path().join("v.vault");
1676 let mut v = Vault::create(&path, pw()).unwrap();
1677 v.set("K", "v1", HashMap::new()).unwrap();
1678 v.set("K", "v2", HashMap::new()).unwrap();
1679 v.rotate(b"new-pw").unwrap();
1680 drop(v);
1681 let v2 = Vault::open(&path, b"new-pw").unwrap();
1682 assert_eq!(&*v2.get("K").unwrap(), "v2");
1683 assert_eq!(&*v2.get_version("K", 1).unwrap(), "v1");
1684 }
1685
1686 #[test]
1689 fn parse_rotation_days_valid() {
1690 assert_eq!(parse_rotation_days("90d"), Some(90));
1691 assert_eq!(parse_rotation_days("30d"), Some(30));
1692 assert_eq!(parse_rotation_days("1d"), Some(1));
1693 assert_eq!(parse_rotation_days(" 7d "), Some(7));
1694 }
1695
1696 #[test]
1697 fn parse_rotation_days_invalid() {
1698 assert_eq!(parse_rotation_days("invalid"), None);
1699 assert_eq!(parse_rotation_days("0d"), None);
1700 assert_eq!(parse_rotation_days("-1d"), None);
1701 assert_eq!(parse_rotation_days(""), None);
1702 assert_eq!(parse_rotation_days("d"), None);
1703 }
1704
1705 #[test]
1706 fn rotation_due_finds_overdue_secrets() {
1707 let dir = tempdir().unwrap();
1708 let path = dir.path().join("v.vault");
1709 let mut v = Vault::create(&path, pw()).unwrap();
1710 let mut tags = HashMap::new();
1711 tags.insert("rotate_policy".into(), "1d".into());
1712 v.set("OLD_KEY", "val", tags).unwrap();
1713 v.file.secrets.get_mut("OLD_KEY").unwrap().updated_at =
1715 Utc::now() - chrono::Duration::days(3);
1716 let due = rotation_due(v.file());
1717 assert_eq!(due.len(), 1);
1718 assert_eq!(due[0].0, "OLD_KEY");
1719 assert!(due[0].1 >= 2); }
1721
1722 #[test]
1723 fn rotation_due_ignores_fresh_secrets() {
1724 let dir = tempdir().unwrap();
1725 let path = dir.path().join("v.vault");
1726 let mut v = Vault::create(&path, pw()).unwrap();
1727 let mut tags = HashMap::new();
1728 tags.insert("rotate_policy".into(), "90d".into());
1729 v.set("FRESH", "val", tags).unwrap();
1730 let due = rotation_due(v.file());
1731 assert!(due.is_empty());
1732 }
1733
1734 #[test]
1735 fn set_preserves_existing_tags_when_update_has_no_tags() {
1736 let dir = tempdir().unwrap();
1737 let path = dir.path().join("v.vault");
1738 let mut v = Vault::create(&path, pw()).unwrap();
1739 let mut tags = HashMap::new();
1740 tags.insert("env".into(), "prod".into());
1741 tags.insert("rotate_policy".into(), "30d".into());
1742 v.set("KEY", "v1", tags.clone()).unwrap();
1743 v.set("KEY", "v2", HashMap::new()).unwrap();
1744 assert_eq!(v.file.secrets["KEY"].tags, tags);
1745 }
1746
1747 #[test]
1748 fn lock_guard_drop_keeps_replaced_lockfile() {
1749 let dir = tempdir().unwrap();
1750 let path = dir.path().join("v.vault");
1751 let lock_path = lock_path_for(&path);
1752 let guard = acquire_lock(&path).unwrap();
1753 std::fs::write(&lock_path, "different-owner").unwrap();
1754 drop(guard);
1755 assert_eq!(
1756 std::fs::read_to_string(&lock_path).unwrap(),
1757 "different-owner"
1758 );
1759 }
1760
1761 #[test]
1762 fn dead_owner_lockfile_is_recovered_for_new_format() {
1763 let dir = tempdir().unwrap();
1764 let path = dir.path().join("v.vault");
1765 let lock_path = lock_path_for(&path);
1766 let stale = LockFileContents {
1767 version: 1,
1768 id: "stale-owner".into(),
1769 pid: u32::MAX,
1770 created_at: Utc::now(),
1771 };
1772 std::fs::write(&lock_path, serde_json::to_string(&stale).unwrap()).unwrap();
1773
1774 let guard = acquire_lock(&path).unwrap();
1775 let contents = std::fs::read_to_string(&lock_path).unwrap();
1776 let recovered: LockFileContents = serde_json::from_str(&contents).unwrap();
1777 assert_eq!(recovered.version, 1);
1778 assert_eq!(recovered.pid, std::process::id());
1779 assert_ne!(recovered.id, stale.id);
1780 drop(guard);
1781 assert!(!lock_path.exists());
1782 }
1783
1784 #[test]
1785 fn opaque_legacy_lockfile_is_not_removed_implicitly() {
1786 let dir = tempdir().unwrap();
1787 let path = dir.path().join("v.vault");
1788 let lock_path = lock_path_for(&path);
1789 std::fs::write(&lock_path, "legacy-uuid-without-metadata").unwrap();
1790
1791 match acquire_lock(&path) {
1792 Err(SafeError::InvalidVault { reason }) => {
1793 assert!(reason.contains("vault is locked by another process"));
1794 }
1795 Ok(_) => panic!("expected lock error, got recovered lock"),
1796 Err(other) => panic!("expected lock error, got {other:?}"),
1797 }
1798 assert_eq!(
1799 std::fs::read_to_string(&lock_path).unwrap(),
1800 "legacy-uuid-without-metadata"
1801 );
1802 }
1803
1804 #[test]
1805 fn process_is_running_sees_current_process() {
1806 assert!(process_is_running(std::process::id()));
1807 }
1808
1809 #[test]
1810 fn process_is_running_rejects_impossible_pid() {
1811 assert!(!process_is_running(u32::MAX));
1812 }
1813
1814 #[test]
1815 fn missing_vault_with_existing_lock_does_not_restore_snapshot() {
1816 let dir = tempdir().unwrap();
1817 let profile_dir = dir.path().join("profiles").join("default");
1818 std::fs::create_dir_all(&profile_dir).unwrap();
1819 let path = profile_dir.join("vault.vault");
1820 let snapshots = dir.path().join("snapshots").join("default");
1821 std::fs::create_dir_all(&snapshots).unwrap();
1822 std::fs::write(
1823 snapshots.join("default-20260407-0000000000000.0000.snap"),
1824 "{}",
1825 )
1826 .unwrap();
1827
1828 let _guard = acquire_lock(&path).unwrap();
1829 match Vault::open(&path, pw()) {
1830 Err(SafeError::InvalidVault { reason }) => {
1831 assert!(reason.contains("vault is locked by another process"));
1832 }
1833 Ok(_) => panic!("expected lock error, got open vault"),
1834 Err(other) => panic!("expected lock error, got {other:?}"),
1835 }
1836 assert!(
1837 !path.exists(),
1838 "open should not restore under another process's lock"
1839 );
1840 }
1841
1842 #[test]
1845 fn secret_count_reflects_set_and_delete() {
1846 let dir = tempdir().unwrap();
1847 let path = dir.path().join("v.vault");
1848 let mut v = Vault::create(&path, pw()).unwrap();
1849 assert_eq!(v.secret_count(), 0);
1850
1851 v.set("A", "1", HashMap::new()).unwrap();
1852 assert_eq!(v.secret_count(), 1);
1853
1854 v.set("B", "2", HashMap::new()).unwrap();
1855 assert_eq!(v.secret_count(), 2);
1856
1857 v.delete("A").unwrap();
1858 assert_eq!(v.secret_count(), 1);
1859 }
1860
1861 #[test]
1864 fn vault_is_team_vault_returns_false_for_regular_vault() {
1865 let dir = tempdir().unwrap();
1866 let path = dir.path().join("v.vault");
1867 let _v = Vault::create(&path, pw()).unwrap();
1868 drop(_v);
1869 assert!(!Vault::is_team_vault(&path));
1870 }
1871
1872 #[test]
1873 fn vault_is_team_vault_returns_false_for_nonexistent_path() {
1874 let dir = tempdir().unwrap();
1875 let path = dir.path().join("does_not_exist.vault");
1876 assert!(!Vault::is_team_vault(&path));
1877 }
1878
1879 #[test]
1880 fn vault_is_team_vault_returns_true_for_team_vault_on_disk() {
1881 use crate::{age_crypto, team};
1882
1883 let dir = tempdir().unwrap();
1884 let path = dir.path().join("team.vault");
1885
1886 let (_secret, recipient) = age_crypto::generate_identity();
1887 let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();
1888
1889 let json = serde_json::to_string_pretty(&file).unwrap();
1891 std::fs::write(&path, json).unwrap();
1892
1893 assert!(Vault::is_team_vault(&path));
1894 }
1895
1896 #[test]
1899 fn open_with_key_using_team_dek_succeeds() {
1900 use crate::{age_crypto, team};
1901
1902 let dir = tempdir().unwrap();
1903 let path = dir.path().join("team.vault");
1904
1905 let (secret, recipient) = age_crypto::generate_identity();
1906 let identities = age::IdentityFile::from_buffer(secret.as_bytes())
1907 .unwrap()
1908 .into_identities()
1909 .unwrap();
1910
1911 let (file, _) = team::create_team_vault(&[recipient]).unwrap();
1912 let json = serde_json::to_string_pretty(&file).unwrap();
1913 std::fs::write(&path, &json).unwrap();
1914
1915 let dek = team::unwrap_dek(&file, &identities).unwrap();
1917 let vault = Vault::open_with_key(&path, dek).unwrap();
1918 assert_eq!(vault.secret_count(), 0);
1919 }
1920
1921 #[test]
1922 fn open_with_key_read_only_blocks_mutation() {
1923 use crate::{age_crypto, team};
1924
1925 let dir = tempdir().unwrap();
1926 let path = dir.path().join("team.vault");
1927
1928 let (secret, recipient) = age_crypto::generate_identity();
1929 let identities = age::IdentityFile::from_buffer(secret.as_bytes())
1930 .unwrap()
1931 .into_identities()
1932 .unwrap();
1933
1934 let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();
1935 std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
1936
1937 let dek = team::unwrap_dek(&file, &identities).unwrap();
1938 let mut writable = Vault::open_with_key(&path, dek).unwrap();
1939 writable
1940 .set("TEAM_SECRET", "value", HashMap::new())
1941 .unwrap();
1942 drop(writable);
1943
1944 let dek = team::unwrap_dek(&file, &identities).unwrap();
1945 let mut vault = Vault::open_with_key_read_only(&path, dek).unwrap();
1946 assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
1947 assert_eq!(&*vault.get("TEAM_SECRET").unwrap(), "value");
1948 assert!(matches!(
1949 vault.set("NEW_SECRET", "blocked", HashMap::new()),
1950 Err(SafeError::InvalidVault { .. })
1951 ));
1952 }
1953
1954 #[test]
1955 fn open_with_key_with_wrong_key_returns_decryption_failed() {
1956 use crate::crypto::VaultKey;
1957 use crate::{age_crypto, team};
1958
1959 let dir = tempdir().unwrap();
1960 let path = dir.path().join("team.vault");
1961
1962 let (_secret, recipient) = age_crypto::generate_identity();
1963 let (file, _) = team::create_team_vault(&[recipient]).unwrap();
1964 let json = serde_json::to_string_pretty(&file).unwrap();
1965 std::fs::write(&path, &json).unwrap();
1966
1967 let wrong_key = VaultKey::from_bytes(crypto::random_salt());
1969 let result = Vault::open_with_key(&path, wrong_key);
1970 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
1971 }
1972
1973 proptest! {
1978 #![proptest_config(ProptestConfig::with_cases(32))]
1979
1980 #[test]
1982 fn prop_set_get_roundtrip(
1983 key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
1984 value in any::<String>(),
1985 ) {
1986 let dir = tempdir().unwrap();
1987 let path = dir.path().join("v.vault");
1988 let mut v = Vault::create(&path, pw()).unwrap();
1989 v.set(&key, &value, HashMap::new()).unwrap();
1990 prop_assert_eq!(&*v.get(&key).unwrap(), value.as_str());
1991 }
1992
1993 #[test]
1995 fn prop_set_delete_not_found(
1996 key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
1997 value in any::<String>(),
1998 ) {
1999 let dir = tempdir().unwrap();
2000 let path = dir.path().join("v.vault");
2001 let mut v = Vault::create(&path, pw()).unwrap();
2002 v.set(&key, &value, HashMap::new()).unwrap();
2003 v.delete(&key).unwrap();
2004 let is_not_found = v.get(&key).is_err();
2005 prop_assert!(is_not_found);
2006 }
2007
2008 #[test]
2010 fn prop_multi_set_list_contains_all(
2011 keys in proptest::collection::vec("[A-Za-z_][A-Za-z0-9_]{0,30}", 1..=8),
2013 ) {
2014 let dir = tempdir().unwrap();
2015 let path = dir.path().join("v.vault");
2016 let mut v = Vault::create(&path, pw()).unwrap();
2017 let mut deduped = keys.clone();
2018 deduped.sort();
2019 deduped.dedup();
2020 for k in &deduped {
2021 v.set(k, "x", HashMap::new()).unwrap();
2022 }
2023 let listed = v.list();
2024 for k in &deduped {
2025 prop_assert!(listed.contains(&k.as_str()), "key {k} missing from list()");
2026 }
2027 }
2028
2029 #[test]
2031 fn prop_persist_roundtrip(
2032 key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
2033 value in any::<String>(),
2034 ) {
2035 let dir = tempdir().unwrap();
2036 let path = dir.path().join("v.vault");
2037 {
2038 let mut v = Vault::create(&path, pw()).unwrap();
2039 v.set(&key, &value, HashMap::new()).unwrap();
2040 }
2041 let v2 = Vault::open(&path, pw()).unwrap();
2042 prop_assert_eq!(&*v2.get(&key).unwrap(), value.as_str());
2043 }
2044 }
2045
2046 #[test]
2051 fn concurrent_opens_all_fail_while_lock_held() {
2052 use std::sync::{Arc, Barrier};
2053
2054 let dir = tempdir().unwrap();
2055 let path = dir.path().join("v.vault");
2056 let _owner = Vault::create(&path, pw()).unwrap();
2058
2059 const N: usize = 8;
2060 let barrier = Arc::new(Barrier::new(N + 1));
2061 let path_arc = Arc::new(path);
2062
2063 let handles: Vec<_> = (0..N)
2064 .map(|_| {
2065 let p = Arc::clone(&path_arc);
2066 let b = Arc::clone(&barrier);
2067 std::thread::spawn(move || {
2068 b.wait(); match Vault::open(&p, pw()) {
2070 Err(SafeError::InvalidVault { reason }) => {
2071 assert!(
2072 reason.contains("vault is locked by another process"),
2073 "unexpected lock reason: {reason}"
2074 );
2075 }
2076 Ok(_) => panic!("concurrent open should not succeed while lock is held"),
2077 Err(e) => panic!("unexpected error variant: {e:?}"),
2078 }
2079 })
2080 })
2081 .collect();
2082
2083 barrier.wait(); for h in handles {
2085 h.join().expect("concurrent open thread panicked");
2086 }
2087 }
2088
2089 #[test]
2092 fn lock_released_after_drop_then_reopen_succeeds() {
2093 let dir = tempdir().unwrap();
2094 let path = dir.path().join("v.vault");
2095 {
2096 let mut owner = Vault::create(&path, pw()).unwrap();
2097 owner.set("K", "v", HashMap::new()).unwrap();
2098 assert!(
2100 Vault::open(&path, pw()).is_err(),
2101 "second open should fail while lock held"
2102 );
2103 } let v = Vault::open(&path, pw()).unwrap();
2106 assert_eq!(&*v.get("K").unwrap(), "v");
2107 }
2108
2109 #[test]
2112 fn revert_to_version_restores_previous_value() {
2113 let dir = tempdir().unwrap();
2114 let path = dir.path().join("v.vault");
2115 let mut v = Vault::create(&path, pw()).unwrap();
2116 v.set("K", "v1", HashMap::new()).unwrap();
2117 v.set("K", "v2", HashMap::new()).unwrap();
2118 v.set("K", "v3", HashMap::new()).unwrap();
2119 v.revert_to_version("K", 1).unwrap();
2121 assert_eq!(&*v.get("K").unwrap(), "v2");
2122 }
2123
2124 #[test]
2125 fn revert_to_version_zero_is_noop() {
2126 let dir = tempdir().unwrap();
2127 let path = dir.path().join("v.vault");
2128 let mut v = Vault::create(&path, pw()).unwrap();
2129 v.set("K", "v1", HashMap::new()).unwrap();
2130 v.set("K", "v2", HashMap::new()).unwrap();
2131 v.revert_to_version("K", 0).unwrap();
2132 assert_eq!(&*v.get("K").unwrap(), "v2");
2133 }
2134
2135 #[test]
2136 fn revert_to_version_out_of_range_errors() {
2137 let dir = tempdir().unwrap();
2138 let path = dir.path().join("v.vault");
2139 let mut v = Vault::create(&path, pw()).unwrap();
2140 v.set("K", "v1", HashMap::new()).unwrap();
2141 assert!(v.revert_to_version("K", 1).is_err());
2143 }
2144
2145 #[test]
2146 fn revert_to_version_survives_persist_roundtrip() {
2147 let dir = tempdir().unwrap();
2148 let path = dir.path().join("v.vault");
2149 let mut v = Vault::create(&path, pw()).unwrap();
2150 v.set("K", "original", HashMap::new()).unwrap();
2151 v.set("K", "updated", HashMap::new()).unwrap();
2152 v.revert_to_version("K", 1).unwrap();
2153 drop(v);
2154 let v2 = Vault::open(&path, pw()).unwrap();
2155 assert_eq!(&*v2.get("K").unwrap(), "original");
2156 }
2157
2158 #[test]
2159 fn prune_history_limits_depth() {
2160 let dir = tempdir().unwrap();
2161 let path = dir.path().join("v.vault");
2162 let mut v = Vault::create(&path, pw()).unwrap();
2163 for i in 0..6 {
2164 v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
2165 }
2166 v.prune_history("K", 2).unwrap();
2168 let versions = v.history("K").unwrap();
2169 assert_eq!(versions.len(), 3);
2171 assert_eq!(&*v.get("K").unwrap(), "v5");
2173 }
2174
2175 #[test]
2176 fn prune_history_to_zero_clears_all_history() {
2177 let dir = tempdir().unwrap();
2178 let path = dir.path().join("v.vault");
2179 let mut v = Vault::create(&path, pw()).unwrap();
2180 v.set("K", "v1", HashMap::new()).unwrap();
2181 v.set("K", "v2", HashMap::new()).unwrap();
2182 v.prune_history("K", 0).unwrap();
2183 assert_eq!(v.file.secrets["K"].history.len(), 0);
2184 assert_eq!(&*v.get("K").unwrap(), "v2");
2186 }
2187
2188 #[test]
2189 fn prune_history_missing_key_returns_error() {
2190 let dir = tempdir().unwrap();
2191 let path = dir.path().join("v.vault");
2192 let mut v = Vault::create(&path, pw()).unwrap();
2193 assert!(matches!(
2194 v.prune_history("NOPE", 3),
2195 Err(SafeError::SecretNotFound { .. })
2196 ));
2197 }
2198}