use std::collections::HashMap;
use zeroize::Zeroizing;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSummary {
pub id: ProjectId,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentSummary {
pub id: EnvId,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretValueSummary {
pub key: String,
pub value: Zeroizing<String>,
pub updated_at: i64,
}
pub fn delete_project(vault: &Vault, project_id: &ProjectId) -> Result<(), CoreError> {
vault.delete_project(project_id).map_err(CoreError::Db)
}
pub fn project_deletion_counts(
vault: &Vault,
project_id: &ProjectId,
) -> Result<(usize, usize), CoreError> {
let environments = list_environments(vault, project_id)?;
let mut secret_count = 0;
for environment in &environments {
secret_count += list_secret_keys(vault, project_id, &environment.name)?.len();
}
Ok((environments.len(), secret_count))
}
pub fn list_projects(vault: &Vault) -> Result<Vec<ProjectSummary>, CoreError> {
Ok(vault
.list_projects()?
.into_iter()
.map(|project| ProjectSummary {
id: project.id,
name: project.name,
})
.collect())
}
pub fn list_environments(
vault: &Vault,
project_id: &ProjectId,
) -> Result<Vec<EnvironmentSummary>, CoreError> {
Ok(vault
.list_environments(project_id)?
.into_iter()
.map(|environment| EnvironmentSummary {
id: environment.id,
name: environment.name,
})
.collect())
}
use crate::db::{DbError, EnvId, ProjectId, Vault};
use super::CoreError;
pub const DEFAULT_ENV: &str = "development";
fn validate_key(key: &str) -> Result<(), CoreError> {
if key.is_empty() || key.contains('=') {
return Err(CoreError::InvalidSecretKey(key.to_owned()));
}
Ok(())
}
pub(super) fn normalize_env(env_name: &str) -> String {
if env_name.is_empty() {
DEFAULT_ENV.to_owned()
} else {
env_name.to_lowercase()
}
}
fn resolve_env(
vault: &Vault,
project_id: &ProjectId,
env_name: &str,
) -> Result<crate::db::EnvId, CoreError> {
let name = normalize_env(env_name);
match vault.get_environment_by_name(project_id, &name) {
Ok(env) => Ok(env.id),
Err(DbError::NotFound) => Ok(vault.create_environment(project_id, &name)?),
Err(e) => Err(CoreError::Db(e)),
}
}
pub fn set_secret(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
key: &str,
plaintext: &str,
) -> Result<(), CoreError> {
validate_key(key)?;
let env_id = resolve_env(vault, project_id, env_name)?;
let encrypted = crate::crypto::encrypt(master_key, plaintext.as_bytes())?;
vault.upsert_secret(&env_id, key, &encrypted.ciphertext, &encrypted.nonce)?;
Ok(())
}
pub fn get_secret(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
key: &str,
) -> Result<Zeroizing<String>, CoreError> {
validate_key(key)?;
let name = normalize_env(env_name);
let env = vault.get_environment_by_name(project_id, &name)?;
let record = vault.get_secret(&env.id, key)?;
let plaintext_bytes =
crate::crypto::decrypt(master_key, &record.value_encrypted, &record.value_nonce)?;
let string = String::from_utf8(plaintext_bytes.to_vec())
.map_err(|_| CoreError::Crypto(crate::crypto::CryptoError::DecryptionFailed))?;
Ok(Zeroizing::new(string))
}
pub fn list_secret_keys(
vault: &Vault,
project_id: &ProjectId,
env_name: &str,
) -> Result<Vec<String>, CoreError> {
let name = normalize_env(env_name);
let env = vault.get_environment_by_name(project_id, &name)?;
Ok(vault
.list_secrets(&env.id)?
.into_iter()
.map(|r| r.key)
.collect())
}
pub fn list_secrets_with_values(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
) -> Result<Vec<(String, String)>, CoreError> {
let name = normalize_env(env_name);
let env = vault.get_environment_by_name(project_id, &name)?;
let records = vault.list_secrets(&env.id)?;
let mut pairs = Vec::with_capacity(records.len());
for record in records {
let plaintext_bytes =
crate::crypto::decrypt(master_key, &record.value_encrypted, &record.value_nonce)?;
let string = String::from_utf8(plaintext_bytes.to_vec())
.map_err(|_| CoreError::Crypto(crate::crypto::CryptoError::DecryptionFailed))?;
pairs.push((record.key, string));
}
Ok(pairs)
}
pub fn list_secrets_with_metadata(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
) -> Result<Vec<SecretValueSummary>, CoreError> {
let name = normalize_env(env_name);
let env = vault.get_environment_by_name(project_id, &name)?;
vault
.list_secrets(&env.id)?
.into_iter()
.map(|record| {
let plaintext_bytes =
crate::crypto::decrypt(master_key, &record.value_encrypted, &record.value_nonce)?;
let value = String::from_utf8(plaintext_bytes.to_vec())
.map_err(|_| CoreError::Crypto(crate::crypto::CryptoError::DecryptionFailed))?;
Ok(SecretValueSummary {
key: record.key,
value: Zeroizing::new(value),
updated_at: record.updated_at,
})
})
.collect()
}
pub fn delete_secret(
vault: &Vault,
project_id: &ProjectId,
env_name: &str,
key: &str,
) -> Result<(), CoreError> {
validate_key(key)?;
let name = normalize_env(env_name);
let env = vault.get_environment_by_name(project_id, &name)?;
vault.delete_secret(&env.id, key)?;
Ok(())
}
pub fn get_env_secrets(
vault: &Vault,
master_key: &[u8; 32],
project_id: &ProjectId,
env_name: &str,
) -> Result<HashMap<String, Zeroizing<String>>, CoreError> {
let name = normalize_env(env_name);
let env = match vault.get_environment_by_name(project_id, &name) {
Ok(env) => env,
Err(DbError::NotFound) => return Ok(HashMap::new()),
Err(e) => return Err(CoreError::Db(e)),
};
let records = vault.list_secrets(&env.id)?;
let mut map = HashMap::new();
for record in records {
let plaintext_bytes =
crate::crypto::decrypt(master_key, &record.value_encrypted, &record.value_nonce)?;
let string = String::from_utf8(plaintext_bytes.to_vec())
.map_err(|_| CoreError::Crypto(crate::crypto::CryptoError::DecryptionFailed))?;
map.insert(record.key, Zeroizing::new(string));
}
Ok(map)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::CoreError;
use crate::db::{ProjectId, Vault};
fn open_test_vault(tmp: &tempfile::TempDir) -> (Vault, ProjectId) {
let path = tmp.path().join("test.vault");
let vault = Vault::open(&path, &[0u8; 32]).expect("open vault");
let pid = vault
.create_project("test-project")
.expect("create project");
(vault, pid)
}
#[test]
fn set_and_get_secret_round_trip() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
set_secret(&vault, &key, &pid, "test", "API_KEY", "secret123").expect("set must succeed");
let env = vault
.get_environment_by_name(&pid, "test")
.expect("env must exist");
let record = vault
.get_secret(&env.id, "API_KEY")
.expect("record must exist");
assert!(
!record.value_encrypted.windows(9).any(|w| w == b"secret123"),
"plaintext must not appear in ciphertext"
);
let plaintext =
get_secret(&vault, &key, &pid, "test", "API_KEY").expect("get must succeed");
assert_eq!(plaintext.as_str(), "secret123");
}
#[test]
fn set_secret_upsert() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
set_secret(&vault, &key, &pid, "test", "API_KEY", "v1").expect("first set");
set_secret(&vault, &key, &pid, "test", "API_KEY", "v2").expect("second set");
let plaintext =
get_secret(&vault, &key, &pid, "test", "API_KEY").expect("get must succeed");
assert_eq!(
plaintext.as_str(),
"v2",
"last write must win (upsert semantics)"
);
}
#[test]
fn get_secret_not_found() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
vault.create_environment(&pid, "test").expect("create env");
let result = get_secret(&vault, &key, &pid, "test", "NONEXISTENT");
assert!(
matches!(result, Err(CoreError::Db(_))),
"missing key must return Db error, got: {:?}",
result
);
}
#[test]
fn list_secret_keys_order() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
set_secret(&vault, &key, &pid, "test", "ZEBRA", "z").expect("set ZEBRA");
set_secret(&vault, &key, &pid, "test", "ALPHA", "a").expect("set ALPHA");
set_secret(&vault, &key, &pid, "test", "MANGO", "m").expect("set MANGO");
let keys = list_secret_keys(&vault, &pid, "test").expect("list must succeed");
assert_eq!(
keys,
vec!["ALPHA", "MANGO", "ZEBRA"],
"keys must be alphabetical"
);
assert_eq!(keys.len(), 3);
}
#[test]
fn delete_secret_removes() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
set_secret(&vault, &key, &pid, "test", "TOKEN", "abc").expect("set");
delete_secret(&vault, &pid, "test", "TOKEN").expect("delete must succeed");
let result = get_secret(&vault, &key, &pid, "test", "TOKEN");
assert!(
matches!(result, Err(CoreError::Db(_))),
"key must be absent after delete, got: {:?}",
result
);
}
#[test]
fn delete_secret_not_found() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
vault.create_environment(&pid, "test").expect("create env");
let result = delete_secret(&vault, &pid, "test", "NONEXISTENT");
assert!(
matches!(result, Err(CoreError::Db(_))),
"deleting a non-existent key must return Db error, got: {:?}",
result
);
}
#[test]
fn get_env_secrets_all_decrypted() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
set_secret(&vault, &key, &pid, "test", "A", "1").expect("set A");
set_secret(&vault, &key, &pid, "test", "B", "2").expect("set B");
set_secret(&vault, &key, &pid, "test", "C", "3").expect("set C");
let secrets = get_env_secrets(&vault, &key, &pid, "test").expect("must succeed");
assert_eq!(secrets.len(), 3, "map must have exactly 3 entries");
assert_eq!(secrets.get("A").map(|v| v.as_str()), Some("1"));
assert_eq!(secrets.get("B").map(|v| v.as_str()), Some("2"));
assert_eq!(secrets.get("C").map(|v| v.as_str()), Some("3"));
}
#[test]
fn get_env_secrets_missing_env_returns_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let result = get_env_secrets(&vault, &[0u8; 32], &pid, "development");
assert!(
result.is_ok(),
"missing environment must return Ok, not Err; got: {:?}",
result
);
assert!(
result.unwrap().is_empty(),
"result must be an empty map for a fresh project"
);
}
#[test]
fn get_env_secrets_empty_env() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
vault.create_environment(&pid, "empty").expect("create env");
let secrets = get_env_secrets(&vault, &[0u8; 32], &pid, "empty")
.expect("must succeed even with no secrets");
assert!(
secrets.is_empty(),
"expected empty map, got {} entries",
secrets.len()
);
}
#[test]
fn get_env_secrets_partial_fail() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
set_secret(&vault, &key, &pid, "test", "VALID", "hello").expect("set VALID");
let env = vault
.get_environment_by_name(&pid, "test")
.expect("env must exist after set_secret");
vault
.upsert_secret(
&env.id,
"CORRUPTED",
b"not-valid-gcm-ciphertext-xxxxx",
&[0u8; 12],
)
.expect("upsert raw garbage ciphertext");
let result = get_env_secrets(&vault, &key, &pid, "test");
assert!(
matches!(result, Err(CoreError::Crypto(_))),
"corrupted ciphertext must return Crypto error; got: {:?}",
result
);
}
#[test]
fn default_env_auto_created() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
set_secret(&vault, &key, &pid, "", "API_KEY", "abc")
.expect("set with empty env_name must succeed");
let env = vault
.get_environment_by_name(&pid, "development")
.expect("\"development\" environment must have been auto-created");
vault
.get_secret(&env.id, "API_KEY")
.expect("secret must exist in development env");
}
#[test]
fn invalid_key_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let (vault, pid) = open_test_vault(&tmp);
let key = [0u8; 32];
let result = set_secret(&vault, &key, &pid, "test", "", "value");
assert!(
matches!(result, Err(CoreError::InvalidSecretKey(_))),
"empty key must return InvalidSecretKey, got: {:?}",
result
);
let result = set_secret(&vault, &key, &pid, "test", "FOO=BAR", "value");
assert!(
matches!(result, Err(CoreError::InvalidSecretKey(_))),
"key with '=' must return InvalidSecretKey, got: {:?}",
result
);
let envs = vault.list_environments(&pid).expect("list environments");
assert!(
envs.is_empty(),
"no environments must be created for invalid keys; got {} envs",
envs.len()
);
}
}