kumo_spf/
spec.rs

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                // https://datatracker.ietf.org/doc/html/rfc7208#section-7.3:
203                //   Uppercase macros expand exactly as their lowercase
204                //   equivalents, and are then URL escaped.  URL escaping
205                //   MUST be performed for characters not in the
206                //   "unreserved" set.
207                // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3:
208                //    unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
209                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    /// digits were present in the transformer section
257    pub transformer_digits: Option<u32>,
258    /// The output needs to be URL-escaped
259    pub url_escape: bool,
260    /// the `r` transformer was present
261    pub reverse: bool,
262    /// The list of delimiters, if any, otherwise an empty string
263    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    /// `s` - <sender>
285    Sender,
286    /// `l` - local-part of <sender>
287    LocalPart,
288    /// `o` - domain of <sender>
289    SenderDomain,
290    /// `d` - <domain>
291    Domain,
292    /// `i` - <ip>
293    Ip,
294    /// `p` - the validated domain name of <ip> (do not use)
295    ValidatedDomainName,
296    /// `v` the string `in-addr` if <ip> is ipv4, or `ip6` is <ip> is ipv6
297    ReverseDns,
298    /// `h` the HELO/EHLO domain
299    HeloDomain,
300    /// `c` - only in "exp" text: the SMTP client IP (easily readable format)
301    ClientIp,
302    /// `r` - only in "exp" text: domain name of host performing the check
303    RelayingHostName,
304    /// `t` - only in "exp" text: the current timestamp
305    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        // <https://datatracker.ietf.org/doc/html/rfc7208#section-7.4>
357
358        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}