use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use zeroize::{Zeroize, Zeroizing};
use tracing::{instrument, warn};
use crate::crypto::{
self, CipherKind, KeyPurpose, KeySchedule, VaultKey, VAULT_KDF_M_COST, VAULT_KDF_P_COST,
VAULT_KDF_T_COST,
};
use crate::errors::{SafeError, SafeResult};
use crate::rbac::RbacProfile;
use crate::snapshot;
const VAULT_SCHEMA: &str = "tsafe/vault/v1";
const VAULT_KDF_ALGORITHM: &str = "argon2id";
pub(crate) const VAULT_CHALLENGE_PLAINTEXT: &[u8] = b"tsafe-vault-challenge-v1";
const KDF_M_COST_MIN: u32 = 8_192; const KDF_M_COST_MAX: u32 = 131_072; const KDF_T_COST_MIN: u32 = 1;
const KDF_T_COST_MAX: u32 = 20;
const KDF_P_COST_MIN: u32 = 1;
const KDF_P_COST_MAX: u32 = 16;
pub fn validate_secret_key(key: &str) -> SafeResult<()> {
if key.is_empty() {
return Err(SafeError::InvalidVault {
reason: "secret key must not be empty".into(),
});
}
if key.len() > 256 {
return Err(SafeError::InvalidVault {
reason: format!("secret key too long ({} chars, max 256)", key.len()),
});
}
let mut chars = key.chars().peekable();
let first = chars.next().unwrap();
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(SafeError::InvalidVault {
reason: format!("secret key '{key}' must start with a letter or underscore"),
});
}
let is_sep = |c: char| c == '.' || c == '-' || c == '/';
let mut prev = first;
for c in chars {
if !(c.is_ascii_alphanumeric() || c == '_' || is_sep(c)) {
return Err(SafeError::InvalidVault {
reason: format!("secret key '{key}' contains invalid character '{c}' — use letters, digits, _, -, ., /"),
});
}
if is_sep(c) && is_sep(prev) {
return Err(SafeError::InvalidVault {
reason: format!("secret key '{key}' has consecutive separators"),
});
}
prev = c;
}
if is_sep(prev) {
return Err(SafeError::InvalidVault {
reason: format!("secret key '{key}' must not end with a separator"),
});
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams {
pub algorithm: String,
pub m_cost: u32,
pub t_cost: u32,
pub p_cost: u32,
pub salt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub nonce: String,
pub ciphertext: String,
pub updated_at: DateTime<Utc>,
}
pub const DEFAULT_HISTORY_KEEP: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretEntry {
pub nonce: String,
pub ciphertext: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub tags: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<HistoryEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultChallenge {
pub nonce: String,
pub ciphertext: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultFile {
#[serde(rename = "_schema")]
pub schema: String,
pub kdf: KdfParams,
pub cipher: String,
pub vault_challenge: VaultChallenge,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub secrets: HashMap<String, SecretEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub age_recipients: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrapped_dek: Option<String>,
}
pub struct Vault {
path: PathBuf,
pub(crate) file: VaultFile,
key: VaultKey,
cipher: CipherKind,
key_schedule: KeySchedule,
access_profile: RbacProfile,
_lock: Option<LockGuard>,
}
struct LockGuard {
path: PathBuf,
contents: String,
_file: File,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct LockFileContents {
version: u8,
id: String,
pid: u32,
created_at: DateTime<Utc>,
}
impl LockFileContents {
fn new() -> Self {
Self {
version: 1,
id: Uuid::new_v4().to_string(),
pid: std::process::id(),
created_at: Utc::now(),
}
}
}
fn acquire_lock(path: &Path) -> SafeResult<LockGuard> {
let lock_path = lock_path_for(path);
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
for _ in 0..2 {
let contents = serde_json::to_string(&LockFileContents::new())?;
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(mut file) => {
file.write_all(contents.as_bytes())?;
file.flush()?;
return Ok(LockGuard {
path: lock_path.clone(),
contents,
_file: file,
});
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
if try_recover_dead_lock(&lock_path)? {
continue;
}
return Err(lock_held_error(&lock_path));
}
Err(e) => return Err(SafeError::Io(e)),
}
}
Err(lock_held_error(&lock_path))
}
fn lock_path_for(vault_path: &Path) -> PathBuf {
vault_path.with_extension("vault.lock")
}
fn lock_held_error(lock_path: &Path) -> SafeError {
SafeError::InvalidVault {
reason: format!(
"vault is locked by another process: {}",
lock_path.display()
),
}
}
fn try_recover_dead_lock(lock_path: &Path) -> SafeResult<bool> {
let original_contents = match std::fs::read_to_string(lock_path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
Err(e) => return Err(SafeError::Io(e)),
};
let lock: LockFileContents = match serde_json::from_str(&original_contents) {
Ok(lock) => lock,
Err(_) => return Ok(false),
};
if process_is_running(lock.pid) {
return Ok(false);
}
let current_contents = match std::fs::read_to_string(lock_path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
Err(e) => return Err(SafeError::Io(e)),
};
if current_contents != original_contents {
return Ok(false);
}
match std::fs::remove_file(lock_path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(e) => Err(SafeError::Io(e)),
}
}
#[cfg(unix)]
fn process_is_running(pid: u32) -> bool {
let Ok(pid) = i32::try_from(pid) else {
return false;
};
let rc = unsafe { libc::kill(pid, 0) };
if rc == 0 {
true
} else {
std::io::Error::last_os_error()
.raw_os_error()
.map(|code| code != libc::ESRCH)
.unwrap_or(true)
}
}
#[cfg(windows)]
fn process_is_running(pid: u32) -> bool {
use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
use windows_sys::Win32::System::Threading::{
GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle.is_null() {
let code = std::io::Error::last_os_error()
.raw_os_error()
.unwrap_or_default();
return code != windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER as i32;
}
let mut exit_code = 0u32;
let ok = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
unsafe {
CloseHandle(handle);
}
ok != 0 && exit_code == STILL_ACTIVE as u32
}
#[cfg(not(any(unix, windows)))]
fn process_is_running(_pid: u32) -> bool {
true
}
impl Drop for LockGuard {
fn drop(&mut self) {
let should_remove = std::fs::read_to_string(&self.path)
.map(|contents| contents == self.contents)
.unwrap_or(false);
if should_remove {
let _ = std::fs::remove_file(&self.path);
}
}
}
impl Vault {
#[instrument(skip(password, path))]
pub fn create(path: &Path, password: &[u8]) -> SafeResult<Self> {
Self::create_with_access_profile(path, password, RbacProfile::ReadWrite)
}
#[instrument(skip(password, path))]
pub fn create_with_access_profile(
path: &Path,
password: &[u8],
access_profile: RbacProfile,
) -> SafeResult<Self> {
access_profile.ensure_write_allowed()?;
if path.exists() {
return Err(SafeError::VaultAlreadyExists {
path: path.display().to_string(),
});
}
let salt = crypto::random_salt();
let key = crypto::derive_key(
password,
&salt,
VAULT_KDF_M_COST,
VAULT_KDF_T_COST,
VAULT_KDF_P_COST,
)?;
let now = Utc::now();
let cipher = crypto::default_vault_cipher();
let key_schedule = KeySchedule::HkdfSha256V1;
let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
&key,
key_schedule,
KeyPurpose::VaultChallenge,
cipher,
VAULT_CHALLENGE_PLAINTEXT,
)?;
let file = VaultFile {
schema: VAULT_SCHEMA.to_string(),
kdf: KdfParams {
algorithm: "argon2id".to_string(),
m_cost: VAULT_KDF_M_COST,
t_cost: VAULT_KDF_T_COST,
p_cost: VAULT_KDF_P_COST,
salt: crypto::encode_b64(&salt),
},
cipher: cipher.as_str().to_string(),
vault_challenge: VaultChallenge {
nonce: crypto::encode_b64(&ch_nonce),
ciphertext: crypto::encode_b64(&ch_ct),
},
created_at: now,
updated_at: now,
secrets: HashMap::new(),
age_recipients: Vec::new(),
wrapped_dek: None,
};
let lock = acquire_lock(path)?;
let vault = Self {
path: path.to_path_buf(),
file,
key,
cipher,
key_schedule,
access_profile,
_lock: Some(lock),
};
vault.save()?;
Ok(vault)
}
#[instrument(skip(password, path))]
pub fn open(path: &Path, password: &[u8]) -> SafeResult<Self> {
Self::open_with_access_profile(path, password, RbacProfile::ReadWrite)
}
#[instrument(skip(password, path))]
pub fn open_read_only(path: &Path, password: &[u8]) -> SafeResult<Self> {
Self::open_with_access_profile(path, password, RbacProfile::ReadOnly)
}
#[instrument(skip(password, path))]
pub fn open_with_access_profile(
path: &Path,
password: &[u8],
access_profile: RbacProfile,
) -> SafeResult<Self> {
let lock = acquire_lock(path)?;
if !path.exists() {
if access_profile.allows_write() {
if let Some(profile) = Self::profile_name_from_path(path) {
let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
SafeError::VaultNotFound {
path: path.display().to_string(),
}
})?;
warn!(
snapshot = %snap.display(),
"vault file was missing — restored from snapshot"
);
} else {
return Err(SafeError::VaultNotFound {
path: path.display().to_string(),
});
}
} else {
return Err(SafeError::VaultNotFound {
path: path.display().to_string(),
});
}
}
let json = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
if !access_profile.allows_write() {
return Err(SafeError::Io(e));
}
let profile = Self::profile_name_from_path(path).ok_or(SafeError::Io(e))?;
let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
SafeError::VaultCorrupted {
reason: "io error and no usable snapshot found".into(),
}
})?;
warn!(
snapshot = %snap.display(),
"vault file was unreadable — restored from snapshot"
);
std::fs::read_to_string(path)?
}
};
let file: VaultFile = match serde_json::from_str(&json) {
Ok(f) => f,
Err(_) => {
if !access_profile.allows_write() {
return Err(SafeError::VaultCorrupted {
reason: "vault JSON is invalid".into(),
});
}
let profile = Self::profile_name_from_path(path).ok_or_else(|| {
SafeError::VaultCorrupted {
reason: "vault JSON is invalid and profile name could not be inferred"
.into(),
}
})?;
let snap = snapshot::restore_latest(path, &profile).map_err(|_| {
SafeError::VaultCorrupted {
reason: "vault JSON is invalid and no usable snapshot was found".into(),
}
})?;
warn!(snapshot = %snap.display(), "vault JSON was corrupt — restored from snapshot");
let recovered = std::fs::read_to_string(path)?;
serde_json::from_str(&recovered).map_err(|e| SafeError::VaultCorrupted {
reason: format!("snapshot also failed to parse: {e}"),
})?
}
};
if file.schema != VAULT_SCHEMA {
return Err(SafeError::InvalidVault {
reason: format!("unknown schema: {}", file.schema),
});
}
let cipher = crypto::parse_cipher_kind(&file.cipher)?;
if file.kdf.algorithm != VAULT_KDF_ALGORITHM {
return Err(SafeError::InvalidVault {
reason: format!(
"unsupported KDF algorithm: '{}' (expected '{VAULT_KDF_ALGORITHM}')",
file.kdf.algorithm
),
});
}
if file.kdf.m_cost < KDF_M_COST_MIN || file.kdf.m_cost > KDF_M_COST_MAX {
return Err(SafeError::InvalidVault {
reason: format!(
"KDF m_cost {} is outside allowed range [{KDF_M_COST_MIN}, {KDF_M_COST_MAX}]",
file.kdf.m_cost
),
});
}
if file.kdf.t_cost < KDF_T_COST_MIN || file.kdf.t_cost > KDF_T_COST_MAX {
return Err(SafeError::InvalidVault {
reason: format!(
"KDF t_cost {} is outside allowed range [{KDF_T_COST_MIN}, {KDF_T_COST_MAX}]",
file.kdf.t_cost
),
});
}
if file.kdf.p_cost < KDF_P_COST_MIN || file.kdf.p_cost > KDF_P_COST_MAX {
return Err(SafeError::InvalidVault {
reason: format!(
"KDF p_cost {} is outside allowed range [{KDF_P_COST_MIN}, {KDF_P_COST_MAX}]",
file.kdf.p_cost
),
});
}
let salt = crypto::decode_b64(&file.kdf.salt)?;
let key = crypto::derive_key(
password,
&salt,
file.kdf.m_cost,
file.kdf.t_cost,
file.kdf.p_cost,
)?;
let ch_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
let ch_ct = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
let key_schedule = crypto::detect_key_schedule(
&key,
KeyPurpose::VaultChallenge,
cipher,
&ch_nonce,
&ch_ct,
VAULT_CHALLENGE_PLAINTEXT,
)?;
Ok(Self {
path: path.to_path_buf(),
file,
key,
cipher,
key_schedule,
access_profile,
_lock: Some(lock),
})
}
pub fn open_with_key(path: &Path, key: crypto::VaultKey) -> SafeResult<Self> {
Self::open_with_key_with_access_profile(path, key, RbacProfile::ReadWrite)
}
pub fn open_with_key_read_only(path: &Path, key: crypto::VaultKey) -> SafeResult<Self> {
Self::open_with_key_with_access_profile(path, key, RbacProfile::ReadOnly)
}
pub fn open_with_key_with_access_profile(
path: &Path,
key: crypto::VaultKey,
access_profile: RbacProfile,
) -> SafeResult<Self> {
let lock = acquire_lock(path)?;
if !path.exists() {
return Err(SafeError::VaultNotFound {
path: path.display().to_string(),
});
}
let json = std::fs::read_to_string(path)?;
let file: VaultFile =
serde_json::from_str(&json).map_err(|e| SafeError::VaultCorrupted {
reason: format!("vault JSON parse error: {e}"),
})?;
let allowed_schema = matches!(file.schema.as_str(), "tsafe/vault/v1" | "tsafe/vault/v2");
if !allowed_schema {
return Err(SafeError::InvalidVault {
reason: format!("unknown schema: {}", file.schema),
});
}
let cipher = crypto::parse_cipher_kind(&file.cipher)?;
let ch_nonce = crypto::decode_b64(&file.vault_challenge.nonce)?;
let ch_ct = crypto::decode_b64(&file.vault_challenge.ciphertext)?;
let key_schedule = crypto::detect_key_schedule(
&key,
KeyPurpose::VaultChallenge,
cipher,
&ch_nonce,
&ch_ct,
VAULT_CHALLENGE_PLAINTEXT,
)?;
Ok(Self {
path: path.to_path_buf(),
file,
key,
cipher,
key_schedule,
access_profile,
_lock: Some(lock),
})
}
pub fn is_team_vault(path: &Path) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|json| serde_json::from_str::<VaultFile>(&json).ok())
.map(|f| !f.age_recipients.is_empty() && f.wrapped_dek.is_some())
.unwrap_or(false)
}
#[instrument(skip(self), fields(secrets = self.file.secrets.len()))]
pub fn save(&self) -> SafeResult<()> {
self.ensure_write_allowed()?;
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
if self.path.exists() {
if let Some(profile) = self.profile_name() {
let _ = snapshot::take(&self.path, &profile, snapshot::DEFAULT_SNAPSHOT_KEEP);
}
}
let json = serde_json::to_string_pretty(&self.file)?;
let tmp = self.path.with_extension("vault.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
#[instrument(skip(self, value, tags, key))]
pub fn set(&mut self, key: &str, value: &str, tags: HashMap<String, String>) -> SafeResult<()> {
self.ensure_write_allowed()?;
validate_secret_key(key)?;
let (nonce, ct) = crypto::encrypt_with_key_schedule(
&self.key,
self.key_schedule,
KeyPurpose::SecretData,
self.cipher,
value.as_bytes(),
)?;
let now = Utc::now();
let (created_at, history, tags) = match self.file.secrets.get(key) {
Some(existing) => {
let mut h = existing.history.clone();
h.push(HistoryEntry {
nonce: existing.nonce.clone(),
ciphertext: existing.ciphertext.clone(),
updated_at: existing.updated_at,
});
if h.len() > DEFAULT_HISTORY_KEEP {
h.drain(..h.len() - DEFAULT_HISTORY_KEEP);
}
let merged_tags = if tags.is_empty() {
existing.tags.clone()
} else {
tags
};
(existing.created_at, h, merged_tags)
}
None => (now, Vec::new(), tags),
};
self.file.secrets.insert(
key.to_string(),
SecretEntry {
nonce: crypto::encode_b64(&nonce),
ciphertext: crypto::encode_b64(&ct),
created_at,
updated_at: now,
tags,
history,
},
);
self.file.updated_at = now;
self.save()
}
#[instrument(skip(self, key))]
pub fn get(&self, key: &str) -> SafeResult<Zeroizing<String>> {
let entry = self
.file
.secrets
.get(key)
.ok_or_else(|| SafeError::SecretNotFound {
key: key.to_string(),
})?;
let nonce = crypto::decode_b64(&entry.nonce)?;
let ct = crypto::decode_b64(&entry.ciphertext)?;
let pt = crypto::decrypt_with_key_schedule(
&self.key,
self.key_schedule,
KeyPurpose::SecretData,
self.cipher,
&nonce,
&ct,
)?;
match String::from_utf8(pt) {
Ok(s) => Ok(Zeroizing::new(s)),
Err(e) => {
let mut bytes = e.into_bytes();
bytes.zeroize();
Err(SafeError::InvalidVault {
reason: "secret is not valid UTF-8".into(),
})
}
}
}
pub fn delete(&mut self, key: &str) -> SafeResult<()> {
self.ensure_write_allowed()?;
if !self.file.secrets.contains_key(key) {
return Err(SafeError::SecretNotFound {
key: key.to_string(),
});
}
self.file.secrets.remove(key);
self.file.updated_at = Utc::now();
self.save()
}
pub fn rename_key(&mut self, old_key: &str, new_key: &str, overwrite: bool) -> SafeResult<()> {
self.ensure_write_allowed()?;
validate_secret_key(new_key)?;
if !self.file.secrets.contains_key(old_key) {
return Err(SafeError::SecretNotFound {
key: old_key.to_string(),
});
}
if !overwrite && self.file.secrets.contains_key(new_key) {
return Err(SafeError::SecretAlreadyExists {
key: new_key.to_string(),
});
}
let entry = self.file.secrets.remove(old_key).unwrap();
self.file.secrets.insert(new_key.to_string(), entry);
self.file.updated_at = Utc::now();
self.save()
}
pub fn list(&self) -> Vec<&str> {
let mut keys: Vec<&str> = self.file.secrets.keys().map(String::as_str).collect();
keys.sort_unstable();
keys
}
pub fn export_all(&self) -> SafeResult<HashMap<String, String>> {
self.list()
.iter()
.map(|k| {
let val = self.get(k)?;
Ok((k.to_string(), (*val).clone()))
})
.collect()
}
pub fn get_version(&self, key: &str, version: usize) -> SafeResult<Zeroizing<String>> {
if version == 0 {
return self.get(key);
}
let entry = self
.file
.secrets
.get(key)
.ok_or_else(|| SafeError::SecretNotFound {
key: key.to_string(),
})?;
let hist_idx =
entry
.history
.len()
.checked_sub(version)
.ok_or_else(|| SafeError::InvalidVault {
reason: format!(
"version {version} does not exist for '{key}' (max {})",
entry.history.len()
),
})?;
let h = &entry.history[hist_idx];
let nonce = crypto::decode_b64(&h.nonce)?;
let ct = crypto::decode_b64(&h.ciphertext)?;
let pt = crypto::decrypt_with_key_schedule(
&self.key,
self.key_schedule,
KeyPurpose::SecretData,
self.cipher,
&nonce,
&ct,
)?;
match String::from_utf8(pt) {
Ok(s) => Ok(Zeroizing::new(s)),
Err(e) => {
let mut bytes = e.into_bytes();
bytes.zeroize();
Err(SafeError::InvalidVault {
reason: "secret is not valid UTF-8".into(),
})
}
}
}
pub fn history(&self, key: &str) -> SafeResult<Vec<(usize, DateTime<Utc>)>> {
let entry = self
.file
.secrets
.get(key)
.ok_or_else(|| SafeError::SecretNotFound {
key: key.to_string(),
})?;
let mut versions = vec![(0usize, entry.updated_at)];
for (i, h) in entry.history.iter().rev().enumerate() {
versions.push((i + 1, h.updated_at));
}
Ok(versions)
}
pub fn revert_to_version(&mut self, key: &str, version: usize) -> SafeResult<()> {
self.ensure_write_allowed()?;
if version == 0 {
return Ok(());
}
let target_value = self.get_version(key, version)?;
let now = Utc::now();
let entry = self
.file
.secrets
.get_mut(key)
.ok_or_else(|| SafeError::SecretNotFound {
key: key.to_string(),
})?;
let current_nonce = entry.nonce.clone();
let current_ciphertext = entry.ciphertext.clone();
let current_updated_at = entry.updated_at;
entry.history.push(HistoryEntry {
nonce: current_nonce,
ciphertext: current_ciphertext,
updated_at: current_updated_at,
});
if entry.history.len() > DEFAULT_HISTORY_KEEP {
entry
.history
.drain(..entry.history.len() - DEFAULT_HISTORY_KEEP);
}
let _ = entry;
let (nonce, ct) = crypto::encrypt_with_key_schedule(
&self.key,
self.key_schedule,
KeyPurpose::SecretData,
self.cipher,
target_value.as_bytes(),
)?;
let entry = self.file.secrets.get_mut(key).unwrap();
entry.nonce = crypto::encode_b64(&nonce);
entry.ciphertext = crypto::encode_b64(&ct);
entry.updated_at = now;
self.file.updated_at = now;
self.save()
}
pub fn prune_history(&mut self, key: &str, keep_n: usize) -> SafeResult<()> {
self.ensure_write_allowed()?;
let entry = self
.file
.secrets
.get_mut(key)
.ok_or_else(|| SafeError::SecretNotFound {
key: key.to_string(),
})?;
if entry.history.len() > keep_n {
entry.history.drain(..entry.history.len() - keep_n);
}
self.file.updated_at = Utc::now();
self.save()
}
#[instrument(skip(self, new_password), fields(secret_count = self.file.secrets.len()))]
pub fn rotate(&mut self, new_password: &[u8]) -> SafeResult<()> {
self.ensure_write_allowed()?;
let all = self.export_all()?;
let meta: HashMap<String, _> = self
.file
.secrets
.iter()
.map(|(k, e)| (k.clone(), (e.tags.clone(), e.created_at, e.history.clone())))
.collect();
let mut history_plaintext: HashMap<String, Vec<(String, DateTime<Utc>)>> = HashMap::new();
for (key, entry) in &self.file.secrets {
let mut pts = Vec::new();
for h in &entry.history {
let nonce = crypto::decode_b64(&h.nonce)?;
let ct = crypto::decode_b64(&h.ciphertext)?;
let pt = crypto::decrypt_with_key_schedule(
&self.key,
self.key_schedule,
KeyPurpose::SecretData,
self.cipher,
&nonce,
&ct,
)?;
let s = String::from_utf8(pt).map_err(|_| SafeError::InvalidVault {
reason: "history entry is not valid UTF-8".into(),
})?;
pts.push((s, h.updated_at));
}
history_plaintext.insert(key.clone(), pts);
}
let new_salt = crypto::random_salt();
let new_key = crypto::derive_key(
new_password,
&new_salt,
VAULT_KDF_M_COST,
VAULT_KDF_T_COST,
VAULT_KDF_P_COST,
)?;
let new_cipher = crypto::default_vault_cipher();
let new_key_schedule = KeySchedule::HkdfSha256V1;
let now = Utc::now();
let mut new_secrets = HashMap::with_capacity(all.len());
for (key, value) in &all {
let (nonce, ct) = crypto::encrypt_with_key_schedule(
&new_key,
new_key_schedule,
KeyPurpose::SecretData,
new_cipher,
value.as_bytes(),
)?;
let (ref tags, created_at, _) = meta[key];
let mut new_history = Vec::new();
if let Some(pts) = history_plaintext.get(key) {
for (pt, updated_at) in pts {
let (hn, hct) = crypto::encrypt_with_key_schedule(
&new_key,
new_key_schedule,
KeyPurpose::SecretData,
new_cipher,
pt.as_bytes(),
)?;
new_history.push(HistoryEntry {
nonce: crypto::encode_b64(&hn),
ciphertext: crypto::encode_b64(&hct),
updated_at: *updated_at,
});
}
}
new_secrets.insert(
key.clone(),
SecretEntry {
nonce: crypto::encode_b64(&nonce),
ciphertext: crypto::encode_b64(&ct),
created_at,
updated_at: now,
tags: tags.clone(),
history: new_history,
},
);
}
let (ch_nonce, ch_ct) = crypto::encrypt_with_key_schedule(
&new_key,
new_key_schedule,
KeyPurpose::VaultChallenge,
new_cipher,
VAULT_CHALLENGE_PLAINTEXT,
)?;
self.file.kdf = KdfParams {
algorithm: "argon2id".to_string(),
m_cost: VAULT_KDF_M_COST,
t_cost: VAULT_KDF_T_COST,
p_cost: VAULT_KDF_P_COST,
salt: crypto::encode_b64(&new_salt),
};
self.file.vault_challenge = VaultChallenge {
nonce: crypto::encode_b64(&ch_nonce),
ciphertext: crypto::encode_b64(&ch_ct),
};
self.file.cipher = new_cipher.as_str().to_string();
self.file.secrets = new_secrets;
self.file.updated_at = now;
self.key = new_key;
self.cipher = new_cipher;
self.key_schedule = new_key_schedule;
self.save()
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn secret_count(&self) -> usize {
self.file.secrets.len()
}
pub fn access_profile(&self) -> RbacProfile {
self.access_profile
}
pub fn with_access_profile(mut self, access_profile: RbacProfile) -> Self {
self.access_profile = access_profile;
self
}
pub fn file(&self) -> &VaultFile {
&self.file
}
pub fn ensure_write_allowed(&self) -> SafeResult<()> {
self.access_profile.ensure_write_allowed()
}
fn profile_name(&self) -> Option<String> {
Self::profile_name_from_path(&self.path)
}
fn profile_name_from_path(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
}
}
pub fn parse_rotation_days(policy: &str) -> Option<i64> {
let s = policy.trim();
s.strip_suffix('d')
.and_then(|prefix| prefix.parse::<i64>().ok())
.filter(|&d| d > 0)
}
pub fn rotation_due(file: &VaultFile) -> Vec<(String, i64, String)> {
let now = Utc::now();
let mut due = Vec::new();
for (key, entry) in &file.secrets {
if let Some(policy) = entry.tags.get("rotate_policy") {
if let Some(days) = parse_rotation_days(policy) {
let age = (now - entry.updated_at).num_days();
if age >= days {
due.push((key.clone(), age - days, policy.clone()));
}
}
}
}
due.sort_by(|a, b| a.0.cmp(&b.0));
due
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use tempfile::tempdir;
fn pw() -> &'static [u8] {
b"test-master-password"
}
#[test]
fn create_and_reopen() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "val", HashMap::new()).unwrap();
drop(v);
let v2 = Vault::open(&path, pw()).unwrap();
assert_eq!(&*v2.get("K").unwrap(), "val");
}
#[test]
fn read_only_open_blocks_save_and_mutation_paths() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut writable = Vault::create(&path, pw()).unwrap();
writable.set("K", "value", HashMap::new()).unwrap();
drop(writable);
let mut vault = Vault::open_read_only(&path, pw()).unwrap();
assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
assert_eq!(&*vault.get("K").unwrap(), "value");
for result in [
vault.save(),
vault.set("NEW", "value", HashMap::new()),
vault.delete("K"),
vault.rename_key("K", "RENAMED", false),
vault.rotate(b"new-password"),
] {
match result {
Err(SafeError::InvalidVault { reason }) => {
assert!(reason.contains("read_only"));
}
other => panic!("expected read-only write denial, got {other:?}"),
}
}
}
#[test]
fn read_only_open_does_not_restore_missing_snapshot() {
let dir = tempdir().unwrap();
let profile_dir = dir.path().join("profiles").join("default");
std::fs::create_dir_all(&profile_dir).unwrap();
let path = profile_dir.join("vault.vault");
let snapshots = dir.path().join("snapshots").join("default");
std::fs::create_dir_all(&snapshots).unwrap();
std::fs::write(
snapshots.join("default-20260407-0000000000000.0000.snap"),
"{}",
)
.unwrap();
match Vault::open_read_only(&path, pw()) {
Err(SafeError::VaultNotFound { .. }) => {}
Ok(_) => panic!("expected read-only open to refuse snapshot restore"),
Err(other) => panic!("expected VaultNotFound, got {other:?}"),
}
assert!(!path.exists(), "read-only open must not restore snapshots");
}
fn root_key_from_file(file: &VaultFile, password: &[u8]) -> VaultKey {
let salt = crypto::decode_b64(&file.kdf.salt).unwrap();
crypto::derive_key(
password,
&salt,
file.kdf.m_cost,
file.kdf.t_cost,
file.kdf.p_cost,
)
.unwrap()
}
fn legacy_vault_file(password: &[u8], value: &str) -> VaultFile {
let salt = crypto::random_salt();
let key = crypto::derive_key(
password,
&salt,
VAULT_KDF_M_COST,
VAULT_KDF_T_COST,
VAULT_KDF_P_COST,
)
.unwrap();
let now = Utc::now();
let (ch_nonce, ch_ct) = crypto::encrypt(&key, VAULT_CHALLENGE_PLAINTEXT).unwrap();
let (nonce, ciphertext) = crypto::encrypt(&key, value.as_bytes()).unwrap();
let mut secrets = HashMap::new();
secrets.insert(
"LEGACY".into(),
SecretEntry {
nonce: crypto::encode_b64(&nonce),
ciphertext: crypto::encode_b64(&ciphertext),
created_at: now,
updated_at: now,
tags: HashMap::new(),
history: Vec::new(),
},
);
VaultFile {
schema: VAULT_SCHEMA.to_string(),
kdf: KdfParams {
algorithm: VAULT_KDF_ALGORITHM.to_string(),
m_cost: VAULT_KDF_M_COST,
t_cost: VAULT_KDF_T_COST,
p_cost: VAULT_KDF_P_COST,
salt: crypto::encode_b64(&salt),
},
cipher: CipherKind::XChaCha20Poly1305.as_str().to_string(),
vault_challenge: VaultChallenge {
nonce: crypto::encode_b64(&ch_nonce),
ciphertext: crypto::encode_b64(&ch_ct),
},
created_at: now,
updated_at: now,
secrets,
age_recipients: Vec::new(),
wrapped_dek: None,
}
}
#[test]
fn second_open_fails_while_lock_is_held() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let _v = Vault::create(&path, pw()).unwrap();
match Vault::open(&path, pw()) {
Err(SafeError::InvalidVault { reason }) => {
assert!(reason.contains("vault is locked by another process"));
}
Ok(_) => panic!("expected lock error, got open vault"),
Err(other) => panic!("expected lock error, got {other:?}"),
}
}
#[test]
fn wrong_password_fails() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v", HashMap::new()).unwrap();
drop(v);
assert!(matches!(
Vault::open(&path, b"wrong"),
Err(SafeError::DecryptionFailed)
));
}
#[test]
fn empty_vault_wrong_password_fails() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
Vault::create(&path, pw()).unwrap();
assert!(Vault::open(&path, b"wrong").is_err());
}
#[test]
fn validate_secret_key_rejects_non_ascii() {
assert!(validate_secret_key("café_KEY").is_err());
assert!(validate_secret_key("emoji_🔑").is_err());
assert!(validate_secret_key("K_日本").is_err());
}
#[test]
fn set_get_roundtrip_unicode_secret_value() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
let val = "snowman☃café日本語";
v.set("UNICODE_VAL", val, HashMap::new()).unwrap();
assert_eq!(&*v.get("UNICODE_VAL").unwrap(), val);
}
#[test]
fn empty_master_password_vault_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, b"").unwrap();
v.set("K", "v", HashMap::new()).unwrap();
drop(v);
let v2 = Vault::open(&path, b"").unwrap();
assert_eq!(&*v2.get("K").unwrap(), "v");
}
#[test]
fn create_twice_fails() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
Vault::create(&path, pw()).unwrap();
assert!(matches!(
Vault::create(&path, pw()),
Err(SafeError::VaultAlreadyExists { .. })
));
}
#[test]
fn set_get_delete_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("DB_PASS", "s3cr3t", HashMap::new()).unwrap();
assert_eq!(&*v.get("DB_PASS").unwrap(), "s3cr3t");
v.delete("DB_PASS").unwrap();
assert!(matches!(
v.get("DB_PASS"),
Err(SafeError::SecretNotFound { .. })
));
}
#[test]
fn list_is_sorted() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("ZZZ", "z", HashMap::new()).unwrap();
v.set("AAA", "a", HashMap::new()).unwrap();
v.set("MMM", "m", HashMap::new()).unwrap();
assert_eq!(v.list(), vec!["AAA", "MMM", "ZZZ"]);
}
#[test]
fn export_all_decrypts_all() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("A", "alpha", HashMap::new()).unwrap();
v.set("B", "beta", HashMap::new()).unwrap();
let all = v.export_all().unwrap();
assert_eq!(all["A"], "alpha");
assert_eq!(all["B"], "beta");
}
#[test]
fn rotate_re_encrypts_under_new_password() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("SECRET", "value", HashMap::new()).unwrap();
v.rotate(b"new-password").unwrap();
drop(v);
assert!(Vault::open(&path, pw()).is_err());
let v2 = Vault::open(&path, b"new-password").unwrap();
assert_eq!(&*v2.get("SECRET").unwrap(), "value");
}
#[test]
fn new_vault_uses_hkdf_scoped_keys_for_challenge_and_secret_data() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut vault = Vault::create(&path, pw()).unwrap();
vault.set("SECRET", "value", HashMap::new()).unwrap();
let root_key = root_key_from_file(vault.file(), pw());
let challenge_nonce = crypto::decode_b64(&vault.file.vault_challenge.nonce).unwrap();
let challenge_ct = crypto::decode_b64(&vault.file.vault_challenge.ciphertext).unwrap();
assert!(matches!(
crypto::decrypt_for_cipher(vault.cipher, &root_key, &challenge_nonce, &challenge_ct),
Err(SafeError::DecryptionFailed)
));
assert_eq!(
crypto::decrypt_with_key_schedule(
&root_key,
KeySchedule::HkdfSha256V1,
KeyPurpose::VaultChallenge,
vault.cipher,
&challenge_nonce,
&challenge_ct
)
.unwrap(),
VAULT_CHALLENGE_PLAINTEXT
);
let entry = &vault.file().secrets["SECRET"];
let secret_nonce = crypto::decode_b64(&entry.nonce).unwrap();
let secret_ct = crypto::decode_b64(&entry.ciphertext).unwrap();
assert!(matches!(
crypto::decrypt_for_cipher(vault.cipher, &root_key, &secret_nonce, &secret_ct),
Err(SafeError::DecryptionFailed)
));
assert_eq!(
crypto::decrypt_with_key_schedule(
&root_key,
KeySchedule::HkdfSha256V1,
KeyPurpose::SecretData,
vault.cipher,
&secret_nonce,
&secret_ct
)
.unwrap(),
b"value"
);
}
#[test]
fn open_legacy_vault_detects_legacy_schedule_and_keeps_writes_consistent() {
let dir = tempdir().unwrap();
let path = dir.path().join("legacy.vault");
let file = legacy_vault_file(pw(), "legacy-value");
std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let mut vault = Vault::open(&path, pw()).unwrap();
assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
assert_eq!(&*vault.get("LEGACY").unwrap(), "legacy-value");
vault
.set("NEW_SECRET", "new-value", HashMap::new())
.unwrap();
let root_key = root_key_from_file(vault.file(), pw());
let entry = &vault.file().secrets["NEW_SECRET"];
let nonce = crypto::decode_b64(&entry.nonce).unwrap();
let ciphertext = crypto::decode_b64(&entry.ciphertext).unwrap();
assert_eq!(
crypto::decrypt_for_cipher(vault.cipher, &root_key, &nonce, &ciphertext).unwrap(),
b"new-value"
);
}
#[test]
fn rotating_legacy_vault_migrates_it_to_hkdf_schedule() {
let dir = tempdir().unwrap();
let path = dir.path().join("legacy.vault");
let file = legacy_vault_file(pw(), "legacy-value");
std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let mut vault = Vault::open(&path, pw()).unwrap();
assert_eq!(vault.key_schedule, KeySchedule::LegacyDirect);
assert_eq!(vault.cipher, CipherKind::XChaCha20Poly1305);
vault.rotate(b"new-password").unwrap();
assert_eq!(vault.key_schedule, KeySchedule::HkdfSha256V1);
assert_eq!(vault.cipher, crypto::default_vault_cipher());
drop(vault);
let reopened = Vault::open(&path, b"new-password").unwrap();
assert_eq!(reopened.key_schedule, KeySchedule::HkdfSha256V1);
assert_eq!(reopened.cipher, crypto::default_vault_cipher());
assert_eq!(&*reopened.get("LEGACY").unwrap(), "legacy-value");
let root_key = root_key_from_file(reopened.file(), b"new-password");
let challenge_nonce = crypto::decode_b64(&reopened.file.vault_challenge.nonce).unwrap();
let challenge_ct = crypto::decode_b64(&reopened.file.vault_challenge.ciphertext).unwrap();
assert!(matches!(
crypto::decrypt_for_cipher(reopened.cipher, &root_key, &challenge_nonce, &challenge_ct),
Err(SafeError::DecryptionFailed)
));
assert_eq!(
crypto::decrypt_with_key_schedule(
&root_key,
KeySchedule::HkdfSha256V1,
KeyPurpose::VaultChallenge,
reopened.cipher,
&challenge_nonce,
&challenge_ct
)
.unwrap(),
VAULT_CHALLENGE_PLAINTEXT
);
}
#[cfg(feature = "fips")]
#[test]
fn fips_build_creates_aes256gcm_vaults() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut vault = Vault::create(&path, pw()).unwrap();
vault.set("SECRET", "value", HashMap::new()).unwrap();
assert_eq!(vault.cipher, CipherKind::Aes256Gcm);
assert_eq!(vault.file.cipher, CipherKind::Aes256Gcm.as_str());
}
#[test]
fn set_preserves_created_at_on_update() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
let created = v.file.secrets["K"].created_at;
v.set("K", "v2", HashMap::new()).unwrap();
assert_eq!(v.file.secrets["K"].created_at, created);
assert_ne!(v.file.secrets["K"].updated_at, created); }
#[test]
fn delete_missing_key_returns_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
assert!(matches!(
v.delete("NOPE"),
Err(SafeError::SecretNotFound { .. })
));
}
#[test]
fn key_validation_allows_dot_and_hyphen_namespaces() {
for key in &[
"github.com.token",
"db-prod.PASSWORD",
"_under.score-mix",
"A.b-c.D",
] {
assert!(validate_secret_key(key).is_ok(), "expected ok for '{key}'");
}
}
#[test]
fn key_validation_rejects_invalid_forms() {
let bad = [
"", "123abc", "-starts-bad", ".starts-bad", "ends.", "ends-", "double..dot", "double--dash", "dot.-dash", "has space", ];
for key in &bad {
assert!(
validate_secret_key(key).is_err(),
"expected error for '{key}'"
);
}
}
#[test]
fn set_builds_history() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
v.set("K", "v2", HashMap::new()).unwrap();
v.set("K", "v3", HashMap::new()).unwrap();
assert_eq!(v.file.secrets["K"].history.len(), 2);
assert_eq!(&*v.get("K").unwrap(), "v3");
}
#[test]
fn get_version_returns_previous_values() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
v.set("K", "v2", HashMap::new()).unwrap();
v.set("K", "v3", HashMap::new()).unwrap();
assert_eq!(&*v.get_version("K", 0).unwrap(), "v3");
assert_eq!(&*v.get_version("K", 1).unwrap(), "v2");
assert_eq!(&*v.get_version("K", 2).unwrap(), "v1");
}
#[test]
fn get_version_out_of_range_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
assert!(v.get_version("K", 1).is_err());
}
#[test]
fn history_capped_at_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
for i in 0..10 {
v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
}
assert_eq!(v.file.secrets["K"].history.len(), DEFAULT_HISTORY_KEEP);
assert_eq!(&*v.get_version("K", 1).unwrap(), "v8");
}
#[test]
fn history_metadata_lists_versions() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
v.set("K", "v2", HashMap::new()).unwrap();
let versions = v.history("K").unwrap();
assert_eq!(versions.len(), 2); assert_eq!(versions[0].0, 0); assert_eq!(versions[1].0, 1); }
#[test]
fn rotate_preserves_history() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
v.set("K", "v2", HashMap::new()).unwrap();
v.rotate(b"new-pw").unwrap();
drop(v);
let v2 = Vault::open(&path, b"new-pw").unwrap();
assert_eq!(&*v2.get("K").unwrap(), "v2");
assert_eq!(&*v2.get_version("K", 1).unwrap(), "v1");
}
#[test]
fn parse_rotation_days_valid() {
assert_eq!(parse_rotation_days("90d"), Some(90));
assert_eq!(parse_rotation_days("30d"), Some(30));
assert_eq!(parse_rotation_days("1d"), Some(1));
assert_eq!(parse_rotation_days(" 7d "), Some(7));
}
#[test]
fn parse_rotation_days_invalid() {
assert_eq!(parse_rotation_days("invalid"), None);
assert_eq!(parse_rotation_days("0d"), None);
assert_eq!(parse_rotation_days("-1d"), None);
assert_eq!(parse_rotation_days(""), None);
assert_eq!(parse_rotation_days("d"), None);
}
#[test]
fn rotation_due_finds_overdue_secrets() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
let mut tags = HashMap::new();
tags.insert("rotate_policy".into(), "1d".into());
v.set("OLD_KEY", "val", tags).unwrap();
v.file.secrets.get_mut("OLD_KEY").unwrap().updated_at =
Utc::now() - chrono::Duration::days(3);
let due = rotation_due(v.file());
assert_eq!(due.len(), 1);
assert_eq!(due[0].0, "OLD_KEY");
assert!(due[0].1 >= 2); }
#[test]
fn rotation_due_ignores_fresh_secrets() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
let mut tags = HashMap::new();
tags.insert("rotate_policy".into(), "90d".into());
v.set("FRESH", "val", tags).unwrap();
let due = rotation_due(v.file());
assert!(due.is_empty());
}
#[test]
fn set_preserves_existing_tags_when_update_has_no_tags() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
let mut tags = HashMap::new();
tags.insert("env".into(), "prod".into());
tags.insert("rotate_policy".into(), "30d".into());
v.set("KEY", "v1", tags.clone()).unwrap();
v.set("KEY", "v2", HashMap::new()).unwrap();
assert_eq!(v.file.secrets["KEY"].tags, tags);
}
#[test]
fn lock_guard_drop_keeps_replaced_lockfile() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let lock_path = lock_path_for(&path);
let guard = acquire_lock(&path).unwrap();
std::fs::write(&lock_path, "different-owner").unwrap();
drop(guard);
assert_eq!(
std::fs::read_to_string(&lock_path).unwrap(),
"different-owner"
);
}
#[test]
fn dead_owner_lockfile_is_recovered_for_new_format() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let lock_path = lock_path_for(&path);
let stale = LockFileContents {
version: 1,
id: "stale-owner".into(),
pid: u32::MAX,
created_at: Utc::now(),
};
std::fs::write(&lock_path, serde_json::to_string(&stale).unwrap()).unwrap();
let guard = acquire_lock(&path).unwrap();
let contents = std::fs::read_to_string(&lock_path).unwrap();
let recovered: LockFileContents = serde_json::from_str(&contents).unwrap();
assert_eq!(recovered.version, 1);
assert_eq!(recovered.pid, std::process::id());
assert_ne!(recovered.id, stale.id);
drop(guard);
assert!(!lock_path.exists());
}
#[test]
fn opaque_legacy_lockfile_is_not_removed_implicitly() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let lock_path = lock_path_for(&path);
std::fs::write(&lock_path, "legacy-uuid-without-metadata").unwrap();
match acquire_lock(&path) {
Err(SafeError::InvalidVault { reason }) => {
assert!(reason.contains("vault is locked by another process"));
}
Ok(_) => panic!("expected lock error, got recovered lock"),
Err(other) => panic!("expected lock error, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(&lock_path).unwrap(),
"legacy-uuid-without-metadata"
);
}
#[test]
fn process_is_running_sees_current_process() {
assert!(process_is_running(std::process::id()));
}
#[test]
fn process_is_running_rejects_impossible_pid() {
assert!(!process_is_running(u32::MAX));
}
#[test]
fn missing_vault_with_existing_lock_does_not_restore_snapshot() {
let dir = tempdir().unwrap();
let profile_dir = dir.path().join("profiles").join("default");
std::fs::create_dir_all(&profile_dir).unwrap();
let path = profile_dir.join("vault.vault");
let snapshots = dir.path().join("snapshots").join("default");
std::fs::create_dir_all(&snapshots).unwrap();
std::fs::write(
snapshots.join("default-20260407-0000000000000.0000.snap"),
"{}",
)
.unwrap();
let _guard = acquire_lock(&path).unwrap();
match Vault::open(&path, pw()) {
Err(SafeError::InvalidVault { reason }) => {
assert!(reason.contains("vault is locked by another process"));
}
Ok(_) => panic!("expected lock error, got open vault"),
Err(other) => panic!("expected lock error, got {other:?}"),
}
assert!(
!path.exists(),
"open should not restore under another process's lock"
);
}
#[test]
fn secret_count_reflects_set_and_delete() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
assert_eq!(v.secret_count(), 0);
v.set("A", "1", HashMap::new()).unwrap();
assert_eq!(v.secret_count(), 1);
v.set("B", "2", HashMap::new()).unwrap();
assert_eq!(v.secret_count(), 2);
v.delete("A").unwrap();
assert_eq!(v.secret_count(), 1);
}
#[test]
fn vault_is_team_vault_returns_false_for_regular_vault() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let _v = Vault::create(&path, pw()).unwrap();
drop(_v);
assert!(!Vault::is_team_vault(&path));
}
#[test]
fn vault_is_team_vault_returns_false_for_nonexistent_path() {
let dir = tempdir().unwrap();
let path = dir.path().join("does_not_exist.vault");
assert!(!Vault::is_team_vault(&path));
}
#[test]
fn vault_is_team_vault_returns_true_for_team_vault_on_disk() {
use crate::{age_crypto, team};
let dir = tempdir().unwrap();
let path = dir.path().join("team.vault");
let (_secret, recipient) = age_crypto::generate_identity();
let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();
let json = serde_json::to_string_pretty(&file).unwrap();
std::fs::write(&path, json).unwrap();
assert!(Vault::is_team_vault(&path));
}
#[test]
fn open_with_key_using_team_dek_succeeds() {
use crate::{age_crypto, team};
let dir = tempdir().unwrap();
let path = dir.path().join("team.vault");
let (secret, recipient) = age_crypto::generate_identity();
let identities = age::IdentityFile::from_buffer(secret.as_bytes())
.unwrap()
.into_identities()
.unwrap();
let (file, _) = team::create_team_vault(&[recipient]).unwrap();
let json = serde_json::to_string_pretty(&file).unwrap();
std::fs::write(&path, &json).unwrap();
let dek = team::unwrap_dek(&file, &identities).unwrap();
let vault = Vault::open_with_key(&path, dek).unwrap();
assert_eq!(vault.secret_count(), 0);
}
#[test]
fn open_with_key_read_only_blocks_mutation() {
use crate::{age_crypto, team};
let dir = tempdir().unwrap();
let path = dir.path().join("team.vault");
let (secret, recipient) = age_crypto::generate_identity();
let identities = age::IdentityFile::from_buffer(secret.as_bytes())
.unwrap()
.into_identities()
.unwrap();
let (file, _dek) = team::create_team_vault(&[recipient]).unwrap();
std::fs::write(&path, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let dek = team::unwrap_dek(&file, &identities).unwrap();
let mut writable = Vault::open_with_key(&path, dek).unwrap();
writable
.set("TEAM_SECRET", "value", HashMap::new())
.unwrap();
drop(writable);
let dek = team::unwrap_dek(&file, &identities).unwrap();
let mut vault = Vault::open_with_key_read_only(&path, dek).unwrap();
assert_eq!(vault.access_profile(), RbacProfile::ReadOnly);
assert_eq!(&*vault.get("TEAM_SECRET").unwrap(), "value");
assert!(matches!(
vault.set("NEW_SECRET", "blocked", HashMap::new()),
Err(SafeError::InvalidVault { .. })
));
}
#[test]
fn open_with_key_with_wrong_key_returns_decryption_failed() {
use crate::crypto::VaultKey;
use crate::{age_crypto, team};
let dir = tempdir().unwrap();
let path = dir.path().join("team.vault");
let (_secret, recipient) = age_crypto::generate_identity();
let (file, _) = team::create_team_vault(&[recipient]).unwrap();
let json = serde_json::to_string_pretty(&file).unwrap();
std::fs::write(&path, &json).unwrap();
let wrong_key = VaultKey::from_bytes(crypto::random_salt());
let result = Vault::open_with_key(&path, wrong_key);
assert!(matches!(result, Err(SafeError::DecryptionFailed)));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(32))]
#[test]
fn prop_set_get_roundtrip(
key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
value in any::<String>(),
) {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set(&key, &value, HashMap::new()).unwrap();
prop_assert_eq!(&*v.get(&key).unwrap(), value.as_str());
}
#[test]
fn prop_set_delete_not_found(
key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
value in any::<String>(),
) {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set(&key, &value, HashMap::new()).unwrap();
v.delete(&key).unwrap();
let is_not_found = v.get(&key).is_err();
prop_assert!(is_not_found);
}
#[test]
fn prop_multi_set_list_contains_all(
keys in proptest::collection::vec("[A-Za-z_][A-Za-z0-9_]{0,30}", 1..=8),
) {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
let mut deduped = keys.clone();
deduped.sort();
deduped.dedup();
for k in &deduped {
v.set(k, "x", HashMap::new()).unwrap();
}
let listed = v.list();
for k in &deduped {
prop_assert!(listed.contains(&k.as_str()), "key {k} missing from list()");
}
}
#[test]
fn prop_persist_roundtrip(
key in "[A-Za-z_][A-Za-z0-9_]{0,63}",
value in any::<String>(),
) {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
{
let mut v = Vault::create(&path, pw()).unwrap();
v.set(&key, &value, HashMap::new()).unwrap();
}
let v2 = Vault::open(&path, pw()).unwrap();
prop_assert_eq!(&*v2.get(&key).unwrap(), value.as_str());
}
}
#[test]
fn concurrent_opens_all_fail_while_lock_held() {
use std::sync::{Arc, Barrier};
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let _owner = Vault::create(&path, pw()).unwrap();
const N: usize = 8;
let barrier = Arc::new(Barrier::new(N + 1));
let path_arc = Arc::new(path);
let handles: Vec<_> = (0..N)
.map(|_| {
let p = Arc::clone(&path_arc);
let b = Arc::clone(&barrier);
std::thread::spawn(move || {
b.wait(); match Vault::open(&p, pw()) {
Err(SafeError::InvalidVault { reason }) => {
assert!(
reason.contains("vault is locked by another process"),
"unexpected lock reason: {reason}"
);
}
Ok(_) => panic!("concurrent open should not succeed while lock is held"),
Err(e) => panic!("unexpected error variant: {e:?}"),
}
})
})
.collect();
barrier.wait(); for h in handles {
h.join().expect("concurrent open thread panicked");
}
}
#[test]
fn lock_released_after_drop_then_reopen_succeeds() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
{
let mut owner = Vault::create(&path, pw()).unwrap();
owner.set("K", "v", HashMap::new()).unwrap();
assert!(
Vault::open(&path, pw()).is_err(),
"second open should fail while lock held"
);
} let v = Vault::open(&path, pw()).unwrap();
assert_eq!(&*v.get("K").unwrap(), "v");
}
#[test]
fn revert_to_version_restores_previous_value() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
v.set("K", "v2", HashMap::new()).unwrap();
v.set("K", "v3", HashMap::new()).unwrap();
v.revert_to_version("K", 1).unwrap();
assert_eq!(&*v.get("K").unwrap(), "v2");
}
#[test]
fn revert_to_version_zero_is_noop() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
v.set("K", "v2", HashMap::new()).unwrap();
v.revert_to_version("K", 0).unwrap();
assert_eq!(&*v.get("K").unwrap(), "v2");
}
#[test]
fn revert_to_version_out_of_range_errors() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
assert!(v.revert_to_version("K", 1).is_err());
}
#[test]
fn revert_to_version_survives_persist_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "original", HashMap::new()).unwrap();
v.set("K", "updated", HashMap::new()).unwrap();
v.revert_to_version("K", 1).unwrap();
drop(v);
let v2 = Vault::open(&path, pw()).unwrap();
assert_eq!(&*v2.get("K").unwrap(), "original");
}
#[test]
fn prune_history_limits_depth() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
for i in 0..6 {
v.set("K", &format!("v{i}"), HashMap::new()).unwrap();
}
v.prune_history("K", 2).unwrap();
let versions = v.history("K").unwrap();
assert_eq!(versions.len(), 3);
assert_eq!(&*v.get("K").unwrap(), "v5");
}
#[test]
fn prune_history_to_zero_clears_all_history() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
v.set("K", "v1", HashMap::new()).unwrap();
v.set("K", "v2", HashMap::new()).unwrap();
v.prune_history("K", 0).unwrap();
assert_eq!(v.file.secrets["K"].history.len(), 0);
assert_eq!(&*v.get("K").unwrap(), "v2");
}
#[test]
fn prune_history_missing_key_returns_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("v.vault");
let mut v = Vault::create(&path, pw()).unwrap();
assert!(matches!(
v.prune_history("NOPE", 3),
Err(SafeError::SecretNotFound { .. })
));
}
}