use crate::{NylError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
mod null;
pub use null::NullProvider;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SecretValue {
String(String),
Object(HashMap<String, serde_json::Value>),
Array(Vec<serde_json::Value>),
Value(serde_json::Value),
}
impl From<String> for SecretValue {
fn from(s: String) -> Self {
SecretValue::String(s)
}
}
impl From<&str> for SecretValue {
fn from(s: &str) -> Self {
SecretValue::String(s.to_string())
}
}
pub trait SecretProvider: Send + Sync {
fn init(&mut self, config_file: &Path) -> Result<()>;
fn keys(&self) -> Result<Vec<String>>;
fn get(&self, key: &str) -> Result<SecretValue>;
fn set(&mut self, key: &str, value: SecretValue) -> Result<()>;
fn unset(&mut self, key: &str) -> Result<()>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum SecretProviderConfig {
Null(NullProviderConfig),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NullProviderConfig {
}
impl Default for SecretProviderConfig {
fn default() -> Self {
SecretProviderConfig::Null(NullProviderConfig::default())
}
}
pub struct SecretsConfig {
pub file: Option<PathBuf>,
provider: Box<dyn SecretProvider>,
}
impl std::fmt::Debug for SecretsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretsConfig")
.field("file", &self.file)
.field("provider", &"<SecretProvider>")
.finish()
}
}
impl SecretsConfig {
pub const FILENAMES: &'static [&'static str] = &["nyl-secrets.yaml", "nyl-secrets.json"];
pub fn load(file: Option<PathBuf>) -> Result<Self> {
Self::load_from_dir(file, None)
}
pub fn load_from_dir(file: Option<PathBuf>, dir: Option<&Path>) -> Result<Self> {
let file = match file {
Some(f) => Some(f),
None => Self::find_secrets_file_in_dir(dir)?,
};
if let Some(ref path) = file {
Self::load_from_file(path)
} else {
Ok(Self {
file: None,
provider: Box::new(NullProvider::new()),
})
}
}
fn find_secrets_file_in_dir(dir: Option<&Path>) -> Result<Option<PathBuf>> {
crate::util::fs::find_config_file(Self::FILENAMES, dir, false)
}
fn load_from_file(path: &Path) -> Result<Self> {
if !path.exists() {
return Err(NylError::Config(format!(
"Secrets file does not exist: {}",
path.display()
)));
}
tracing::debug!("Reading secrets file: {}", path.display());
let contents = std::fs::read_to_string(path)?;
let config: SecretProviderConfig = if path.extension().and_then(|s| s.to_str()) == Some("json") {
serde_json::from_str(&contents)
.map_err(|e| NylError::Config(format!("Failed to parse secrets JSON: {}", e)))?
} else {
serde_norway::from_str(&contents)
.map_err(|e| NylError::Config(format!("Failed to parse secrets YAML: {}", e)))?
};
let mut provider = Self::create_provider(&config)?;
provider.init(path)?;
Ok(Self {
file: Some(path.to_path_buf()),
provider,
})
}
fn create_provider(config: &SecretProviderConfig) -> Result<Box<dyn SecretProvider>> {
match config {
SecretProviderConfig::Null(_) => Ok(Box::new(NullProvider::new())),
}
}
pub fn get(&self, key: &str) -> Result<SecretValue> {
self.provider.get(key)
}
pub fn set(&mut self, key: &str, value: SecretValue) -> Result<()> {
self.provider.set(key, value)
}
pub fn unset(&mut self, key: &str) -> Result<()> {
self.provider.unset(key)
}
pub fn keys(&self) -> Result<Vec<String>> {
self.provider.keys()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_secret_value_string() {
let value = SecretValue::from("test-secret");
match value {
SecretValue::String(s) => assert_eq!(s, "test-secret"),
_ => panic!("Expected String variant"),
}
}
#[test]
fn test_secret_value_deserialization() {
let json = r#""simple-string""#;
let value: SecretValue = serde_json::from_str(json).unwrap();
assert_eq!(value, SecretValue::String("simple-string".to_string()));
let json = r#"{"key": "value"}"#;
let value: SecretValue = serde_json::from_str(json).unwrap();
match value {
SecretValue::Object(map) => {
assert_eq!(map.get("key").unwrap(), "value");
}
_ => panic!("Expected Object variant"),
}
}
#[test]
fn test_secrets_config_default() {
let temp = TempDir::new().unwrap();
let config = SecretsConfig::load_from_dir(None, Some(temp.path())).unwrap();
assert!(config.file.is_none());
assert!(config.keys().unwrap().is_empty());
}
#[test]
fn test_secrets_config_load_null_provider() {
let temp = TempDir::new().unwrap();
let secrets_path = temp.path().join("nyl-secrets.yaml");
fs::write(&secrets_path, "type: null\n").unwrap();
let config = SecretsConfig::load_from_file(&secrets_path).unwrap();
assert_eq!(config.file, Some(secrets_path));
assert!(config.keys().unwrap().is_empty());
}
#[test]
fn test_secrets_config_operations() {
let mut config = SecretsConfig {
file: None,
provider: Box::new(NullProvider::new()),
};
config.set("api_key", SecretValue::from("secret-123")).unwrap();
let value = config.get("api_key").unwrap();
assert_eq!(value, SecretValue::String("secret-123".to_string()));
let keys = config.keys().unwrap();
assert_eq!(keys, vec!["api_key"]);
config.unset("api_key").unwrap();
assert!(config.keys().unwrap().is_empty());
}
#[test]
fn test_secrets_config_missing_file() {
let temp = TempDir::new().unwrap();
let missing = temp.path().join("missing.yaml");
let result = SecretsConfig::load_from_file(&missing);
assert!(result.is_err());
}
#[test]
fn test_secret_provider_config_deserialization() {
let yaml = "type: null\n";
let config: SecretProviderConfig = serde_norway::from_str(yaml).unwrap();
assert!(matches!(config, SecretProviderConfig::Null(_)));
}
}