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        // Create the temp file with owner-only (0o600) permissions on Unix so the
689        // vault — which carries the KDF salt, Argon2 cost params, and the
690        // encrypted blob — is never readable by other local users, even
691        // transiently. The audit log (audit.rs) already does this; the vault is
692        // the more sensitive artifact and must match.
693        crate::fsperm::write_owner_only(&tmp, json.as_bytes())?;
694        std::fs::rename(&tmp, &self.path)?;
695        // The rename preserves the tmp file's mode on Unix, but if a vault file
696        // already existed at the destination with looser perms (e.g. a pre-fix
697        // 0o644 vault), tighten it explicitly to be safe.
698        crate::fsperm::set_owner_only(&self.path)?;
699        Ok(())
700    }
701
702    // ── secret operations ────────────────────────────────────────────────────
703
704    /// Insert or update a secret. Idempotent — repeated calls with same value are safe.
705    /// Key must match `[A-Za-z_][A-Za-z0-9_]*` (valid env-var name) and be ≤ 256 chars.
706    #[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    /// Decrypt and return a secret value wrapped in `Zeroizing` so it is
754    /// automatically wiped from memory when dropped.
755    #[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        // Convert Vec<u8> → String without cloning. On error, FromUtf8Error
775        // returns the bytes via into_bytes() so we can zeroize them.
776        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    /// Remove a secret. Returns `SecretNotFound` if the key does not exist.
789    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    /// Rename / move a secret key within this vault.
802    ///
803    /// The full entry (ciphertext, tags, history) is preserved under `new_key`.
804    /// Returns `SecretNotFound` if `old_key` does not exist, `SecretAlreadyExists`
805    /// if `new_key` is already occupied and `overwrite` is false.
806    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    /// List all secret key names in sorted order.
826    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    /// Decrypt and return all secrets as a plain map.  Prefer `get` for single access.
833    /// Values are plain `String`s (not `Zeroizing`) for ergonomic iteration;
834    /// callers should drop the map promptly after use.
835    pub fn export_all(&self) -> SafeResult<HashMap<String, String>> {
836        self.list()
837            .iter()
838            .map(|k| {
839                let val = self.get(k)?;
840                // Unwrap from Zeroizing — caller owns the HashMap and should drop it promptly.
841                Ok((k.to_string(), (*val).clone()))
842            })
843            .collect()
844    }
845
846    // ── versioning ─────────────────────────────────────────────────────────
847
848    /// Decrypt a specific version of a secret. Version 0 is the current value,
849    /// version 1 is the most recent previous value, etc.
850    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    /// List version metadata for a key. Returns `(version_number, updated_at)` pairs,
896    /// newest first. Version 0 is the current value.
897    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    /// Revert a secret to a previous version. `version` follows the same numbering as
913    /// `history()`: 0 is current, 1 is the most recent previous value, etc.
914    ///
915    /// The reverted value becomes the new current version, and the old current value
916    /// is pushed onto the history stack (capped at `DEFAULT_HISTORY_KEEP`).
917    pub fn revert_to_version(&mut self, key: &str, version: usize) -> SafeResult<()> {
918        self.ensure_write_allowed()?;
919        if version == 0 {
920            // Already at the requested version — nothing to do.
921            return Ok(());
922        }
923        // Decrypt the target version first while the entry is immutably borrowed.
924        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        // Push current value onto the history stack before replacing it.
934        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; // release the mutable borrow before re-borrowing below
948
949        // Re-encrypt the target value and store it as the new current version.
950        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    /// Prune the version history for a secret to keep at most `keep_n` previous versions.
966    /// If the secret has fewer than `keep_n` history entries nothing changes.
967    /// `keep_n == 0` clears all history.
968    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    // ── key rotation ─────────────────────────────────────────────────────────
985
986    /// Re-encrypt all secrets under a new master password. Atomic — vault is
987    /// only updated on-disk after all secrets are successfully re-encrypted.
988    #[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        // Decrypt all current values and history entries before re-keying.
992        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        // Decrypt all history entries under the old key.
1001        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            // Re-encrypt history entries under the new key.
1047            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    // ── accessors ────────────────────────────────────────────────────────────
1106
1107    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    /// Relabel the current handle with a different access profile.
1119    pub fn with_access_profile(mut self, access_profile: RbacProfile) -> Self {
1120        self.access_profile = access_profile;
1121        self
1122    }
1123
1124    /// Read-only access to the raw vault file metadata.
1125    /// Use `get()`, `list()`, `export_all()` etc. for secret access.
1126    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    /// Derive the profile name from the vault file path (`<name>.vault`).
1135    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
1146// ── rotation policies ───────────────────────────────────────────────────────
1147
1148/// Parse a rotation policy duration string like `"90d"` into days.
1149pub 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
1156/// Return secrets that are overdue for rotation based on their `rotate_policy` tag.
1157/// Returns `Vec<(key, days_overdue, policy_string)>`, sorted by key.
1158pub 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// ── tests ────────────────────────────────────────────────────────────────────
1176
1177#[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        // Regression: challenge must be verified even with zero secrets.
1338        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    /// Secret *keys* are ASCII-only by contract; reject Unicode in key material.
1345    #[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    /// Secret *values* may be arbitrary UTF-8 (Track 5 / findings testing gaps).
1353    #[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    /// Empty master password is allowed by the engine (weak; callers may forbid in UX).
1364    #[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); // may be equal in very fast tests; best effort
1571    }
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        // Namespaced keys like github.com.token or db-prod.PASSWORD must be accepted.
1587        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            "",             // empty
1601            "123abc",       // starts with digit
1602            "-starts-bad",  // starts with separator
1603            ".starts-bad",  // starts with separator
1604            "ends.",        // ends with separator
1605            "ends-",        // ends with separator
1606            "double..dot",  // consecutive separators
1607            "double--dash", // consecutive separators
1608            "dot.-dash",    // consecutive mixed separators
1609            "has space",    // space not allowed
1610        ];
1611        for key in &bad {
1612            assert!(
1613                validate_secret_key(key).is_err(),
1614                "expected error for '{key}'"
1615            );
1616        }
1617    }
1618
1619    // ── versioning tests ────────────────────────────────────────────────────
1620
1621    #[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        // Most recent history entry should be v8 (v9 is current)
1665        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); // current + 1 history
1677        assert_eq!(versions[0].0, 0); // current
1678        assert_eq!(versions[1].0, 1); // previous
1679    }
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    // ── rotation policy tests ───────────────────────────────────────────────
1696
1697    #[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        // Backdate the entry to 3 days ago.
1723        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); // at least 2 days overdue
1729    }
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    // ── secret_count ─────────────────────────────────────────────────────────
1852
1853    #[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    // ── Vault::is_team_vault (path-based check) ───────────────────────────────
1871
1872    #[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        // Write team vault to disk (bypassing Vault::save which needs auth).
1899        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    // ── open_with_key ─────────────────────────────────────────────────────────
1906
1907    #[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        // Unwrap the DEK via age identity, then open the vault with it.
1925        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        // Use a random wrong key.
1977        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    // ── property-based roundtrip tests ───────────────────────────────────────
1983    //
1984    // Limit to 32 cases per property to keep KDF overhead tolerable in CI.
1985
1986    proptest! {
1987        #![proptest_config(ProptestConfig::with_cases(32))]
1988
1989        /// set → get returns exactly the same value (in-memory, no persist).
1990        #[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        /// set → delete → get returns SecretNotFound.
2003        #[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        /// Multiple distinct keys: list() contains every inserted key.
2018        #[test]
2019        fn prop_multi_set_list_contains_all(
2020            // Generate up to 8 distinct-ish keys; dedup to avoid duplicates.
2021            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        /// Persist roundtrip: values survive drop-and-reopen with correct password.
2039        #[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    // ── concurrent lock stress tests ─────────────────────────────────────────
2056
2057    /// N threads simultaneously attempt Vault::open on the same locked vault.
2058    /// All must fail with a lock error — no panics, no silent data corruption.
2059    #[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        // Main thread holds the vault (and thus the lock file).
2066        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(); // synchronise start so all N hit the lock together
2078                    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(); // release all threads simultaneously
2093        for h in handles {
2094            h.join().expect("concurrent open thread panicked");
2095        }
2096    }
2097
2098    /// After the vault owner drops its handle the lock is released;
2099    /// the next open (even from a spawn) must succeed without error.
2100    #[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            // While held a second open fails.
2108            assert!(
2109                Vault::open(&path, pw()).is_err(),
2110                "second open should fail while lock held"
2111            );
2112        } // lock released here via Drop
2113          // After drop the lock file is gone; a fresh open must succeed.
2114        let v = Vault::open(&path, pw()).unwrap();
2115        assert_eq!(&*v.get("K").unwrap(), "v");
2116    }
2117
2118    // ── revert_to_version / prune_history ────────────────────────────────────
2119
2120    #[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        // v3 is current (version 0), v2 is version 1, v1 is version 2.
2129        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        // Only current exists; no history to revert to.
2151        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        // Now history has DEFAULT_HISTORY_KEEP (5) entries; current is v5.
2176        v.prune_history("K", 2).unwrap();
2177        let versions = v.history("K").unwrap();
2178        // 1 current + 2 history = 3 total entries
2179        assert_eq!(versions.len(), 3);
2180        // Current value is still v5
2181        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        // Current value is still intact
2194        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}