1use futures::future::BoxFuture;
2use std::collections::BTreeMap;
3
4pub const MAX_AGE_CAP: u64 = 31557600;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
12pub enum PolicyMode {
13 Enforce,
14 Testing,
15 None,
16}
17
18#[derive(Debug)]
19pub struct MtaStsPolicy {
20 pub mode: PolicyMode,
21 pub mx: Vec<String>,
22 pub max_age: u64,
23 pub fields: BTreeMap<String, Vec<String>>,
24}
25
26impl MtaStsPolicy {
27 pub fn parse(data: &str) -> anyhow::Result<Self> {
28 let mut fields: BTreeMap<String, Vec<String>> = BTreeMap::new();
29
30 for line in data.lines() {
31 if line.is_empty() {
32 continue;
33 }
34 let (key, value) = line
35 .split_once(':')
36 .ok_or_else(|| anyhow::anyhow!("invalid STS policy {data}"))?;
37 let key = key.trim();
38 let value = value.trim();
39
40 fields
41 .entry(key.to_string())
42 .or_default()
43 .push(value.to_string());
44 }
45
46 let version = fields
47 .remove("version")
48 .ok_or_else(|| anyhow::anyhow!("STS policy {data} is missing a version"))?;
49 if version.len() != 1 || version[0] != "STSv1" {
50 anyhow::bail!("STS policy {data} has incompatible STS version");
51 }
52
53 let mode = match fields.remove("mode") {
54 None => anyhow::bail!("STS policy {data} is missing required mode"),
55 Some(mode) if mode.len() == 1 => match mode[0].as_str() {
56 "enforce" => PolicyMode::Enforce,
57 "testing" => PolicyMode::Testing,
58 "none" => PolicyMode::None,
59 _ => anyhow::bail!("STS policy {data} has invalid mode"),
60 },
61 _ => anyhow::bail!("STS policy {data} has invalid mode"),
62 };
63
64 let mut mx = match fields.remove("mx") {
65 None if mode == PolicyMode::None => vec![],
66 None => anyhow::bail!("STS policy {data} is missing required mx"),
67 Some(v) => v,
68 };
69
70 mx.iter_mut()
73 .for_each(|entry| *entry = entry.to_lowercase());
74
75 let max_age: u64 = match fields.remove("max_age") {
76 None => anyhow::bail!("STS policy {data} is missing required max_age"),
77 Some(v) if v.len() == 1 => {
78 let max_age = &v[0];
79 let max_age: u64 = max_age.parse().map_err(|err| {
80 anyhow::anyhow!(
81 "STS policy {data} has max_age {max_age} \
82 that is not a valid integer: {err:#}"
83 )
84 })?;
85 max_age.min(MAX_AGE_CAP)
86 }
87 _ => anyhow::bail!("STS policy {data} has invalid max_age"),
88 };
89
90 Ok(Self {
91 fields,
92 mode,
93 mx,
94 max_age,
95 })
96 }
97
98 pub fn mx_name_matches(&self, name: &str) -> bool {
102 for pattern in &self.mx {
103 if name_match(name, pattern) {
104 return true;
105 }
106 }
107 false
108 }
109}
110
111fn name_match(name: &str, pattern: &str) -> bool {
112 let name = name.trim_end_matches('.');
115
116 if pattern.starts_with("*.") {
117 let suffix = &pattern[1..];
118 if let Some(lhs) = name.strip_suffix(suffix) {
119 return lhs.find('.').is_none();
121 }
122 false
123 } else {
124 name == pattern
125 }
126}
127
128pub trait Get: Sync + Send {
129 fn http_get<'a>(&'a self, url: &'a str) -> BoxFuture<'a, anyhow::Result<String>>;
130}
131
132pub async fn load_policy_for_domain(
133 policy_domain: &str,
134 getter: &dyn Get,
135) -> anyhow::Result<MtaStsPolicy> {
136 let policy_domain = policy_domain.trim_end_matches('.');
140 let url = format!("https://mta-sts.{policy_domain}/.well-known/mta-sts.txt");
141 let policy = getter.http_get(&url).await?;
142 MtaStsPolicy::parse(&policy)
143}
144
145#[cfg(test)]
146pub(crate) mod test {
147 use super::*;
148
149 pub struct TestGetter {
150 policies: BTreeMap<&'static str, &'static str>,
151 }
152
153 impl TestGetter {
154 pub fn new<I: IntoIterator<Item = (&'static str, &'static str)>>(iter: I) -> Self {
155 Self {
156 policies: BTreeMap::from_iter(iter),
157 }
158 }
159 }
160
161 impl Get for TestGetter {
162 fn http_get<'a>(&'a self, url: &'a str) -> BoxFuture<'a, anyhow::Result<String>> {
163 Box::pin(async move {
164 match self.policies.get(url) {
165 Some(result) => Ok(result.to_string()),
166 None => anyhow::bail!("404 {url}"),
167 }
168 })
169 }
170 }
171
172 const SAMPLE_POLICY: &str =
173 "version: STSv1 \nmode: enforce\nmx: mail.example.com\r\nmx:\t*.example.net\nmx: backupmx.example.com\nmax_age: 604800";
174
175 #[tokio::test]
176 async fn get_policy() {
177 let getter = TestGetter::new([(
178 "https://mta-sts.example.com/.well-known/mta-sts.txt",
179 SAMPLE_POLICY,
180 )]);
181
182 k9::snapshot!(
183 load_policy_for_domain("example.com.", &getter)
184 .await
185 .unwrap(),
186 r#"
187MtaStsPolicy {
188 mode: Enforce,
189 mx: [
190 "mail.example.com",
191 "*.example.net",
192 "backupmx.example.com",
193 ],
194 max_age: 604800,
195 fields: {},
196}
197"#
198 );
199 }
200
201 #[test]
202 fn parse_policy() {
203 k9::snapshot!(
204 MtaStsPolicy::parse(SAMPLE_POLICY).unwrap(),
205 r#"
206MtaStsPolicy {
207 mode: Enforce,
208 mx: [
209 "mail.example.com",
210 "*.example.net",
211 "backupmx.example.com",
212 ],
213 max_age: 604800,
214 fields: {},
215}
216"#
217 );
218 }
219
220 #[test]
221 fn parse_clamps_oversized_max_age() {
222 let policy = MtaStsPolicy::parse(
223 "version: STSv1\nmode: enforce\nmx: mail.example.com\nmax_age: 18446744073709551615",
224 )
225 .unwrap();
226 k9::assert_equal!(policy.max_age, MAX_AGE_CAP);
227 }
228
229 #[test]
230 fn name_matching() {
231 assert!(name_match("foo.com", "foo.com"));
232 assert!(name_match("foo.com.", "foo.com"));
233 assert!(!name_match("bar.com", "foo.com"));
234 assert!(name_match("foo.com", "*.com"));
235 assert!(name_match("mx.example.com", "*.example.com"));
236 assert!(!name_match("not.mx.example.com", "*.example.com"));
237 assert!(!name_match("example.com", "*.example.com"));
238 }
239}