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(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 #[default]
343 Pass,
344 Fail,
346 SoftFail,
348 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 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 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}