use crate::Result;
use crate::policy::Policy;
use regex::Regex;
use serde_yaml;
use std::fs;
use std::path::{Path, PathBuf};
pub struct PolicyLoader {
base_path: PathBuf,
}
impl PolicyLoader {
pub fn new(base_path: impl Into<PathBuf>) -> Self {
Self {
base_path: base_path.into(),
}
}
pub fn load(&self, policy_path: &Path) -> Result<Policy> {
let mut policy = self.load_yaml(policy_path)?;
if let Some(ref extends_path) = policy.extends.clone() {
let parent_path = self.resolve_path(policy_path, extends_path);
let base_policy = self.load(&parent_path)?;
policy = self.merge_policies(base_policy, policy)?;
}
self.validate(&policy)?;
Ok(policy)
}
pub fn load_from_string(yaml_content: &str) -> Result<Policy> {
let yaml_value: serde_yaml::Value =
serde_yaml::from_str(yaml_content).map_err(|e| crate::TlsError::ParseError {
message: format!("Failed to parse YAML: {}", e),
})?;
let policy_value = if let Some(policy_obj) = yaml_value.get("policy") {
policy_obj.clone()
} else {
yaml_value
};
let policy: Policy =
serde_yaml::from_value(policy_value).map_err(|e| crate::TlsError::ParseError {
message: format!("Failed to parse policy YAML: {}", e),
})?;
let loader = PolicyLoader::new(".");
loader.validate(&policy)?;
Ok(policy)
}
fn load_yaml(&self, path: &Path) -> Result<Policy> {
let content =
fs::read_to_string(path).map_err(|e| crate::TlsError::IoError { source: e })?;
let yaml_value: serde_yaml::Value =
serde_yaml::from_str(&content).map_err(|e| crate::TlsError::ParseError {
message: format!("Failed to parse YAML: {}", e),
})?;
let policy_value = if let Some(policy_obj) = yaml_value.get("policy") {
policy_obj.clone()
} else {
yaml_value
};
serde_yaml::from_value(policy_value).map_err(|e| crate::TlsError::ParseError {
message: format!("Failed to parse policy YAML: {}", e),
})
}
fn resolve_path(&self, current_file: &Path, relative_path: &str) -> PathBuf {
if let Some(parent) = current_file.parent() {
parent.join(relative_path)
} else {
self.base_path.join(relative_path)
}
}
fn merge_policies(&self, mut base: Policy, override_policy: Policy) -> Result<Policy> {
let merged = Policy {
name: override_policy.name,
version: override_policy.version,
description: override_policy.description.or(base.description),
organization: override_policy.organization.or(base.organization),
effective_date: override_policy.effective_date.or(base.effective_date),
extends: None,
protocols: override_policy.protocols.or(base.protocols),
ciphers: override_policy.ciphers.or(base.ciphers),
certificates: override_policy.certificates.or(base.certificates),
vulnerabilities: override_policy.vulnerabilities.or(base.vulnerabilities),
rating: override_policy.rating.or(base.rating),
compliance: override_policy.compliance.or(base.compliance),
exceptions: {
base.exceptions.extend(override_policy.exceptions);
base.exceptions
},
};
Ok(merged)
}
fn validate(&self, policy: &Policy) -> Result<()> {
if policy.name.is_empty() {
return Err(crate::TlsError::ConfigError {
message: "Policy name cannot be empty".to_string(),
});
}
if policy.version.is_empty() {
return Err(crate::TlsError::ConfigError {
message: "Policy version cannot be empty".to_string(),
});
}
if let Some(ref cipher_policy) = policy.ciphers {
if let Some(ref min_strength) = cipher_policy.min_strength
&& !["LOW", "MEDIUM", "HIGH"].contains(&min_strength.as_str())
{
return Err(crate::TlsError::ConfigError {
message: format!(
"Invalid min_strength: {}. Must be LOW, MEDIUM, or HIGH",
min_strength
),
});
}
if let Some(ref patterns) = cipher_policy.prohibited_patterns {
for pattern in patterns {
Regex::new(pattern).map_err(|e| crate::TlsError::ConfigError {
message: format!("Invalid prohibited cipher pattern '{}': {}", pattern, e),
})?;
}
}
if let Some(ref patterns) = cipher_policy.required_patterns {
for pattern in patterns {
Regex::new(pattern).map_err(|e| crate::TlsError::ConfigError {
message: format!("Invalid required cipher pattern '{}': {}", pattern, e),
})?;
}
}
}
if let Some(ref cert_policy) = policy.certificates
&& let Some(min_key_size) = cert_policy.min_key_size
&& min_key_size < 1024
{
return Err(crate::TlsError::ConfigError {
message: "min_key_size must be at least 1024".to_string(),
});
}
if let Some(ref rating_policy) = policy.rating {
if let Some(ref min_grade) = rating_policy.min_grade {
let valid_grades = [
"A+", "A", "A-", "B", "B+", "B-", "C", "C+", "C-", "D", "E", "F", "T", "M",
];
if !valid_grades.contains(&min_grade.as_str()) {
return Err(crate::TlsError::ConfigError {
message: format!(
"Invalid min_grade: {}. Must be one of: {}",
min_grade,
valid_grades.join(", ")
),
});
}
}
if let Some(min_score) = rating_policy.min_score
&& min_score > 100
{
return Err(crate::TlsError::ConfigError {
message: "min_score must be between 0 and 100".to_string(),
});
}
}
for exception in &policy.exceptions {
if exception.reason.is_empty() {
return Err(crate::TlsError::ConfigError {
message: "Exception reason cannot be empty".to_string(),
});
}
if exception.approved_by.is_empty() {
return Err(crate::TlsError::ConfigError {
message: "Exception approved_by cannot be empty".to_string(),
});
}
if let Some(ref expires) = exception.expires {
use chrono::NaiveDate;
NaiveDate::parse_from_str(expires, "%Y-%m-%d").map_err(|_| {
crate::TlsError::ConfigError {
message: format!(
"Invalid exception expiry date '{}'. Must be in YYYY-MM-DD format",
expires
),
}
})?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic_policy() {
let yaml = r#"
policy:
name: "Test Policy"
version: "1.0"
description: "Test policy"
protocols:
required: ["TLSv1.2", "TLSv1.3"]
prohibited: ["SSLv2", "SSLv3"]
action: FAIL
"#;
let result = PolicyLoader::load_from_string(yaml);
assert!(result.is_ok());
let policy = result.expect("test assertion should succeed");
assert_eq!(policy.name, "Test Policy");
assert_eq!(policy.version, "1.0");
assert!(policy.protocols.is_some());
}
#[test]
fn test_validate_min_strength() {
let yaml = r#"
policy:
name: "Test Policy"
version: "1.0"
ciphers:
min_strength: "INVALID"
action: FAIL
"#;
let result = PolicyLoader::load_from_string(yaml);
assert!(result.is_err());
}
#[test]
fn test_validate_regex_patterns() {
let yaml = r#"
policy:
name: "Test Policy"
version: "1.0"
ciphers:
prohibited_patterns:
- ".*_RC4_.*"
- "[invalid regex"
action: FAIL
"#;
let result = PolicyLoader::load_from_string(yaml);
assert!(result.is_err());
}
#[test]
fn test_validate_exception_dates() {
let yaml = r#"
policy:
name: "Test Policy"
version: "1.0"
exceptions:
- domain: "example.com"
rules: ["protocols.prohibited"]
reason: "Test"
expires: "invalid-date"
approved_by: "Admin"
"#;
let result = PolicyLoader::load_from_string(yaml);
assert!(result.is_err());
}
#[test]
fn test_empty_name_validation() {
let yaml = r#"
policy:
name: ""
version: "1.0"
"#;
let result = PolicyLoader::load_from_string(yaml);
assert!(result.is_err());
}
#[test]
fn test_parse_without_policy_wrapper() {
let yaml = r#"
name: "Test Policy"
version: "1.0"
description: "Test policy without wrapper"
protocols:
required: ["TLSv1.2", "TLSv1.3"]
prohibited: ["SSLv2", "SSLv3"]
action: FAIL
"#;
let result = PolicyLoader::load_from_string(yaml);
assert!(result.is_ok());
let policy = result.expect("test assertion should succeed");
assert_eq!(policy.name, "Test Policy");
assert_eq!(policy.version, "1.0");
assert!(policy.protocols.is_some());
}
#[test]
fn test_load_example_policy_file() {
use std::path::PathBuf;
let policy_path =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/policies/base-security.yaml");
if policy_path.exists() {
let loader = PolicyLoader::new(".");
let result = loader.load(&policy_path);
match &result {
Ok(policy) => {
assert_eq!(policy.name, "Base Security Policy");
assert_eq!(policy.version, "1.0");
assert!(policy.protocols.is_some());
assert!(policy.ciphers.is_some());
assert!(policy.certificates.is_some());
}
Err(e) => panic!("Failed to load example policy: {:?}", e),
}
}
}
}