use std::collections::HashMap;
use std::path::PathBuf;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use super::error::SecretsResult;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct SecretsConfig {
pub cache: CacheConfig,
#[cfg(feature = "secrets-vault")]
pub openbao: Option<super::OpenBaoConfig>,
#[cfg(feature = "secrets-aws")]
pub aws: Option<super::AwsConfig>,
#[cfg(not(feature = "secrets-vault"))]
#[serde(skip)]
pub openbao: Option<()>,
#[cfg(not(feature = "secrets-aws"))]
#[serde(skip)]
pub aws: Option<()>,
pub sources: HashMap<String, SecretSource>,
}
impl SecretsConfig {
#[must_use]
pub fn from_cascade() -> Self {
#[cfg(feature = "config")]
{
if let Some(cfg) = crate::config::try_get()
&& let Ok(secrets) = cfg.unmarshal_key_registered::<Self>("secrets")
{
return secrets;
}
}
Self::default()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub enabled: bool,
pub directory: Option<PathBuf>,
pub ttl_secs: u64,
pub stale_grace_secs: u64,
pub refresh_interval_secs: u64,
pub refresh_jitter_secs: u64,
pub encryption_key: Option<crate::SensitiveString>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
directory: None, ttl_secs: 3600, stale_grace_secs: 86400, refresh_interval_secs: 1800, refresh_jitter_secs: 300, encryption_key: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum SecretSource {
File {
path: String,
},
OpenBao {
path: String,
key: String,
},
Aws {
secret_id: String,
key: Option<String>,
},
}
#[derive(Clone)]
pub struct SecretValue {
pub data: Vec<u8>,
pub fetched_at: SystemTime,
pub metadata: SecretMetadata,
}
impl std::fmt::Debug for SecretValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretValue")
.field("data", &"[REDACTED]")
.field("fetched_at", &self.fetched_at)
.field("metadata", &self.metadata)
.finish()
}
}
impl SecretValue {
#[must_use]
pub fn new(data: Vec<u8>) -> Self {
Self {
data,
fetched_at: SystemTime::now(),
metadata: SecretMetadata::default(),
}
}
#[must_use]
pub fn with_metadata(data: Vec<u8>, metadata: SecretMetadata) -> Self {
Self {
data,
fetched_at: SystemTime::now(),
metadata,
}
}
pub fn as_str(&self) -> SecretsResult<&str> {
std::str::from_utf8(&self.data)
.map_err(|e| super::error::SecretsError::InvalidData(format!("not valid UTF-8: {e}")))
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.data
}
#[must_use]
pub fn is_expired(&self, ttl_secs: u64) -> bool {
self.fetched_at
.elapsed()
.map(|d| d.as_secs() >= ttl_secs)
.unwrap_or(true)
}
#[must_use]
pub fn is_within_grace(&self, ttl_secs: u64, grace_secs: u64) -> bool {
self.fetched_at
.elapsed()
.map(|d| d.as_secs() <= ttl_secs + grace_secs)
.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretMetadata {
pub version: Option<String>,
pub source_path: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RotationEvent {
pub name: String,
pub old_version: Option<String>,
pub new_version: String,
pub rotated_at: SystemTime,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct CacheEntry {
pub data: String,
pub fetched_at_secs: u64,
pub metadata: SecretMetadata,
}
impl CacheEntry {
pub fn from_value(value: &SecretValue) -> Self {
use base64::Engine;
Self {
data: base64::engine::general_purpose::STANDARD.encode(&value.data),
fetched_at_secs: value
.fetched_at
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
metadata: value.metadata.clone(),
}
}
pub fn to_value(&self) -> SecretsResult<SecretValue> {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD
.decode(&self.data)
.map_err(|e| super::error::SecretsError::CacheError(format!("invalid base64: {e}")))?;
let fetched_at =
SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(self.fetched_at_secs);
Ok(SecretValue {
data,
fetched_at,
metadata: self.metadata.clone(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secret_value_new() {
let value = SecretValue::new(b"test-secret".to_vec());
assert_eq!(value.as_bytes(), b"test-secret");
assert_eq!(value.as_str().unwrap(), "test-secret");
}
#[test]
fn test_secret_value_expiry() {
let value = SecretValue::new(b"test".to_vec());
assert!(!value.is_expired(3600));
assert!(value.is_within_grace(3600, 86400));
}
#[test]
fn test_cache_entry_roundtrip() {
let value = SecretValue::new(b"secret-data".to_vec());
let entry = CacheEntry::from_value(&value);
let restored = entry.to_value().unwrap();
assert_eq!(restored.data, value.data);
}
#[test]
fn test_secret_source_file_serialization() {
let source = SecretSource::File {
path: "/etc/ssl/cert.pem".to_string(),
};
let json = serde_json::to_string(&source).unwrap();
assert!(json.contains("\"provider\":\"file\""));
}
#[test]
fn test_cache_config_default() {
let config = CacheConfig::default();
assert!(config.enabled);
assert_eq!(config.ttl_secs, 3600);
assert_eq!(config.stale_grace_secs, 86400);
assert!(config.encryption_key.is_none());
}
}