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 _ => {} }
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 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 #[default]
344 Pass,
345 Fail,
347 SoftFail,
349 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 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 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}