use crate::error::MtaStsError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PolicyMode {
Enforce,
Testing,
None,
}
impl PolicyMode {
fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"enforce" => Some(Self::Enforce),
"testing" => Some(Self::Testing),
"none" => Some(Self::None),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
pub mode: PolicyMode,
pub mx: Vec<String>,
pub max_age: u64,
}
impl Policy {
pub fn parse(body: &str) -> Result<Self, MtaStsError> {
let mut version_seen = false;
let mut mode: Option<PolicyMode> = None;
let mut mx: Vec<String> = Vec::new();
let mut max_age: Option<u64> = None;
for line in body.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
let value = value.trim();
match key.as_str() {
"version" => {
if !value.eq_ignore_ascii_case("STSv1") {
return Err(MtaStsError::UnsupportedVersion(value.to_string()));
}
version_seen = true;
}
"mode" => {
mode = Some(
PolicyMode::parse(value)
.ok_or_else(|| MtaStsError::InvalidMode(value.to_string()))?,
);
}
"mx" if !value.is_empty() => {
mx.push(value.to_ascii_lowercase());
}
"mx" => {} "max_age" => {
max_age = Some(
value
.parse()
.map_err(|_| MtaStsError::InvalidMaxAge(value.to_string()))?,
);
}
_ => {}
}
}
if !version_seen {
return Err(MtaStsError::MissingField("version"));
}
let mode = mode.ok_or(MtaStsError::MissingField("mode"))?;
if mx.is_empty() {
return Err(MtaStsError::MissingField("mx"));
}
let max_age = max_age.ok_or(MtaStsError::MissingField("max_age"))?;
Ok(Self { mode, mx, max_age })
}
}
#[cfg(test)]
mod tests {
use super::*;
const SIMPLE_POLICY: &str = "\
version: STSv1
mode: enforce
mx: mail.example.com
max_age: 604800
";
const WILDCARD_POLICY: &str = "\
version: STSv1
mode: enforce
mx: *.example.com
mx: backup.example.com
max_age: 86400
";
#[test]
fn parse_minimal() {
let p = Policy::parse(SIMPLE_POLICY).unwrap();
assert_eq!(p.mode, PolicyMode::Enforce);
assert_eq!(p.mx, vec!["mail.example.com"]);
assert_eq!(p.max_age, 604800);
}
#[test]
fn parse_wildcard_and_multiple_mx() {
let p = Policy::parse(WILDCARD_POLICY).unwrap();
assert_eq!(p.mx, vec!["*.example.com", "backup.example.com"]);
}
#[test]
fn parse_mode_testing() {
let body = "version: STSv1\nmode: testing\nmx: x.example\nmax_age: 86400";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mode, PolicyMode::Testing);
}
#[test]
fn parse_mode_none() {
let body = "version: STSv1\nmode: none\nmx: x.example\nmax_age: 86400";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mode, PolicyMode::None);
}
#[test]
fn parse_crlf_line_endings() {
let body = "version: STSv1\r\nmode: enforce\r\nmx: m.example\r\nmax_age: 86400";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mode, PolicyMode::Enforce);
assert_eq!(p.mx, vec!["m.example"]);
}
#[test]
fn parse_case_insensitive_field_names() {
let body = "Version: STSv1\nMode: Enforce\nMX: m.example\nMax_Age: 86400";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mode, PolicyMode::Enforce);
}
#[test]
fn parse_skips_blank_and_comment_lines() {
let body = "\
version: STSv1
# this is a comment
mode: enforce
mx: m.example
max_age: 86400
";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mx, vec!["m.example"]);
}
#[test]
fn parse_ignores_unknown_fields() {
let body = "version: STSv1\nmode: enforce\nmx: m.example\nmax_age: 86400\nfuture_field: x";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mx, vec!["m.example"]);
}
#[test]
fn parse_lowercases_mx_hostnames() {
let body = "version: STSv1\nmode: enforce\nmx: Mail.Example.COM\nmax_age: 86400";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mx, vec!["mail.example.com"]);
}
#[test]
fn parse_rejects_missing_version() {
let body = "mode: enforce\nmx: m.example\nmax_age: 86400";
let r = Policy::parse(body);
assert!(matches!(r, Err(MtaStsError::MissingField("version"))));
}
#[test]
fn parse_rejects_unsupported_version() {
let body = "version: STSv2\nmode: enforce\nmx: m.example\nmax_age: 86400";
let r = Policy::parse(body);
assert!(matches!(r, Err(MtaStsError::UnsupportedVersion(_))));
}
#[test]
fn parse_rejects_missing_mode() {
let body = "version: STSv1\nmx: m.example\nmax_age: 86400";
let r = Policy::parse(body);
assert!(matches!(r, Err(MtaStsError::MissingField("mode"))));
}
#[test]
fn parse_rejects_missing_mx() {
let body = "version: STSv1\nmode: enforce\nmax_age: 86400";
let r = Policy::parse(body);
assert!(matches!(r, Err(MtaStsError::MissingField("mx"))));
}
#[test]
fn parse_rejects_missing_max_age() {
let body = "version: STSv1\nmode: enforce\nmx: m.example";
let r = Policy::parse(body);
assert!(matches!(r, Err(MtaStsError::MissingField("max_age"))));
}
#[test]
fn parse_rejects_invalid_mode() {
let body = "version: STSv1\nmode: garbage\nmx: m.example\nmax_age: 86400";
let r = Policy::parse(body);
assert!(matches!(r, Err(MtaStsError::InvalidMode(_))));
}
#[test]
fn parse_rejects_invalid_max_age() {
let body = "version: STSv1\nmode: enforce\nmx: m.example\nmax_age: notanumber";
let r = Policy::parse(body);
assert!(matches!(r, Err(MtaStsError::InvalidMaxAge(_))));
}
#[test]
fn parse_empty_mx_value_skipped() {
let body = "version: STSv1\nmode: enforce\nmx:\nmx: real.example\nmax_age: 86400";
let p = Policy::parse(body).unwrap();
assert_eq!(p.mx, vec!["real.example"]);
}
}