kumo_spf/
record.rs

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