kumo_spf/
record.rs

1use crate::spec::MacroSpec;
2use crate::{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(&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(&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(&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.resolve_ip(&exchange.to_string()).await {
203                        Ok(ips) => ips,
204                        Err(err) => {
205                            return Err(SpfResult {
206                                disposition: SpfDisposition::TempError,
207                                context: format!("error looking up IP for {exchange}: {err}"),
208                            })
209                        }
210                    };
211
212                    if resolved
213                        .iter()
214                        .any(|&resolved_ip| cidr_len.matches(cx.client_ip, resolved_ip))
215                    {
216                        matched = true;
217                        break;
218                    }
219                }
220
221                matched
222            }
223            Mechanism::Ip4 {
224                ip4_network,
225                cidr_len,
226            } => DualCidrLength {
227                v4: *cidr_len,
228                ..Default::default()
229            }
230            .matches(cx.client_ip, IpAddr::V4(*ip4_network)),
231            Mechanism::Ip6 {
232                ip6_network,
233                cidr_len,
234            } => DualCidrLength {
235                v6: *cidr_len,
236                ..Default::default()
237            }
238            .matches(cx.client_ip, IpAddr::V6(*ip6_network)),
239            Mechanism::Ptr { domain } => {
240                // Note that validated_domain applies lookup limits
241                let validated_domain = cx.validated_domain(domain.as_ref(), resolver).await?;
242
243                validated_domain.is_some()
244            }
245            Mechanism::Include { domain } => {
246                let domain = cx.domain(Some(domain), resolver).await?;
247                let nested = cx.with_domain(&domain);
248                use SpfDisposition::*;
249                match Box::pin(nested.check(resolver, false)).await {
250                    SpfResult {
251                        disposition: Pass, ..
252                    } => true,
253                    SpfResult {
254                        disposition: Fail | SoftFail | Neutral,
255                        ..
256                    } => false,
257                    SpfResult {
258                        disposition: TempError,
259                        context,
260                    } => {
261                        return Err(SpfResult {
262                            disposition: TempError,
263                            context: format!(
264                                "temperror while evaluating include:{domain}: {context}"
265                            ),
266                        })
267                    }
268                    SpfResult {
269                        disposition: disp @ PermError | disp @ None,
270                        context,
271                    } => {
272                        return Err(SpfResult {
273                            disposition: PermError,
274                            context: format!("{disp} while evaluating include:{domain}: {context}"),
275                        })
276                    }
277                }
278            }
279            Mechanism::Exists { domain } => {
280                cx.check_lookup_limit()?;
281                let domain = cx.domain(Some(domain), resolver).await?;
282                match resolver.resolve_ip(&domain).await {
283                    Ok(ips) => ips.iter().any(|ip| ip.is_ipv4()),
284                    Err(err) => {
285                        return Err(SpfResult {
286                            disposition: SpfDisposition::TempError,
287                            context: format!("error looking up IP for {domain}: {err}"),
288                        })
289                    }
290                }
291            }
292        };
293
294        Ok(if matched {
295            Some(SpfResult {
296                disposition: SpfDisposition::from(self.qualifier),
297                context: format!("matched '{self}' directive"),
298            })
299        } else {
300            None
301        })
302    }
303}
304
305impl fmt::Display for Directive {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        if self.qualifier != Qualifier::Pass {
308            write!(f, "{}", self.qualifier.as_str())?;
309        }
310        write!(f, "{}", self.mechanism)
311    }
312}
313
314#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
315pub(crate) enum Qualifier {
316    /// `+`
317    #[default]
318    Pass,
319    /// `-`
320    Fail,
321    /// `~`
322    SoftFail,
323    /// `?`
324    Neutral,
325}
326
327impl Qualifier {
328    fn parse(s: &str) -> Option<Self> {
329        Some(match s {
330            "+" => Self::Pass,
331            "-" => Self::Fail,
332            "~" => Self::SoftFail,
333            "?" => Self::Neutral,
334            _ => return None,
335        })
336    }
337
338    fn as_str(&self) -> &'static str {
339        match self {
340            Self::Pass => "+",
341            Self::Fail => "-",
342            Self::SoftFail => "~",
343            Self::Neutral => "?",
344        }
345    }
346}
347
348#[derive(Debug)]
349struct DualCidrLength {
350    pub v4: u8,
351    pub v6: u8,
352}
353
354impl DualCidrLength {
355    /// Whether the `observed` IP address (from the client's IP) matches the `specified` address
356    /// (from/via the SPF record), given the specified CIDR mask lengths.
357    fn matches(&self, observed: IpAddr, specified: IpAddr) -> bool {
358        match (observed, specified, self) {
359            (IpAddr::V4(observed), IpAddr::V4(specified), DualCidrLength { v4, .. }) => {
360                let mask = u32::MAX << (32 - v4);
361                let specified_masked = Ipv4Addr::from_bits(specified.to_bits() & mask);
362                let observed_masked = Ipv4Addr::from(observed.to_bits() & mask);
363                specified_masked == observed_masked
364            }
365            (IpAddr::V6(observed), IpAddr::V6(specified), DualCidrLength { v6, .. }) => {
366                let mask = u128::MAX << (128 - v6);
367                let specified_masked = Ipv6Addr::from_bits(specified.to_bits() & mask);
368                let observed_masked = Ipv6Addr::from(observed.to_bits() & mask);
369                specified_masked == observed_masked
370            }
371            _ => false,
372        }
373    }
374}
375
376impl Default for DualCidrLength {
377    fn default() -> Self {
378        Self { v4: 32, v6: 128 }
379    }
380}
381
382impl DualCidrLength {
383    fn parse_from_end(s: &str) -> Result<(&str, Self), String> {
384        match s.rsplit_once('/') {
385            Some((left, right)) => {
386                let right_cidr: u8 = right
387                    .parse()
388                    .map_err(|err| format!("invalid dual-cidr-length in {s}: {err}"))?;
389
390                if left.ends_with('/') {
391                    // we have another cidr length
392                    if let Some((prefix, v4cidr)) = left[0..left.len() - 1].rsplit_once('/') {
393                        let left_cidr: u8 = v4cidr.parse().map_err(|err| {
394                            format!(
395                                "invalid dual-cidr-length in {s}: parsing v4 cidr portion: {err}"
396                            )
397                        })?;
398                        return Ok((
399                            prefix,
400                            Self {
401                                v4: left_cidr,
402                                v6: right_cidr,
403                            },
404                        ));
405                    }
406                }
407                Ok((
408                    left,
409                    Self {
410                        v4: right_cidr,
411                        ..Self::default()
412                    },
413                ))
414            }
415            None => Ok((s, Self::default())),
416        }
417    }
418}
419
420impl fmt::Display for DualCidrLength {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        if self.v4 == 32 && self.v6 == 128 {
423            return Ok(());
424        }
425
426        write!(f, "/{}", self.v4)?;
427        if self.v6 != 128 {
428            write!(f, "/{}", self.v6)?;
429        }
430
431        Ok(())
432    }
433}
434
435#[derive(Debug)]
436enum Mechanism {
437    All,
438    Include {
439        domain: MacroSpec,
440    },
441    A {
442        domain: Option<MacroSpec>,
443        cidr_len: DualCidrLength,
444    },
445    Mx {
446        domain: Option<MacroSpec>,
447        cidr_len: DualCidrLength,
448    },
449    Ptr {
450        domain: Option<MacroSpec>,
451    },
452    Ip4 {
453        ip4_network: Ipv4Addr,
454        cidr_len: u8,
455    },
456    Ip6 {
457        ip6_network: Ipv6Addr,
458        cidr_len: u8,
459    },
460    Exists {
461        domain: MacroSpec,
462    },
463}
464
465impl fmt::Display for Mechanism {
466    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
467        match self {
468            Self::All => write!(f, "all"),
469            Self::Include { domain } => write!(f, "include:{}", domain),
470            Self::A { domain, cidr_len } => {
471                write!(f, "a")?;
472                if let Some(domain) = domain {
473                    write!(f, ":{}", domain)?;
474                }
475                write!(f, "{}", cidr_len)
476            }
477            Self::Mx { domain, cidr_len } => {
478                write!(f, "mx")?;
479                if let Some(domain) = domain {
480                    write!(f, ":{}", domain)?;
481                }
482                write!(f, "{}", cidr_len)
483            }
484            Self::Ptr { domain } => {
485                write!(f, "ptr")?;
486                if let Some(domain) = domain {
487                    write!(f, ":{}", domain)?;
488                }
489                Ok(())
490            }
491            Self::Ip4 {
492                ip4_network,
493                cidr_len,
494            } => write!(f, "ip4:{}/{}", ip4_network, cidr_len),
495            Self::Ip6 {
496                ip6_network,
497                cidr_len,
498            } => write!(f, "ip6:{}/{}", ip6_network, cidr_len),
499            Self::Exists { domain } => write!(f, "exists:{}", domain),
500        }
501    }
502}
503
504fn starts_with_ident<'a>(s: &'a str, ident: &str) -> Option<&'a str> {
505    if s.len() < ident.len() {
506        return None;
507    }
508
509    if s[0..ident.len()].eq_ignore_ascii_case(ident) {
510        Some(&s[ident.len()..])
511    } else {
512        None
513    }
514}
515
516impl Mechanism {
517    fn parse(s: &str) -> Result<Self, String> {
518        if s.eq_ignore_ascii_case("all") {
519            return Ok(Self::All);
520        }
521
522        if let Some(spec) = starts_with_ident(s, "include:") {
523            return Ok(Self::Include {
524                domain: MacroSpec::parse(spec)?,
525            });
526        }
527
528        if let Some(remain) = starts_with_ident(s, "a") {
529            let (remain, cidr_len) = DualCidrLength::parse_from_end(remain)?;
530
531            let domain = if let Some(spec) = remain.strip_prefix(":") {
532                Some(MacroSpec::parse(spec)?)
533            } else if remain.is_empty() {
534                None
535            } else {
536                return Err(format!("invalid 'a' mechanism: {s}"));
537            };
538
539            return Ok(Self::A { domain, cidr_len });
540        }
541        if let Some(remain) = starts_with_ident(s, "mx") {
542            let (remain, cidr_len) = DualCidrLength::parse_from_end(remain)?;
543
544            let domain = if let Some(spec) = remain.strip_prefix(":") {
545                Some(MacroSpec::parse(spec)?)
546            } else if remain.is_empty() {
547                None
548            } else {
549                return Err(format!("invalid 'mx' mechanism: {s}"));
550            };
551
552            return Ok(Self::Mx { domain, cidr_len });
553        }
554        if let Some(remain) = starts_with_ident(s, "ptr") {
555            let domain = if let Some(spec) = remain.strip_prefix(":") {
556                Some(MacroSpec::parse(spec)?)
557            } else if remain.is_empty() {
558                None
559            } else {
560                return Err(format!("invalid 'ptr' mechanism: {s}"));
561            };
562
563            return Ok(Self::Ptr { domain });
564        }
565        if let Some(remain) = starts_with_ident(s, "ip4:") {
566            let (addr, len) = if let Some((addr, len)) = remain.split_once('/') {
567                (addr, len)
568            } else {
569                // Default CIDR length for IPv4 is /32 (single host)
570                (remain, "32")
571            };
572            let ip4_network = addr
573                .parse()
574                .map_err(|err| format!("invalid 'ip4' mechanism: {s}: {err}"))?;
575            let cidr_len = len
576                .parse()
577                .map_err(|err| format!("invalid 'ip4' mechanism: {s}: {err}"))?;
578
579            return Ok(Self::Ip4 {
580                ip4_network,
581                cidr_len,
582            });
583        }
584        if let Some(remain) = starts_with_ident(s, "ip6:") {
585            let (addr, len) = if let Some((addr, len)) = remain.split_once('/') {
586                (addr, len)
587            } else {
588                // Default CIDR length for IPv6 is /128 (single host)
589                (remain, "128")
590            };
591            let ip6_network = addr
592                .parse()
593                .map_err(|err| format!("invalid 'ip6' mechanism: {s}: {err}"))?;
594            let cidr_len = len
595                .parse()
596                .map_err(|err| format!("invalid 'ip6' mechanism: {s}: {err}"))?;
597
598            return Ok(Self::Ip6 {
599                ip6_network,
600                cidr_len,
601            });
602        }
603        if let Some(spec) = starts_with_ident(s, "exists:") {
604            return Ok(Self::Exists {
605                domain: MacroSpec::parse(spec)?,
606            });
607        }
608
609        Err(format!("invalid mechanism {s}"))
610    }
611}
612
613#[derive(Debug)]
614enum Modifier {
615    Redirect(MacroSpec),
616    Explanation(MacroSpec),
617    Unknown,
618}
619
620impl Modifier {
621    fn parse(s: &str) -> Result<Self, String> {
622        if let Some(spec) = starts_with_ident(s, "redirect=") {
623            return Ok(Self::Redirect(MacroSpec::parse(spec)?));
624        }
625        if let Some(spec) = starts_with_ident(s, "exp=") {
626            return Ok(Self::Explanation(MacroSpec::parse(spec)?));
627        }
628
629        let (name, _) = s
630            .split_once('=')
631            .ok_or_else(|| format!("invalid modifier {s}"))?;
632
633        let valid = !name.is_empty()
634            && name
635                .chars()
636                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
637            && name.chars().next().unwrap().is_ascii_alphabetic();
638        if !valid {
639            return Err(format!("modifier name '{name}' is invalid"));
640        }
641
642        Ok(Self::Unknown)
643    }
644}
645
646#[cfg(test)]
647mod test {
648    use super::*;
649
650    fn parse(s: &str) -> Record {
651        eprintln!("**\n{s}");
652        match Record::parse(s) {
653            Ok(r) => r,
654            Err(err) => panic!("{err}: {s}"),
655        }
656    }
657
658    #[test]
659    fn test_parse() {
660        k9::snapshot!(
661            Record::parse("v=spf1 -exists:%(ir).sbl.example.org").unwrap_err(),
662            r#"invalid token '-exists:%(ir).sbl.example.org'"#
663        );
664        k9::snapshot!(
665            Record::parse("v=spf1 -exists:%{ir.sbl.example.org").unwrap_err(),
666            r#"invalid token '-exists:%{ir.sbl.example.org'"#
667        );
668        k9::snapshot!(
669            Record::parse("v=spf1 -exists:%{ir").unwrap_err(),
670            r#"invalid token '-exists:%{ir'"#
671        );
672
673        // Extraneous space is not strictly allowed, but we're relaxed
674        // about it to support common real world usage
675        k9::snapshot!(
676            Record::parse("v=spf1 ").unwrap(),
677            "
678Record {
679    directives: [],
680    redirect: None,
681    explanation: None,
682}
683"
684        );
685
686        k9::snapshot!(
687            parse("v=spf1 mx  -all exp=explain._spf.%{d}"),
688            r#"
689Record {
690    directives: [
691        Directive {
692            qualifier: Pass,
693            mechanism: Mx {
694                domain: None,
695                cidr_len: DualCidrLength {
696                    v4: 32,
697                    v6: 128,
698                },
699            },
700        },
701        Directive {
702            qualifier: Fail,
703            mechanism: All,
704        },
705    ],
706    redirect: None,
707    explanation: Some(
708        MacroSpec {
709            elements: [
710                Literal(
711                    "explain._spf.",
712                ),
713                Macro(
714                    MacroTerm {
715                        name: Domain,
716                        transformer_digits: None,
717                        url_escape: false,
718                        reverse: false,
719                        delimiters: "",
720                    },
721                ),
722            ],
723        },
724    ),
725}
726"#
727        );
728
729        k9::snapshot!(
730            parse("v=spf1 -exists:%{ir}.sbl.example.org"),
731            r#"
732Record {
733    directives: [
734        Directive {
735            qualifier: Fail,
736            mechanism: Exists {
737                domain: MacroSpec {
738                    elements: [
739                        Macro(
740                            MacroTerm {
741                                name: Ip,
742                                transformer_digits: None,
743                                url_escape: false,
744                                reverse: true,
745                                delimiters: "",
746                            },
747                        ),
748                        Literal(
749                            ".sbl.example.org",
750                        ),
751                    ],
752                },
753            },
754        },
755    ],
756    redirect: None,
757    explanation: None,
758}
759"#
760        );
761
762        k9::snapshot!(
763            parse("v=spf1 +all"),
764            "
765Record {
766    directives: [
767        Directive {
768            qualifier: Pass,
769            mechanism: All,
770        },
771    ],
772    redirect: None,
773    explanation: None,
774}
775"
776        );
777        k9::snapshot!(
778            parse("v=spf1 a -all"),
779            "
780Record {
781    directives: [
782        Directive {
783            qualifier: Pass,
784            mechanism: A {
785                domain: None,
786                cidr_len: DualCidrLength {
787                    v4: 32,
788                    v6: 128,
789                },
790            },
791        },
792        Directive {
793            qualifier: Fail,
794            mechanism: All,
795        },
796    ],
797    redirect: None,
798    explanation: None,
799}
800"
801        );
802        k9::snapshot!(
803            parse("v=spf1 a:example.org -all"),
804            r#"
805Record {
806    directives: [
807        Directive {
808            qualifier: Pass,
809            mechanism: A {
810                domain: Some(
811                    MacroSpec {
812                        elements: [
813                            Literal(
814                                "example.org",
815                            ),
816                        ],
817                    },
818                ),
819                cidr_len: DualCidrLength {
820                    v4: 32,
821                    v6: 128,
822                },
823            },
824        },
825        Directive {
826            qualifier: Fail,
827            mechanism: All,
828        },
829    ],
830    redirect: None,
831    explanation: None,
832}
833"#
834        );
835        k9::snapshot!(
836            parse("v=spf1 mx -all"),
837            "
838Record {
839    directives: [
840        Directive {
841            qualifier: Pass,
842            mechanism: Mx {
843                domain: None,
844                cidr_len: DualCidrLength {
845                    v4: 32,
846                    v6: 128,
847                },
848            },
849        },
850        Directive {
851            qualifier: Fail,
852            mechanism: All,
853        },
854    ],
855    redirect: None,
856    explanation: None,
857}
858"
859        );
860        k9::snapshot!(
861            parse("v=spf1 mx:example.org -all"),
862            r#"
863Record {
864    directives: [
865        Directive {
866            qualifier: Pass,
867            mechanism: Mx {
868                domain: Some(
869                    MacroSpec {
870                        elements: [
871                            Literal(
872                                "example.org",
873                            ),
874                        ],
875                    },
876                ),
877                cidr_len: DualCidrLength {
878                    v4: 32,
879                    v6: 128,
880                },
881            },
882        },
883        Directive {
884            qualifier: Fail,
885            mechanism: All,
886        },
887    ],
888    redirect: None,
889    explanation: None,
890}
891"#
892        );
893        k9::snapshot!(
894            parse("v=spf1 mx mx:example.org -all"),
895            r#"
896Record {
897    directives: [
898        Directive {
899            qualifier: Pass,
900            mechanism: Mx {
901                domain: None,
902                cidr_len: DualCidrLength {
903                    v4: 32,
904                    v6: 128,
905                },
906            },
907        },
908        Directive {
909            qualifier: Pass,
910            mechanism: Mx {
911                domain: Some(
912                    MacroSpec {
913                        elements: [
914                            Literal(
915                                "example.org",
916                            ),
917                        ],
918                    },
919                ),
920                cidr_len: DualCidrLength {
921                    v4: 32,
922                    v6: 128,
923                },
924            },
925        },
926        Directive {
927            qualifier: Fail,
928            mechanism: All,
929        },
930    ],
931    redirect: None,
932    explanation: None,
933}
934"#
935        );
936        k9::snapshot!(
937            parse("v=spf1 mx/30 -all"),
938            "
939Record {
940    directives: [
941        Directive {
942            qualifier: Pass,
943            mechanism: Mx {
944                domain: None,
945                cidr_len: DualCidrLength {
946                    v4: 30,
947                    v6: 128,
948                },
949            },
950        },
951        Directive {
952            qualifier: Fail,
953            mechanism: All,
954        },
955    ],
956    redirect: None,
957    explanation: None,
958}
959"
960        );
961        k9::snapshot!(
962            parse("v=spf1 mx/30 mx:example.org/30 -all"),
963            r#"
964Record {
965    directives: [
966        Directive {
967            qualifier: Pass,
968            mechanism: Mx {
969                domain: None,
970                cidr_len: DualCidrLength {
971                    v4: 30,
972                    v6: 128,
973                },
974            },
975        },
976        Directive {
977            qualifier: Pass,
978            mechanism: Mx {
979                domain: Some(
980                    MacroSpec {
981                        elements: [
982                            Literal(
983                                "example.org",
984                            ),
985                        ],
986                    },
987                ),
988                cidr_len: DualCidrLength {
989                    v4: 30,
990                    v6: 128,
991                },
992            },
993        },
994        Directive {
995            qualifier: Fail,
996            mechanism: All,
997        },
998    ],
999    redirect: None,
1000    explanation: None,
1001}
1002"#
1003        );
1004        k9::snapshot!(
1005            parse("v=spf1 ptr -all"),
1006            "
1007Record {
1008    directives: [
1009        Directive {
1010            qualifier: Pass,
1011            mechanism: Ptr {
1012                domain: None,
1013            },
1014        },
1015        Directive {
1016            qualifier: Fail,
1017            mechanism: All,
1018        },
1019    ],
1020    redirect: None,
1021    explanation: None,
1022}
1023"
1024        );
1025        k9::snapshot!(
1026            parse("v=spf1 ip4:192.0.2.128/28 -all"),
1027            "
1028Record {
1029    directives: [
1030        Directive {
1031            qualifier: Pass,
1032            mechanism: Ip4 {
1033                ip4_network: 192.0.2.128,
1034                cidr_len: 28,
1035            },
1036        },
1037        Directive {
1038            qualifier: Fail,
1039            mechanism: All,
1040        },
1041    ],
1042    redirect: None,
1043    explanation: None,
1044}
1045"
1046        );
1047        k9::snapshot!(
1048            parse("v=spf1 include:example.com include:example.net -all"),
1049            r#"
1050Record {
1051    directives: [
1052        Directive {
1053            qualifier: Pass,
1054            mechanism: Include {
1055                domain: MacroSpec {
1056                    elements: [
1057                        Literal(
1058                            "example.com",
1059                        ),
1060                    ],
1061                },
1062            },
1063        },
1064        Directive {
1065            qualifier: Pass,
1066            mechanism: Include {
1067                domain: MacroSpec {
1068                    elements: [
1069                        Literal(
1070                            "example.net",
1071                        ),
1072                    ],
1073                },
1074            },
1075        },
1076        Directive {
1077            qualifier: Fail,
1078            mechanism: All,
1079        },
1080    ],
1081    redirect: None,
1082    explanation: None,
1083}
1084"#
1085        );
1086        k9::snapshot!(
1087            parse("v=spf1 redirect=example.org"),
1088            r#"
1089Record {
1090    directives: [],
1091    redirect: Some(
1092        MacroSpec {
1093            elements: [
1094                Literal(
1095                    "example.org",
1096                ),
1097            ],
1098        },
1099    ),
1100    explanation: None,
1101}
1102"#
1103        );
1104    }
1105}