use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct SslConfig {
pub enabled: bool,
pub cert_file: String,
pub key_file: String,
pub ca_file: Option<String>,
pub require_client_cert: bool,
}
impl SslConfig {
pub fn validate(&self) -> Result<(), String> {
if self.enabled {
if self.cert_file.is_empty() {
return Err("Certificate file must be specified when SSL is enabled".to_string());
}
if self.key_file.is_empty() {
return Err("Key file must be specified when SSL is enabled".to_string());
}
}
Ok(())
}
pub fn is_configured(&self) -> bool {
self.enabled && !self.cert_file.is_empty() && !self.key_file.is_empty()
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct AuditConfig {
pub enabled: bool,
pub log_file: String,
pub log_success: bool,
pub log_failure: bool,
pub log_query_content: bool,
pub max_file_size_mb: u64,
pub max_files: u32,
}
impl AuditConfig {
pub fn new() -> Self {
Self {
enabled: false,
log_file: "audit.log".to_string(),
log_success: true,
log_failure: true,
log_query_content: false,
max_file_size_mb: 100,
max_files: 10,
}
}
}
impl Default for AuditConfig {
fn default() -> Self {
Self::new()
}
}
impl AuditConfig {
pub fn validate(&self) -> Result<(), String> {
if self.log_file.is_empty() {
return Err("Audit log file path cannot be empty".to_string());
}
crate::validate_log_file_field(
"security.audit.log_file",
&self.log_file,
self.max_file_size_mb,
self.max_files,
)?;
Ok(())
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct PasswordPolicyConfig {
pub min_length: usize,
pub require_uppercase: bool,
pub require_lowercase: bool,
pub require_digit: bool,
pub require_special: bool,
pub max_age_days: u64,
pub history_size: usize,
}
pub const SPECIAL_CHARACTERS: &str = "!@#$%^&*()-_=+[]{}|;:',.<>?/`~\"\\";
impl Default for PasswordPolicyConfig {
fn default() -> Self {
Self {
min_length: 8,
require_uppercase: true,
require_lowercase: true,
require_digit: true,
require_special: false,
max_age_days: 0, history_size: 0, }
}
}
impl PasswordPolicyConfig {
pub fn validate(&self) -> Result<(), String> {
if self.min_length < 8 {
return Err("Minimum password length must be at least 8".to_string());
}
Ok(())
}
pub fn validate_password(&self, username: &str, password: &str) -> Result<(), String> {
if password.chars().count() < self.min_length {
return Err(format!(
"password must be at least {} characters long",
self.min_length
));
}
if self.require_uppercase && !password.chars().any(|c| c.is_ascii_uppercase()) {
return Err("password must contain at least one uppercase letter".to_string());
}
if self.require_lowercase && !password.chars().any(|c| c.is_ascii_lowercase()) {
return Err("password must contain at least one lowercase letter".to_string());
}
if self.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
return Err("password must contain at least one digit".to_string());
}
if self.require_special && !password.chars().any(|c| SPECIAL_CHARACTERS.contains(c)) {
return Err("password must contain at least one special character".to_string());
}
if !username.is_empty() && password.to_lowercase().contains(&username.to_lowercase()) {
return Err("password must not contain the username".to_string());
}
Ok(())
}
pub fn has_complexity_requirements(&self) -> bool {
self.require_uppercase
|| self.require_lowercase
|| self.require_digit
|| self.require_special
}
pub fn has_expiration(&self) -> bool {
self.max_age_days > 0
}
pub fn has_history(&self) -> bool {
self.history_size > 0
}
}
#[derive(Debug, Default, Deserialize, Serialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct SecurityConfig {
#[serde(default)]
pub audit: AuditConfig,
#[serde(default)]
pub password_policy: PasswordPolicyConfig,
}
impl SecurityConfig {
pub fn validate(&self) -> Result<(), String> {
self.audit.validate()?;
self.password_policy.validate()?;
Ok(())
}
pub fn is_audit_enabled(&self) -> bool {
self.audit.enabled
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ssl_config_default() {
let config = SslConfig::default();
assert!(!config.enabled);
assert!(!config.is_configured());
}
#[test]
fn test_ssl_config_validate() {
let config = SslConfig {
enabled: true,
cert_file: "cert.pem".to_string(),
key_file: "key.pem".to_string(),
..Default::default()
};
assert!(config.validate().is_ok());
assert!(config.is_configured());
let config = SslConfig {
enabled: true,
cert_file: String::new(),
key_file: String::new(),
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn test_audit_config_default() {
let config = AuditConfig::default();
assert!(!config.enabled);
assert_eq!(config.log_file, "audit.log");
assert!(config.log_success);
assert!(config.log_failure);
}
#[test]
fn test_audit_config_rejects_relative_directory() {
let config = AuditConfig::default();
assert!(config.validate().is_ok());
let nested = AuditConfig {
log_file: "logs/audit.log".to_string(),
..Default::default()
};
assert!(nested.validate().is_err());
let absolute = AuditConfig {
log_file: "/var/log/linkrs/audit.log".to_string(),
..Default::default()
};
assert!(absolute.validate().is_ok());
}
#[test]
fn test_password_policy_config_default() {
let config = PasswordPolicyConfig::default();
assert_eq!(config.min_length, 8);
assert!(config.require_uppercase);
assert!(config.require_lowercase);
assert!(config.require_digit);
assert!(!config.require_special);
assert!(config.has_complexity_requirements());
assert!(!config.has_expiration());
assert!(!config.has_history());
}
#[test]
fn test_password_policy_min_length_floor() {
let weak_floor = PasswordPolicyConfig {
min_length: 6,
..Default::default()
};
assert!(weak_floor.validate().is_err());
let floor = PasswordPolicyConfig {
min_length: 8,
..Default::default()
};
assert!(floor.validate().is_ok());
}
#[test]
fn test_password_policy_validate_password() {
let policy = PasswordPolicyConfig::default();
assert!(policy.validate_password("alice", "Str0ngPass").is_ok());
assert!(policy.validate_password("alice", "Short1").is_err());
assert!(policy.validate_password("alice", "alllowercase1").is_err());
assert!(policy.validate_password("alice", "ALLUPPERCASE1").is_err());
assert!(policy.validate_password("alice", "NoDigitsHere").is_err());
assert!(policy.validate_password("alice", "Alice2024X").is_err());
let relaxed = PasswordPolicyConfig {
require_special: true,
..Default::default()
};
assert!(relaxed.validate_password("bob", "Str0ngPass").is_err());
assert!(relaxed.validate_password("bob", "Str0ngPass!").is_ok());
}
#[test]
fn test_security_config_default() {
let config = SecurityConfig::default();
assert!(!config.is_audit_enabled());
assert!(config.validate().is_ok());
}
}