use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretRef {
pub name: String,
pub key: Option<String>,
pub namespace: Option<String>,
}
impl SecretRef {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
key: None,
namespace: None,
}
}
pub fn key(mut self, key: impl Into<String>) -> Self {
self.key = Some(key.into());
self
}
pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = Some(namespace.into());
self
}
pub fn path(&self) -> String {
let mut path = String::new();
if let Some(ref ns) = self.namespace {
path.push_str(ns);
path.push('/');
}
path.push_str(&self.name);
if let Some(ref key) = self.key {
path.push('/');
path.push_str(key);
}
path
}
}
#[derive(Clone)]
pub struct SecretValue {
value: Vec<u8>,
}
impl SecretValue {
pub fn new(value: impl Into<Vec<u8>>) -> Self {
Self {
value: value.into(),
}
}
pub fn from_string(s: impl Into<String>) -> Self {
Self {
value: s.into().into_bytes(),
}
}
pub fn as_bytes(&self) -> &[u8] {
&self.value
}
pub fn as_str(&self) -> Option<&str> {
std::str::from_utf8(&self.value).ok()
}
pub fn len(&self) -> usize {
self.value.len()
}
pub fn is_empty(&self) -> bool {
self.value.is_empty()
}
}
impl std::fmt::Debug for SecretValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SecretValue([REDACTED, {} bytes])", self.value.len())
}
}
impl Drop for SecretValue {
fn drop(&mut self) {
for byte in &mut self.value {
*byte = 0;
}
}
}
pub trait SecretStore: Send + Sync {
fn get(&self, secret_ref: &SecretRef) -> Result<SecretValue, super::SecurityError>;
fn set(&self, secret_ref: &SecretRef, value: SecretValue) -> Result<(), super::SecurityError>;
fn delete(&self, secret_ref: &SecretRef) -> Result<(), super::SecurityError>;
fn list(&self, namespace: Option<&str>) -> Result<Vec<String>, super::SecurityError>;
}
#[derive(Default)]
pub struct InMemorySecretStore {
secrets: Arc<RwLock<HashMap<String, SecretValue>>>,
}
impl InMemorySecretStore {
pub fn new() -> Self {
Self {
secrets: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn get_async(
&self,
secret_ref: &SecretRef,
) -> Result<SecretValue, super::SecurityError> {
let secrets = self.secrets.read().await;
secrets.get(&secret_ref.path()).cloned().ok_or_else(|| {
super::SecurityError::SecretNotFound {
name: secret_ref.path(),
}
})
}
pub async fn set_async(
&self,
secret_ref: &SecretRef,
value: SecretValue,
) -> Result<(), super::SecurityError> {
let mut secrets = self.secrets.write().await;
secrets.insert(secret_ref.path(), value);
Ok(())
}
pub async fn delete_async(&self, secret_ref: &SecretRef) -> Result<(), super::SecurityError> {
let mut secrets = self.secrets.write().await;
secrets.remove(&secret_ref.path());
Ok(())
}
pub async fn list_async(
&self,
namespace: Option<&str>,
) -> Result<Vec<String>, super::SecurityError> {
let secrets = self.secrets.read().await;
let names: Vec<String> = secrets
.keys()
.filter(|k| {
namespace
.map(|ns| k.starts_with(&format!("{}/", ns)))
.unwrap_or(true)
})
.cloned()
.collect();
Ok(names)
}
}
pub struct EnvSecretStore {
prefix: Option<String>,
}
impl EnvSecretStore {
pub fn new() -> Self {
Self { prefix: None }
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = Some(prefix.into());
self
}
fn env_name(&self, secret_ref: &SecretRef) -> String {
let name = secret_ref.name.to_uppercase().replace(['-', '/'], "_");
match &self.prefix {
Some(prefix) => format!("{}_{}", prefix.to_uppercase(), name),
None => name,
}
}
pub fn get(&self, secret_ref: &SecretRef) -> Result<SecretValue, super::SecurityError> {
let env_name = self.env_name(secret_ref);
std::env::var(&env_name)
.map(SecretValue::from_string)
.map_err(|_| super::SecurityError::SecretNotFound {
name: secret_ref.path(),
})
}
}
impl Default for EnvSecretStore {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_ref() {
let secret_ref = SecretRef::new("database-password")
.namespace("prod")
.key("password");
assert_eq!(secret_ref.path(), "prod/database-password/password");
}
#[test]
fn test_secret_value() {
let secret = SecretValue::from_string("super-secret");
assert_eq!(secret.as_str(), Some("super-secret"));
assert_eq!(secret.len(), 12);
}
#[test]
fn test_secret_value_debug() {
let secret = SecretValue::from_string("super-secret");
let debug = format!("{:?}", secret);
assert!(!debug.contains("super-secret"));
assert!(debug.contains("REDACTED"));
}
#[tokio::test]
async fn test_in_memory_store() {
let store = InMemorySecretStore::new();
let secret_ref = SecretRef::new("test-secret");
let value = SecretValue::from_string("test-value");
store.set_async(&secret_ref, value).await.unwrap();
let retrieved = store.get_async(&secret_ref).await.unwrap();
assert_eq!(retrieved.as_str(), Some("test-value"));
store.delete_async(&secret_ref).await.unwrap();
assert!(store.get_async(&secret_ref).await.is_err());
}
#[test]
fn test_env_secret_store_name() {
let store = EnvSecretStore::new().with_prefix("RUSTKERNEL");
let secret_ref = SecretRef::new("database-password");
assert_eq!(store.env_name(&secret_ref), "RUSTKERNEL_DATABASE_PASSWORD");
}
}