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