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