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 crate::fsperm::write_owner_only(&tmp, json.as_bytes())?;
694 std::fs::rename(&tmp, &self.path)?;
695 crate::fsperm::set_owner_only(&self.path)?;
699 Ok(())
700 }
701
702 #[instrument(skip(self, value, tags, key))]
707 pub fn set(&mut self, key: &str, value: &str, tags: HashMap<String, String>) -> SafeResult<()> {
708 self.ensure_write_allowed()?;
709 validate_secret_key(key)?;
710 let (nonce, ct) = crypto::encrypt_with_key_schedule(
711 &self.key,
712 self.key_schedule,
713 KeyPurpose::SecretData,
714 self.cipher,
715 value.as_bytes(),
716 )?;
717 let now = Utc::now();
718 let (created_at, history, tags) = match self.file.secrets.get(key) {
719 Some(existing) => {
720 let mut h = existing.history.clone();
721 h.push(HistoryEntry {
722 nonce: existing.nonce.clone(),
723 ciphertext: existing.ciphertext.clone(),
724 updated_at: existing.updated_at,
725 });
726 if h.len() > DEFAULT_HISTORY_KEEP {
727 h.drain(..h.len() - DEFAULT_HISTORY_KEEP);
728 }
729 let merged_tags = if tags.is_empty() {
730 existing.tags.clone()
731 } else {
732 tags
733 };
734 (existing.created_at, h, merged_tags)
735 }
736 None => (now, Vec::new(), tags),
737 };
738 self.file.secrets.insert(
739 key.to_string(),
740 SecretEntry {
741 nonce: crypto::encode_b64(&nonce),
742 ciphertext: crypto::encode_b64(&ct),
743 created_at,
744 updated_at: now,
745 tags,
746 history,
747 },
748 );
749 self.file.updated_at = now;
750 self.save()
751 }
752
753 #[instrument(skip(self, key))]
756 pub fn get(&self, key: &str) -> SafeResult<Zeroizing<String>> {
757 let entry = self
758 .file
759 .secrets
760 .get(key)
761 .ok_or_else(|| SafeError::SecretNotFound {
762 key: key.to_string(),
763 })?;
764 let nonce = crypto::decode_b64(&entry.nonce)?;
765 let ct = crypto::decode_b64(&entry.ciphertext)?;
766 let pt = crypto::decrypt_with_key_schedule(
767 &self.key,
768 self.key_schedule,
769 KeyPurpose::SecretData,
770 self.cipher,
771 &nonce,
772 &ct,
773 )?;
774 match String::from_utf8(pt) {
777 Ok(s) => Ok(Zeroizing::new(s)),
778 Err(e) => {
779 let mut bytes = e.into_bytes();
780 bytes.zeroize();
781 Err(SafeError::InvalidVault {
782 reason: "secret is not valid UTF-8".into(),
783 })
784 }
785 }
786 }
787
788 pub fn delete(&mut self, key: &str) -> SafeResult<()> {
790 self.ensure_write_allowed()?;
791 if !self.file.secrets.contains_key(key) {
792 return Err(SafeError::SecretNotFound {
793 key: key.to_string(),
794 });
795 }
796 self.file.secrets.remove(key);
797 self.file.updated_at = Utc::now();
798 self.save()
799 }
800
801 pub fn rename_key(&mut self, old_key: &str, new_key: &str, overwrite: bool) -> SafeResult<()> {
807 self.ensure_write_allowed()?;
808 validate_secret_key(new_key)?;
809 if !self.file.secrets.contains_key(old_key) {
810 return Err(SafeError::SecretNotFound {
811 key: old_key.to_string(),
812 });
813 }
814 if !overwrite && self.file.secrets.contains_key(new_key) {
815 return Err(SafeError::SecretAlreadyExists {
816 key: new_key.to_string(),
817 });
818 }
819 let entry = self.file.secrets.remove(old_key).unwrap();
820 self.file.secrets.insert(new_key.to_string(), entry);
821 self.file.updated_at = Utc::now();
822 self.save()
823 }
824
825 pub fn list(&self) -> Vec<&str> {
827 let mut keys: Vec<&str> = self.file.secrets.keys().map(String::as_str).collect();
828 keys.sort_unstable();
829 keys
830 }
831
832 pub fn export_all(&self) -> SafeResult<HashMap<String, String>> {
836 self.list()
837 .iter()
838 .map(|k| {
839 let val = self.get(k)?;
840 Ok((k.to_string(), (*val).clone()))
842 })
843 .collect()
844 }
845
846 pub fn get_version(&self, key: &str, version: usize) -> SafeResult<Zeroizing<String>> {
851 if version == 0 {
852 return self.get(key);
853 }
854 let entry = self
855 .file
856 .secrets
857 .get(key)
858 .ok_or_else(|| SafeError::SecretNotFound {
859 key: key.to_string(),
860 })?;
861 let hist_idx =
862 entry
863 .history
864 .len()
865 .checked_sub(version)
866 .ok_or_else(|| SafeError::InvalidVault {
867 reason: format!(
868 "version {version} does not exist for '{key}' (max {})",
869 entry.history.len()
870 ),
871 })?;
872 let h = &entry.history[hist_idx];
873 let nonce = crypto::decode_b64(&h.nonce)?;
874 let ct = crypto::decode_b64(&h.ciphertext)?;
875 let pt = crypto::decrypt_with_key_schedule(
876 &self.key,
877 self.key_schedule,
878 KeyPurpose::SecretData,
879 self.cipher,
880 &nonce,
881 &ct,
882 )?;
883 match String::from_utf8(pt) {
884 Ok(s) => Ok(Zeroizing::new(s)),
885 Err(e) => {
886 let mut bytes = e.into_bytes();
887 bytes.zeroize();
888 Err(SafeError::InvalidVault {
889 reason: "secret is not valid UTF-8".into(),
890 })
891 }
892 }
893 }
894
895 pub fn history(&self, key: &str) -> SafeResult<Vec<(usize, DateTime<Utc>)>> {
898 let entry = self
899 .file
900 .secrets
901 .get(key)
902 .ok_or_else(|| SafeError::SecretNotFound {
903 key: key.to_string(),
904 })?;
905 let mut versions = vec![(0usize, entry.updated_at)];
906 for (i, h) in entry.history.iter().rev().enumerate() {
907 versions.push((i + 1, h.updated_at));
908 }
909 Ok(versions)
910 }
911
912 pub fn revert_to_version(&mut self, key: &str, version: usize) -> SafeResult<()> {
918 self.ensure_write_allowed()?;
919 if version == 0 {
920 return Ok(());
922 }
923 let target_value = self.get_version(key, version)?;
925 let now = Utc::now();
926 let entry = self
927 .file
928 .secrets
929 .get_mut(key)
930 .ok_or_else(|| SafeError::SecretNotFound {
931 key: key.to_string(),
932 })?;
933 let current_nonce = entry.nonce.clone();
935 let current_ciphertext = entry.ciphertext.clone();
936 let current_updated_at = entry.updated_at;
937 entry.history.push(HistoryEntry {
938 nonce: current_nonce,
939 ciphertext: current_ciphertext,
940 updated_at: current_updated_at,
941 });
942 if entry.history.len() > DEFAULT_HISTORY_KEEP {
943 entry
944 .history
945 .drain(..entry.history.len() - DEFAULT_HISTORY_KEEP);
946 }
947 let _ = entry; let (nonce, ct) = crypto::encrypt_with_key_schedule(
951 &self.key,
952 self.key_schedule,
953 KeyPurpose::SecretData,
954 self.cipher,
955 target_value.as_bytes(),
956 )?;
957 let entry = self.file.secrets.get_mut(key).unwrap();
958 entry.nonce = crypto::encode_b64(&nonce);
959 entry.ciphertext = crypto::encode_b64(&ct);
960 entry.updated_at = now;
961 self.file.updated_at = now;
962 self.save()
963 }
964
965 pub fn prune_history(&mut self, key: &str, keep_n: usize) -> SafeResult<()> {
969 self.ensure_write_allowed()?;
970 let entry = self
971 .file
972 .secrets
973 .get_mut(key)
974 .ok_or_else(|| SafeError::SecretNotFound {
975 key: key.to_string(),
976 })?;
977 if entry.history.len() > keep_n {
978 entry.history.drain(..entry.history.len() - keep_n);
979 }
980 self.file.updated_at = Utc::now();
981 self.save()
982 }
983
984 #[instrument(skip(self, new_password), fields(secret_count = self.file.secrets.len()))]
989 pub fn rotate(&mut self, new_password: &[u8]) -> SafeResult<()> {
990 self.ensure_write_allowed()?;
991 let all = self.export_all()?;
993 let meta: HashMap<String, _> = self
994 .file
995 .secrets
996 .iter()
997 .map(|(k, e)| (k.clone(), (e.tags.clone(), e.created_at, e.history.clone())))
998 .collect();
999
1000 let mut history_plaintext: HashMap<String, Vec<(String, DateTime<Utc>)>> = HashMap::new();
1002 for (key, entry) in &self.file.secrets {
1003 let mut pts = Vec::new();
1004 for h in &entry.history {
1005 let nonce = crypto::decode_b64(&h.nonce)?;
1006 let ct = crypto::decode_b64(&h.ciphertext)?;
1007 let pt = crypto::decrypt_with_key_schedule(
1008 &self.key,
1009 self.key_schedule,
1010 KeyPurpose::SecretData,
1011 self.cipher,
1012 &nonce,
1013 &ct,
1014 )?;
1015 let s = String::from_utf8(pt).map_err(|_| SafeError::InvalidVault {
1016 reason: "history entry is not valid UTF-8".into(),
1017 })?;
1018 pts.push((s, h.updated_at));
1019 }
1020 history_plaintext.insert(key.clone(), pts);
1021 }
1022
1023 let new_salt = crypto::random_salt();
1024 let new_key = crypto::derive_key(
1025 new_password,
1026 &new_salt,
1027 VAULT_KDF_M_COST,
1028 VAULT_KDF_T_COST,
1029 VAULT_KDF_P_COST,
1030 )?;
1031 let new_cipher = crypto::default_vault_cipher();
1032 let new_key_schedule = KeySchedule::HkdfSha256V1;
1033
1034 let now = Utc::now();
1035 let mut new_secrets = HashMap::with_capacity(all.len());
1036 for (key, value) in &all {
1037 let (nonce, ct) = crypto::encrypt_with_key_schedule(
1038 &new_key,
1039 new_key_schedule,
1040 KeyPurpose::SecretData,
1041 new_cipher,
1042 value.as_bytes(),
1043 )?;
1044 let (ref tags, created_at, _) = meta[key];
1045
1046 let mut new_history = Vec::new();
1048 if let Some(pts) = history_plaintext.get(key) {
1049 for (pt, updated_at) in pts {
1050 let (hn, hct) = crypto::encrypt_with_key_schedule(
1051 &new_key,
1052 new_key_schedule,
1053 KeyPurpose::SecretData,
1054 new_cipher,
1055 pt.as_bytes(),
1056 )?;
1057 new_history.push(HistoryEntry {
1058 nonce: crypto::encode_b64(&hn),
1059 ciphertext: crypto::encode_b64(&hct),
1060 updated_at: *updated_at,
1061 });
1062 }
1063 }
1064
1065 new_secrets.insert(
1066 key.clone(),
1067 SecretEntry {
1068 nonce: crypto::encode_b64(&nonce),
1069 ciphertext: crypto::encode_b64(&ct),
1070 created_at,
1071 updated_at: now,
1072 tags: tags.clone(),
1073 history: new_history,
1074 },
1075 );
1076 }
1077
1078 let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
1079 &new_key,
1080 new_key_schedule,
1081 KeyPurpose::VaultChallenge,
1082 new_cipher,
1083 VAULT_CHALLENGE_PLAINTEXT,
1084 )?;
1085 self.file.kdf = KdfParams {
1086 algorithm: "argon2id".to_string(),
1087 m_cost: VAULT_KDF_M_COST,
1088 t_cost: VAULT_KDF_T_COST,
1089 p_cost: VAULT_KDF_P_COST,
1090 salt: crypto::encode_b64(&new_salt),
1091 };
1092 self.file.vault_challenge = VaultChallenge {
1093 nonce: crypto::encode_b64(&ch_nonce),
1094 ciphertext: crypto::encode_b64(&ch_ct),
1095 };
1096 self.file.cipher = new_cipher.as_str().to_string();
1097 self.file.secrets = new_secrets;
1098 self.file.updated_at = now;
1099 self.key = new_key;
1100 self.cipher = new_cipher;
1101 self.key_schedule = new_key_schedule;
1102 self.save()
1103 }
1104
1105 pub fn path(&self) -> &Path {
1108 &self.path
1109 }
1110 pub fn secret_count(&self) -> usize {
1111 self.file.secrets.len()
1112 }
1113
1114 pub fn access_profile(&self) -> RbacProfile {
1115 self.access_profile
1116 }
1117
1118 pub fn with_access_profile(mut self, access_profile: RbacProfile) -> Self {
1120 self.access_profile = access_profile;
1121 self
1122 }
1123
1124 pub fn file(&self) -> &VaultFile {
1127 &self.file
1128 }
1129
1130 pub fn ensure_write_allowed(&self) -> SafeResult<()> {
1131 self.access_profile.ensure_write_allowed()
1132 }
1133
1134 fn profile_name(&self) -> Option<String> {
1136 Self::profile_name_from_path(&self.path)
1137 }
1138
1139 fn profile_name_from_path(path: &Path) -> Option<String> {
1140 path.file_stem()
1141 .and_then(|s| s.to_str())
1142 .map(|s| s.to_string())
1143 }
1144}
1145
1146pub fn parse_rotation_days(policy: &str) -> Option<i64> {
1150 let s = policy.trim();
1151 s.strip_suffix('d')
1152 .and_then(|prefix| prefix.parse::<i64>().ok())
1153 .filter(|&d| d > 0)
1154}
1155
1156pub fn rotation_due(file: &VaultFile) -> Vec<(String, i64, String)> {
1159 let now = Utc::now();
1160 let mut due = Vec::new();
1161 for (key, entry) in &file.secrets {
1162 if let Some(policy) = entry.tags.get("rotate_policy") {
1163 if let Some(days) = parse_rotation_days(policy) {
1164 let age = (now - entry.updated_at).num_days();
1165 if age >= days {
1166 due.push((key.clone(), age - days, policy.clone()));
1167 }
1168 }
1169 }
1170 }
1171 due.sort_by(|a, b| a.0.cmp(&b.0));
1172 due
1173}
1174
1175#[cfg(test)]
1178mod tests {
1179 use super::*;
1180 use proptest::prelude::*;
1181 use tempfile::tempdir;
1182
1183 fn pw() -> &'static [u8] {
1184 b"test-master-password"
1185 }
1186
1187 #[test]
1188 fn create_and_reopen() {
1189 let dir = tempdir().unwrap();
1190 let path = dir.path().join("v.vault");
1191 let mut v = Vault::create(&path, pw()).unwrap();
1192 v.set("K", "val", HashMap::new()).unwrap();
1193 drop(v);
1194 let v2 = Vault::open(&path, pw()).unwrap();
1195 assert_eq!(&*v2.get("K").unwrap(), "val");
1196 }
1197
1198 #[test]
1199 fn read_only_open_blocks_save_and_mutation_paths() {
1200 let dir = tempdir().unwrap();
1201 let path = dir.path().join("v.vault");
1202 let mut writable = Vault::create(&path, pw()).unwrap();
1203 writable.set("K", "value", HashMap::new()).unwrap();
1204 drop(writable);
1205
1206 let mut vault = Vault::open_read_only(&path, pw()).unwrap();
1207 assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
1208 assert_eq!(&*vault.get("K").unwrap(), "value");
1209
1210 for result in [
1211 vault.save(),
1212 vault.set("NEW", "value", HashMap::new()),
1213 vault.delete("K"),
1214 vault.rename_key("K", "RENAMED", false),
1215 vault.rotate(b"new-password"),
1216 ] {
1217 match result {
1218 Err(SafeError::InvalidVault { reason }) => {
1219 assert!(reason.contains("read_only"));
1220 }
1221 other => panic!("expected read-only write denial, got {other:?}"),
1222 }
1223 }
1224 }
1225
1226 #[test]
1227 fn read_only_open_does_not_restore_missing_snapshot() {
1228 let dir = tempdir().unwrap();
1229 let profile_dir = dir.path().join("profiles").join("default");
1230 std::fs::create_dir_all(&profile_dir).unwrap();
1231 let path = profile_dir.join("vault.vault");
1232 let snapshots = dir.path().join("snapshots").join("default");
1233 std::fs::create_dir_all(&snapshots).unwrap();
1234 std::fs::write(
1235 snapshots.join("default-20260407-0000000000000.0000.snap"),
1236 "{}",
1237 )
1238 .unwrap();
1239
1240 match Vault::open_read_only(&path, pw()) {
1241 Err(SafeError::VaultNotFound { .. }) => {}
1242 Ok(_) => panic!("expected read-only open to refuse snapshot restore"),
1243 Err(other) => panic!("expected VaultNotFound, got {other:?}"),
1244 }
1245 assert!(!path.exists(), "read-only open must not restore snapshots");
1246 }
1247
1248 fn root_key_from_file(file: &VaultFile, password: &[u8]) -> VaultKey {
1249 let salt = crypto::decode_b64(&file.kdf.salt).unwrap();
1250 crypto::derive_key(
1251 password,
1252 &salt,
1253 file.kdf.m_cost,
1254 file.kdf.t_cost,
1255 file.kdf.p_cost,
1256 )
1257 .unwrap()
1258 }
1259
1260 fn legacy_vault_file(password: &[u8], value: &str) -> VaultFile {
1261 let salt = crypto::random_salt();
1262 let key = crypto::derive_key(
1263 password,
1264 &salt,
1265 VAULT_KDF_M_COST,
1266 VAULT_KDF_T_COST,
1267 VAULT_KDF_P_COST,
1268 )
1269 .unwrap();
1270 let now = Utc::now();
1271 let (ch_nonce, ch_ct) = crypto::encrypt(&key, VAULT_CHALLENGE_PLAINTEXT).unwrap();
1272 let (nonce, ciphertext) = crypto::encrypt(&key, value.as_bytes()).unwrap();
1273 let mut secrets = HashMap::new();
1274 secrets.insert(
1275 "LEGACY".into(),
1276 SecretEntry {
1277 nonce: crypto::encode_b64(&nonce),
1278 ciphertext: crypto::encode_b64(&ciphertext),
1279 created_at: now,
1280 updated_at: now,
1281 tags: HashMap::new(),
1282 history: Vec::new(),
1283 },
1284 );
1285 VaultFile {
1286 schema: VAULT_SCHEMA.to_string(),
1287 kdf: KdfParams {
1288 algorithm: VAULT_KDF_ALGORITHM.to_string(),
1289 m_cost: VAULT_KDF_M_COST,
1290 t_cost: VAULT_KDF_T_COST,
1291 p_cost: VAULT_KDF_P_COST,
1292 salt: crypto::encode_b64(&salt),
1293 },
1294 cipher: CipherKind::XChaCha20Poly1305.as_str().to_string(),
1295 vault_challenge: VaultChallenge {
1296 nonce: crypto::encode_b64(&ch_nonce),
1297 ciphertext: crypto::encode_b64(&ch_ct),
1298 },
1299 created_at: now,
1300 updated_at: now,
1301 secrets,
1302 age_recipients: Vec::new(),
1303 wrapped_dek: None,
1304 }
1305 }
1306
1307 #[test]
1308 fn second_open_fails_while_lock_is_held() {
1309 let dir = tempdir().unwrap();
1310 let path = dir.path().join("v.vault");
1311 let _v = Vault::create(&path, pw()).unwrap();
1312
1313 match Vault::open(&path, pw()) {
1314 Err(SafeError::InvalidVault { reason }) => {
1315 assert!(reason.contains("vault is locked by another process"));
1316 }
1317 Ok(_) => panic!("expected lock error, got open vault"),
1318 Err(other) => panic!("expected lock error, got {other:?}"),
1319 }
1320 }
1321
1322 #[test]
1323 fn wrong_password_fails() {
1324 let dir = tempdir().unwrap();
1325 let path = dir.path().join("v.vault");
1326 let mut v = Vault::create(&path, pw()).unwrap();
1327 v.set("K", "v", HashMap::new()).unwrap();
1328 drop(v);
1329 assert!(matches!(
1330 Vault::open(&path, b"wrong"),
1331 Err(SafeError::DecryptionFailed)
1332 ));
1333 }
1334
1335 #[test]
1336 fn empty_vault_wrong_password_fails() {
1337 let dir = tempdir().unwrap();
1339 let path = dir.path().join("v.vault");
1340 Vault::create(&path, pw()).unwrap();
1341 assert!(Vault::open(&path, b"wrong").is_err());
1342 }
1343
1344 #[test]
1346 fn validate_secret_key_rejects_non_ascii() {
1347 assert!(validate_secret_key("café_KEY").is_err());
1348 assert!(validate_secret_key("emoji_🔑").is_err());
1349 assert!(validate_secret_key("K_日本").is_err());
1350 }
1351
1352 #[test]
1354 fn set_get_roundtrip_unicode_secret_value() {
1355 let dir = tempdir().unwrap();
1356 let path = dir.path().join("v.vault");
1357 let mut v = Vault::create(&path, pw()).unwrap();
1358 let val = "snowman☃café日本語";
1359 v.set("UNICODE_VAL", val, HashMap::new()).unwrap();
1360 assert_eq!(&*v.get("UNICODE_VAL").unwrap(), val);
1361 }
1362
1363 #[test]
1365 fn empty_master_password_vault_roundtrip() {
1366 let dir = tempdir().unwrap();
1367 let path = dir.path().join("v.vault");
1368 let mut v = Vault::create(&path, b"").unwrap();
1369 v.set("K", "v", HashMap::new()).unwrap();
1370 drop(v);
1371 let v2 = Vault::open(&path, b"").unwrap();
1372 assert_eq!(&*v2.get("K").unwrap(), "v");
1373 }
1374
1375 #[test]
1376 fn create_twice_fails() {
1377 let dir = tempdir().unwrap();
1378 let path = dir.path().join("v.vault");
1379 Vault::create(&path, pw()).unwrap();
1380 assert!(matches!(
1381 Vault::create(&path, pw()),
1382 Err(SafeError::VaultAlreadyExists { .. })
1383 ));
1384 }
1385
1386 #[test]
1387 fn set_get_delete_roundtrip() {
1388 let dir = tempdir().unwrap();
1389 let path = dir.path().join("v.vault");
1390 let mut v = Vault::create(&path, pw()).unwrap();
1391 v.set("DB_PASS", "s3cr3t", HashMap::new()).unwrap();
1392 assert_eq!(&*v.get("DB_PASS").unwrap(), "s3cr3t");
1393 v.delete("DB_PASS").unwrap();
1394 assert!(matches!(
1395 v.get("DB_PASS"),
1396 Err(SafeError::SecretNotFound { .. })
1397 ));
1398 }
1399
1400 #[test]
1401 fn list_is_sorted() {
1402 let dir = tempdir().unwrap();
1403 let path = dir.path().join("v.vault");
1404 let mut v = Vault::create(&path, pw()).unwrap();
1405 v.set("ZZZ", "z", HashMap::new()).unwrap();
1406 v.set("AAA", "a", HashMap::new()).unwrap();
1407 v.set("MMM", "m", HashMap::new()).unwrap();
1408 assert_eq!(v.list(), vec!["AAA", "MMM", "ZZZ"]);
1409 }
1410
1411 #[test]
1412 fn export_all_decrypts_all() {
1413 let dir = tempdir().unwrap();
1414 let path = dir.path().join("v.vault");
1415 let mut v = Vault::create(&path, pw()).unwrap();
1416 v.set("A", "alpha", HashMap::new()).unwrap();
1417 v.set("B", "beta", HashMap::new()).unwrap();
1418 let all = v.export_all().unwrap();
1419 assert_eq!(all["A"], "alpha");
1420 assert_eq!(all["B"], "beta");
1421 }
1422
1423 #[test]
1424 fn rotate_re_encrypts_under_new_password() {
1425 let dir = tempdir().unwrap();
1426 let path = dir.path().join("v.vault");
1427 let mut v = Vault::create(&path, pw()).unwrap();
1428 v.set("SECRET", "value", HashMap::new()).unwrap();
1429 v.rotate(b"new-password").unwrap();
1430 drop(v);
1431 assert!(Vault::open(&path, pw()).is_err());
1432 let v2 = Vault::open(&path, b"new-password").unwrap();
1433 assert_eq!(&*v2.get("SECRET").unwrap(), "value");
1434 }
1435
1436 #[test]
1437 fn new_vault_uses_hkdf_scoped_keys_for_challenge_and_secret_data() {
1438 let dir = tempdir().unwrap();
1439 let path = dir.path().join("v.vault");
1440 let mut vault = Vault::create(&path, pw()).unwrap();
1441 vault.set("SECRET", "value", HashMap::new()).unwrap();
1442
1443 let root_key = root_key_from_file(vault.file(), pw());
1444 let challenge_nonce = crypto::decode_b64(&vault.file.vault_challenge.nonce).unwrap();
1445 let challenge_ct = crypto::decode_b64(&vault.file.vault_challenge.ciphertext).unwrap();
1446 assert!(matches!(
1447 crypto::decrypt_for_cipher(vault.cipher, &root_key, &challenge_nonce, &challenge_ct),
1448 Err(SafeError::DecryptionFailed)
1449 ));
1450 assert_eq!(
1451 crypto::decrypt_with_key_schedule(
1452 &root_key,
1453 KeySchedule::HkdfSha256V1,
1454 KeyPurpose::VaultChallenge,
1455 vault.cipher,
1456 &challenge_nonce,
1457 &challenge_ct
1458 )
1459 .unwrap(),
1460 VAULT_CHALLENGE_PLAINTEXT
1461 );
1462
1463 let entry = &vault.file().secrets["SECRET"];
1464 let secret_nonce = crypto::decode_b64(&entry.nonce).unwrap();
1465 let secret_ct = crypto::decode_b64(&entry.ciphertext).unwrap();
1466 assert!(matches!(
1467 crypto::decrypt_for_cipher(vault.cipher, &root_key, &secret_nonce, &secret_ct),
1468 Err(SafeError::DecryptionFailed)
1469 ));
1470 assert_eq!(
1471 crypto::decrypt_with_key_schedule(
1472 &root_key,
1473 KeySchedule::HkdfSha256V1,
1474 KeyPurpose::SecretData,
1475 vault.cipher,
1476 &secret_nonce,
1477 &secret_ct
1478 )
1479 .unwrap(),
1480 b"value"
1481 );
1482 }
1483
1484 #[test]
1485 fn open_legacy_vault_detects_legacy_schedule_and_keeps_writes_consistent() {
1486 let dir = tempdir().unwrap();
1487 let path = dir.path().join("legacy.vault");
1488 let file = legacy_vault_file(pw(), "legacy-value");
1489 std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
1490
1491 let mut vault = Vault::open(&path, pw()).unwrap();
1492 assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
1493 assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
1494 assert_eq!(&*vault.get("LEGACY").unwrap(), "legacy-value");
1495
1496 vault
1497 .set("NEW_SECRET", "new-value", HashMap::new())
1498 .unwrap();
1499 let root_key = root_key_from_file(vault.file(), pw());
1500 let entry = &vault.file().secrets["NEW_SECRET"];
1501 let nonce = crypto::decode_b64(&entry.nonce).unwrap();
1502 let ciphertext = crypto::decode_b64(&entry.ciphertext).unwrap();
1503 assert_eq!(
1504 crypto::decrypt_for_cipher(vault.cipher, &root_key, &nonce, &ciphertext).unwrap(),
1505 b"new-value"
1506 );
1507 }
1508
1509 #[test]
1510 fn rotating_legacy_vault_migrates_it_to_hkdf_schedule() {
1511 let dir = tempdir().unwrap();
1512 let path = dir.path().join("legacy.vault");
1513 let file = legacy_vault_file(pw(), "legacy-value");
1514 std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
1515
1516 let mut vault = Vault::open(&path, pw()).unwrap();
1517 assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
1518 assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
1519 vault.rotate(b"new-password").unwrap();
1520 assert_eq!(vault.key_schedule, KeySchedule::HkdfSha256V1);
1521 assert_eq!(vault.cipher, crypto::default_vault_cipher());
1522 drop(vault);
1523
1524 let reopened = Vault::open(&path, b"new-password").unwrap();
1525 assert_eq!(reopened.key_schedule, KeySchedule::HkdfSha256V1);
1526 assert_eq!(reopened.cipher, crypto::default_vault_cipher());
1527 assert_eq!(&*reopened.get("LEGACY").unwrap(), "legacy-value");
1528
1529 let root_key = root_key_from_file(reopened.file(), b"new-password");
1530 let challenge_nonce = crypto::decode_b64(&reopened.file.vault_challenge.nonce).unwrap();
1531 let challenge_ct = crypto::decode_b64(&reopened.file.vault_challenge.ciphertext).unwrap();
1532 assert!(matches!(
1533 crypto::decrypt_for_cipher(reopened.cipher, &root_key, &challenge_nonce, &challenge_ct),
1534 Err(SafeError::DecryptionFailed)
1535 ));
1536 assert_eq!(
1537 crypto::decrypt_with_key_schedule(
1538 &root_key,
1539 KeySchedule::HkdfSha256V1,
1540 KeyPurpose::VaultChallenge,
1541 reopened.cipher,
1542 &challenge_nonce,
1543 &challenge_ct
1544 )
1545 .unwrap(),
1546 VAULT_CHALLENGE_PLAINTEXT
1547 );
1548 }
1549
1550 #[cfg(feature = "fips")]
1551 #[test]
1552 fn fips_build_creates_aes256gcm_vaults() {
1553 let dir = tempdir().unwrap();
1554 let path = dir.path().join("v.vault");
1555 let mut vault = Vault::create(&path, pw()).unwrap();
1556 vault.set("SECRET", "value", HashMap::new()).unwrap();
1557 assert_eq!(vault.cipher, CipherKind::Aes256Gcm);
1558 assert_eq!(vault.file.cipher, CipherKind::Aes256Gcm.as_str());
1559 }
1560
1561 #[test]
1562 fn set_preserves_created_at_on_update() {
1563 let dir = tempdir().unwrap();
1564 let path = dir.path().join("v.vault");
1565 let mut v = Vault::create(&path, pw()).unwrap();
1566 v.set("K", "v1", HashMap::new()).unwrap();
1567 let created = v.file.secrets["K"].created_at;
1568 v.set("K", "v2", HashMap::new()).unwrap();
1569 assert_eq!(v.file.secrets["K"].created_at, created);
1570 assert_ne!(v.file.secrets["K"].updated_at, created); }
1572
1573 #[test]
1574 fn delete_missing_key_returns_error() {
1575 let dir = tempdir().unwrap();
1576 let path = dir.path().join("v.vault");
1577 let mut v = Vault::create(&path, pw()).unwrap();
1578 assert!(matches!(
1579 v.delete("NOPE"),
1580 Err(SafeError::SecretNotFound { .. })
1581 ));
1582 }
1583
1584 #[test]
1585 fn key_validation_allows_dot_and_hyphen_namespaces() {
1586 for key in &[
1588 "github.com.token",
1589 "db-prod.PASSWORD",
1590 "_under.score-mix",
1591 "A.b-c.D",
1592 ] {
1593 assert!(validate_secret_key(key).is_ok(), "expected ok for '{key}'");
1594 }
1595 }
1596
1597 #[test]
1598 fn key_validation_rejects_invalid_forms() {
1599 let bad = [
1600 "", "123abc", "-starts-bad", ".starts-bad", "ends.", "ends-", "double..dot", "double--dash", "dot.-dash", "has space", ];
1611 for key in &bad {
1612 assert!(
1613 validate_secret_key(key).is_err(),
1614 "expected error for '{key}'"
1615 );
1616 }
1617 }
1618
1619 #[test]
1622 fn set_builds_history() {
1623 let dir = tempdir().unwrap();
1624 let path = dir.path().join("v.vault");
1625 let mut v = Vault::create(&path, pw()).unwrap();
1626 v.set("K", "v1", HashMap::new()).unwrap();
1627 v.set("K", "v2", HashMap::new()).unwrap();
1628 v.set("K", "v3", HashMap::new()).unwrap();
1629 assert_eq!(v.file.secrets["K"].history.len(), 2);
1630 assert_eq!(&*v.get("K").unwrap(), "v3");
1631 }
1632
1633 #[test]
1634 fn get_version_returns_previous_values() {
1635 let dir = tempdir().unwrap();
1636 let path = dir.path().join("v.vault");
1637 let mut v = Vault::create(&path, pw()).unwrap();
1638 v.set("K", "v1", HashMap::new()).unwrap();
1639 v.set("K", "v2", HashMap::new()).unwrap();
1640 v.set("K", "v3", HashMap::new()).unwrap();
1641 assert_eq!(&*v.get_version("K", 0).unwrap(), "v3");
1642 assert_eq!(&*v.get_version("K", 1).unwrap(), "v2");
1643 assert_eq!(&*v.get_version("K", 2).unwrap(), "v1");
1644 }
1645
1646 #[test]
1647 fn get_version_out_of_range_errors() {
1648 let dir = tempdir().unwrap();
1649 let path = dir.path().join("v.vault");
1650 let mut v = Vault::create(&path, pw()).unwrap();
1651 v.set("K", "v1", HashMap::new()).unwrap();
1652 assert!(v.get_version("K", 1).is_err());
1653 }
1654
1655 #[test]
1656 fn history_capped_at_default() {
1657 let dir = tempdir().unwrap();
1658 let path = dir.path().join("v.vault");
1659 let mut v = Vault::create(&path, pw()).unwrap();
1660 for i in 0..10 {
1661 v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
1662 }
1663 assert_eq!(v.file.secrets["K"].history.len(), DEFAULT_HISTORY_KEEP);
1664 assert_eq!(&*v.get_version("K", 1).unwrap(), "v8");
1666 }
1667
1668 #[test]
1669 fn history_metadata_lists_versions() {
1670 let dir = tempdir().unwrap();
1671 let path = dir.path().join("v.vault");
1672 let mut v = Vault::create(&path, pw()).unwrap();
1673 v.set("K", "v1", HashMap::new()).unwrap();
1674 v.set("K", "v2", HashMap::new()).unwrap();
1675 let versions = v.history("K").unwrap();
1676 assert_eq!(versions.len(), 2); assert_eq!(versions[0].0, 0); assert_eq!(versions[1].0, 1); }
1680
1681 #[test]
1682 fn rotate_preserves_history() {
1683 let dir = tempdir().unwrap();
1684 let path = dir.path().join("v.vault");
1685 let mut v = Vault::create(&path, pw()).unwrap();
1686 v.set("K", "v1", HashMap::new()).unwrap();
1687 v.set("K", "v2", HashMap::new()).unwrap();
1688 v.rotate(b"new-pw").unwrap();
1689 drop(v);
1690 let v2 = Vault::open(&path, b"new-pw").unwrap();
1691 assert_eq!(&*v2.get("K").unwrap(), "v2");
1692 assert_eq!(&*v2.get_version("K", 1).unwrap(), "v1");
1693 }
1694
1695 #[test]
1698 fn parse_rotation_days_valid() {
1699 assert_eq!(parse_rotation_days("90d"), Some(90));
1700 assert_eq!(parse_rotation_days("30d"), Some(30));
1701 assert_eq!(parse_rotation_days("1d"), Some(1));
1702 assert_eq!(parse_rotation_days(" 7d "), Some(7));
1703 }
1704
1705 #[test]
1706 fn parse_rotation_days_invalid() {
1707 assert_eq!(parse_rotation_days("invalid"), None);
1708 assert_eq!(parse_rotation_days("0d"), None);
1709 assert_eq!(parse_rotation_days("-1d"), None);
1710 assert_eq!(parse_rotation_days(""), None);
1711 assert_eq!(parse_rotation_days("d"), None);
1712 }
1713
1714 #[test]
1715 fn rotation_due_finds_overdue_secrets() {
1716 let dir = tempdir().unwrap();
1717 let path = dir.path().join("v.vault");
1718 let mut v = Vault::create(&path, pw()).unwrap();
1719 let mut tags = HashMap::new();
1720 tags.insert("rotate_policy".into(), "1d".into());
1721 v.set("OLD_KEY", "val", tags).unwrap();
1722 v.file.secrets.get_mut("OLD_KEY").unwrap().updated_at =
1724 Utc::now() - chrono::Duration::days(3);
1725 let due = rotation_due(v.file());
1726 assert_eq!(due.len(), 1);
1727 assert_eq!(due[0].0, "OLD_KEY");
1728 assert!(due[0].1 >= 2); }
1730
1731 #[test]
1732 fn rotation_due_ignores_fresh_secrets() {
1733 let dir = tempdir().unwrap();
1734 let path = dir.path().join("v.vault");
1735 let mut v = Vault::create(&path, pw()).unwrap();
1736 let mut tags = HashMap::new();
1737 tags.insert("rotate_policy".into(), "90d".into());
1738 v.set("FRESH", "val", tags).unwrap();
1739 let due = rotation_due(v.file());
1740 assert!(due.is_empty());
1741 }
1742
1743 #[test]
1744 fn set_preserves_existing_tags_when_update_has_no_tags() {
1745 let dir = tempdir().unwrap();
1746 let path = dir.path().join("v.vault");
1747 let mut v = Vault::create(&path, pw()).unwrap();
1748 let mut tags = HashMap::new();
1749 tags.insert("env".into(), "prod".into());
1750 tags.insert("rotate_policy".into(), "30d".into());
1751 v.set("KEY", "v1", tags.clone()).unwrap();
1752 v.set("KEY", "v2", HashMap::new()).unwrap();
1753 assert_eq!(v.file.secrets["KEY"].tags, tags);
1754 }
1755
1756 #[test]
1757 fn lock_guard_drop_keeps_replaced_lockfile() {
1758 let dir = tempdir().unwrap();
1759 let path = dir.path().join("v.vault");
1760 let lock_path = lock_path_for(&path);
1761 let guard = acquire_lock(&path).unwrap();
1762 std::fs::write(&lock_path, "different-owner").unwrap();
1763 drop(guard);
1764 assert_eq!(
1765 std::fs::read_to_string(&lock_path).unwrap(),
1766 "different-owner"
1767 );
1768 }
1769
1770 #[test]
1771 fn dead_owner_lockfile_is_recovered_for_new_format() {
1772 let dir = tempdir().unwrap();
1773 let path = dir.path().join("v.vault");
1774 let lock_path = lock_path_for(&path);
1775 let stale = LockFileContents {
1776 version: 1,
1777 id: "stale-owner".into(),
1778 pid: u32::MAX,
1779 created_at: Utc::now(),
1780 };
1781 std::fs::write(&lock_path, serde_json::to_string(&stale).unwrap()).unwrap();
1782
1783 let guard = acquire_lock(&path).unwrap();
1784 let contents = std::fs::read_to_string(&lock_path).unwrap();
1785 let recovered: LockFileContents = serde_json::from_str(&contents).unwrap();
1786 assert_eq!(recovered.version, 1);
1787 assert_eq!(recovered.pid, std::process::id());
1788 assert_ne!(recovered.id, stale.id);
1789 drop(guard);
1790 assert!(!lock_path.exists());
1791 }
1792
1793 #[test]
1794 fn opaque_legacy_lockfile_is_not_removed_implicitly() {
1795 let dir = tempdir().unwrap();
1796 let path = dir.path().join("v.vault");
1797 let lock_path = lock_path_for(&path);
1798 std::fs::write(&lock_path, "legacy-uuid-without-metadata").unwrap();
1799
1800 match acquire_lock(&path) {
1801 Err(SafeError::InvalidVault { reason }) => {
1802 assert!(reason.contains("vault is locked by another process"));
1803 }
1804 Ok(_) => panic!("expected lock error, got recovered lock"),
1805 Err(other) => panic!("expected lock error, got {other:?}"),
1806 }
1807 assert_eq!(
1808 std::fs::read_to_string(&lock_path).unwrap(),
1809 "legacy-uuid-without-metadata"
1810 );
1811 }
1812
1813 #[test]
1814 fn process_is_running_sees_current_process() {
1815 assert!(process_is_running(std::process::id()));
1816 }
1817
1818 #[test]
1819 fn process_is_running_rejects_impossible_pid() {
1820 assert!(!process_is_running(u32::MAX));
1821 }
1822
1823 #[test]
1824 fn missing_vault_with_existing_lock_does_not_restore_snapshot() {
1825 let dir = tempdir().unwrap();
1826 let profile_dir = dir.path().join("profiles").join("default");
1827 std::fs::create_dir_all(&profile_dir).unwrap();
1828 let path = profile_dir.join("vault.vault");
1829 let snapshots = dir.path().join("snapshots").join("default");
1830 std::fs::create_dir_all(&snapshots).unwrap();
1831 std::fs::write(
1832 snapshots.join("default-20260407-0000000000000.0000.snap"),
1833 "{}",
1834 )
1835 .unwrap();
1836
1837 let _guard = acquire_lock(&path).unwrap();
1838 match Vault::open(&path, pw()) {
1839 Err(SafeError::InvalidVault { reason }) => {
1840 assert!(reason.contains("vault is locked by another process"));
1841 }
1842 Ok(_) => panic!("expected lock error, got open vault"),
1843 Err(other) => panic!("expected lock error, got {other:?}"),
1844 }
1845 assert!(
1846 !path.exists(),
1847 "open should not restore under another process's lock"
1848 );
1849 }
1850
1851 #[test]
1854 fn secret_count_reflects_set_and_delete() {
1855 let dir = tempdir().unwrap();
1856 let path = dir.path().join("v.vault");
1857 let mut v = Vault::create(&path, pw()).unwrap();
1858 assert_eq!(v.secret_count(), 0);
1859
1860 v.set("A", "1", HashMap::new()).unwrap();
1861 assert_eq!(v.secret_count(), 1);
1862
1863 v.set("B", "2", HashMap::new()).unwrap();
1864 assert_eq!(v.secret_count(), 2);
1865
1866 v.delete("A").unwrap();
1867 assert_eq!(v.secret_count(), 1);
1868 }
1869
1870 #[test]
1873 fn vault_is_team_vault_returns_false_for_regular_vault() {
1874 let dir = tempdir().unwrap();
1875 let path = dir.path().join("v.vault");
1876 let _v = Vault::create(&path, pw()).unwrap();
1877 drop(_v);
1878 assert!(!Vault::is_team_vault(&path));
1879 }
1880
1881 #[test]
1882 fn vault_is_team_vault_returns_false_for_nonexistent_path() {
1883 let dir = tempdir().unwrap();
1884 let path = dir.path().join("does_not_exist.vault");
1885 assert!(!Vault::is_team_vault(&path));
1886 }
1887
1888 #[test]
1889 fn vault_is_team_vault_returns_true_for_team_vault_on_disk() {
1890 use crate::{age_crypto, team};
1891
1892 let dir = tempdir().unwrap();
1893 let path = dir.path().join("team.vault");
1894
1895 let (_secret, recipient) = age_crypto::generate_identity();
1896 let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();
1897
1898 let json = serde_json::to_string_pretty(&file).unwrap();
1900 std::fs::write(&path, json).unwrap();
1901
1902 assert!(Vault::is_team_vault(&path));
1903 }
1904
1905 #[test]
1908 fn open_with_key_using_team_dek_succeeds() {
1909 use crate::{age_crypto, team};
1910
1911 let dir = tempdir().unwrap();
1912 let path = dir.path().join("team.vault");
1913
1914 let (secret, recipient) = age_crypto::generate_identity();
1915 let identities = age::IdentityFile::from_buffer(secret.as_bytes())
1916 .unwrap()
1917 .into_identities()
1918 .unwrap();
1919
1920 let (file, _) = team::create_team_vault(&[recipient]).unwrap();
1921 let json = serde_json::to_string_pretty(&file).unwrap();
1922 std::fs::write(&path, &json).unwrap();
1923
1924 let dek = team::unwrap_dek(&file, &identities).unwrap();
1926 let vault = Vault::open_with_key(&path, dek).unwrap();
1927 assert_eq!(vault.secret_count(), 0);
1928 }
1929
1930 #[test]
1931 fn open_with_key_read_only_blocks_mutation() {
1932 use crate::{age_crypto, team};
1933
1934 let dir = tempdir().unwrap();
1935 let path = dir.path().join("team.vault");
1936
1937 let (secret, recipient) = age_crypto::generate_identity();
1938 let identities = age::IdentityFile::from_buffer(secret.as_bytes())
1939 .unwrap()
1940 .into_identities()
1941 .unwrap();
1942
1943 let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();
1944 std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
1945
1946 let dek = team::unwrap_dek(&file, &identities).unwrap();
1947 let mut writable = Vault::open_with_key(&path, dek).unwrap();
1948 writable
1949 .set("TEAM_SECRET", "value", HashMap::new())
1950 .unwrap();
1951 drop(writable);
1952
1953 let dek = team::unwrap_dek(&file, &identities).unwrap();
1954 let mut vault = Vault::open_with_key_read_only(&path, dek).unwrap();
1955 assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
1956 assert_eq!(&*vault.get("TEAM_SECRET").unwrap(), "value");
1957 assert!(matches!(
1958 vault.set("NEW_SECRET", "blocked", HashMap::new()),
1959 Err(SafeError::InvalidVault { .. })
1960 ));
1961 }
1962
1963 #[test]
1964 fn open_with_key_with_wrong_key_returns_decryption_failed() {
1965 use crate::crypto::VaultKey;
1966 use crate::{age_crypto, team};
1967
1968 let dir = tempdir().unwrap();
1969 let path = dir.path().join("team.vault");
1970
1971 let (_secret, recipient) = age_crypto::generate_identity();
1972 let (file, _) = team::create_team_vault(&[recipient]).unwrap();
1973 let json = serde_json::to_string_pretty(&file).unwrap();
1974 std::fs::write(&path, &json).unwrap();
1975
1976 let wrong_key = VaultKey::from_bytes(crypto::random_salt());
1978 let result = Vault::open_with_key(&path, wrong_key);
1979 assert!(matches!(result, Err(SafeError::DecryptionFailed)));
1980 }
1981
1982 proptest! {
1987 #![proptest_config(ProptestConfig::with_cases(32))]
1988
1989 #[test]
1991 fn prop_set_get_roundtrip(
1992 key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
1993 value in any::<String>(),
1994 ) {
1995 let dir = tempdir().unwrap();
1996 let path = dir.path().join("v.vault");
1997 let mut v = Vault::create(&path, pw()).unwrap();
1998 v.set(&key, &value, HashMap::new()).unwrap();
1999 prop_assert_eq!(&*v.get(&key).unwrap(), value.as_str());
2000 }
2001
2002 #[test]
2004 fn prop_set_delete_not_found(
2005 key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
2006 value in any::<String>(),
2007 ) {
2008 let dir = tempdir().unwrap();
2009 let path = dir.path().join("v.vault");
2010 let mut v = Vault::create(&path, pw()).unwrap();
2011 v.set(&key, &value, HashMap::new()).unwrap();
2012 v.delete(&key).unwrap();
2013 let is_not_found = v.get(&key).is_err();
2014 prop_assert!(is_not_found);
2015 }
2016
2017 #[test]
2019 fn prop_multi_set_list_contains_all(
2020 keys in proptest::collection::vec("[A-Za-z_][A-Za-z0-9_]{0,30}", 1..=8),
2022 ) {
2023 let dir = tempdir().unwrap();
2024 let path = dir.path().join("v.vault");
2025 let mut v = Vault::create(&path, pw()).unwrap();
2026 let mut deduped = keys.clone();
2027 deduped.sort();
2028 deduped.dedup();
2029 for k in &deduped {
2030 v.set(k, "x", HashMap::new()).unwrap();
2031 }
2032 let listed = v.list();
2033 for k in &deduped {
2034 prop_assert!(listed.contains(&k.as_str()), "key {k} missing from list()");
2035 }
2036 }
2037
2038 #[test]
2040 fn prop_persist_roundtrip(
2041 key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
2042 value in any::<String>(),
2043 ) {
2044 let dir = tempdir().unwrap();
2045 let path = dir.path().join("v.vault");
2046 {
2047 let mut v = Vault::create(&path, pw()).unwrap();
2048 v.set(&key, &value, HashMap::new()).unwrap();
2049 }
2050 let v2 = Vault::open(&path, pw()).unwrap();
2051 prop_assert_eq!(&*v2.get(&key).unwrap(), value.as_str());
2052 }
2053 }
2054
2055 #[test]
2060 fn concurrent_opens_all_fail_while_lock_held() {
2061 use std::sync::{Arc, Barrier};
2062
2063 let dir = tempdir().unwrap();
2064 let path = dir.path().join("v.vault");
2065 let _owner = Vault::create(&path, pw()).unwrap();
2067
2068 const N: usize = 8;
2069 let barrier = Arc::new(Barrier::new(N + 1));
2070 let path_arc = Arc::new(path);
2071
2072 let handles: Vec<_> = (0..N)
2073 .map(|_| {
2074 let p = Arc::clone(&path_arc);
2075 let b = Arc::clone(&barrier);
2076 std::thread::spawn(move || {
2077 b.wait(); match Vault::open(&p, pw()) {
2079 Err(SafeError::InvalidVault { reason }) => {
2080 assert!(
2081 reason.contains("vault is locked by another process"),
2082 "unexpected lock reason: {reason}"
2083 );
2084 }
2085 Ok(_) => panic!("concurrent open should not succeed while lock is held"),
2086 Err(e) => panic!("unexpected error variant: {e:?}"),
2087 }
2088 })
2089 })
2090 .collect();
2091
2092 barrier.wait(); for h in handles {
2094 h.join().expect("concurrent open thread panicked");
2095 }
2096 }
2097
2098 #[test]
2101 fn lock_released_after_drop_then_reopen_succeeds() {
2102 let dir = tempdir().unwrap();
2103 let path = dir.path().join("v.vault");
2104 {
2105 let mut owner = Vault::create(&path, pw()).unwrap();
2106 owner.set("K", "v", HashMap::new()).unwrap();
2107 assert!(
2109 Vault::open(&path, pw()).is_err(),
2110 "second open should fail while lock held"
2111 );
2112 } let v = Vault::open(&path, pw()).unwrap();
2115 assert_eq!(&*v.get("K").unwrap(), "v");
2116 }
2117
2118 #[test]
2121 fn revert_to_version_restores_previous_value() {
2122 let dir = tempdir().unwrap();
2123 let path = dir.path().join("v.vault");
2124 let mut v = Vault::create(&path, pw()).unwrap();
2125 v.set("K", "v1", HashMap::new()).unwrap();
2126 v.set("K", "v2", HashMap::new()).unwrap();
2127 v.set("K", "v3", HashMap::new()).unwrap();
2128 v.revert_to_version("K", 1).unwrap();
2130 assert_eq!(&*v.get("K").unwrap(), "v2");
2131 }
2132
2133 #[test]
2134 fn revert_to_version_zero_is_noop() {
2135 let dir = tempdir().unwrap();
2136 let path = dir.path().join("v.vault");
2137 let mut v = Vault::create(&path, pw()).unwrap();
2138 v.set("K", "v1", HashMap::new()).unwrap();
2139 v.set("K", "v2", HashMap::new()).unwrap();
2140 v.revert_to_version("K", 0).unwrap();
2141 assert_eq!(&*v.get("K").unwrap(), "v2");
2142 }
2143
2144 #[test]
2145 fn revert_to_version_out_of_range_errors() {
2146 let dir = tempdir().unwrap();
2147 let path = dir.path().join("v.vault");
2148 let mut v = Vault::create(&path, pw()).unwrap();
2149 v.set("K", "v1", HashMap::new()).unwrap();
2150 assert!(v.revert_to_version("K", 1).is_err());
2152 }
2153
2154 #[test]
2155 fn revert_to_version_survives_persist_roundtrip() {
2156 let dir = tempdir().unwrap();
2157 let path = dir.path().join("v.vault");
2158 let mut v = Vault::create(&path, pw()).unwrap();
2159 v.set("K", "original", HashMap::new()).unwrap();
2160 v.set("K", "updated", HashMap::new()).unwrap();
2161 v.revert_to_version("K", 1).unwrap();
2162 drop(v);
2163 let v2 = Vault::open(&path, pw()).unwrap();
2164 assert_eq!(&*v2.get("K").unwrap(), "original");
2165 }
2166
2167 #[test]
2168 fn prune_history_limits_depth() {
2169 let dir = tempdir().unwrap();
2170 let path = dir.path().join("v.vault");
2171 let mut v = Vault::create(&path, pw()).unwrap();
2172 for i in 0..6 {
2173 v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
2174 }
2175 v.prune_history("K", 2).unwrap();
2177 let versions = v.history("K").unwrap();
2178 assert_eq!(versions.len(), 3);
2180 assert_eq!(&*v.get("K").unwrap(), "v5");
2182 }
2183
2184 #[test]
2185 fn prune_history_to_zero_clears_all_history() {
2186 let dir = tempdir().unwrap();
2187 let path = dir.path().join("v.vault");
2188 let mut v = Vault::create(&path, pw()).unwrap();
2189 v.set("K", "v1", HashMap::new()).unwrap();
2190 v.set("K", "v2", HashMap::new()).unwrap();
2191 v.prune_history("K", 0).unwrap();
2192 assert_eq!(v.file.secrets["K"].history.len(), 0);
2193 assert_eq!(&*v.get("K").unwrap(), "v2");
2195 }
2196
2197 #[test]
2198 fn prune_history_missing_key_returns_error() {
2199 let dir = tempdir().unwrap();
2200 let path = dir.path().join("v.vault");
2201 let mut v = Vault::create(&path, pw()).unwrap();
2202 assert!(matches!(
2203 v.prune_history("NOPE", 3),
2204 Err(SafeError::SecretNotFound { .. })
2205 ));
2206 }
2207}