use crate::error::ConfigError;
use crate::utils::FileFormat;
use figment::providers::{Env, Format, Json, Serialized, Toml, Yaml};
use figment::{Figment, Profile};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Clone, Default)]
pub struct ConfigBuilder {
figment: Figment,
defaults: Vec<(String, serde_json::Value)>,
}
impl ConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn set_default<K, V>(mut self, key: K, value: V) -> Result<Self, ConfigError>
where
K: AsRef<str>,
V: Serialize + Into<serde_json::Value>,
{
let key_str = key.as_ref().to_string();
let json_value = serde_json::to_value(&value).map_err(|e| {
ConfigError::SerializationError(format!("Failed to serialize default value: {}", e))
})?;
self.defaults.push((key_str, json_value));
Ok(self)
}
pub fn add_source<S>(mut self, source: S) -> Self
where
S: Into<Source>,
{
let source = source.into();
self.figment = self.figment.merge(source.into_figment());
self
}
pub fn build<T>(self) -> Result<T, ConfigError>
where
T: DeserializeOwned + Serialize,
{
let mut figment = self.figment;
let mut defaults_map = serde_json::Map::new();
for (key, value) in self.defaults {
insert_nested_value(&mut defaults_map, &key, value)?;
}
if !defaults_map.is_empty() {
let defaults_value = serde_json::Value::Object(defaults_map);
figment = figment.merge(figment::providers::Serialized::defaults(defaults_value));
}
figment.extract().map_err(|e| {
ConfigError::ParseError(format!(
"Failed to extract configuration: {}. Check if all required fields are provided and have correct types.",
e
))
})
}
#[cfg(feature = "validation")]
pub fn build_with_validation<T>(self) -> Result<T, ConfigError>
where
T: DeserializeOwned + Serialize + validator::Validate,
{
let config = self.build::<T>()?;
config.validate().map_err(|e| {
ConfigError::ValidationError(format!("Configuration validation failed: {}", e))
})?;
Ok(config)
}
pub fn clear_defaults(mut self) -> Self {
self.defaults.clear();
self
}
pub fn defaults_count(&self) -> usize {
self.defaults.len()
}
}
pub(crate) fn insert_nested_value(
map: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: serde_json::Value,
) -> Result<(), ConfigError> {
if key.is_empty() {
return Err(ConfigError::ParseError("Key cannot be empty".to_string()));
}
let parts: Vec<&str> = key.split('.').collect();
if parts.len() == 1 {
map.insert(parts[0].to_string(), value);
return Ok(());
}
let current_key = parts[0].to_string();
let remaining_key = parts[1..].join(".");
if !map.contains_key(¤t_key) {
map.insert(
current_key.clone(),
serde_json::Value::Object(serde_json::Map::new()),
);
} else {
if !matches!(map[¤t_key], serde_json::Value::Object(_)) {
return Err(ConfigError::ParseError(format!(
"Cannot set nested value '{}' because '{}' is not an object",
remaining_key, current_key
)));
}
}
if let serde_json::Value::Object(ref mut nested_map) = map[¤t_key] {
insert_nested_value(nested_map, &remaining_key, value)?;
}
Ok(())
}
#[derive(Clone)]
pub enum Source {
File(FileSource),
Environment(EnvironmentSource),
}
impl std::fmt::Debug for Source {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Source::File(file) => write!(f, "File({:?})", file),
Source::Environment(env) => write!(f, "Environment({:?})", env),
}
}
}
impl Source {
fn into_figment(self) -> Figment {
match self {
Source::File(file) => file.into_figment(),
Source::Environment(env) => env.into_figment(),
}
}
}
impl From<FileSource> for Source {
fn from(file: FileSource) -> Self {
Source::File(file)
}
}
impl From<EnvironmentSource> for Source {
fn from(env: EnvironmentSource) -> Self {
Source::Environment(env)
}
}
#[derive(Clone, Debug)]
pub struct FileSource {
name: PathBuf,
format: Option<FileFormat>,
required: bool,
}
impl FileSource {
pub fn with_name(name: impl AsRef<Path>) -> Self {
let path = name.as_ref();
if path.as_os_str().is_empty() {
#[cfg(feature = "tracing")]
tracing::warn!("File path is empty, using default configuration");
return Self {
name: PathBuf::from("config"),
format: None,
required: false,
};
}
let path_str = path.to_string_lossy();
let suspicious_patterns = [
"..", "./", "//", "\\", "%2e%2e", "%2e%2e/", "..%2f", ];
let is_suspicious = suspicious_patterns
.iter()
.any(|pattern| path_str.contains(pattern));
if is_suspicious {
#[cfg(feature = "tracing")]
tracing::error!(
"Path contains suspicious patterns that may indicate a path traversal attempt: {}. Using safe default.",
path_str
);
return Self {
name: PathBuf::from("config"),
format: None,
required: false,
};
}
let sensitive_prefixes = [
"/etc/",
"/usr/",
"/var/log/",
"/root/",
"/home/",
"C:\\Windows\\",
"C:\\Program Files\\",
];
let lower_path = path_str.to_lowercase();
let is_sensitive = sensitive_prefixes
.iter()
.any(|prefix| lower_path.starts_with(prefix));
if is_sensitive {
#[cfg(feature = "tracing")]
tracing::error!(
"Path points to sensitive system directory: {}. Using safe default.",
path_str
);
return Self {
name: PathBuf::from("config"),
format: None,
required: false,
};
}
let canonical_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let canonical_str = canonical_path.to_string_lossy();
let canonical_sensitive = sensitive_prefixes
.iter()
.any(|prefix| canonical_str.to_lowercase().starts_with(prefix));
if canonical_sensitive {
#[cfg(feature = "tracing")]
tracing::error!(
"Resolved path points to sensitive system directory: {}. Using safe default.",
canonical_str
);
return Self {
name: PathBuf::from("config"),
format: None,
required: false,
};
}
Self {
name: canonical_path,
format: None,
required: false,
}
}
pub fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
pub fn format(mut self, format: FileFormat) -> Self {
self.format = Some(format);
self
}
fn into_figment(self) -> Figment {
let path = self.name;
let format = self
.format
.or_else(|| {
path.extension()
.and_then(|ext| ext.to_str())
.and_then(|ext| FileFormat::from_str(ext).ok())
})
.unwrap_or(FileFormat::Toml);
match format {
FileFormat::Toml => Figment::from(Toml::file(path)),
FileFormat::Json => Figment::from(Json::file(path)),
FileFormat::Yaml => Figment::from(Yaml::file(path)),
FileFormat::Ini => {
let content = std::fs::read_to_string(&path).unwrap_or_default();
let ini_value = serde_ini::from_str::<serde_json::Value>(&content)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
Figment::from(Serialized::from(ini_value, Profile::Default))
}
FileFormat::Unknown => Figment::from(Toml::file(path)), }
}
}
#[derive(Clone, Debug)]
pub struct EnvironmentSource {
prefix: Option<String>,
separator: String,
}
impl EnvironmentSource {
pub fn with_prefix(prefix: impl Into<String>) -> Self {
let prefix_str = prefix.into();
if prefix_str.is_empty() {
#[cfg(feature = "tracing")]
tracing::warn!("Empty prefix for environment variables, no prefix will be applied");
}
Self {
prefix: if prefix_str.is_empty() {
None
} else {
Some(prefix_str)
},
separator: "_".to_string(),
}
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
let sep = separator.into();
if sep.is_empty() {
#[cfg(feature = "tracing")]
tracing::warn!(
"Empty separator for environment variables may cause unexpected behavior"
);
}
self.separator = sep;
self
}
fn into_figment(self) -> Figment {
if let Some(prefix) = self.prefix {
Figment::from(Env::prefixed(&prefix).split(&self.separator))
} else {
Figment::from(Env::raw())
}
}
}
pub type File = FileSource;
pub type Environment = EnvironmentSource;
pub trait ConfigSaveExt {
fn save(&self, path: impl AsRef<std::path::Path>) -> Result<u64, ConfigError>;
fn save_to_with_format(
&self,
path: impl AsRef<std::path::Path>,
format: FileFormat,
) -> Result<u64, ConfigError>;
}
impl<T> ConfigSaveExt for T
where
T: Serialize,
{
fn save(&self, path: impl AsRef<std::path::Path>) -> Result<u64, ConfigError> {
let ext = path
.as_ref()
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase())
.unwrap_or_else(|| "json".to_string());
let format = match ext.as_str() {
"toml" => FileFormat::Toml,
"yaml" | "yml" => FileFormat::Yaml,
"ini" => FileFormat::Ini,
_ => FileFormat::Json,
};
self.save_to_with_format(path, format)
}
fn save_to_with_format(
&self,
path: impl AsRef<std::path::Path>,
format: FileFormat,
) -> Result<u64, ConfigError> {
use std::fs::File;
use std::io::Write;
let data = serde_json::to_value(self).map_err(|e| {
ConfigError::SerializationError(format!("Failed to serialize config: {}", e))
})?;
let content = crate::utils::file_format::serialize_to_format(&data, format)
.map_err(ConfigError::SerializationError)?;
let mut file = File::create(path)
.map_err(|e| ConfigError::IoError(format!("Failed to create file: {}", e)))?;
file.write_all(content.as_bytes())
.map_err(|e| ConfigError::IoError(format!("Failed to write file: {}", e)))?;
Ok(content.len() as u64)
}
}