use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use orion::aead;
use orion::kdf;
use secrecy::SecretString;
use serde::{Deserialize, Serialize};
use tracing::debug;
#[derive(Debug, thiserror::Error)]
pub enum VaultError {
#[error("vault I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("vault crypto error: {0}")]
Crypto(String),
#[error("vault JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(untagged)]
pub enum VaultEntry {
Key(String),
Credential {
fields: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
service_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
},
}
impl VaultEntry {
fn primary_value(&self) -> Option<&str> {
match self {
VaultEntry::Key(s) => Some(s.as_str()),
VaultEntry::Credential { fields, .. } => {
fields.values().next().map(|s| s.as_str())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VaultBackend {
Local,
Doppler,
}
impl VaultBackend {
pub fn from_env() -> Self {
match std::env::var("NIKA_VAULT_BACKEND").as_deref() {
Ok("doppler") => Self::Doppler,
_ => Self::Local,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct AuditEntry {
pub timestamp: String,
pub op: String,
pub service: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
pub source: String,
}
pub struct VaultAuditLog {
log_path: PathBuf,
}
impl VaultAuditLog {
pub fn new(secrets_dir: &Path) -> Self {
Self {
log_path: secrets_dir.join("audit.jsonl"),
}
}
pub fn path(&self) -> &Path {
&self.log_path
}
pub fn log(
&self,
op: &str,
service: &str,
field: Option<&str>,
source: &str,
) -> Result<(), VaultError> {
let entry = AuditEntry {
timestamp: chrono::Utc::now().to_rfc3339(),
op: op.to_string(),
service: service.to_string(),
field: field.map(|f| f.to_string()),
source: source.to_string(),
};
let mut line = serde_json::to_string(&entry)?;
line.push('\n');
if let Some(parent) = self.log_path.parent() {
std::fs::create_dir_all(parent)?;
}
use std::io::Write;
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)?;
let mut writer = std::io::BufWriter::new(file);
writer.write_all(line.as_bytes())?;
writer.flush()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
std::fs::set_permissions(&self.log_path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
pub fn read_all(&self) -> Result<Vec<AuditEntry>, VaultError> {
if !self.log_path.exists() {
return Ok(vec![]);
}
let content = std::fs::read_to_string(&self.log_path)?;
let mut entries = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let entry: AuditEntry = serde_json::from_str(line)?;
entries.push(entry);
}
Ok(entries)
}
}
pub struct DopplerBackend;
impl DopplerBackend {
pub fn get(key: &str) -> Result<Option<String>, VaultError> {
let output = std::process::Command::new("doppler")
.args(["secrets", "get", key, "--plain"])
.output();
match output {
Ok(out) if out.status.success() => {
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::debug!("doppler get failed for {key}: {stderr}");
Ok(None)
}
Err(e) => {
tracing::debug!("doppler CLI not available: {e}");
Ok(None)
}
}
}
pub fn list() -> Result<Vec<String>, VaultError> {
let output = std::process::Command::new("doppler")
.args(["secrets", "--json"])
.output();
match output {
Ok(out) if out.status.success() => {
let parsed: serde_json::Value =
serde_json::from_slice(&out.stdout).map_err(VaultError::Json)?;
if let serde_json::Value::Object(map) = parsed {
Ok(map.keys().cloned().collect())
} else {
Ok(vec![])
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
tracing::debug!("doppler list failed: {stderr}");
Ok(vec![])
}
Err(e) => {
tracing::debug!("doppler CLI not available: {e}");
Ok(vec![])
}
}
}
pub fn is_available() -> bool {
std::process::Command::new("doppler")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
#[derive(Serialize, Deserialize, Default)]
struct VaultPayload {
version: u32,
secrets: BTreeMap<String, VaultEntry>,
}
pub struct NikaVault {
vault_path: PathBuf,
salt_path: PathBuf,
}
impl NikaVault {
pub fn new(secrets_dir: &Path) -> Self {
Self {
vault_path: secrets_dir.join("vault.enc"),
salt_path: secrets_dir.join("vault.salt"),
}
}
pub fn get(&self, provider: &str) -> Result<Option<SecretString>, VaultError> {
let payload = match self.read_payload()? {
Some(p) => p,
None => return Ok(None),
};
Ok(payload.secrets.get(provider).and_then(|entry| {
entry.primary_value().map(|s| SecretString::from(s.to_owned()))
}))
}
pub fn set(&self, provider: &str, secret: &str) -> Result<(), VaultError> {
let mut payload = self.read_payload()?.unwrap_or_default();
payload.version = 2;
payload
.secrets
.insert(provider.to_string(), VaultEntry::Key(secret.to_string()));
self.write_payload(&payload)
}
pub fn delete(&self, provider: &str) -> Result<bool, VaultError> {
let mut payload = match self.read_payload()? {
Some(p) => p,
None => return Ok(false),
};
let existed = payload.secrets.remove(provider).is_some();
if existed {
self.write_payload(&payload)?;
}
Ok(existed)
}
pub fn list(&self) -> Result<Vec<String>, VaultError> {
let payload = self.read_payload()?.unwrap_or_default();
Ok(payload.secrets.keys().cloned().collect())
}
pub fn get_credential(
&self,
service: &str,
field: &str,
) -> Result<Option<SecretString>, VaultError> {
let payload = match self.read_payload()? {
Some(p) => p,
None => return Ok(None),
};
let entry = match payload.secrets.get(service) {
Some(e) => e,
None => return Ok(None),
};
match entry {
VaultEntry::Key(s) => {
if field == "key" {
Ok(Some(SecretString::from(s.clone())))
} else {
Ok(None)
}
}
VaultEntry::Credential { fields, .. } => Ok(fields
.get(field)
.map(|s| SecretString::from(s.clone()))),
}
}
pub fn set_credential(
&self,
service: &str,
fields: BTreeMap<String, String>,
service_url: Option<String>,
category: Option<String>,
) -> Result<(), VaultError> {
let mut payload = self.read_payload()?.unwrap_or_default();
payload.version = 2;
payload.secrets.insert(
service.to_string(),
VaultEntry::Credential {
fields,
service_url,
category,
created_at: Some(chrono::Utc::now().to_rfc3339()),
expires_at: None,
},
);
self.write_payload(&payload)
}
pub fn get_entry(&self, service: &str) -> Result<Option<VaultEntry>, VaultError> {
let payload = match self.read_payload()? {
Some(p) => p,
None => return Ok(None),
};
Ok(payload.secrets.get(service).cloned())
}
fn read_payload(&self) -> Result<Option<VaultPayload>, VaultError> {
if !self.vault_path.exists() {
return Ok(None);
}
let ciphertext = std::fs::read(&self.vault_path)?;
let key = self.derive_key()?;
let plaintext = aead::open(&key, &ciphertext)
.map_err(|e| VaultError::Crypto(format!("decrypt failed: {e}")))?;
let payload: VaultPayload = serde_json::from_slice(&plaintext)?;
Ok(Some(payload))
}
fn write_payload(&self, payload: &VaultPayload) -> Result<(), VaultError> {
if let Some(parent) = self.vault_path.parent() {
std::fs::create_dir_all(parent)?;
}
let plaintext = serde_json::to_vec(payload)?;
let key = self.derive_key()?;
let ciphertext = aead::seal(&key, &plaintext)
.map_err(|e| VaultError::Crypto(format!("encrypt failed: {e}")))?;
std::fs::write(&self.vault_path, &ciphertext)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&self.vault_path, perms.clone())?;
if self.salt_path.exists() {
std::fs::set_permissions(&self.salt_path, perms)?;
}
}
debug!("vault written: {} providers", payload.secrets.len());
Ok(())
}
fn derive_key(&self) -> Result<orion::aead::SecretKey, VaultError> {
let salt = self.load_or_create_salt()?;
let fingerprint = machine_fingerprint()?;
let password = kdf::Password::from_slice(fingerprint.as_bytes())
.map_err(|e| VaultError::Crypto(format!("KDF password: {e}")))?;
let kdf_salt = kdf::Salt::from_slice(&salt)
.map_err(|e| VaultError::Crypto(format!("KDF salt: {e}")))?;
let derived = kdf::derive_key(&password, &kdf_salt, 3, 1 << 16, 32)
.map_err(|e| VaultError::Crypto(format!("KDF derive: {e}")))?;
orion::aead::SecretKey::from_slice(derived.unprotected_as_bytes())
.map_err(|e| VaultError::Crypto(format!("AEAD key: {e}")))
}
fn load_or_create_salt(&self) -> Result<Vec<u8>, VaultError> {
if self.salt_path.exists() {
let salt = std::fs::read(&self.salt_path)?;
if salt.len() >= 16 {
return Ok(salt);
}
debug!("vault salt too short ({} bytes), regenerating", salt.len());
}
if let Some(parent) = self.salt_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut salt = vec![0u8; 16];
orion::util::secure_rand_bytes(&mut salt)
.map_err(|e| VaultError::Crypto(format!("CSPRNG: {e}")))?;
std::fs::write(&self.salt_path, &salt)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&self.salt_path, std::fs::Permissions::from_mode(0o600))?;
}
debug!("vault salt created");
Ok(salt)
}
}
fn machine_fingerprint() -> Result<String, VaultError> {
if let Ok(pass) = std::env::var("NIKA_VAULT_PASSPHRASE") {
if !pass.is_empty() {
return Ok(format!("nika-vault-v1:passphrase:{pass}"));
}
}
let machine_id = get_machine_id()?;
let username = whoami::username();
Ok(format!("nika-vault-v1:{machine_id}:{username}"))
}
#[cfg(target_os = "linux")]
fn get_machine_id() -> Result<String, VaultError> {
std::fs::read_to_string("/etc/machine-id")
.map(|s| s.trim().to_string())
.map_err(|e| {
VaultError::Io(std::io::Error::new(
e.kind(),
format!("Cannot read /etc/machine-id: {e}. Set NIKA_VAULT_PASSPHRASE."),
))
})
}
#[cfg(target_os = "macos")]
fn get_machine_id() -> Result<String, VaultError> {
let output = std::process::Command::new("ioreg")
.args(["-rd1", "-c", "IOPlatformExpertDevice"])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("IOPlatformUUID") {
if let Some(uuid) = line.split('"').nth(3) {
return Ok(uuid.to_string());
}
}
}
Err(VaultError::Crypto("IOPlatformUUID not found".into()))
}
#[cfg(target_os = "windows")]
fn get_machine_id() -> Result<String, VaultError> {
let output = std::process::Command::new("reg")
.args([
"query",
r"HKLM\SOFTWARE\Microsoft\Cryptography",
"/v",
"MachineGuid",
])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.contains("MachineGuid") {
if let Some(guid) = line.split_whitespace().last() {
return Ok(guid.to_string());
}
}
}
Err(VaultError::Crypto("MachineGuid not found".into()))
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn get_machine_id() -> Result<String, VaultError> {
Err(VaultError::Crypto(
"No machine-id on this platform. Set NIKA_VAULT_PASSPHRASE.".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::ExposeSecret;
use serial_test::serial;
use tempfile::TempDir;
fn test_vault() -> (TempDir, NikaVault) {
let dir = TempDir::new().unwrap();
std::env::set_var("NIKA_VAULT_PASSPHRASE", "test-only");
let vault = NikaVault::new(dir.path());
(dir, vault)
}
#[test]
#[serial]
fn set_and_get() {
let (_dir, vault) = test_vault();
vault.set("anthropic", "sk-ant-test").unwrap();
let s = vault.get("anthropic").unwrap().unwrap();
assert_eq!(s.expose_secret(), "sk-ant-test");
}
#[test]
#[serial]
fn get_nonexistent() {
let (_dir, vault) = test_vault();
assert!(vault.get("nope").unwrap().is_none());
}
#[test]
#[serial]
fn overwrite() {
let (_dir, vault) = test_vault();
vault.set("k", "old").unwrap();
vault.set("k", "new").unwrap();
assert_eq!(vault.get("k").unwrap().unwrap().expose_secret(), "new");
}
#[test]
#[serial]
fn delete_existing() {
let (_dir, vault) = test_vault();
vault.set("x", "val").unwrap();
assert!(vault.delete("x").unwrap());
assert!(vault.get("x").unwrap().is_none());
}
#[test]
#[serial]
fn delete_nonexistent() {
let (_dir, vault) = test_vault();
assert!(!vault.delete("nope").unwrap());
}
#[test]
#[serial]
fn list_providers() {
let (_dir, vault) = test_vault();
vault.set("a", "1").unwrap();
vault.set("b", "2").unwrap();
let mut list = vault.list().unwrap();
list.sort();
assert_eq!(list, vec!["a", "b"]);
}
#[test]
#[serial]
fn corrupted_file_errors() {
let (dir, vault) = test_vault();
vault.set("dummy", "x").unwrap();
std::fs::write(dir.path().join("vault.enc"), b"garbage").unwrap();
assert!(vault.get("any").is_err());
}
#[test]
#[serial]
#[cfg(unix)]
fn file_permissions() {
use std::os::unix::fs::PermissionsExt;
let (dir, vault) = test_vault();
vault.set("test", "secret").unwrap();
let perms = std::fs::metadata(dir.path().join("vault.enc"))
.unwrap()
.permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
#[test]
#[serial]
fn multiple_providers_persist() {
let (_dir, vault) = test_vault();
vault.set("anthropic", "sk-1").unwrap();
vault.set("openai", "sk-2").unwrap();
vault.set("gemini", "sk-3").unwrap();
assert_eq!(
vault.get("anthropic").unwrap().unwrap().expose_secret(),
"sk-1"
);
assert_eq!(
vault.get("openai").unwrap().unwrap().expose_secret(),
"sk-2"
);
assert_eq!(
vault.get("gemini").unwrap().unwrap().expose_secret(),
"sk-3"
);
}
#[test]
#[serial]
fn backward_compat_key_still_works() {
let (_dir, vault) = test_vault();
vault.set("anthropic", "sk-ant-test").unwrap();
let s = vault.get("anthropic").unwrap().unwrap();
assert_eq!(s.expose_secret(), "sk-ant-test");
let s2 = vault.get_credential("anthropic", "key").unwrap().unwrap();
assert_eq!(s2.expose_secret(), "sk-ant-test");
assert!(vault
.get_credential("anthropic", "secret")
.unwrap()
.is_none());
let entry = vault.get_entry("anthropic").unwrap().unwrap();
assert!(matches!(entry, VaultEntry::Key(ref s) if s == "sk-ant-test"));
}
#[test]
#[serial]
fn credential_set_and_get() {
let (_dir, vault) = test_vault();
let mut fields = BTreeMap::new();
fields.insert("api_key".to_string(), "sk_live_123".to_string());
fields.insert("secret".to_string(), "whsec_456".to_string());
fields.insert("org_id".to_string(), "org_789".to_string());
vault
.set_credential(
"stripe",
fields,
Some("https://api.stripe.com".to_string()),
Some("payment".to_string()),
)
.unwrap();
let api_key = vault.get_credential("stripe", "api_key").unwrap().unwrap();
assert_eq!(api_key.expose_secret(), "sk_live_123");
let secret = vault.get_credential("stripe", "secret").unwrap().unwrap();
assert_eq!(secret.expose_secret(), "whsec_456");
let org_id = vault.get_credential("stripe", "org_id").unwrap().unwrap();
assert_eq!(org_id.expose_secret(), "org_789");
assert!(vault
.get_credential("stripe", "nonexistent")
.unwrap()
.is_none());
let entry = vault.get_entry("stripe").unwrap().unwrap();
match entry {
VaultEntry::Credential {
fields,
service_url,
category,
created_at,
..
} => {
assert_eq!(fields.len(), 3);
assert_eq!(service_url.as_deref(), Some("https://api.stripe.com"));
assert_eq!(category.as_deref(), Some("payment"));
assert!(created_at.is_some(), "created_at should be auto-set");
}
VaultEntry::Key(_) => panic!("Expected Credential, got Key"),
}
}
#[test]
#[serial]
fn credential_get_returns_primary_for_simple_get() {
let (_dir, vault) = test_vault();
let mut fields = BTreeMap::new();
fields.insert("api_key".to_string(), "sk_live_first".to_string());
fields.insert("secret".to_string(), "whsec_second".to_string());
vault
.set_credential("stripe", fields, None, None)
.unwrap();
let s = vault.get("stripe").unwrap().unwrap();
assert_eq!(s.expose_secret(), "sk_live_first");
}
#[test]
#[serial]
fn credential_list_services() {
let (_dir, vault) = test_vault();
vault.set("anthropic", "sk-ant").unwrap();
let mut fields = BTreeMap::new();
fields.insert("api_key".to_string(), "sk_live".to_string());
vault
.set_credential("stripe", fields, None, None)
.unwrap();
let mut list = vault.list().unwrap();
list.sort();
assert_eq!(list, vec!["anthropic", "stripe"]);
}
#[test]
#[serial]
fn credential_delete() {
let (_dir, vault) = test_vault();
let mut fields = BTreeMap::new();
fields.insert("api_key".to_string(), "sk_live".to_string());
vault
.set_credential("stripe", fields, None, None)
.unwrap();
assert!(vault.get_credential("stripe", "api_key").unwrap().is_some());
assert!(vault.delete("stripe").unwrap());
assert!(vault.get_credential("stripe", "api_key").unwrap().is_none());
assert!(vault.get("stripe").unwrap().is_none());
assert!(!vault.delete("stripe").unwrap());
}
#[test]
#[serial]
fn credential_overwrite_key_with_credential() {
let (_dir, vault) = test_vault();
vault.set("stripe", "old-key").unwrap();
assert_eq!(
vault.get("stripe").unwrap().unwrap().expose_secret(),
"old-key"
);
let mut fields = BTreeMap::new();
fields.insert("api_key".to_string(), "sk_live_new".to_string());
vault
.set_credential("stripe", fields, None, None)
.unwrap();
let val = vault.get_credential("stripe", "api_key").unwrap().unwrap();
assert_eq!(val.expose_secret(), "sk_live_new");
}
#[test]
#[serial]
fn credential_overwrite_credential_with_key() {
let (_dir, vault) = test_vault();
let mut fields = BTreeMap::new();
fields.insert("api_key".to_string(), "sk_live".to_string());
vault
.set_credential("stripe", fields, None, None)
.unwrap();
vault.set("stripe", "simple-key").unwrap();
assert_eq!(
vault.get("stripe").unwrap().unwrap().expose_secret(),
"simple-key"
);
assert!(vault
.get_credential("stripe", "api_key")
.unwrap()
.is_none());
}
#[test]
fn vault_entry_serde_roundtrip_key() {
let entry = VaultEntry::Key("sk-test".to_string());
let json = serde_json::to_string(&entry).unwrap();
let deserialized: VaultEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, deserialized);
}
#[test]
fn vault_entry_serde_roundtrip_credential() {
let mut fields = BTreeMap::new();
fields.insert("api_key".to_string(), "sk_live".to_string());
fields.insert("secret".to_string(), "whsec_456".to_string());
let entry = VaultEntry::Credential {
fields,
service_url: Some("https://api.stripe.com".to_string()),
category: Some("payment".to_string()),
created_at: Some("2026-03-31T12:00:00Z".to_string()),
expires_at: None,
};
let json = serde_json::to_string(&entry).unwrap();
let deserialized: VaultEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, deserialized);
}
#[test]
fn vault_entry_deserialize_plain_string_as_key() {
let deserialized: VaultEntry = serde_json::from_str(r#""sk-ant-test""#).unwrap();
assert_eq!(deserialized, VaultEntry::Key("sk-ant-test".to_string()));
}
#[test]
#[serial]
fn credential_nonexistent_service() {
let (_dir, vault) = test_vault();
assert!(vault
.get_credential("nonexistent", "key")
.unwrap()
.is_none());
}
#[test]
#[serial]
fn local_backend_is_default() {
unsafe { std::env::remove_var("NIKA_VAULT_BACKEND") };
assert_eq!(VaultBackend::from_env(), VaultBackend::Local);
}
#[test]
#[serial]
fn doppler_backend_selected_from_env() {
std::env::set_var("NIKA_VAULT_BACKEND", "doppler");
assert_eq!(VaultBackend::from_env(), VaultBackend::Doppler);
unsafe { std::env::remove_var("NIKA_VAULT_BACKEND") };
}
#[test]
#[serial]
fn unknown_backend_defaults_to_local() {
std::env::set_var("NIKA_VAULT_BACKEND", "unknown-backend");
assert_eq!(VaultBackend::from_env(), VaultBackend::Local);
unsafe { std::env::remove_var("NIKA_VAULT_BACKEND") };
}
#[test]
#[serial]
fn empty_backend_defaults_to_local() {
std::env::set_var("NIKA_VAULT_BACKEND", "");
assert_eq!(VaultBackend::from_env(), VaultBackend::Local);
unsafe { std::env::remove_var("NIKA_VAULT_BACKEND") };
}
#[test]
fn doppler_get_returns_none_when_cli_unavailable() {
let result = DopplerBackend::get("NONEXISTENT_KEY_12345");
assert!(result.is_ok(), "get should not error even without doppler");
}
#[test]
fn doppler_list_returns_empty_when_cli_unavailable() {
let result = DopplerBackend::list();
assert!(result.is_ok(), "list should not error even without doppler");
}
#[test]
fn audit_log_writes_and_reads() {
let dir = TempDir::new().unwrap();
let audit = VaultAuditLog::new(dir.path());
audit
.log("get", "stripe", Some("secret"), "workflow")
.unwrap();
audit.log("set", "twilio", Some("sid"), "cli").unwrap();
audit.log("delete", "old-service", None, "cli").unwrap();
let entries = audit.read_all().unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].op, "get");
assert_eq!(entries[0].service, "stripe");
assert_eq!(entries[0].field.as_deref(), Some("secret"));
assert_eq!(entries[0].source, "workflow");
assert_eq!(entries[1].op, "set");
assert_eq!(entries[1].service, "twilio");
assert_eq!(entries[1].field.as_deref(), Some("sid"));
assert_eq!(entries[2].op, "delete");
assert_eq!(entries[2].service, "old-service");
assert!(entries[2].field.is_none());
}
#[test]
fn audit_log_timestamp_is_rfc3339() {
let dir = TempDir::new().unwrap();
let audit = VaultAuditLog::new(dir.path());
audit.log("get", "test", None, "test").unwrap();
let entries = audit.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert!(
chrono::DateTime::parse_from_rfc3339(&entries[0].timestamp).is_ok(),
"timestamp should be valid RFC 3339: {}",
entries[0].timestamp
);
}
#[test]
fn audit_log_empty_file() {
let dir = TempDir::new().unwrap();
let audit = VaultAuditLog::new(dir.path());
let entries = audit.read_all().unwrap();
assert!(entries.is_empty());
}
#[test]
fn audit_log_append_mode() {
let dir = TempDir::new().unwrap();
let audit = VaultAuditLog::new(dir.path());
audit.log("get", "s1", None, "src1").unwrap();
audit.log("set", "s2", None, "src2").unwrap();
let audit2 = VaultAuditLog::new(dir.path());
audit2.log("delete", "s3", None, "src3").unwrap();
let entries = audit2.read_all().unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].service, "s1");
assert_eq!(entries[1].service, "s2");
assert_eq!(entries[2].service, "s3");
}
#[test]
fn audit_entry_json_roundtrip() {
let entry = AuditEntry {
timestamp: "2026-04-01T00:00:00+00:00".to_string(),
op: "get".to_string(),
service: "stripe".to_string(),
field: Some("secret".to_string()),
source: "workflow".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
let parsed: AuditEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, parsed);
}
#[test]
fn audit_entry_skips_none_field() {
let entry = AuditEntry {
timestamp: "2026-04-01T00:00:00+00:00".to_string(),
op: "list".to_string(),
service: "all".to_string(),
field: None,
source: "cli".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(
!json.contains("field"),
"field should be skipped when None: {json}"
);
}
#[test]
#[cfg(unix)]
fn audit_log_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let audit = VaultAuditLog::new(dir.path());
audit.log("get", "test", None, "test").unwrap();
let perms = std::fs::metadata(audit.path()).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600);
}
#[test]
fn vault_backend_clone_and_debug() {
let b = VaultBackend::Local;
let b2 = b.clone();
assert_eq!(b, b2);
assert_eq!(format!("{:?}", b), "Local");
assert_eq!(format!("{:?}", VaultBackend::Doppler), "Doppler");
}
}