1use crate::SpfContext;
2use dns_resolver::{IpDisplay, Resolver};
3use std::fmt::{self, Write};
4use std::time::SystemTime;
5
6fn starts_with_number(input: &str) -> Result<(Option<u32>, &str), String> {
7 let i = input
8 .find(|c: char| !c.is_numeric() && c != '.')
9 .unwrap_or(input.len());
10 if i == 0 {
11 return Ok((None, input));
12 }
13 let number = input[..i]
14 .parse::<u32>()
15 .map_err(|err| format!("error parsing number from {input}: {err}"))?;
16 Ok((Some(number), &input[i..]))
17}
18
19#[derive(Debug)]
20pub(crate) struct MacroSpec {
21 elements: Vec<MacroElement>,
22}
23
24impl MacroSpec {
25 pub(crate) fn parse(s: &str) -> Result<Self, String> {
26 let mut elements = vec![];
27
28 fn add_literal(elements: &mut Vec<MacroElement>, literal: &str) {
29 match elements.last_mut() {
30 Some(MacroElement::Literal(prior)) => {
31 prior.push_str(literal);
32 }
33 _ => {
34 elements.push(MacroElement::Literal(literal.to_string()));
35 }
36 }
37 }
38
39 fn is_macro_literal(c: char) -> bool {
40 let c = c as u32;
41 (0x20..=0x24).contains(&c) || (0x26..=0x7e).contains(&c)
42 }
43
44 let mut s = s;
45 while !s.is_empty() {
46 if s.starts_with("%%") {
47 add_literal(&mut elements, "%");
48 s = &s[2..];
49 continue;
50 }
51 if s.starts_with("%_") {
52 add_literal(&mut elements, " ");
53 s = &s[2..];
54 continue;
55 }
56 if s.starts_with("%-") {
57 add_literal(&mut elements, "%20");
58 s = &s[2..];
59 continue;
60 }
61 if s.starts_with("%{") {
62 if s.len() < 4 {
63 return Err(format!("unexpected end of input in {s}"));
64 }
65
66 let (name, url_escape) = MacroName::parse(
67 s.chars()
68 .nth(2)
69 .ok_or_else(|| format!("unexpected end of input in {s}"))?,
70 )?;
71 let mut transformer_digits = None;
72 let mut reverse = false;
73
74 let remain = if let Ok((n, r)) = starts_with_number(&s[3..]) {
75 transformer_digits = n;
76 r
77 } else {
78 &s[3..]
79 };
80
81 let delimiters = if remain.starts_with('r') {
82 reverse = true;
83 &remain[1..]
84 } else {
85 remain
86 };
87
88 let (delimiters, remain) = delimiters
89 .split_once('}')
90 .ok_or_else(|| format!("expected '}}' to close macro in {s}"))?;
91
92 elements.push(MacroElement::Macro(MacroTerm {
93 name,
94 transformer_digits,
95 reverse,
96 url_escape,
97 delimiters: delimiters.to_string(),
98 }));
99
100 s = remain;
101 continue;
102 }
103
104 if !is_macro_literal(s.chars().next().unwrap()) {
105 return Err(format!("invalid macro char in '{s}'"));
106 }
107
108 add_literal(&mut elements, &s[0..1]);
109 s = &s[1..];
110 }
111
112 Ok(Self { elements })
113 }
114
115 pub(crate) async fn expand(
116 &self,
117 cx: &SpfContext<'_>,
118 resolver: &dyn Resolver,
119 ) -> Result<String, String> {
120 let (mut result, mut buf) = (String::new(), String::new());
121 for element in &self.elements {
122 let m = match element {
123 MacroElement::Literal(t) => {
124 result.push_str(t);
125 continue;
126 }
127 MacroElement::Macro(m) => m,
128 };
129
130 buf.clear();
131 match m.name {
132 MacroName::Sender => buf.push_str(cx.sender),
133 MacroName::LocalPart => buf.push_str(cx.local_part),
134 MacroName::SenderDomain => buf.push_str(cx.sender_domain),
135 MacroName::Domain => buf.push_str(cx.domain),
136 MacroName::ReverseDns => buf.push_str(if cx.client_ip.is_ipv4() {
137 "in-addr"
138 } else {
139 "ip6"
140 }),
141 MacroName::ClientIp => {
142 buf.write_fmt(format_args!("{}", cx.client_ip)).unwrap();
143 }
144 MacroName::Ip => buf
145 .write_fmt(format_args!(
146 "{}",
147 IpDisplay {
148 ip: cx.client_ip,
149 reverse: false
150 }
151 ))
152 .unwrap(),
153 MacroName::CurrentUnixTimeStamp => buf
154 .write_fmt(format_args!(
155 "{}",
156 cx.now
157 .duration_since(SystemTime::UNIX_EPOCH)
158 .map(|d| d.as_secs())
159 .unwrap_or(0)
160 ))
161 .unwrap(),
162 MacroName::HeloDomain => {
163 buf.push_str(cx.ehlo_domain.unwrap_or(""));
164 }
165 MacroName::RelayingHostName => {
166 buf.push_str(cx.relaying_host_name);
167 }
168 MacroName::ValidatedDomainName => {
169 match Box::pin(cx.validated_domain(None, resolver)).await {
170 Ok(Some(name)) => {
171 buf.push_str(&name);
172 }
173 Ok(None) | Err(_) => {
174 buf.push_str("unknown");
175 }
176 }
177 }
178 };
179
180 let delimiters = if m.delimiters.is_empty() {
181 "."
182 } else {
183 &m.delimiters
184 };
185
186 let mut tokens: Vec<&str> = buf.split(|c| delimiters.contains(c)).collect();
187
188 if m.reverse {
189 tokens.reverse();
190 }
191
192 if let Some(n) = m.transformer_digits {
193 let n = n as usize;
194 while tokens.len() > n {
195 tokens.remove(0);
196 }
197 }
198
199 let output = tokens.join(".");
200
201 if m.url_escape {
202 for c in output.chars() {
210 if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '~' {
211 result.push(c);
212 } else {
213 let mut bytes = [0u8; 4];
214 for b in c.encode_utf8(&mut bytes).bytes() {
215 result.push_str(&format!("%{b:02x}"));
216 }
217 }
218 }
219 } else {
220 result.push_str(&output);
221 }
222 }
223
224 Ok(result)
225 }
226}
227
228impl fmt::Display for MacroSpec {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 let mut first = true;
231 for element in &self.elements {
232 if first {
233 first = false;
234 } else {
235 f.write_str(" ")?;
236 }
237
238 match element {
239 MacroElement::Literal(lit) => write!(f, "{lit}")?,
240 MacroElement::Macro(term) => write!(f, "{term}")?,
241 }
242 }
243 Ok(())
244 }
245}
246
247#[derive(Debug)]
248enum MacroElement {
249 Literal(String),
250 Macro(MacroTerm),
251}
252
253#[derive(Debug)]
254struct MacroTerm {
255 pub name: MacroName,
256 pub transformer_digits: Option<u32>,
258 pub url_escape: bool,
260 pub reverse: bool,
262 pub delimiters: String,
264}
265
266impl fmt::Display for MacroTerm {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 write!(f, "%{}{}", self.name.as_char(), self.delimiters)?;
269 if let Some(digits) = self.transformer_digits {
270 write!(f, "{}", digits)?;
271 }
272 if self.reverse {
273 f.write_str("r")?;
274 }
275 if self.url_escape {
276 f.write_str("/")?;
277 }
278 Ok(())
279 }
280}
281
282#[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)]
283enum MacroName {
284 Sender,
286 LocalPart,
288 SenderDomain,
290 Domain,
292 Ip,
294 ValidatedDomainName,
296 ReverseDns,
298 HeloDomain,
300 ClientIp,
302 RelayingHostName,
304 CurrentUnixTimeStamp,
306}
307
308impl MacroName {
309 fn parse(c: char) -> Result<(Self, bool), String> {
310 let escape = c.is_ascii_uppercase();
311 Ok((
312 match c.to_ascii_lowercase() {
313 's' => Self::Sender,
314 'l' => Self::LocalPart,
315 'o' => Self::SenderDomain,
316 'd' => Self::Domain,
317 'i' => Self::Ip,
318 'p' => Self::ValidatedDomainName,
319 'v' => Self::ReverseDns,
320 'h' => Self::HeloDomain,
321 'c' => Self::ClientIp,
322 'r' => Self::RelayingHostName,
323 't' => Self::CurrentUnixTimeStamp,
324 _ => return Err(format!("invalid macro name {c}")),
325 },
326 escape,
327 ))
328 }
329
330 fn as_char(&self) -> char {
331 match self {
332 Self::Sender => 's',
333 Self::LocalPart => 'l',
334 Self::SenderDomain => 'o',
335 Self::Domain => 'd',
336 Self::Ip => 'i',
337 Self::ValidatedDomainName => 'p',
338 Self::ReverseDns => 'v',
339 Self::HeloDomain => 'h',
340 Self::ClientIp => 'c',
341 Self::RelayingHostName => 'r',
342 Self::CurrentUnixTimeStamp => 't',
343 }
344 }
345}
346
347#[cfg(test)]
348mod test {
349 use super::*;
350 use crate::spec::MacroSpec;
351 use dns_resolver::TestResolver;
352 use std::net::IpAddr;
353
354 #[tokio::test]
355 async fn test_eval() {
356 let mut ctx = SpfContext::new(
359 "strong-bad@email.example.com",
360 "email.example.com",
361 IpAddr::from([192, 0, 2, 3]),
362 )
363 .unwrap()
364 .with_ehlo_domain(Some("email.example.com"))
365 .with_relaying_host_name(Some("mx.mbp.com"));
366
367 let resolver = TestResolver::default()
368 .with_zone(
369 r#"
370$ORIGIN 2.0.192.in-addr.arpa.
3713 600 PTR email.example.com.
372 "#,
373 )
374 .unwrap()
375 .with_zone(
376 r#"
377$ORIGIN example.com.
378@ 600 MX 10 email
379 A 192.0.2.3
380email A 192.0.2.3
381 "#,
382 )
383 .unwrap();
384
385 for (input, expect) in &[
386 ("%{s}", "strong-bad@email.example.com"),
387 ("%{o}", "email.example.com"),
388 ("%{d}", "email.example.com"),
389 ("%{d4}", "email.example.com"),
390 ("%{d3}", "email.example.com"),
391 ("%{d2}", "example.com"),
392 ("%{d1}", "com"),
393 ("%{dr}", "com.example.email"),
394 ("%{d2r}", "example.email"),
395 ("%{l}", "strong-bad"),
396 ("%{l-}", "strong.bad"),
397 ("%{lr}", "strong-bad"),
398 ("%{lr-}", "bad.strong"),
399 ("%{l1r-}", "strong"),
400 ("%{h}", "email.example.com"),
401 ("%{h2}", "example.com"),
402 ("%{r}", "mx.mbp.com"),
403 ("%{rr}", "com.mbp.mx"),
404 ("%{p}", "email.example.com"),
405 ] {
406 let spec = MacroSpec::parse(input).unwrap();
407 let output = spec.expand(&ctx, &resolver).await.unwrap();
408 k9::assert_equal!(&output, expect, "{input}");
409 }
410
411 for (input, expect) in &[
412 (
413 "%{ir}.%{v}._spf.%{d2}",
414 "3.2.0.192.in-addr._spf.example.com",
415 ),
416 ("%{lr-}.lp._spf.%{d2}", "bad.strong.lp._spf.example.com"),
417 (
418 "%{lr-}.lp.%{ir}.%{v}._spf.%{d2}",
419 "bad.strong.lp.3.2.0.192.in-addr._spf.example.com",
420 ),
421 (
422 "%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}",
423 "3.2.0.192.in-addr.strong.lp._spf.example.com",
424 ),
425 (
426 "%{d2}.trusted-domains.example.net",
427 "example.com.trusted-domains.example.net",
428 ),
429 ("%{c}", "192.0.2.3"),
430 ] {
431 let spec = MacroSpec::parse(input).unwrap();
432 let output = spec.expand(&ctx, &resolver).await.unwrap();
433 k9::assert_equal!(&output, expect, "{input}");
434 }
435
436 ctx.client_ip = IpAddr::from([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0xcb01]);
437 for (input, expect) in &[
438 (
439 "%{ir}.%{v}._spf.%{d2}",
440 "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0\
441 .0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com",
442 ),
443 ("%{c}", "2001:db8::cb01"),
444 ("%{C}", "2001%3adb8%3a%3acb01"),
445 ] {
446 let spec = MacroSpec::parse(input).unwrap();
447 let output = spec.expand(&ctx, &resolver).await.unwrap();
448 k9::assert_equal!(&output, expect, "{input}");
449 }
450 }
451}