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 Qualifier::parse(&s[0..1]) {
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[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_parse() {
663        k9::snapshot!(
664            Record::parse("v=spf1 -exists:%(ir).sbl.example.org").unwrap_err(),
665            r#"invalid token '-exists:%(ir).sbl.example.org'"#
666        );
667        k9::snapshot!(
668            Record::parse("v=spf1 -exists:%{ir.sbl.example.org").unwrap_err(),
669            r#"invalid token '-exists:%{ir.sbl.example.org'"#
670        );
671        k9::snapshot!(
672            Record::parse("v=spf1 -exists:%{ir").unwrap_err(),
673            r#"invalid token '-exists:%{ir'"#
674        );
675
676        // Extraneous space is not strictly allowed, but we're relaxed
677        // about it to support common real world usage
678        k9::snapshot!(
679            Record::parse("v=spf1 ").unwrap(),
680            "
681Record {
682    directives: [],
683    redirect: None,
684    explanation: None,
685}
686"
687        );
688
689        k9::snapshot!(
690            parse("v=spf1 mx  -all exp=explain._spf.%{d}"),
691            r#"
692Record {
693    directives: [
694        Directive {
695            qualifier: Pass,
696            mechanism: Mx {
697                domain: None,
698                cidr_len: DualCidrLength {
699                    v4: 32,
700                    v6: 128,
701                },
702            },
703        },
704        Directive {
705            qualifier: Fail,
706            mechanism: All,
707        },
708    ],
709    redirect: None,
710    explanation: Some(
711        MacroSpec {
712            elements: [
713                Literal(
714                    "explain._spf.",
715                ),
716                Macro(
717                    MacroTerm {
718                        name: Domain,
719                        transformer_digits: None,
720                        url_escape: false,
721                        reverse: false,
722                        delimiters: "",
723                    },
724                ),
725            ],
726        },
727    ),
728}
729"#
730        );
731
732        k9::snapshot!(
733            parse("v=spf1 -exists:%{ir}.sbl.example.org"),
734            r#"
735Record {
736    directives: [
737        Directive {
738            qualifier: Fail,
739            mechanism: Exists {
740                domain: MacroSpec {
741                    elements: [
742                        Macro(
743                            MacroTerm {
744                                name: Ip,
745                                transformer_digits: None,
746                                url_escape: false,
747                                reverse: true,
748                                delimiters: "",
749                            },
750                        ),
751                        Literal(
752                            ".sbl.example.org",
753                        ),
754                    ],
755                },
756            },
757        },
758    ],
759    redirect: None,
760    explanation: None,
761}
762"#
763        );
764
765        k9::snapshot!(
766            parse("v=spf1 +all"),
767            "
768Record {
769    directives: [
770        Directive {
771            qualifier: Pass,
772            mechanism: All,
773        },
774    ],
775    redirect: None,
776    explanation: None,
777}
778"
779        );
780        k9::snapshot!(
781            parse("v=spf1 a -all"),
782            "
783Record {
784    directives: [
785        Directive {
786            qualifier: Pass,
787            mechanism: A {
788                domain: None,
789                cidr_len: DualCidrLength {
790                    v4: 32,
791                    v6: 128,
792                },
793            },
794        },
795        Directive {
796            qualifier: Fail,
797            mechanism: All,
798        },
799    ],
800    redirect: None,
801    explanation: None,
802}
803"
804        );
805        k9::snapshot!(
806            parse("v=spf1 a:example.org -all"),
807            r#"
808Record {
809    directives: [
810        Directive {
811            qualifier: Pass,
812            mechanism: A {
813                domain: Some(
814                    MacroSpec {
815                        elements: [
816                            Literal(
817                                "example.org",
818                            ),
819                        ],
820                    },
821                ),
822                cidr_len: DualCidrLength {
823                    v4: 32,
824                    v6: 128,
825                },
826            },
827        },
828        Directive {
829            qualifier: Fail,
830            mechanism: All,
831        },
832    ],
833    redirect: None,
834    explanation: None,
835}
836"#
837        );
838        k9::snapshot!(
839            parse("v=spf1 mx -all"),
840            "
841Record {
842    directives: [
843        Directive {
844            qualifier: Pass,
845            mechanism: Mx {
846                domain: None,
847                cidr_len: DualCidrLength {
848                    v4: 32,
849                    v6: 128,
850                },
851            },
852        },
853        Directive {
854            qualifier: Fail,
855            mechanism: All,
856        },
857    ],
858    redirect: None,
859    explanation: None,
860}
861"
862        );
863        k9::snapshot!(
864            parse("v=spf1 mx:example.org -all"),
865            r#"
866Record {
867    directives: [
868        Directive {
869            qualifier: Pass,
870            mechanism: Mx {
871                domain: Some(
872                    MacroSpec {
873                        elements: [
874                            Literal(
875                                "example.org",
876                            ),
877                        ],
878                    },
879                ),
880                cidr_len: DualCidrLength {
881                    v4: 32,
882                    v6: 128,
883                },
884            },
885        },
886        Directive {
887            qualifier: Fail,
888            mechanism: All,
889        },
890    ],
891    redirect: None,
892    explanation: None,
893}
894"#
895        );
896        k9::snapshot!(
897            parse("v=spf1 mx mx:example.org -all"),
898            r#"
899Record {
900    directives: [
901        Directive {
902            qualifier: Pass,
903            mechanism: Mx {
904                domain: None,
905                cidr_len: DualCidrLength {
906                    v4: 32,
907                    v6: 128,
908                },
909            },
910        },
911        Directive {
912            qualifier: Pass,
913            mechanism: Mx {
914                domain: Some(
915                    MacroSpec {
916                        elements: [
917                            Literal(
918                                "example.org",
919                            ),
920                        ],
921                    },
922                ),
923                cidr_len: DualCidrLength {
924                    v4: 32,
925                    v6: 128,
926                },
927            },
928        },
929        Directive {
930            qualifier: Fail,
931            mechanism: All,
932        },
933    ],
934    redirect: None,
935    explanation: None,
936}
937"#
938        );
939        k9::snapshot!(
940            parse("v=spf1 mx/30 -all"),
941            "
942Record {
943    directives: [
944        Directive {
945            qualifier: Pass,
946            mechanism: Mx {
947                domain: None,
948                cidr_len: DualCidrLength {
949                    v4: 30,
950                    v6: 128,
951                },
952            },
953        },
954        Directive {
955            qualifier: Fail,
956            mechanism: All,
957        },
958    ],
959    redirect: None,
960    explanation: None,
961}
962"
963        );
964        k9::snapshot!(
965            parse("v=spf1 mx/30 mx:example.org/30 -all"),
966            r#"
967Record {
968    directives: [
969        Directive {
970            qualifier: Pass,
971            mechanism: Mx {
972                domain: None,
973                cidr_len: DualCidrLength {
974                    v4: 30,
975                    v6: 128,
976                },
977            },
978        },
979        Directive {
980            qualifier: Pass,
981            mechanism: Mx {
982                domain: Some(
983                    MacroSpec {
984                        elements: [
985                            Literal(
986                                "example.org",
987                            ),
988                        ],
989                    },
990                ),
991                cidr_len: DualCidrLength {
992                    v4: 30,
993                    v6: 128,
994                },
995            },
996        },
997        Directive {
998            qualifier: Fail,
999            mechanism: All,
1000        },
1001    ],
1002    redirect: None,
1003    explanation: None,
1004}
1005"#
1006        );
1007        k9::snapshot!(
1008            parse("v=spf1 ptr -all"),
1009            "
1010Record {
1011    directives: [
1012        Directive {
1013            qualifier: Pass,
1014            mechanism: Ptr {
1015                domain: None,
1016            },
1017        },
1018        Directive {
1019            qualifier: Fail,
1020            mechanism: All,
1021        },
1022    ],
1023    redirect: None,
1024    explanation: None,
1025}
1026"
1027        );
1028        k9::snapshot!(
1029            parse("v=spf1 ip4:192.0.2.128/28 -all"),
1030            "
1031Record {
1032    directives: [
1033        Directive {
1034            qualifier: Pass,
1035            mechanism: Ip4 {
1036                ip4_network: 192.0.2.128,
1037                cidr_len: 28,
1038            },
1039        },
1040        Directive {
1041            qualifier: Fail,
1042            mechanism: All,
1043        },
1044    ],
1045    redirect: None,
1046    explanation: None,
1047}
1048"
1049        );
1050        k9::snapshot!(
1051            parse("v=spf1 include:example.com include:example.net -all"),
1052            r#"
1053Record {
1054    directives: [
1055        Directive {
1056            qualifier: Pass,
1057            mechanism: Include {
1058                domain: MacroSpec {
1059                    elements: [
1060                        Literal(
1061                            "example.com",
1062                        ),
1063                    ],
1064                },
1065            },
1066        },
1067        Directive {
1068            qualifier: Pass,
1069            mechanism: Include {
1070                domain: MacroSpec {
1071                    elements: [
1072                        Literal(
1073                            "example.net",
1074                        ),
1075                    ],
1076                },
1077            },
1078        },
1079        Directive {
1080            qualifier: Fail,
1081            mechanism: All,
1082        },
1083    ],
1084    redirect: None,
1085    explanation: None,
1086}
1087"#
1088        );
1089        k9::snapshot!(
1090            parse("v=spf1 redirect=example.org"),
1091            r#"
1092Record {
1093    directives: [],
1094    redirect: Some(
1095        MacroSpec {
1096            elements: [
1097                Literal(
1098                    "example.org",
1099                ),
1100            ],
1101        },
1102    ),
1103    explanation: None,
1104}
1105"#
1106        );
1107    }
1108}