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 = u32::MAX << (32 - 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 = u128::MAX << (128 - 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
379impl Default for DualCidrLength {
380    fn default() -> Self {
381        Self { v4: 32, v6: 128 }
382    }
383}
384
385impl DualCidrLength {
386    fn parse_from_end(s: &str) -> Result<(&str, Self), String> {
387        match s.rsplit_once('/') {
388            Some((left, right)) => {
389                let right_cidr: u8 = right
390                    .parse()
391                    .map_err(|err| format!("invalid dual-cidr-length in {s}: {err}"))?;
392
393                if left.ends_with('/') {
394                    // we have another cidr length
395                    if let Some((prefix, v4cidr)) = left[0..left.len() - 1].rsplit_once('/') {
396                        let left_cidr: u8 = v4cidr.parse().map_err(|err| {
397                            format!(
398                                "invalid dual-cidr-length in {s}: parsing v4 cidr portion: {err}"
399                            )
400                        })?;
401                        return Ok((
402                            prefix,
403                            Self {
404                                v4: left_cidr,
405                                v6: right_cidr,
406                            },
407                        ));
408                    }
409                }
410                Ok((
411                    left,
412                    Self {
413                        v4: right_cidr,
414                        ..Self::default()
415                    },
416                ))
417            }
418            None => Ok((s, Self::default())),
419        }
420    }
421}
422
423impl fmt::Display for DualCidrLength {
424    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
425        if self.v4 == 32 && self.v6 == 128 {
426            return Ok(());
427        }
428
429        write!(f, "/{}", self.v4)?;
430        if self.v6 != 128 {
431            write!(f, "/{}", self.v6)?;
432        }
433
434        Ok(())
435    }
436}
437
438#[derive(Debug)]
439enum Mechanism {
440    All,
441    Include {
442        domain: MacroSpec,
443    },
444    A {
445        domain: Option<MacroSpec>,
446        cidr_len: DualCidrLength,
447    },
448    Mx {
449        domain: Option<MacroSpec>,
450        cidr_len: DualCidrLength,
451    },
452    Ptr {
453        domain: Option<MacroSpec>,
454    },
455    Ip4 {
456        ip4_network: Ipv4Addr,
457        cidr_len: u8,
458    },
459    Ip6 {
460        ip6_network: Ipv6Addr,
461        cidr_len: u8,
462    },
463    Exists {
464        domain: MacroSpec,
465    },
466}
467
468impl fmt::Display for Mechanism {
469    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
470        match self {
471            Self::All => write!(f, "all"),
472            Self::Include { domain } => write!(f, "include:{}", domain),
473            Self::A { domain, cidr_len } => {
474                write!(f, "a")?;
475                if let Some(domain) = domain {
476                    write!(f, ":{}", domain)?;
477                }
478                write!(f, "{}", cidr_len)
479            }
480            Self::Mx { domain, cidr_len } => {
481                write!(f, "mx")?;
482                if let Some(domain) = domain {
483                    write!(f, ":{}", domain)?;
484                }
485                write!(f, "{}", cidr_len)
486            }
487            Self::Ptr { domain } => {
488                write!(f, "ptr")?;
489                if let Some(domain) = domain {
490                    write!(f, ":{}", domain)?;
491                }
492                Ok(())
493            }
494            Self::Ip4 {
495                ip4_network,
496                cidr_len,
497            } => write!(f, "ip4:{}/{}", ip4_network, cidr_len),
498            Self::Ip6 {
499                ip6_network,
500                cidr_len,
501            } => write!(f, "ip6:{}/{}", ip6_network, cidr_len),
502            Self::Exists { domain } => write!(f, "exists:{}", domain),
503        }
504    }
505}
506
507fn starts_with_ident<'a>(s: &'a str, ident: &str) -> Option<&'a str> {
508    if s.len() < ident.len() {
509        return None;
510    }
511
512    if s.get(0..ident.len())?.eq_ignore_ascii_case(ident) {
513        Some(&s[ident.len()..])
514    } else {
515        None
516    }
517}
518
519impl Mechanism {
520    fn parse(s: &str) -> Result<Self, String> {
521        if s.eq_ignore_ascii_case("all") {
522            return Ok(Self::All);
523        }
524
525        if let Some(spec) = starts_with_ident(s, "include:") {
526            return Ok(Self::Include {
527                domain: MacroSpec::parse(spec)?,
528            });
529        }
530
531        if let Some(remain) = starts_with_ident(s, "a") {
532            let (remain, cidr_len) = DualCidrLength::parse_from_end(remain)?;
533
534            let domain = if let Some(spec) = remain.strip_prefix(":") {
535                Some(MacroSpec::parse(spec)?)
536            } else if remain.is_empty() {
537                None
538            } else {
539                return Err(format!("invalid 'a' mechanism: {s}"));
540            };
541
542            return Ok(Self::A { domain, cidr_len });
543        }
544        if let Some(remain) = starts_with_ident(s, "mx") {
545            let (remain, cidr_len) = DualCidrLength::parse_from_end(remain)?;
546
547            let domain = if let Some(spec) = remain.strip_prefix(":") {
548                Some(MacroSpec::parse(spec)?)
549            } else if remain.is_empty() {
550                None
551            } else {
552                return Err(format!("invalid 'mx' mechanism: {s}"));
553            };
554
555            return Ok(Self::Mx { domain, cidr_len });
556        }
557        if let Some(remain) = starts_with_ident(s, "ptr") {
558            let domain = if let Some(spec) = remain.strip_prefix(":") {
559                Some(MacroSpec::parse(spec)?)
560            } else if remain.is_empty() {
561                None
562            } else {
563                return Err(format!("invalid 'ptr' mechanism: {s}"));
564            };
565
566            return Ok(Self::Ptr { domain });
567        }
568        if let Some(remain) = starts_with_ident(s, "ip4:") {
569            let (addr, len) = if let Some((addr, len)) = remain.split_once('/') {
570                (addr, len)
571            } else {
572                // Default CIDR length for IPv4 is /32 (single host)
573                (remain, "32")
574            };
575            let ip4_network = addr
576                .parse()
577                .map_err(|err| format!("invalid 'ip4' mechanism: {s}: {err}"))?;
578            let cidr_len = len
579                .parse()
580                .map_err(|err| format!("invalid 'ip4' mechanism: {s}: {err}"))?;
581
582            return Ok(Self::Ip4 {
583                ip4_network,
584                cidr_len,
585            });
586        }
587        if let Some(remain) = starts_with_ident(s, "ip6:") {
588            let (addr, len) = if let Some((addr, len)) = remain.split_once('/') {
589                (addr, len)
590            } else {
591                // Default CIDR length for IPv6 is /128 (single host)
592                (remain, "128")
593            };
594            let ip6_network = addr
595                .parse()
596                .map_err(|err| format!("invalid 'ip6' mechanism: {s}: {err}"))?;
597            let cidr_len = len
598                .parse()
599                .map_err(|err| format!("invalid 'ip6' mechanism: {s}: {err}"))?;
600
601            return Ok(Self::Ip6 {
602                ip6_network,
603                cidr_len,
604            });
605        }
606        if let Some(spec) = starts_with_ident(s, "exists:") {
607            return Ok(Self::Exists {
608                domain: MacroSpec::parse(spec)?,
609            });
610        }
611
612        Err(format!("invalid mechanism {s}"))
613    }
614}
615
616#[derive(Debug)]
617enum Modifier {
618    Redirect(MacroSpec),
619    Explanation(MacroSpec),
620    Unknown,
621}
622
623impl Modifier {
624    fn parse(s: &str) -> Result<Self, String> {
625        if let Some(spec) = starts_with_ident(s, "redirect=") {
626            return Ok(Self::Redirect(MacroSpec::parse(spec)?));
627        }
628        if let Some(spec) = starts_with_ident(s, "exp=") {
629            return Ok(Self::Explanation(MacroSpec::parse(spec)?));
630        }
631
632        let (name, _) = s
633            .split_once('=')
634            .ok_or_else(|| format!("invalid modifier {s}"))?;
635
636        let valid = !name.is_empty()
637            && name
638                .chars()
639                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
640            && name.chars().next().unwrap().is_ascii_alphabetic();
641        if !valid {
642            return Err(format!("modifier name '{name}' is invalid"));
643        }
644
645        Ok(Self::Unknown)
646    }
647}
648
649#[cfg(test)]
650mod test {
651    use super::*;
652
653    fn parse(s: &str) -> Record {
654        eprintln!("**\n{s}");
655        match Record::parse(s) {
656            Ok(r) => r,
657            Err(err) => panic!("{err}: {s}"),
658        }
659    }
660
661    #[test]
662    fn test_zero_width() {
663        k9::snapshot!(
664            Record::parse(
665                "v=spf1 mx a include:spf.host.example.com \u{200b}include:_spf.example.com ?all"
666            )
667            .unwrap_err(),
668            r#"invalid token '\u{200b}include:_spf.example.com'"#
669        );
670    }
671
672    #[test]
673    fn test_parse() {
674        k9::snapshot!(
675            Record::parse("v=spf1 -exists:%(ir).sbl.example.org").unwrap_err(),
676            r#"invalid token '-exists:%(ir).sbl.example.org'"#
677        );
678        k9::snapshot!(
679            Record::parse("v=spf1 -exists:%{ir.sbl.example.org").unwrap_err(),
680            r#"invalid token '-exists:%{ir.sbl.example.org'"#
681        );
682        k9::snapshot!(
683            Record::parse("v=spf1 -exists:%{ir").unwrap_err(),
684            r#"invalid token '-exists:%{ir'"#
685        );
686
687        // Extraneous space is not strictly allowed, but we're relaxed
688        // about it to support common real world usage
689        k9::snapshot!(
690            Record::parse("v=spf1 ").unwrap(),
691            "
692Record {
693    directives: [],
694    redirect: None,
695    explanation: None,
696}
697"
698        );
699
700        k9::snapshot!(
701            parse("v=spf1 mx  -all exp=explain._spf.%{d}"),
702            r#"
703Record {
704    directives: [
705        Directive {
706            qualifier: Pass,
707            mechanism: Mx {
708                domain: None,
709                cidr_len: DualCidrLength {
710                    v4: 32,
711                    v6: 128,
712                },
713            },
714        },
715        Directive {
716            qualifier: Fail,
717            mechanism: All,
718        },
719    ],
720    redirect: None,
721    explanation: Some(
722        MacroSpec {
723            elements: [
724                Literal(
725                    "explain._spf.",
726                ),
727                Macro(
728                    MacroTerm {
729                        name: Domain,
730                        transformer_digits: None,
731                        url_escape: false,
732                        reverse: false,
733                        delimiters: "",
734                    },
735                ),
736            ],
737        },
738    ),
739}
740"#
741        );
742
743        k9::snapshot!(
744            parse("v=spf1 -exists:%{ir}.sbl.example.org"),
745            r#"
746Record {
747    directives: [
748        Directive {
749            qualifier: Fail,
750            mechanism: Exists {
751                domain: MacroSpec {
752                    elements: [
753                        Macro(
754                            MacroTerm {
755                                name: Ip,
756                                transformer_digits: None,
757                                url_escape: false,
758                                reverse: true,
759                                delimiters: "",
760                            },
761                        ),
762                        Literal(
763                            ".sbl.example.org",
764                        ),
765                    ],
766                },
767            },
768        },
769    ],
770    redirect: None,
771    explanation: None,
772}
773"#
774        );
775
776        k9::snapshot!(
777            parse("v=spf1 +all"),
778            "
779Record {
780    directives: [
781        Directive {
782            qualifier: Pass,
783            mechanism: All,
784        },
785    ],
786    redirect: None,
787    explanation: None,
788}
789"
790        );
791        k9::snapshot!(
792            parse("v=spf1 a -all"),
793            "
794Record {
795    directives: [
796        Directive {
797            qualifier: Pass,
798            mechanism: A {
799                domain: None,
800                cidr_len: DualCidrLength {
801                    v4: 32,
802                    v6: 128,
803                },
804            },
805        },
806        Directive {
807            qualifier: Fail,
808            mechanism: All,
809        },
810    ],
811    redirect: None,
812    explanation: None,
813}
814"
815        );
816        k9::snapshot!(
817            parse("v=spf1 a:example.org -all"),
818            r#"
819Record {
820    directives: [
821        Directive {
822            qualifier: Pass,
823            mechanism: A {
824                domain: Some(
825                    MacroSpec {
826                        elements: [
827                            Literal(
828                                "example.org",
829                            ),
830                        ],
831                    },
832                ),
833                cidr_len: DualCidrLength {
834                    v4: 32,
835                    v6: 128,
836                },
837            },
838        },
839        Directive {
840            qualifier: Fail,
841            mechanism: All,
842        },
843    ],
844    redirect: None,
845    explanation: None,
846}
847"#
848        );
849        k9::snapshot!(
850            parse("v=spf1 mx -all"),
851            "
852Record {
853    directives: [
854        Directive {
855            qualifier: Pass,
856            mechanism: Mx {
857                domain: None,
858                cidr_len: DualCidrLength {
859                    v4: 32,
860                    v6: 128,
861                },
862            },
863        },
864        Directive {
865            qualifier: Fail,
866            mechanism: All,
867        },
868    ],
869    redirect: None,
870    explanation: None,
871}
872"
873        );
874        k9::snapshot!(
875            parse("v=spf1 mx:example.org -all"),
876            r#"
877Record {
878    directives: [
879        Directive {
880            qualifier: Pass,
881            mechanism: Mx {
882                domain: Some(
883                    MacroSpec {
884                        elements: [
885                            Literal(
886                                "example.org",
887                            ),
888                        ],
889                    },
890                ),
891                cidr_len: DualCidrLength {
892                    v4: 32,
893                    v6: 128,
894                },
895            },
896        },
897        Directive {
898            qualifier: Fail,
899            mechanism: All,
900        },
901    ],
902    redirect: None,
903    explanation: None,
904}
905"#
906        );
907        k9::snapshot!(
908            parse("v=spf1 mx mx:example.org -all"),
909            r#"
910Record {
911    directives: [
912        Directive {
913            qualifier: Pass,
914            mechanism: Mx {
915                domain: None,
916                cidr_len: DualCidrLength {
917                    v4: 32,
918                    v6: 128,
919                },
920            },
921        },
922        Directive {
923            qualifier: Pass,
924            mechanism: Mx {
925                domain: Some(
926                    MacroSpec {
927                        elements: [
928                            Literal(
929                                "example.org",
930                            ),
931                        ],
932                    },
933                ),
934                cidr_len: DualCidrLength {
935                    v4: 32,
936                    v6: 128,
937                },
938            },
939        },
940        Directive {
941            qualifier: Fail,
942            mechanism: All,
943        },
944    ],
945    redirect: None,
946    explanation: None,
947}
948"#
949        );
950        k9::snapshot!(
951            parse("v=spf1 mx/30 -all"),
952            "
953Record {
954    directives: [
955        Directive {
956            qualifier: Pass,
957            mechanism: Mx {
958                domain: None,
959                cidr_len: DualCidrLength {
960                    v4: 30,
961                    v6: 128,
962                },
963            },
964        },
965        Directive {
966            qualifier: Fail,
967            mechanism: All,
968        },
969    ],
970    redirect: None,
971    explanation: None,
972}
973"
974        );
975        k9::snapshot!(
976            parse("v=spf1 mx/30 mx:example.org/30 -all"),
977            r#"
978Record {
979    directives: [
980        Directive {
981            qualifier: Pass,
982            mechanism: Mx {
983                domain: None,
984                cidr_len: DualCidrLength {
985                    v4: 30,
986                    v6: 128,
987                },
988            },
989        },
990        Directive {
991            qualifier: Pass,
992            mechanism: Mx {
993                domain: Some(
994                    MacroSpec {
995                        elements: [
996                            Literal(
997                                "example.org",
998                            ),
999                        ],
1000                    },
1001                ),
1002                cidr_len: DualCidrLength {
1003                    v4: 30,
1004                    v6: 128,
1005                },
1006            },
1007        },
1008        Directive {
1009            qualifier: Fail,
1010            mechanism: All,
1011        },
1012    ],
1013    redirect: None,
1014    explanation: None,
1015}
1016"#
1017        );
1018        k9::snapshot!(
1019            parse("v=spf1 ptr -all"),
1020            "
1021Record {
1022    directives: [
1023        Directive {
1024            qualifier: Pass,
1025            mechanism: Ptr {
1026                domain: None,
1027            },
1028        },
1029        Directive {
1030            qualifier: Fail,
1031            mechanism: All,
1032        },
1033    ],
1034    redirect: None,
1035    explanation: None,
1036}
1037"
1038        );
1039        k9::snapshot!(
1040            parse("v=spf1 ip4:192.0.2.128/28 -all"),
1041            "
1042Record {
1043    directives: [
1044        Directive {
1045            qualifier: Pass,
1046            mechanism: Ip4 {
1047                ip4_network: 192.0.2.128,
1048                cidr_len: 28,
1049            },
1050        },
1051        Directive {
1052            qualifier: Fail,
1053            mechanism: All,
1054        },
1055    ],
1056    redirect: None,
1057    explanation: None,
1058}
1059"
1060        );
1061        k9::snapshot!(
1062            parse("v=spf1 include:example.com include:example.net -all"),
1063            r#"
1064Record {
1065    directives: [
1066        Directive {
1067            qualifier: Pass,
1068            mechanism: Include {
1069                domain: MacroSpec {
1070                    elements: [
1071                        Literal(
1072                            "example.com",
1073                        ),
1074                    ],
1075                },
1076            },
1077        },
1078        Directive {
1079            qualifier: Pass,
1080            mechanism: Include {
1081                domain: MacroSpec {
1082                    elements: [
1083                        Literal(
1084                            "example.net",
1085                        ),
1086                    ],
1087                },
1088            },
1089        },
1090        Directive {
1091            qualifier: Fail,
1092            mechanism: All,
1093        },
1094    ],
1095    redirect: None,
1096    explanation: None,
1097}
1098"#
1099        );
1100        k9::snapshot!(
1101            parse("v=spf1 redirect=example.org"),
1102            r#"
1103Record {
1104    directives: [],
1105    redirect: Some(
1106        MacroSpec {
1107            elements: [
1108                Literal(
1109                    "example.org",
1110                ),
1111            ],
1112        },
1113    ),
1114    explanation: None,
1115}
1116"#
1117        );
1118    }
1119}