kumo_spf/
record.rs

1use crate::spec::MacroSpec;
2use crate::{fully_qualify, SpfContext, SpfDisposition, SpfResult};
3use dns_resolver::Resolver;
4use std::fmt;
5use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
6
7#[derive(Debug, Default)]
8pub(crate) struct Record {
9    directives: Vec<Directive>,
10    redirect: Option<MacroSpec>,
11    explanation: Option<MacroSpec>,
12}
13
14impl Record {
15    pub(crate) fn parse(s: &str) -> Result<Self, String> {
16        let mut tokens = s.split_whitespace();
17        let version = tokens
18            .next()
19            .ok_or_else(|| format!("expected version in {s}"))?;
20        if version != "v=spf1" {
21            return Err(format!("expected SPF version 1 in {s}"));
22        }
23
24        let mut new = Self::default();
25        for t in tokens {
26            if t.is_empty() {
27                return Err("invalid empty token".to_string());
28            }
29
30            if let Ok(directive) = Directive::parse(t) {
31                if new.redirect.is_some() || new.explanation.is_some() {
32                    return Err("directive after modifier".to_owned());
33                }
34
35                new.directives.push(directive);
36                continue;
37            }
38
39            if let Ok(modifier) = Modifier::parse(t) {
40                match modifier {
41                    Modifier::Redirect(domain) => match new.redirect {
42                        Some(_) => return Err("duplicate redirect modifier".to_owned()),
43                        None => new.redirect = Some(domain),
44                    },
45                    Modifier::Explanation(domain) => match new.explanation {
46                        Some(_) => return Err("duplicate explanation modifier".to_owned()),
47                        None => new.explanation = Some(domain),
48                    },
49                    _ => {} // "Unrecognized modifiers MUST be ignored"
50                }
51                continue;
52            }
53
54            return Err(format!("invalid token '{t}'"));
55        }
56
57        Ok(new)
58    }
59
60    pub(crate) async fn evaluate(&self, cx: &SpfContext<'_>, resolver: &dyn Resolver) -> SpfResult {
61        let mut failed = None;
62        for directive in &self.directives {
63            match directive.evaluate(cx, resolver).await {
64                Ok(Some(SpfResult {
65                    disposition: SpfDisposition::Fail,
66                    context,
67                })) => {
68                    failed = Some(context);
69                    break;
70                }
71                Ok(Some(result)) => return result,
72                Ok(None) => continue,
73                Err(err) => return err,
74            }
75        }
76
77        if let Some(domain) = &self.redirect {
78            let domain = match cx.domain(Some(domain), resolver).await {
79                Ok(domain) => domain,
80                Err(err) => return err,
81            };
82
83            let nested = cx.with_domain(&domain);
84            match Box::pin(nested.check(resolver, false)).await {
85                SpfResult {
86                    disposition: SpfDisposition::Fail,
87                    context,
88                } => failed = Some(context),
89                result => return result,
90            }
91        }
92
93        let failed = match failed {
94            Some(failed) => failed,
95            None => {
96                return SpfResult {
97                    disposition: SpfDisposition::Neutral,
98                    context: "default result".to_owned(),
99                }
100            }
101        };
102
103        let domain = match &self.explanation {
104            Some(domain) => match cx.domain(Some(domain), resolver).await {
105                Ok(domain) => domain,
106                Err(err) => return err,
107            },
108            None => return SpfResult::fail(failed),
109        };
110
111        // "If there are any DNS processing errors (any RCODE other than 0), or
112        // if no records are returned, or if more than one record is returned,
113        // or if there are syntax errors in the explanation string, then proceed
114        // as if no "exp" modifier was given."
115        let explanation = match resolver.resolve_txt(&fully_qualify(&domain)).await {
116            Ok(answers) if answers.records.len() == 1 => answers.as_txt().pop().unwrap(),
117            Ok(_) | Err(_) => return SpfResult::fail(failed),
118        };
119
120        let spec = match MacroSpec::parse(&explanation) {
121            Ok(spec) => spec,
122            Err(_) => return SpfResult::fail(failed),
123        };
124
125        match spec.expand(cx, resolver).await {
126            Ok(explanation) => SpfResult::fail(explanation),
127            Err(_) => SpfResult::fail(failed),
128        }
129    }
130}
131
132#[derive(Debug)]
133struct Directive {
134    pub qualifier: Qualifier,
135    pub mechanism: Mechanism,
136}
137
138impl Directive {
139    fn parse(s: &str) -> Result<Self, String> {
140        let mut qualifier = Qualifier::default();
141        let s = match s.get(0..1).and_then(Qualifier::parse) {
142            Some(q) => {
143                qualifier = q;
144                &s[1..]
145            }
146            None => s,
147        };
148
149        Ok(Self {
150            qualifier,
151            mechanism: Mechanism::parse(s)?,
152        })
153    }
154
155    async fn evaluate(
156        &self,
157        cx: &SpfContext<'_>,
158        resolver: &dyn Resolver,
159    ) -> Result<Option<SpfResult>, SpfResult> {
160        let matched = match &self.mechanism {
161            Mechanism::All => true,
162            Mechanism::A { domain, cidr_len } => {
163                cx.check_lookup_limit()?;
164                let domain = cx.domain(domain.as_ref(), resolver).await?;
165                let resolved = match resolver.resolve_ip(&fully_qualify(&domain)).await {
166                    Ok(ips) => ips,
167                    Err(err) => {
168                        return Err(SpfResult {
169                            disposition: SpfDisposition::TempError,
170                            context: format!("error looking up IP for {domain}: {err}"),
171                        })
172                    }
173                };
174
175                resolved
176                    .iter()
177                    .any(|&resolved_ip| cidr_len.matches(cx.client_ip, resolved_ip))
178            }
179            Mechanism::Mx { domain, cidr_len } => {
180                cx.check_lookup_limit()?;
181                let domain = cx.domain(domain.as_ref(), resolver).await?;
182                let exchanges = match resolver.resolve_mx(&fully_qualify(&domain)).await {
183                    Ok(exchanges) => exchanges,
184                    Err(err) => {
185                        return Err(SpfResult {
186                            disposition: SpfDisposition::TempError,
187                            context: format!("error looking up IP for {domain}: {err}"),
188                        })
189                    }
190                };
191
192                let mut matched = false;
193                for (idx, exchange) in exchanges.into_iter().enumerate() {
194                    if idx >= 10 {
195                        // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4
196                        return Err(SpfResult {
197                            disposition: SpfDisposition::PermError,
198                            context: format!("too many MX records for {domain}"),
199                        });
200                    }
201
202                    let resolved = match resolver
203                        .resolve_ip(&fully_qualify(&exchange.to_string()))
204                        .await
205                    {
206                        Ok(ips) => ips,
207                        Err(err) => {
208                            return Err(SpfResult {
209                                disposition: SpfDisposition::TempError,
210                                context: format!("error looking up IP for {exchange}: {err}"),
211                            })
212                        }
213                    };
214
215                    if resolved
216                        .iter()
217                        .any(|&resolved_ip| cidr_len.matches(cx.client_ip, resolved_ip))
218                    {
219                        matched = true;
220                        break;
221                    }
222                }
223
224                matched
225            }
226            Mechanism::Ip4 {
227                ip4_network,
228                cidr_len,
229            } => DualCidrLength {
230                v4: *cidr_len,
231                ..Default::default()
232            }
233            .matches(cx.client_ip, IpAddr::V4(*ip4_network)),
234            Mechanism::Ip6 {
235                ip6_network,
236                cidr_len,
237            } => DualCidrLength {
238                v6: *cidr_len,
239                ..Default::default()
240            }
241            .matches(cx.client_ip, IpAddr::V6(*ip6_network)),
242            Mechanism::Ptr { domain } => {
243                // Note that validated_domain applies lookup limits
244                let validated_domain = cx.validated_domain(domain.as_ref(), resolver).await?;
245
246                validated_domain.is_some()
247            }
248            Mechanism::Include { domain } => {
249                let domain = cx.domain(Some(domain), resolver).await?;
250                let nested = cx.with_domain(&domain);
251                use SpfDisposition::*;
252                match Box::pin(nested.check(resolver, false)).await {
253                    SpfResult {
254                        disposition: Pass, ..
255                    } => true,
256                    SpfResult {
257                        disposition: Fail | SoftFail | Neutral,
258                        ..
259                    } => false,
260                    SpfResult {
261                        disposition: TempError,
262                        context,
263                    } => {
264                        return Err(SpfResult {
265                            disposition: TempError,
266                            context: format!(
267                                "temperror while evaluating include:{domain}: {context}"
268                            ),
269                        })
270                    }
271                    SpfResult {
272                        disposition: disp @ PermError | disp @ None,
273                        context,
274                    } => {
275                        return Err(SpfResult {
276                            disposition: PermError,
277                            context: format!("{disp} while evaluating include:{domain}: {context}"),
278                        })
279                    }
280                }
281            }
282            Mechanism::Exists { domain } => {
283                cx.check_lookup_limit()?;
284                let domain = cx.domain(Some(domain), resolver).await?;
285                match resolver.resolve_ip(&fully_qualify(&domain)).await {
286                    Ok(ips) => ips.iter().any(|ip| ip.is_ipv4()),
287                    Err(err) => {
288                        return Err(SpfResult {
289                            disposition: SpfDisposition::TempError,
290                            context: format!("error looking up IP for {domain}: {err}"),
291                        })
292                    }
293                }
294            }
295        };
296
297        Ok(if matched {
298            Some(SpfResult {
299                disposition: SpfDisposition::from(self.qualifier),
300                context: format!("matched '{self}' directive"),
301            })
302        } else {
303            None
304        })
305    }
306}
307
308impl fmt::Display for Directive {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        if self.qualifier != Qualifier::Pass {
311            write!(f, "{}", self.qualifier.as_str())?;
312        }
313        write!(f, "{}", self.mechanism)
314    }
315}
316
317#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
318pub(crate) enum Qualifier {
319    /// `+`
320    #[default]
321    Pass,
322    /// `-`
323    Fail,
324    /// `~`
325    SoftFail,
326    /// `?`
327    Neutral,
328}
329
330impl Qualifier {
331    fn parse(s: &str) -> Option<Self> {
332        Some(match s {
333            "+" => Self::Pass,
334            "-" => Self::Fail,
335            "~" => Self::SoftFail,
336            "?" => Self::Neutral,
337            _ => return None,
338        })
339    }
340
341    fn as_str(&self) -> &'static str {
342        match self {
343            Self::Pass => "+",
344            Self::Fail => "-",
345            Self::SoftFail => "~",
346            Self::Neutral => "?",
347        }
348    }
349}
350
351#[derive(Debug)]
352struct DualCidrLength {
353    pub v4: u8,
354    pub v6: u8,
355}
356
357impl DualCidrLength {
358    /// Whether the `observed` IP address (from the client's IP) matches the `specified` address
359    /// (from/via the SPF record), given the specified CIDR mask lengths.
360    fn matches(&self, observed: IpAddr, specified: IpAddr) -> bool {
361        match (observed, specified, self) {
362            (IpAddr::V4(observed), IpAddr::V4(specified), DualCidrLength { v4, .. }) => {
363                let mask = ipv4_prefix_mask(*v4);
364                let specified_masked = Ipv4Addr::from_bits(specified.to_bits() & mask);
365                let observed_masked = Ipv4Addr::from(observed.to_bits() & mask);
366                specified_masked == observed_masked
367            }
368            (IpAddr::V6(observed), IpAddr::V6(specified), DualCidrLength { v6, .. }) => {
369                let mask = ipv6_prefix_mask(*v6);
370                let specified_masked = Ipv6Addr::from_bits(specified.to_bits() & mask);
371                let observed_masked = Ipv6Addr::from(observed.to_bits() & mask);
372                specified_masked == observed_masked
373            }
374            _ => false,
375        }
376    }
377}
378
379/// Build the network mask for an IPv4 prefix length. A `/0` prefix yields the
380/// all-zero mask, matching every address.
381fn ipv4_prefix_mask(prefix: u8) -> u32 {
382    u32::MAX
383        .checked_shl((32 - prefix.min(32)) as u32)
384        .unwrap_or(0)
385}
386
387/// Build the network mask for an IPv6 prefix length. See [`ipv4_prefix_mask`].
388fn ipv6_prefix_mask(prefix: u8) -> u128 {
389    u128::MAX
390        .checked_shl((128 - prefix.min(128)) as u32)
391        .unwrap_or(0)
392}
393
394impl Default for DualCidrLength {
395    fn default() -> Self {
396        Self { v4: 32, v6: 128 }
397    }
398}
399
400/// Parse a CIDR prefix length, rejecting values that exceed `max` bits for the
401/// address family (32 for IPv4, 128 for IPv6). RFC 7208 treats an out-of-range
402/// length as a syntax error.
403fn parse_cidr_length(s: &str, max: u8) -> Result<u8, String> {
404    let len: u8 = s.parse().map_err(|err| format!("{err}"))?;
405    if len > max {
406        return Err(format!("CIDR length /{len} exceeds maximum of /{max}"));
407    }
408    Ok(len)
409}
410
411impl DualCidrLength {
412    fn parse_from_end(s: &str) -> Result<(&str, Self), String> {
413        match s.rsplit_once('/') {
414            Some((left, right)) => {
415                if left.ends_with('/') {
416                    // we have another cidr length
417                    if let Some((prefix, v4cidr)) = left[0..left.len() - 1].rsplit_once('/') {
418                        let v4 = parse_cidr_length(v4cidr, 32).map_err(|err| {
419                            format!(
420                                "invalid dual-cidr-length in {s}: parsing v4 cidr portion: {err}"
421                            )
422                        })?;
423                        let v6 = parse_cidr_length(right, 128)
424                            .map_err(|err| format!("invalid dual-cidr-length in {s}: {err}"))?;
425                        return Ok((prefix, Self { v4, v6 }));
426                    }
427                }
428                let v4 = parse_cidr_length(right, 32)
429                    .map_err(|err| format!("invalid dual-cidr-length in {s}: {err}"))?;
430                Ok((
431                    left,
432                    Self {
433                        v4,
434                        ..Self::default()
435                    },
436                ))
437            }
438            None => Ok((s, Self::default())),
439        }
440    }
441}
442
443impl fmt::Display for DualCidrLength {
444    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
445        if self.v4 == 32 && self.v6 == 128 {
446            return Ok(());
447        }
448
449        write!(f, "/{}", self.v4)?;
450        if self.v6 != 128 {
451            write!(f, "/{}", self.v6)?;
452        }
453
454        Ok(())
455    }
456}
457
458#[derive(Debug)]
459enum Mechanism {
460    All,
461    Include {
462        domain: MacroSpec,
463    },
464    A {
465        domain: Option<MacroSpec>,
466        cidr_len: DualCidrLength,
467    },
468    Mx {
469        domain: Option<MacroSpec>,
470        cidr_len: DualCidrLength,
471    },
472    Ptr {
473        domain: Option<MacroSpec>,
474    },
475    Ip4 {
476        ip4_network: Ipv4Addr,
477        cidr_len: u8,
478    },
479    Ip6 {
480        ip6_network: Ipv6Addr,
481        cidr_len: u8,
482    },
483    Exists {
484        domain: MacroSpec,
485    },
486}
487
488impl fmt::Display for Mechanism {
489    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490        match self {
491            Self::All => write!(f, "all"),
492            Self::Include { domain } => write!(f, "include:{}", domain),
493            Self::A { domain, cidr_len } => {
494                write!(f, "a")?;
495                if let Some(domain) = domain {
496                    write!(f, ":{}", domain)?;
497                }
498                write!(f, "{}", cidr_len)
499            }
500            Self::Mx { domain, cidr_len } => {
501                write!(f, "mx")?;
502                if let Some(domain) = domain {
503                    write!(f, ":{}", domain)?;
504                }
505                write!(f, "{}", cidr_len)
506            }
507            Self::Ptr { domain } => {
508                write!(f, "ptr")?;
509                if let Some(domain) = domain {
510                    write!(f, ":{}", domain)?;
511                }
512                Ok(())
513            }
514            Self::Ip4 {
515                ip4_network,
516                cidr_len,
517            } => write!(f, "ip4:{}/{}", ip4_network, cidr_len),
518            Self::Ip6 {
519                ip6_network,
520                cidr_len,
521            } => write!(f, "ip6:{}/{}", ip6_network, cidr_len),
522            Self::Exists { domain } => write!(f, "exists:{}", domain),
523        }
524    }
525}
526
527fn starts_with_ident<'a>(s: &'a str, ident: &str) -> Option<&'a str> {
528    if s.len() < ident.len() {
529        return None;
530    }
531
532    if s.get(0..ident.len())?.eq_ignore_ascii_case(ident) {
533        Some(&s[ident.len()..])
534    } else {
535        None
536    }
537}
538
539impl Mechanism {
540    fn parse(s: &str) -> Result<Self, String> {
541        if s.eq_ignore_ascii_case("all") {
542            return Ok(Self::All);
543        }
544
545        if let Some(spec) = starts_with_ident(s, "include:") {
546            return Ok(Self::Include {
547                domain: MacroSpec::parse(spec)?,
548            });
549        }
550
551        if let Some(remain) = starts_with_ident(s, "a") {
552            let (remain, cidr_len) = DualCidrLength::parse_from_end(remain)?;
553
554            let domain = if let Some(spec) = remain.strip_prefix(":") {
555                Some(MacroSpec::parse(spec)?)
556            } else if remain.is_empty() {
557                None
558            } else {
559                return Err(format!("invalid 'a' mechanism: {s}"));
560            };
561
562            return Ok(Self::A { domain, cidr_len });
563        }
564        if let Some(remain) = starts_with_ident(s, "mx") {
565            let (remain, cidr_len) = DualCidrLength::parse_from_end(remain)?;
566
567            let domain = if let Some(spec) = remain.strip_prefix(":") {
568                Some(MacroSpec::parse(spec)?)
569            } else if remain.is_empty() {
570                None
571            } else {
572                return Err(format!("invalid 'mx' mechanism: {s}"));
573            };
574
575            return Ok(Self::Mx { domain, cidr_len });
576        }
577        if let Some(remain) = starts_with_ident(s, "ptr") {
578            let domain = if let Some(spec) = remain.strip_prefix(":") {
579                Some(MacroSpec::parse(spec)?)
580            } else if remain.is_empty() {
581                None
582            } else {
583                return Err(format!("invalid 'ptr' mechanism: {s}"));
584            };
585
586            return Ok(Self::Ptr { domain });
587        }
588        if let Some(remain) = starts_with_ident(s, "ip4:") {
589            let (addr, len) = if let Some((addr, len)) = remain.split_once('/') {
590                (addr, len)
591            } else {
592                // Default CIDR length for IPv4 is /32 (single host)
593                (remain, "32")
594            };
595            let ip4_network = addr
596                .parse()
597                .map_err(|err| format!("invalid 'ip4' mechanism: {s}: {err}"))?;
598            let cidr_len = parse_cidr_length(len, 32)
599                .map_err(|err| format!("invalid 'ip4' mechanism: {s}: {err}"))?;
600
601            return Ok(Self::Ip4 {
602                ip4_network,
603                cidr_len,
604            });
605        }
606        if let Some(remain) = starts_with_ident(s, "ip6:") {
607            let (addr, len) = if let Some((addr, len)) = remain.split_once('/') {
608                (addr, len)
609            } else {
610                // Default CIDR length for IPv6 is /128 (single host)
611                (remain, "128")
612            };
613            let ip6_network = addr
614                .parse()
615                .map_err(|err| format!("invalid 'ip6' mechanism: {s}: {err}"))?;
616            let cidr_len = parse_cidr_length(len, 128)
617                .map_err(|err| format!("invalid 'ip6' mechanism: {s}: {err}"))?;
618
619            return Ok(Self::Ip6 {
620                ip6_network,
621                cidr_len,
622            });
623        }
624        if let Some(spec) = starts_with_ident(s, "exists:") {
625            return Ok(Self::Exists {
626                domain: MacroSpec::parse(spec)?,
627            });
628        }
629
630        Err(format!("invalid mechanism {s}"))
631    }
632}
633
634#[derive(Debug)]
635enum Modifier {
636    Redirect(MacroSpec),
637    Explanation(MacroSpec),
638    Unknown,
639}
640
641impl Modifier {
642    fn parse(s: &str) -> Result<Self, String> {
643        if let Some(spec) = starts_with_ident(s, "redirect=") {
644            return Ok(Self::Redirect(MacroSpec::parse(spec)?));
645        }
646        if let Some(spec) = starts_with_ident(s, "exp=") {
647            return Ok(Self::Explanation(MacroSpec::parse(spec)?));
648        }
649
650        let (name, _) = s
651            .split_once('=')
652            .ok_or_else(|| format!("invalid modifier {s}"))?;
653
654        let valid = !name.is_empty()
655            && name
656                .chars()
657                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
658            && name.chars().next().unwrap().is_ascii_alphabetic();
659        if !valid {
660            return Err(format!("modifier name '{name}' is invalid"));
661        }
662
663        Ok(Self::Unknown)
664    }
665}
666
667#[cfg(test)]
668mod test {
669    use super::*;
670
671    fn parse(s: &str) -> Record {
672        eprintln!("**\n{s}");
673        match Record::parse(s) {
674            Ok(r) => r,
675            Err(err) => panic!("{err}: {s}"),
676        }
677    }
678
679    #[test]
680    fn test_cidr_zero_matches_all() {
681        // A /0 prefix masks off every bit, so any address matches. The mask
682        // computation must not overflow the 32- or 128-bit shift.
683        let all_v4 = DualCidrLength { v4: 0, v6: 128 };
684        assert!(all_v4.matches("192.0.2.1".parse().unwrap(), "0.0.0.0".parse().unwrap()));
685        let all_v6 = DualCidrLength { v4: 32, v6: 0 };
686        assert!(all_v6.matches("2001:db8::1".parse().unwrap(), "::".parse().unwrap()));
687    }
688
689    #[test]
690    fn test_cidr_boundary_accepted() {
691        // The maximum prefix for each family, and /0, must parse. A `>=`
692        // instead of `>` range check would reject valid records and silently
693        // change SPF results for real senders.
694        assert!(matches!(
695            Mechanism::parse("ip4:0.0.0.0/0").unwrap(),
696            Mechanism::Ip4 { cidr_len: 0, .. }
697        ));
698        assert!(matches!(
699            Mechanism::parse("ip4:192.0.2.1/32").unwrap(),
700            Mechanism::Ip4 { cidr_len: 32, .. }
701        ));
702        assert!(matches!(
703            Mechanism::parse("ip6:::/0").unwrap(),
704            Mechanism::Ip6 { cidr_len: 0, .. }
705        ));
706        assert!(matches!(
707            Mechanism::parse("ip6:2001:db8::/128").unwrap(),
708            Mechanism::Ip6 { cidr_len: 128, .. }
709        ));
710        assert!(matches!(
711            Mechanism::parse("a/0").unwrap(),
712            Mechanism::A {
713                cidr_len: DualCidrLength { v4: 0, .. },
714                ..
715            }
716        ));
717        assert!(matches!(
718            Mechanism::parse("mx/24//64").unwrap(),
719            Mechanism::Mx {
720                cidr_len: DualCidrLength { v4: 24, v6: 64 },
721                ..
722            }
723        ));
724    }
725
726    #[test]
727    fn test_cidr_out_of_range_rejected() {
728        k9::snapshot!(
729            Record::parse("v=spf1 ip4:0.0.0.0/33 -all").unwrap_err(),
730            "invalid token 'ip4:0.0.0.0/33'"
731        );
732        k9::snapshot!(
733            Record::parse("v=spf1 ip6:::/129 -all").unwrap_err(),
734            "invalid token 'ip6:::/129'"
735        );
736        k9::snapshot!(
737            Record::parse("v=spf1 a/33 -all").unwrap_err(),
738            "invalid token 'a/33'"
739        );
740        k9::snapshot!(
741            Record::parse("v=spf1 mx/0//129 -all").unwrap_err(),
742            "invalid token 'mx/0//129'"
743        );
744    }
745
746    #[test]
747    fn test_zero_width() {
748        k9::snapshot!(
749            Record::parse(
750                "v=spf1 mx a include:spf.host.example.com \u{200b}include:_spf.example.com ?all"
751            )
752            .unwrap_err(),
753            r#"invalid token '\u{200b}include:_spf.example.com'"#
754        );
755    }
756
757    #[test]
758    fn test_parse() {
759        k9::snapshot!(
760            Record::parse("v=spf1 -exists:%(ir).sbl.example.org").unwrap_err(),
761            r#"invalid token '-exists:%(ir).sbl.example.org'"#
762        );
763        k9::snapshot!(
764            Record::parse("v=spf1 -exists:%{ir.sbl.example.org").unwrap_err(),
765            r#"invalid token '-exists:%{ir.sbl.example.org'"#
766        );
767        k9::snapshot!(
768            Record::parse("v=spf1 -exists:%{ir").unwrap_err(),
769            r#"invalid token '-exists:%{ir'"#
770        );
771
772        // Extraneous space is not strictly allowed, but we're relaxed
773        // about it to support common real world usage
774        k9::snapshot!(
775            Record::parse("v=spf1 ").unwrap(),
776            "
777Record {
778    directives: [],
779    redirect: None,
780    explanation: None,
781}
782"
783        );
784
785        k9::snapshot!(
786            parse("v=spf1 mx  -all exp=explain._spf.%{d}"),
787            r#"
788Record {
789    directives: [
790        Directive {
791            qualifier: Pass,
792            mechanism: Mx {
793                domain: None,
794                cidr_len: DualCidrLength {
795                    v4: 32,
796                    v6: 128,
797                },
798            },
799        },
800        Directive {
801            qualifier: Fail,
802            mechanism: All,
803        },
804    ],
805    redirect: None,
806    explanation: Some(
807        MacroSpec {
808            elements: [
809                Literal(
810                    "explain._spf.",
811                ),
812                Macro(
813                    MacroTerm {
814                        name: Domain,
815                        transformer_digits: None,
816                        url_escape: false,
817                        reverse: false,
818                        delimiters: "",
819                    },
820                ),
821            ],
822        },
823    ),
824}
825"#
826        );
827
828        k9::snapshot!(
829            parse("v=spf1 -exists:%{ir}.sbl.example.org"),
830            r#"
831Record {
832    directives: [
833        Directive {
834            qualifier: Fail,
835            mechanism: Exists {
836                domain: MacroSpec {
837                    elements: [
838                        Macro(
839                            MacroTerm {
840                                name: Ip,
841                                transformer_digits: None,
842                                url_escape: false,
843                                reverse: true,
844                                delimiters: "",
845                            },
846                        ),
847                        Literal(
848                            ".sbl.example.org",
849                        ),
850                    ],
851                },
852            },
853        },
854    ],
855    redirect: None,
856    explanation: None,
857}
858"#
859        );
860
861        k9::snapshot!(
862            parse("v=spf1 +all"),
863            "
864Record {
865    directives: [
866        Directive {
867            qualifier: Pass,
868            mechanism: All,
869        },
870    ],
871    redirect: None,
872    explanation: None,
873}
874"
875        );
876        k9::snapshot!(
877            parse("v=spf1 a -all"),
878            "
879Record {
880    directives: [
881        Directive {
882            qualifier: Pass,
883            mechanism: A {
884                domain: None,
885                cidr_len: DualCidrLength {
886                    v4: 32,
887                    v6: 128,
888                },
889            },
890        },
891        Directive {
892            qualifier: Fail,
893            mechanism: All,
894        },
895    ],
896    redirect: None,
897    explanation: None,
898}
899"
900        );
901        k9::snapshot!(
902            parse("v=spf1 a:example.org -all"),
903            r#"
904Record {
905    directives: [
906        Directive {
907            qualifier: Pass,
908            mechanism: A {
909                domain: Some(
910                    MacroSpec {
911                        elements: [
912                            Literal(
913                                "example.org",
914                            ),
915                        ],
916                    },
917                ),
918                cidr_len: DualCidrLength {
919                    v4: 32,
920                    v6: 128,
921                },
922            },
923        },
924        Directive {
925            qualifier: Fail,
926            mechanism: All,
927        },
928    ],
929    redirect: None,
930    explanation: None,
931}
932"#
933        );
934        k9::snapshot!(
935            parse("v=spf1 mx -all"),
936            "
937Record {
938    directives: [
939        Directive {
940            qualifier: Pass,
941            mechanism: Mx {
942                domain: None,
943                cidr_len: DualCidrLength {
944                    v4: 32,
945                    v6: 128,
946                },
947            },
948        },
949        Directive {
950            qualifier: Fail,
951            mechanism: All,
952        },
953    ],
954    redirect: None,
955    explanation: None,
956}
957"
958        );
959        k9::snapshot!(
960            parse("v=spf1 mx:example.org -all"),
961            r#"
962Record {
963    directives: [
964        Directive {
965            qualifier: Pass,
966            mechanism: Mx {
967                domain: Some(
968                    MacroSpec {
969                        elements: [
970                            Literal(
971                                "example.org",
972                            ),
973                        ],
974                    },
975                ),
976                cidr_len: DualCidrLength {
977                    v4: 32,
978                    v6: 128,
979                },
980            },
981        },
982        Directive {
983            qualifier: Fail,
984            mechanism: All,
985        },
986    ],
987    redirect: None,
988    explanation: None,
989}
990"#
991        );
992        k9::snapshot!(
993            parse("v=spf1 mx mx:example.org -all"),
994            r#"
995Record {
996    directives: [
997        Directive {
998            qualifier: Pass,
999            mechanism: Mx {
1000                domain: None,
1001                cidr_len: DualCidrLength {
1002                    v4: 32,
1003                    v6: 128,
1004                },
1005            },
1006        },
1007        Directive {
1008            qualifier: Pass,
1009            mechanism: Mx {
1010                domain: Some(
1011                    MacroSpec {
1012                        elements: [
1013                            Literal(
1014                                "example.org",
1015                            ),
1016                        ],
1017                    },
1018                ),
1019                cidr_len: DualCidrLength {
1020                    v4: 32,
1021                    v6: 128,
1022                },
1023            },
1024        },
1025        Directive {
1026            qualifier: Fail,
1027            mechanism: All,
1028        },
1029    ],
1030    redirect: None,
1031    explanation: None,
1032}
1033"#
1034        );
1035        k9::snapshot!(
1036            parse("v=spf1 mx/30 -all"),
1037            "
1038Record {
1039    directives: [
1040        Directive {
1041            qualifier: Pass,
1042            mechanism: Mx {
1043                domain: None,
1044                cidr_len: DualCidrLength {
1045                    v4: 30,
1046                    v6: 128,
1047                },
1048            },
1049        },
1050        Directive {
1051            qualifier: Fail,
1052            mechanism: All,
1053        },
1054    ],
1055    redirect: None,
1056    explanation: None,
1057}
1058"
1059        );
1060        k9::snapshot!(
1061            parse("v=spf1 mx/30 mx:example.org/30 -all"),
1062            r#"
1063Record {
1064    directives: [
1065        Directive {
1066            qualifier: Pass,
1067            mechanism: Mx {
1068                domain: None,
1069                cidr_len: DualCidrLength {
1070                    v4: 30,
1071                    v6: 128,
1072                },
1073            },
1074        },
1075        Directive {
1076            qualifier: Pass,
1077            mechanism: Mx {
1078                domain: Some(
1079                    MacroSpec {
1080                        elements: [
1081                            Literal(
1082                                "example.org",
1083                            ),
1084                        ],
1085                    },
1086                ),
1087                cidr_len: DualCidrLength {
1088                    v4: 30,
1089                    v6: 128,
1090                },
1091            },
1092        },
1093        Directive {
1094            qualifier: Fail,
1095            mechanism: All,
1096        },
1097    ],
1098    redirect: None,
1099    explanation: None,
1100}
1101"#
1102        );
1103        k9::snapshot!(
1104            parse("v=spf1 ptr -all"),
1105            "
1106Record {
1107    directives: [
1108        Directive {
1109            qualifier: Pass,
1110            mechanism: Ptr {
1111                domain: None,
1112            },
1113        },
1114        Directive {
1115            qualifier: Fail,
1116            mechanism: All,
1117        },
1118    ],
1119    redirect: None,
1120    explanation: None,
1121}
1122"
1123        );
1124        k9::snapshot!(
1125            parse("v=spf1 ip4:192.0.2.128/28 -all"),
1126            "
1127Record {
1128    directives: [
1129        Directive {
1130            qualifier: Pass,
1131            mechanism: Ip4 {
1132                ip4_network: 192.0.2.128,
1133                cidr_len: 28,
1134            },
1135        },
1136        Directive {
1137            qualifier: Fail,
1138            mechanism: All,
1139        },
1140    ],
1141    redirect: None,
1142    explanation: None,
1143}
1144"
1145        );
1146        k9::snapshot!(
1147            parse("v=spf1 include:example.com include:example.net -all"),
1148            r#"
1149Record {
1150    directives: [
1151        Directive {
1152            qualifier: Pass,
1153            mechanism: Include {
1154                domain: MacroSpec {
1155                    elements: [
1156                        Literal(
1157                            "example.com",
1158                        ),
1159                    ],
1160                },
1161            },
1162        },
1163        Directive {
1164            qualifier: Pass,
1165            mechanism: Include {
1166                domain: MacroSpec {
1167                    elements: [
1168                        Literal(
1169                            "example.net",
1170                        ),
1171                    ],
1172                },
1173            },
1174        },
1175        Directive {
1176            qualifier: Fail,
1177            mechanism: All,
1178        },
1179    ],
1180    redirect: None,
1181    explanation: None,
1182}
1183"#
1184        );
1185        k9::snapshot!(
1186            parse("v=spf1 redirect=example.org"),
1187            r#"
1188Record {
1189    directives: [],
1190    redirect: Some(
1191        MacroSpec {
1192            elements: [
1193                Literal(
1194                    "example.org",
1195                ),
1196            ],
1197        },
1198    ),
1199    explanation: None,
1200}
1201"#
1202        );
1203    }
1204}