use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SecretsConfig {
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub secrets: HashMap<String, SecretDefinition>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<SecretProviderConfig>,
}
impl SecretsConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_secret(mut self, key: impl Into<String>, definition: SecretDefinition) -> Self {
self.secrets.insert(key.into(), definition);
self
}
pub fn with_required_env_secret(
mut self,
key: impl Into<String>,
env_var: impl Into<String>,
description: impl Into<String>,
) -> Self {
let key = key.into();
self.secrets.insert(
key.clone(),
SecretDefinition {
key: key.clone(),
description: Some(description.into()),
required: true,
provider: None,
env_var: Some(env_var.into()),
file_path: None,
file_mode: None,
},
);
self
}
pub fn with_required_file_secret(
mut self,
key: impl Into<String>,
file_path: impl Into<String>,
description: impl Into<String>,
) -> Self {
let key = key.into();
self.secrets.insert(
key.clone(),
SecretDefinition {
key: key.clone(),
description: Some(description.into()),
required: true,
provider: None,
env_var: None,
file_path: Some(file_path.into()),
file_mode: Some("0600".to_string()),
},
);
self
}
pub fn with_provider(mut self, provider: SecretProviderConfig) -> Self {
self.providers.push(provider);
self
}
pub fn keys(&self) -> Vec<&str> {
self.secrets.keys().map(|s| s.as_str()).collect()
}
pub fn required_keys(&self) -> Vec<&str> {
self.secrets
.iter()
.filter(|(_, def)| def.required)
.map(|(k, _)| k.as_str())
.collect()
}
pub fn optional_keys(&self) -> Vec<&str> {
self.secrets
.iter()
.filter(|(_, def)| !def.required)
.map(|(k, _)| k.as_str())
.collect()
}
pub fn get(&self, key: &str) -> Option<&SecretDefinition> {
self.secrets.get(key)
}
pub fn contains(&self, key: &str) -> bool {
self.secrets.contains_key(key)
}
pub fn len(&self) -> usize {
self.secrets.len()
}
pub fn is_empty(&self) -> bool {
self.secrets.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct SecretDefinition {
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_var: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_mode: Option<String>,
}
impl SecretDefinition {
pub fn required(key: impl Into<String>) -> Self {
let key = key.into();
Self {
key: key.clone(),
description: None,
required: true,
provider: None,
env_var: None,
file_path: None,
file_mode: None,
}
}
pub fn optional(key: impl Into<String>) -> Self {
let key = key.into();
Self {
key: key.clone(),
description: None,
required: false,
provider: None,
env_var: None,
file_path: None,
file_mode: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
self.provider = Some(provider.into());
self
}
pub fn inject_as_env(mut self, env_var: impl Into<String>) -> Self {
self.env_var = Some(env_var.into());
self
}
pub fn write_to_file(mut self, path: impl Into<String>) -> Self {
self.file_path = Some(path.into());
self
}
pub fn with_file_mode(mut self, mode: impl Into<String>) -> Self {
self.file_mode = Some(mode.into());
self
}
pub fn has_env_var(&self) -> bool {
self.env_var.is_some()
}
pub fn has_file_path(&self) -> bool {
self.file_path.is_some()
}
pub fn injection_targets(&self) -> Vec<SecretInjectionTarget> {
let mut targets = Vec::new();
if let Some(ref env_var) = self.env_var {
targets.push(SecretInjectionTarget::EnvVar(env_var.clone()));
}
if let Some(ref file_path) = self.file_path {
targets.push(SecretInjectionTarget::File {
path: file_path.clone(),
mode: self.file_mode.clone(),
});
}
targets
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SecretInjectionTarget {
EnvVar(String),
File {
path: String,
mode: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum SecretProviderConfig {
Keychain,
EnvironmentVariable {
prefix: String,
},
File {
path: String,
format: SecretFileFormat,
},
External {
provider_type: ExternalSecretProvider,
#[serde(default)]
config: HashMap<String, String>,
},
}
impl SecretProviderConfig {
pub fn keychain() -> Self {
Self::Keychain
}
pub fn environment_variable(prefix: impl Into<String>) -> Self {
Self::EnvironmentVariable {
prefix: prefix.into(),
}
}
pub fn file(path: impl Into<String>, format: SecretFileFormat) -> Self {
Self::File {
path: path.into(),
format,
}
}
pub fn external(provider_type: ExternalSecretProvider) -> Self {
Self::External {
provider_type,
config: HashMap::new(),
}
}
pub fn name(&self) -> &'static str {
match self {
Self::Keychain => "keychain",
Self::EnvironmentVariable { .. } => "environment",
Self::File { .. } => "file",
Self::External { provider_type, .. } => provider_type.name(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ExternalSecretProvider {
Vault,
AwsSecretsManager,
GcpSecretManager,
AzureKeyVault,
OnePassword,
Doppler,
}
impl ExternalSecretProvider {
pub fn name(&self) -> &'static str {
match self {
Self::Vault => "vault",
Self::AwsSecretsManager => "aws-secrets-manager",
Self::GcpSecretManager => "gcp-secret-manager",
Self::AzureKeyVault => "azure-key-vault",
Self::OnePassword => "1password",
Self::Doppler => "doppler",
}
}
pub fn display_name(&self) -> &'static str {
match self {
Self::Vault => "HashiCorp Vault",
Self::AwsSecretsManager => "AWS Secrets Manager",
Self::GcpSecretManager => "GCP Secret Manager",
Self::AzureKeyVault => "Azure Key Vault",
Self::OnePassword => "1Password",
Self::Doppler => "Doppler",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SecretFileFormat {
Env,
Json,
Yaml,
Raw,
}
impl SecretFileFormat {
pub fn extension(&self) -> &'static str {
match self {
Self::Env => "env",
Self::Json => "json",
Self::Yaml => "yaml",
Self::Raw => "txt",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secrets_config_builder() {
let config = SecretsConfig::new()
.with_required_env_secret("api-key", "API_KEY", "API authentication key")
.with_required_file_secret("db-password", "/run/secrets/db", "Database password");
assert_eq!(config.secrets.len(), 2);
assert!(config.secrets.get("api-key").unwrap().required);
assert!(config.secrets.get("api-key").unwrap().env_var.is_some());
assert!(config.secrets.get("db-password").unwrap().file_path.is_some());
}
#[test]
fn test_secret_definition_builder() {
let secret = SecretDefinition::required("api-key")
.with_description("API key for authentication")
.inject_as_env("API_KEY")
.with_provider("keychain");
assert!(secret.required);
assert_eq!(secret.env_var, Some("API_KEY".to_string()));
assert_eq!(secret.provider, Some("keychain".to_string()));
}
#[test]
fn test_secret_injection_targets() {
let secret = SecretDefinition::required("multi-target")
.inject_as_env("SECRET_VAR")
.write_to_file("/run/secrets/key")
.with_file_mode("0400");
let targets = secret.injection_targets();
assert_eq!(targets.len(), 2);
assert!(targets.contains(&SecretInjectionTarget::EnvVar("SECRET_VAR".to_string())));
assert!(targets.contains(&SecretInjectionTarget::File {
path: "/run/secrets/key".to_string(),
mode: Some("0400".to_string()),
}));
}
#[test]
fn test_secret_provider_config() {
let keychain = SecretProviderConfig::keychain();
assert_eq!(keychain.name(), "keychain");
let env = SecretProviderConfig::environment_variable("SECRET_");
assert_eq!(env.name(), "environment");
let file = SecretProviderConfig::file("/secrets.json", SecretFileFormat::Json);
assert_eq!(file.name(), "file");
let vault = SecretProviderConfig::external(ExternalSecretProvider::Vault);
assert_eq!(vault.name(), "vault");
}
#[test]
fn test_secrets_config_queries() {
let config = SecretsConfig::new()
.with_secret("required-key", SecretDefinition::required("required-key"))
.with_secret("optional-key", SecretDefinition::optional("optional-key"));
assert_eq!(config.required_keys(), vec!["required-key"]);
assert_eq!(config.optional_keys(), vec!["optional-key"]);
assert!(config.contains("required-key"));
assert!(!config.contains("nonexistent"));
}
#[test]
fn test_secrets_config_serialization() {
let config = SecretsConfig::new()
.with_required_env_secret("api-key", "API_KEY", "API key")
.with_provider(SecretProviderConfig::keychain());
let json = serde_json::to_string(&config).unwrap();
let deserialized: SecretsConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.secrets.len(), deserialized.secrets.len());
assert_eq!(config.providers.len(), deserialized.providers.len());
}
#[test]
fn test_external_provider_names() {
assert_eq!(ExternalSecretProvider::Vault.name(), "vault");
assert_eq!(
ExternalSecretProvider::AwsSecretsManager.display_name(),
"AWS Secrets Manager"
);
}
}