Skip to main content

tsafe_core/
vault.rs

1//! Encrypted vault read/write — the core data layer.
2//!
3//! A vault is a single JSON file on disk that stores secrets as Argon2id-derived
4//! XChaCha20-Poly1305 ciphertexts.  Opening a vault decrypts all values into
5//! memory; saving re-encrypts everything and writes atomically (temp file → rename).
6//!
7//! # Key contract
8//! Secret keys must pass [`validate_secret_key`] before being stored.  Valid keys
9//! are 1–256 ASCII characters matching `[A-Za-z_][A-Za-z0-9_\-./]*` with no
10//! consecutive separators.
11
12use 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";
35/// Known plaintext used to verify the vault key on open.
36pub(crate) const VAULT_CHALLENGE_PLAINTEXT: &[u8] = b"tsafe-vault-challenge-v1";
37
38// Bounds for KDF parameters to prevent DoS (extreme memory) or downgrade (trivial brute-force).
39const KDF_M_COST_MIN: u32 = 8_192; // 8 MiB — schema minimum
40const KDF_M_COST_MAX: u32 = 131_072; // 128 MiB
41const 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
46/// Validate a secret key name.
47///
48/// Rules:
49/// - 1–256 characters, ASCII only
50/// - Must start with a letter or underscore
51/// - Remaining chars: letters, digits, `_`, `-`, `.`, `/`
52/// - No consecutive separators (`..`, `--`, `//`, `.-`, `/-`, etc.)
53/// - Must not end with a separator
54///
55/// This allows namespaced keys like `myapp/DB_PASSWORD`, `github.com.password`,
56/// `db-prod.PASSWORD`.
57pub 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// ── on-disk types (serde) ────────────────────────────────────────────────────
99
100#[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/// A previous version of a secret's encrypted value.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct HistoryEntry {
112    pub nonce: String,
113    pub ciphertext: String,
114    pub updated_at: DateTime<Utc>,
115}
116
117/// How many previous versions to retain per secret.
118pub const DEFAULT_HISTORY_KEEP: usize = 5;
119
120/// An encrypted secret entry stored in the vault file.
121#[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    /// Previous versions of this secret (oldest first). Backward-compatible via serde default.
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub history: Vec<HistoryEntry>,
132}
133
134/// The vault's key-verification challenge (encrypted known plaintext).
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct VaultChallenge {
137    pub nonce: String,
138    pub ciphertext: String,
139}
140
141/// The full vault file as written to disk.
142#[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    /// age X25519 recipient public keys (team vaults only). Backward-compatible via serde default.
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub age_recipients: Vec<String>,
155    /// age-wrapped data encryption key (team vaults only). Backward-compatible via serde default.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub wrapped_dek: Option<String>,
158}
159
160// ── in-memory vault (unlocked) ───────────────────────────────────────────────
161
162/// An open, authenticated vault. `key` is held in memory and zeroed on drop.
163/// Holds an advisory lock file (`<name>.vault.lock`) to prevent concurrent access.
164/// The lock is acquired before any read or snapshot-restore work begins and is
165/// only removed by the process that created it.
166pub 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    /// Advisory lock owned by this vault handle.
174    _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
202/// Acquire an advisory lock for the vault at `path`.
203/// Creates `<path>.vault.lock`; returns an ownership-aware guard.
204/// New-format lock files can be recovered when their owning process is gone;
205/// legacy opaque lock files remain manual-only to avoid accidental lock stealing.
206fn 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    // kill(pid, 0) performs an existence/permission probe without sending a signal.
288    // SAFETY: kill(2) with signal 0 is a pure existence/permission probe — no signal
289    // is delivered, no memory is read or written through pointers, and the FFI takes
290    // only by-value scalar arguments. The call is sound for any `pid: pid_t` value;
291    // negative pids hit process-group semantics, which is well-defined POSIX
292    // behavior, not UB.
293    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    // SAFETY: OpenProcess takes by-value scalar arguments only (no pointers); all
312    // three values are well-defined Windows constants/u32 inputs. Returns either
313    // a NULL handle (on failure — checked immediately below) or a fresh owning
314    // HANDLE that we close via CloseHandle further down. No memory is read or
315    // written through pointers.
316    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    // SAFETY: `handle` was returned by OpenProcess above and verified non-NULL,
326    // so it is a valid, owning HANDLE with PROCESS_QUERY_LIMITED_INFORMATION
327    // access (sufficient for GetExitCodeProcess per MSDN). `&mut exit_code`
328    // points to a properly-initialized u32 on this stack frame, alive for the
329    // call duration; GetExitCodeProcess writes a u32 there and does not retain
330    // the pointer.
331    let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
332    // SAFETY: `handle` is the same owning HANDLE returned by OpenProcess and has
333    // not been closed yet — this is its single CloseHandle. After this point we
334    // do not use `handle` again, so there is no double-free or use-after-close.
335    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    // Unknown platforms stay conservative and require manual unlock.
344    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    // ── constructors ─────────────────────────────────────────────────────────
360
361    /// Create a new empty vault at `path`, protected by `password`.
362    #[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    /// Create a new vault with an explicit access profile.
368    ///
369    /// This is mainly a future-facing policy hook. A `read_only` profile is
370    /// rejected because creation is inherently a write operation.
371    #[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    /// Open and authenticate an existing vault.
437    /// If the vault file is missing or corrupt, auto-heals from the latest snapshot.
438    #[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    /// Open an existing vault in read-only mode.
444    ///
445    /// Unlike the normal open path, read-only opens do not auto-heal missing or
446    /// corrupt vaults from snapshots because that would mutate on-disk state.
447    #[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    /// Open and authenticate an existing vault with an explicit access profile.
453    #[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        // Auto-heal: restore from latest snapshot if file is absent.
461        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        // Auto-heal: restore from latest snapshot if file is unreadable or unparseable.
486        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        // Reject KDF params outside safe bounds to prevent DoS or brute-force downgrade.
548        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        // Authenticate the key by decrypting the challenge.
582        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    /// Open a vault with a pre-derived key (e.g. a DEK unwrapped from an age header).
604    /// Skips KDF validation — the caller is responsible for providing a valid key.
605    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    /// Open a vault with a pre-derived key in read-only mode.
610    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    /// Open a vault with a pre-derived key and an explicit access profile.
615    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        // Verify the key against the challenge.
639        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    /// Check if a vault file on disk is a team vault (has age recipients).
661    /// Reads only the metadata — does not require authentication.
662    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    // ── persistence ───────────────────────────────────────────────────────────
671
672    /// Atomically write vault to disk (write-to-tmp then rename).
673    /// A snapshot of the previous state is taken before overwriting.
674    #[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        // Snapshot the existing file before overwriting so we can recover.
681        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    // ── secret operations ────────────────────────────────────────────────────
694
695    /// Insert or update a secret. Idempotent — repeated calls with same value are safe.
696    /// Key must match `[A-Za-z_][A-Za-z0-9_]*` (valid env-var name) and be ≤ 256 chars.
697    #[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    /// Decrypt and return a secret value wrapped in `Zeroizing` so it is
745    /// automatically wiped from memory when dropped.
746    #[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        // Convert Vec<u8> → String without cloning. On error, FromUtf8Error
766        // returns the bytes via into_bytes() so we can zeroize them.
767        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    /// Remove a secret. Returns `SecretNotFound` if the key does not exist.
780    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    /// Rename / move a secret key within this vault.
793    ///
794    /// The full entry (ciphertext, tags, history) is preserved under `new_key`.
795    /// Returns `SecretNotFound` if `old_key` does not exist, `SecretAlreadyExists`
796    /// if `new_key` is already occupied and `overwrite` is false.
797    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    /// List all secret key names in sorted order.
817    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    /// Decrypt and return all secrets as a plain map.  Prefer `get` for single access.
824    /// Values are plain `String`s (not `Zeroizing`) for ergonomic iteration;
825    /// callers should drop the map promptly after use.
826    pub fn export_all(&self) -> SafeResult<HashMap<String, String>> {
827        self.list()
828            .iter()
829            .map(|k| {
830                let val = self.get(k)?;
831                // Unwrap from Zeroizing — caller owns the HashMap and should drop it promptly.
832                Ok((k.to_string(), (*val).clone()))
833            })
834            .collect()
835    }
836
837    // ── versioning ─────────────────────────────────────────────────────────
838
839    /// Decrypt a specific version of a secret. Version 0 is the current value,
840    /// version 1 is the most recent previous value, etc.
841    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    /// List version metadata for a key. Returns `(version_number, updated_at)` pairs,
887    /// newest first. Version 0 is the current value.
888    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    /// Revert a secret to a previous version. `version` follows the same numbering as
904    /// `history()`: 0 is current, 1 is the most recent previous value, etc.
905    ///
906    /// The reverted value becomes the new current version, and the old current value
907    /// is pushed onto the history stack (capped at `DEFAULT_HISTORY_KEEP`).
908    pub fn revert_to_version(&mut self, key: &str, version: usize) -> SafeResult<()> {
909        self.ensure_write_allowed()?;
910        if version == 0 {
911            // Already at the requested version — nothing to do.
912            return Ok(());
913        }
914        // Decrypt the target version first while the entry is immutably borrowed.
915        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        // Push current value onto the history stack before replacing it.
925        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; // release the mutable borrow before re-borrowing below
939
940        // Re-encrypt the target value and store it as the new current version.
941        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    /// Prune the version history for a secret to keep at most `keep_n` previous versions.
957    /// If the secret has fewer than `keep_n` history entries nothing changes.
958    /// `keep_n == 0` clears all history.
959    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    // ── key rotation ─────────────────────────────────────────────────────────
976
977    /// Re-encrypt all secrets under a new master password. Atomic — vault is
978    /// only updated on-disk after all secrets are successfully re-encrypted.
979    #[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        // Decrypt all current values and history entries before re-keying.
983        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        // Decrypt all history entries under the old key.
992        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            // Re-encrypt history entries under the new key.
1038            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    // ── accessors ────────────────────────────────────────────────────────────
1097
1098    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    /// Relabel the current handle with a different access profile.
1110    pub fn with_access_profile(mut self, access_profile: RbacProfile) -> Self {
1111        self.access_profile = access_profile;
1112        self
1113    }
1114
1115    /// Read-only access to the raw vault file metadata.
1116    /// Use `get()`, `list()`, `export_all()` etc. for secret access.
1117    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    /// Derive the profile name from the vault file path (`<name>.vault`).
1126    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
1137// ── rotation policies ───────────────────────────────────────────────────────
1138
1139/// Parse a rotation policy duration string like `"90d"` into days.
1140pub 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
1147/// Return secrets that are overdue for rotation based on their `rotate_policy` tag.
1148/// Returns `Vec<(key, days_overdue, policy_string)>`, sorted by key.
1149pub 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// ── tests ────────────────────────────────────────────────────────────────────
1167
1168#[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        // Regression: challenge must be verified even with zero secrets.
1329        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    /// Secret *keys* are ASCII-only by contract; reject Unicode in key material.
1336    #[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    /// Secret *values* may be arbitrary UTF-8 (Track 5 / findings testing gaps).
1344    #[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    /// Empty master password is allowed by the engine (weak; callers may forbid in UX).
1355    #[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); // may be equal in very fast tests; best effort
1562    }
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        // Namespaced keys like github.com.token or db-prod.PASSWORD must be accepted.
1578        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            "",             // empty
1592            "123abc",       // starts with digit
1593            "-starts-bad",  // starts with separator
1594            ".starts-bad",  // starts with separator
1595            "ends.",        // ends with separator
1596            "ends-",        // ends with separator
1597            "double..dot",  // consecutive separators
1598            "double--dash", // consecutive separators
1599            "dot.-dash",    // consecutive mixed separators
1600            "has space",    // space not allowed
1601        ];
1602        for key in &bad {
1603            assert!(
1604                validate_secret_key(key).is_err(),
1605                "expected error for '{key}'"
1606            );
1607        }
1608    }
1609
1610    // ── versioning tests ────────────────────────────────────────────────────
1611
1612    #[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        // Most recent history entry should be v8 (v9 is current)
1656        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); // current + 1 history
1668        assert_eq!(versions[0].0, 0); // current
1669        assert_eq!(versions[1].0, 1); // previous
1670    }
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    // ── rotation policy tests ───────────────────────────────────────────────
1687
1688    #[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        // Backdate the entry to 3 days ago.
1714        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); // at least 2 days overdue
1720    }
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    // ── secret_count ─────────────────────────────────────────────────────────
1843
1844    #[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    // ── Vault::is_team_vault (path-based check) ───────────────────────────────
1862
1863    #[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        // Write team vault to disk (bypassing Vault::save which needs auth).
1890        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    // ── open_with_key ─────────────────────────────────────────────────────────
1897
1898    #[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        // Unwrap the DEK via age identity, then open the vault with it.
1916        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        // Use a random wrong key.
1968        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    // ── property-based roundtrip tests ───────────────────────────────────────
1974    //
1975    // Limit to 32 cases per property to keep KDF overhead tolerable in CI.
1976
1977    proptest! {
1978        #![proptest_config(ProptestConfig::with_cases(32))]
1979
1980        /// set → get returns exactly the same value (in-memory, no persist).
1981        #[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        /// set → delete → get returns SecretNotFound.
1994        #[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        /// Multiple distinct keys: list() contains every inserted key.
2009        #[test]
2010        fn prop_multi_set_list_contains_all(
2011            // Generate up to 8 distinct-ish keys; dedup to avoid duplicates.
2012            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        /// Persist roundtrip: values survive drop-and-reopen with correct password.
2030        #[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    // ── concurrent lock stress tests ─────────────────────────────────────────
2047
2048    /// N threads simultaneously attempt Vault::open on the same locked vault.
2049    /// All must fail with a lock error — no panics, no silent data corruption.
2050    #[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        // Main thread holds the vault (and thus the lock file).
2057        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(); // synchronise start so all N hit the lock together
2069                    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(); // release all threads simultaneously
2084        for h in handles {
2085            h.join().expect("concurrent open thread panicked");
2086        }
2087    }
2088
2089    /// After the vault owner drops its handle the lock is released;
2090    /// the next open (even from a spawn) must succeed without error.
2091    #[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            // While held a second open fails.
2099            assert!(
2100                Vault::open(&path, pw()).is_err(),
2101                "second open should fail while lock held"
2102            );
2103        } // lock released here via Drop
2104          // After drop the lock file is gone; a fresh open must succeed.
2105        let v = Vault::open(&path, pw()).unwrap();
2106        assert_eq!(&*v.get("K").unwrap(), "v");
2107    }
2108
2109    // ── revert_to_version / prune_history ────────────────────────────────────
2110
2111    #[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        // v3 is current (version 0), v2 is version 1, v1 is version 2.
2120        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        // Only current exists; no history to revert to.
2142        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        // Now history has DEFAULT_HISTORY_KEEP (5) entries; current is v5.
2167        v.prune_history("K", 2).unwrap();
2168        let versions = v.history("K").unwrap();
2169        // 1 current + 2 history = 3 total entries
2170        assert_eq!(versions.len(), 3);
2171        // Current value is still v5
2172        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        // Current value is still intact
2185        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}