kumo_spf/
spec.rs

1use crate::SpfContext;
2use dns_resolver::IpDisplay;
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            (0x21..=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) fn expand(&self, cx: &SpfContext<'_>) -> Result<String, String> {
116        let (mut result, mut buf) = (String::new(), String::new());
117        for element in &self.elements {
118            let m = match element {
119                MacroElement::Literal(t) => {
120                    result.push_str(t);
121                    continue;
122                }
123                MacroElement::Macro(m) => m,
124            };
125
126            buf.clear();
127            match m.name {
128                MacroName::Sender => buf.push_str(cx.sender),
129                MacroName::LocalPart => buf.push_str(cx.local_part),
130                MacroName::SenderDomain => buf.push_str(cx.sender_domain),
131                MacroName::Domain => buf.push_str(cx.domain),
132                MacroName::ReverseDns => buf.push_str(match cx.client_ip.is_ipv4() {
133                    true => "in-addr",
134                    false => "ip6",
135                }),
136                MacroName::ClientIp => {
137                    buf.write_fmt(format_args!("{}", cx.client_ip)).unwrap();
138                }
139                MacroName::Ip => buf
140                    .write_fmt(format_args!(
141                        "{}",
142                        IpDisplay {
143                            ip: cx.client_ip,
144                            reverse: false
145                        }
146                    ))
147                    .unwrap(),
148                MacroName::CurrentUnixTimeStamp => buf
149                    .write_fmt(format_args!(
150                        "{}",
151                        cx.now
152                            .duration_since(SystemTime::UNIX_EPOCH)
153                            .map(|d| d.as_secs())
154                            .unwrap_or(0)
155                    ))
156                    .unwrap(),
157                MacroName::RelayingHostName
158                | MacroName::HeloDomain
159                | MacroName::ValidatedDomainName => {
160                    return Err(format!("{:?} has not been implemented", m.name))
161                }
162            };
163
164            let delimiters = if m.delimiters.is_empty() {
165                "."
166            } else {
167                &m.delimiters
168            };
169
170            let mut tokens: Vec<&str> = buf.split(|c| delimiters.contains(c)).collect();
171
172            if m.reverse {
173                tokens.reverse();
174            }
175
176            if let Some(n) = m.transformer_digits {
177                let n = n as usize;
178                while tokens.len() > n {
179                    tokens.remove(0);
180                }
181            }
182
183            let output = tokens.join(".");
184
185            if m.url_escape {
186                // https://datatracker.ietf.org/doc/html/rfc7208#section-7.3:
187                //   Uppercase macros expand exactly as their lowercase
188                //   equivalents, and are then URL escaped.  URL escaping
189                //   MUST be performed for characters not in the
190                //   "unreserved" set.
191                // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3:
192                //    unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
193                for c in output.chars() {
194                    if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '~' {
195                        result.push(c);
196                    } else {
197                        let mut bytes = [0u8; 4];
198                        for b in c.encode_utf8(&mut bytes).bytes() {
199                            result.push_str(&format!("%{b:02x}"));
200                        }
201                    }
202                }
203            } else {
204                result.push_str(&output);
205            }
206        }
207
208        Ok(result)
209    }
210}
211
212impl fmt::Display for MacroSpec {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        let mut first = true;
215        for element in &self.elements {
216            if first {
217                first = false;
218            } else {
219                f.write_str(" ")?;
220            }
221
222            match element {
223                MacroElement::Literal(lit) => write!(f, "{lit}")?,
224                MacroElement::Macro(term) => write!(f, "{term}")?,
225            }
226        }
227        Ok(())
228    }
229}
230
231#[derive(Debug)]
232enum MacroElement {
233    Literal(String),
234    Macro(MacroTerm),
235}
236
237#[derive(Debug)]
238struct MacroTerm {
239    pub name: MacroName,
240    /// digits were present in the transformer section
241    pub transformer_digits: Option<u32>,
242    /// The output needs to be URL-escaped
243    pub url_escape: bool,
244    /// the `r` transformer was present
245    pub reverse: bool,
246    /// The list of delimiters, if any, otherwise an empty string
247    pub delimiters: String,
248}
249
250impl fmt::Display for MacroTerm {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        write!(f, "%{}{}", self.name.as_char(), self.delimiters)?;
253        if let Some(digits) = self.transformer_digits {
254            write!(f, "{}", digits)?;
255        }
256        if self.reverse {
257            f.write_str("r")?;
258        }
259        if self.url_escape {
260            f.write_str("/")?;
261        }
262        Ok(())
263    }
264}
265
266#[derive(Debug, Hash, Eq, PartialEq, Copy, Clone)]
267enum MacroName {
268    /// `s` - <sender>
269    Sender,
270    /// `l` - local-part of <sender>
271    LocalPart,
272    /// `o` - domain of <sender>
273    SenderDomain,
274    /// `d` - <domain>
275    Domain,
276    /// `i` - <ip>
277    Ip,
278    /// `p` - the validated domain name of <ip> (do not use)
279    ValidatedDomainName,
280    /// `v` the string `in-addr` if <ip> is ipv4, or `ip6` is <ip> is ipv6
281    ReverseDns,
282    /// `h` the HELO/EHLO domain
283    HeloDomain,
284    /// `c` - only in "exp" text: the SMTP client IP (easily readable format)
285    ClientIp,
286    /// `r` - only in "exp" text: domain name of host performing the check
287    RelayingHostName,
288    /// `t` - only in "exp" text: the current timestamp
289    CurrentUnixTimeStamp,
290}
291
292impl MacroName {
293    fn parse(c: char) -> Result<(Self, bool), String> {
294        let escape = c.is_ascii_uppercase();
295        Ok((
296            match c.to_ascii_lowercase() {
297                's' => Self::Sender,
298                'l' => Self::LocalPart,
299                'o' => Self::SenderDomain,
300                'd' => Self::Domain,
301                'i' => Self::Ip,
302                'p' => Self::ValidatedDomainName,
303                'v' => Self::ReverseDns,
304                'h' => Self::HeloDomain,
305                'c' => Self::ClientIp,
306                'r' => Self::RelayingHostName,
307                't' => Self::CurrentUnixTimeStamp,
308                _ => return Err(format!("invalid macro name {c}")),
309            },
310            escape,
311        ))
312    }
313
314    fn as_char(&self) -> char {
315        match self {
316            Self::Sender => 's',
317            Self::LocalPart => 'l',
318            Self::SenderDomain => 'o',
319            Self::Domain => 'd',
320            Self::Ip => 'i',
321            Self::ValidatedDomainName => 'p',
322            Self::ReverseDns => 'v',
323            Self::HeloDomain => 'h',
324            Self::ClientIp => 'c',
325            Self::RelayingHostName => 'r',
326            Self::CurrentUnixTimeStamp => 't',
327        }
328    }
329}
330
331#[cfg(test)]
332mod test {
333    use std::net::IpAddr;
334
335    use super::*;
336    use crate::spec::MacroSpec;
337
338    #[test]
339    fn test_eval() {
340        // <https://datatracker.ietf.org/doc/html/rfc7208#section-7.4>
341
342        let mut ctx = SpfContext::new(
343            "strong-bad@email.example.com",
344            "email.example.com",
345            IpAddr::from([192, 0, 2, 3]),
346        )
347        .unwrap();
348
349        for (input, expect) in &[
350            ("%{s}", "strong-bad@email.example.com"),
351            ("%{o}", "email.example.com"),
352            ("%{d}", "email.example.com"),
353            ("%{d4}", "email.example.com"),
354            ("%{d3}", "email.example.com"),
355            ("%{d2}", "example.com"),
356            ("%{d1}", "com"),
357            ("%{dr}", "com.example.email"),
358            ("%{d2r}", "example.email"),
359            ("%{l}", "strong-bad"),
360            ("%{l-}", "strong.bad"),
361            ("%{lr}", "strong-bad"),
362            ("%{lr-}", "bad.strong"),
363            ("%{l1r-}", "strong"),
364        ] {
365            let spec = MacroSpec::parse(input).unwrap();
366            let output = spec.expand(&ctx).unwrap();
367            k9::assert_equal!(&output, expect, "{input}");
368        }
369
370        for (input, expect) in &[
371            (
372                "%{ir}.%{v}._spf.%{d2}",
373                "3.2.0.192.in-addr._spf.example.com",
374            ),
375            ("%{lr-}.lp._spf.%{d2}", "bad.strong.lp._spf.example.com"),
376            (
377                "%{lr-}.lp.%{ir}.%{v}._spf.%{d2}",
378                "bad.strong.lp.3.2.0.192.in-addr._spf.example.com",
379            ),
380            (
381                "%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}",
382                "3.2.0.192.in-addr.strong.lp._spf.example.com",
383            ),
384            (
385                "%{d2}.trusted-domains.example.net",
386                "example.com.trusted-domains.example.net",
387            ),
388            ("%{c}", "192.0.2.3"),
389        ] {
390            let spec = MacroSpec::parse(input).unwrap();
391            let output = spec.expand(&ctx).unwrap();
392            k9::assert_equal!(&output, expect, "{input}");
393        }
394
395        ctx.client_ip = IpAddr::from([0x2001, 0xdb8, 0, 0, 0, 0, 0, 0xcb01]);
396        for (input, expect) in &[
397            (
398                "%{ir}.%{v}._spf.%{d2}",
399                "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0\
400                 .0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com",
401            ),
402            ("%{c}", "2001:db8::cb01"),
403            ("%{C}", "2001%3adb8%3a%3acb01"),
404        ] {
405            let spec = MacroSpec::parse(input).unwrap();
406            let output = spec.expand(&ctx).unwrap();
407            k9::assert_equal!(&output, expect, "{input}");
408        }
409    }
410}