use super::*;
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
global_per_min: default_global_per_min(),
per_ip_per_min: default_per_ip_per_min(),
per_key_per_min: default_per_key_per_min(),
global_burst: default_global_burst(),
per_ip_burst: default_per_ip_burst(),
per_key_burst: default_per_key_burst(),
ip_table_size: default_ip_table_size(),
burst_size: 0,
}
}
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
format: default_log_format(),
level: default_log_level(),
mask_recipient: false,
}
}
}
pub fn load_from_str(toml_str: &str) -> Result<AppConfig, ConfigError> {
let config: AppConfig = toml::from_str(toml_str)?;
validate_config(&config)?;
Ok(config)
}
pub fn load(path: &Path) -> Result<AppConfig, ConfigError> {
let text = std::fs::read_to_string(path)?;
let config: AppConfig = toml::from_str(&text)?;
validate_config(&config)?;
Ok(config)
}
pub fn validate_config(config: &AppConfig) -> Result<(), ConfigError> {
config
.server
.bind_address
.parse::<std::net::SocketAddr>()
.map_err(|_| ConfigError::InvalidBindAddress)?;
config
.mail
.default_from
.parse::<Address>()
.map_err(|_| ConfigError::InvalidDefaultFrom)?;
for cidr in config.security.trusted_source_cidrs.iter()
.chain(config.security.allowed_source_cidrs.iter())
{
cidr.parse::<ipnet::IpNet>()
.map_err(|_| ConfigError::InvalidCidr(cidr.clone()))?;
}
if config.smtp.port == 0 {
return Err(ConfigError::InvalidSmtpPort);
}
match config.smtp.tls.as_str() {
"none" | "starttls" | "tls" => {}
other => return Err(ConfigError::Validation(
format!("smtp.tls must be \"none\", \"starttls\", or \"tls\"; got \"{other}\"")
)),
}
match (&config.smtp.auth_user, &config.smtp.auth_password) {
(Some(_), None) | (None, Some(_)) => {
return Err(ConfigError::Validation(
"smtp.auth_user and smtp.auth_password must both be set or both absent".into(),
));
}
_ => {}
}
match (&config.server.tls_cert, &config.server.tls_key) {
(Some(_), None) | (None, Some(_)) => {
return Err(ConfigError::Validation(
"server.tls_cert and server.tls_key must both be set or both be absent".into()
));
}
(Some(_), Some(_)) => {
#[cfg(not(feature = "tls"))]
return Err(ConfigError::Validation(
"server.tls_cert/tls_key is configured but TLS is not available in this build. Rebuild with: cargo build --features tls".into()
));
}
(None, None) => {}
}
if config.status.enabled {
if config.status.store == "sqlite" {
if config.status.db_path.is_none() {
return Err(ConfigError::Validation(
"status.db_path is required when status.store = \"sqlite\"".into()
));
}
#[cfg(not(feature = "sqlite"))]
return Err(ConfigError::Validation(
"status.store = \"sqlite\" is not available in this build. Rebuild with: cargo build --features sqlite".into()
));
} else if config.status.store == "redis" {
if config.status.redis_url.is_none() {
return Err(ConfigError::Validation(
"status.redis_url is required when status.store = \"redis\"".into()
));
}
#[cfg(not(feature = "redis"))]
return Err(ConfigError::Validation(
"status.store = \"redis\" is not available in this build. Rebuild with: cargo build --features redis".into()
));
} else if !matches!(config.status.store.as_str(), "memory") {
return Err(ConfigError::Validation(
format!("status.store must be \"memory\", \"sqlite\", or \"redis\"; got \"{}\""
, config.status.store)
));
}
}
if config.smtp.mode == "pipe"
&& (config.smtp.auth_user.is_some() || config.smtp.auth_password.is_some())
{
return Err(ConfigError::Validation(
r#"smtp.auth_user/auth_password are not applicable when smtp.mode = "pipe""#.into(),
));
}
if config.rate_limit.global_per_min == 0 || config.rate_limit.per_ip_per_min == 0 {
return Err(ConfigError::InvalidRateLimit);
}
let valid_levels = ["trace", "debug", "info", "warn", "error"];
if !valid_levels.contains(&config.logging.level.as_str()) {
return Err(ConfigError::InvalidLogLevel);
}
let valid_formats = ["text", "json"];
if !valid_formats.contains(&config.logging.format.as_str()) {
return Err(ConfigError::InvalidLogFormat);
}
const MIN_SECRET_LEN: usize = 32;
const BLOCKED: &[&str] = &[
"your-secret-here", "generate-with-openssl-rand-base64-32",
"changeme", "secret", "password", "example-secret", "replace-me",
];
for key in &config.security.api_keys {
let s = key.secret.expose();
if s.len() < MIN_SECRET_LEN {
return Err(ConfigError::Validation(format!(
"api_keys[{}].secret: minimum {} bytes required (got {}). Generate with: openssl rand -base64 32",
key.id, MIN_SECRET_LEN, s.len()
)));
}
if BLOCKED.iter().any(|b| s.contains(b)) {
return Err(ConfigError::Validation(format!(
"api_keys[{}].secret: placeholder value detected. Replace with: openssl rand -base64 32", key.id
)));
}
}
if config.security.api_keys.is_empty() {
return Err(ConfigError::NoApiKeys);
}
if !config.security.api_keys.iter().any(|k| k.enabled) {
return Err(ConfigError::NoEnabledApiKeys);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_config_str() -> String {
r#"
[server]
bind_address = "127.0.0.1:8080"
[[security.api_keys]]
id = "test"
secret = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
enabled = true
[rate_limit]
[mail]
default_from = "noreply@example.com"
[smtp]
[logging]
"#
.into()
}
#[test]
fn valid_config_parses() {
let config: AppConfig = toml::from_str(&minimal_config_str()).unwrap();
assert!(validate_config(&config).is_ok());
}
#[test]
fn invalid_bind_address() {
let text = minimal_config_str().replace("127.0.0.1:8080", "notanaddress");
let config: AppConfig = toml::from_str(&text).unwrap();
assert!(matches!(validate_config(&config), Err(ConfigError::InvalidBindAddress)));
}
#[test]
fn invalid_default_from() {
let text = minimal_config_str().replace("noreply@example.com", "notanemail");
let config: AppConfig = toml::from_str(&text).unwrap();
assert!(matches!(validate_config(&config), Err(ConfigError::InvalidDefaultFrom)));
}
#[test]
fn secret_string_is_redacted_in_debug() {
let s = SecretString::new("very-secret");
assert!(!format!("{:?}", s).contains("very-secret"));
assert!(!format!("{}", s).contains("very-secret"));
assert_eq!(s.expose(), "very-secret");
}
#[test]
fn defaults_are_sensible() {
let config: AppConfig = toml::from_str(&minimal_config_str()).unwrap();
assert_eq!(config.server.max_request_body_bytes, 1_048_576);
assert_eq!(config.smtp.port, 25);
assert_eq!(config.rate_limit.global_per_min, 60);
assert_eq!(config.logging.format, "text");
}
}