use anyhow::{anyhow, Result};
use rand::RngCore;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use zeroize::{Zeroize, ZeroizeOnDrop};
use crate::crypto::{self, VaultKey, SALT_SIZE};
use crate::meta::VaultMeta;
pub const SVAULT_DIR: &str = ".svault";
#[derive(Zeroize, ZeroizeOnDrop)]
struct SecretStore(String);
#[allow(dead_code)]
pub struct Vault {
pub vault_dir: PathBuf,
pub meta: VaultMeta,
key: VaultKey,
}
impl Vault {
pub fn init(vault_dir: &Path, passphrase: &str, meta_input: VaultMeta) -> Result<Self> {
if vault_dir.exists() {
return Err(anyhow!("Vault already exists at {}", vault_dir.display()));
}
std::fs::create_dir_all(vault_dir)?;
std::fs::write(vault_dir.join(".gitignore"), ".session\naudit.log\n")?;
let mut salt = [0u8; SALT_SIZE];
rand::thread_rng().fill_bytes(&mut salt);
let key = VaultKey::derive(passphrase, &salt)?;
let empty = serde_json::to_vec(&HashMap::<String, String>::new())?;
let encrypted = crypto::encrypt(&key, &salt, &empty)?;
std::fs::write(vault_dir.join("vault.enc"), &encrypted)?;
meta_input.save(vault_dir, key.bytes())?;
let meta = VaultMeta::load_verified(vault_dir, key.bytes())?;
Ok(Self {
vault_dir: vault_dir.to_path_buf(),
meta,
key,
})
}
pub fn open(vault_dir: &Path, passphrase: &str) -> Result<Self> {
let encrypted = std::fs::read(vault_dir.join("vault.enc"))?;
if encrypted.len() < SALT_SIZE {
return Err(anyhow!("vault.enc is too short — may be corrupted"));
}
let salt = &encrypted[..SALT_SIZE];
let key = VaultKey::derive(passphrase, salt)?;
crypto::decrypt(&key, &encrypted)?;
let meta = VaultMeta::load_verified(vault_dir, key.bytes())?;
Ok(Self {
vault_dir: vault_dir.to_path_buf(),
meta,
key,
})
}
pub fn save_meta(&self, meta: &VaultMeta) -> Result<()> {
meta.save(&self.vault_dir, self.key.bytes())
}
pub fn add_secret(&self, name: &str, value: &str) -> Result<()> {
let mut secrets = self.load_secrets()?;
secrets.insert(name.to_string(), value.to_string());
self.save_secrets(&secrets)
}
pub fn get_secret(&self, name: &str) -> Result<Option<String>> {
Ok(self.load_secrets()?.get(name).cloned())
}
pub fn list_secret_names(&self) -> Result<Vec<String>> {
let mut names: Vec<String> = self.load_secrets()?.into_keys().collect();
names.sort();
Ok(names)
}
pub fn remove_secret(&self, name: &str) -> Result<bool> {
let mut secrets = self.load_secrets()?;
let removed = secrets.remove(name).is_some();
if removed {
self.save_secrets(&secrets)?;
}
Ok(removed)
}
fn load_secrets(&self) -> Result<HashMap<String, String>> {
let encrypted = std::fs::read(self.vault_dir.join("vault.enc"))?;
let plaintext = crypto::decrypt(&self.key, &encrypted)?;
let store = SecretStore(String::from_utf8(plaintext)?);
Ok(serde_json::from_str(&store.0)?)
}
fn save_secrets(&self, secrets: &HashMap<String, String>) -> Result<()> {
let json = SecretStore(serde_json::to_string(secrets)?);
let encrypted = std::fs::read(self.vault_dir.join("vault.enc"))?;
let salt: [u8; SALT_SIZE] = encrypted[..SALT_SIZE].try_into().unwrap();
let data = crypto::encrypt(&self.key, &salt, json.0.as_bytes())?;
std::fs::write(self.vault_dir.join("vault.enc"), data)?;
Ok(())
}
}
pub fn list_vault_dirs() -> Vec<PathBuf> {
list_vault_dirs_in(Path::new(SVAULT_DIR))
}
pub fn list_vault_dirs_in(base: &Path) -> Vec<PathBuf> {
if !base.exists() {
return vec![];
}
let Ok(entries) = std::fs::read_dir(base) else {
return vec![];
};
let mut dirs: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir() && p.join("meta.yaml").exists())
.collect();
dirs.sort();
dirs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::{AccessConfig, VaultMeta, VaultSettings};
use tempfile::TempDir;
fn tmp_vault(dir: &TempDir, name: &str, passphrase: &str) -> Vault {
let vault_dir = dir.path().join(name);
let meta = VaultMeta::new(
name.to_string(),
"test vault".to_string(),
AccessConfig::default(),
VaultSettings::default(),
);
Vault::init(&vault_dir, passphrase, meta).expect("init failed")
}
#[test]
fn create_and_open() {
let dir = TempDir::new().unwrap();
let v = tmp_vault(&dir, "test", "Str0ng!Pass#99");
assert_eq!(v.meta.name, "test");
let v2 = Vault::open(&dir.path().join("test"), "Str0ng!Pass#99").unwrap();
assert_eq!(v2.meta.name, "test");
}
#[test]
fn wrong_passphrase_is_rejected() {
let dir = TempDir::new().unwrap();
tmp_vault(&dir, "test", "Str0ng!Pass#99");
let result = Vault::open(&dir.path().join("test"), "wrong-passphrase");
assert!(result.is_err());
let msg = format!("{}", result.err().unwrap());
assert!(msg.contains("Wrong passphrase") || msg.contains("Decryption failed"));
}
#[test]
fn add_get_secret() {
let dir = TempDir::new().unwrap();
let v = tmp_vault(&dir, "test", "Str0ng!Pass#99");
v.add_secret("API_KEY", "super-secret-value").unwrap();
let val = v.get_secret("API_KEY").unwrap();
assert_eq!(val, Some("super-secret-value".to_string()));
}
#[test]
fn list_secrets() {
let dir = TempDir::new().unwrap();
let v = tmp_vault(&dir, "test", "Str0ng!Pass#99");
v.add_secret("B_KEY", "b").unwrap();
v.add_secret("A_KEY", "a").unwrap();
let names = v.list_secret_names().unwrap();
assert_eq!(names, vec!["A_KEY", "B_KEY"]);
}
#[test]
fn remove_secret() {
let dir = TempDir::new().unwrap();
let v = tmp_vault(&dir, "test", "Str0ng!Pass#99");
v.add_secret("KEY", "value").unwrap();
assert!(v.remove_secret("KEY").unwrap());
assert_eq!(v.get_secret("KEY").unwrap(), None);
assert!(!v.remove_secret("KEY").unwrap());
}
#[test]
fn secrets_persist_across_open() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path().join("test");
{
let v = tmp_vault(&dir, "test", "Str0ng!Pass#99");
v.add_secret("DB_URL", "postgres://localhost/mydb").unwrap();
v.add_secret("REDIS_URL", "redis://localhost:6379").unwrap();
}
let v2 = Vault::open(&vault_dir, "Str0ng!Pass#99").unwrap();
assert_eq!(
v2.get_secret("DB_URL").unwrap(),
Some("postgres://localhost/mydb".to_string())
);
assert_eq!(
v2.get_secret("REDIS_URL").unwrap(),
Some("redis://localhost:6379".to_string())
);
}
#[test]
fn tampered_vault_enc_is_rejected() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path().join("test");
tmp_vault(&dir, "test", "Str0ng!Pass#99");
let enc_path = vault_dir.join("vault.enc");
let mut data = std::fs::read(&enc_path).unwrap();
let mid = data.len() / 2;
data[mid] ^= 0xFF; std::fs::write(&enc_path, data).unwrap();
let result = Vault::open(&vault_dir, "Str0ng!Pass#99");
assert!(result.is_err());
}
#[test]
fn tampered_meta_yaml_is_rejected() {
let dir = TempDir::new().unwrap();
let vault_dir = dir.path().join("test");
let v = tmp_vault(&dir, "test", "Str0ng!Pass#99");
let key = v.key.bytes().to_vec();
let meta_path = vault_dir.join("meta.yaml");
let content = std::fs::read_to_string(&meta_path).unwrap();
let tampered = content.replace("allow_agent: true", "allow_agent: false");
std::fs::write(&meta_path, tampered).unwrap();
let result = VaultMeta::load_verified(&vault_dir, &key);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("tampered"));
}
}