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 Qualifier::parse(&s[0..1]) {
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[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_parse() {
663 k9::snapshot!(
664 Record::parse("v=spf1 -exists:%(ir).sbl.example.org").unwrap_err(),
665 r#"invalid token '-exists:%(ir).sbl.example.org'"#
666 );
667 k9::snapshot!(
668 Record::parse("v=spf1 -exists:%{ir.sbl.example.org").unwrap_err(),
669 r#"invalid token '-exists:%{ir.sbl.example.org'"#
670 );
671 k9::snapshot!(
672 Record::parse("v=spf1 -exists:%{ir").unwrap_err(),
673 r#"invalid token '-exists:%{ir'"#
674 );
675
676 k9::snapshot!(
679 Record::parse("v=spf1 ").unwrap(),
680 "
681Record {
682 directives: [],
683 redirect: None,
684 explanation: None,
685}
686"
687 );
688
689 k9::snapshot!(
690 parse("v=spf1 mx -all exp=explain._spf.%{d}"),
691 r#"
692Record {
693 directives: [
694 Directive {
695 qualifier: Pass,
696 mechanism: Mx {
697 domain: None,
698 cidr_len: DualCidrLength {
699 v4: 32,
700 v6: 128,
701 },
702 },
703 },
704 Directive {
705 qualifier: Fail,
706 mechanism: All,
707 },
708 ],
709 redirect: None,
710 explanation: Some(
711 MacroSpec {
712 elements: [
713 Literal(
714 "explain._spf.",
715 ),
716 Macro(
717 MacroTerm {
718 name: Domain,
719 transformer_digits: None,
720 url_escape: false,
721 reverse: false,
722 delimiters: "",
723 },
724 ),
725 ],
726 },
727 ),
728}
729"#
730 );
731
732 k9::snapshot!(
733 parse("v=spf1 -exists:%{ir}.sbl.example.org"),
734 r#"
735Record {
736 directives: [
737 Directive {
738 qualifier: Fail,
739 mechanism: Exists {
740 domain: MacroSpec {
741 elements: [
742 Macro(
743 MacroTerm {
744 name: Ip,
745 transformer_digits: None,
746 url_escape: false,
747 reverse: true,
748 delimiters: "",
749 },
750 ),
751 Literal(
752 ".sbl.example.org",
753 ),
754 ],
755 },
756 },
757 },
758 ],
759 redirect: None,
760 explanation: None,
761}
762"#
763 );
764
765 k9::snapshot!(
766 parse("v=spf1 +all"),
767 "
768Record {
769 directives: [
770 Directive {
771 qualifier: Pass,
772 mechanism: All,
773 },
774 ],
775 redirect: None,
776 explanation: None,
777}
778"
779 );
780 k9::snapshot!(
781 parse("v=spf1 a -all"),
782 "
783Record {
784 directives: [
785 Directive {
786 qualifier: Pass,
787 mechanism: A {
788 domain: None,
789 cidr_len: DualCidrLength {
790 v4: 32,
791 v6: 128,
792 },
793 },
794 },
795 Directive {
796 qualifier: Fail,
797 mechanism: All,
798 },
799 ],
800 redirect: None,
801 explanation: None,
802}
803"
804 );
805 k9::snapshot!(
806 parse("v=spf1 a:example.org -all"),
807 r#"
808Record {
809 directives: [
810 Directive {
811 qualifier: Pass,
812 mechanism: A {
813 domain: Some(
814 MacroSpec {
815 elements: [
816 Literal(
817 "example.org",
818 ),
819 ],
820 },
821 ),
822 cidr_len: DualCidrLength {
823 v4: 32,
824 v6: 128,
825 },
826 },
827 },
828 Directive {
829 qualifier: Fail,
830 mechanism: All,
831 },
832 ],
833 redirect: None,
834 explanation: None,
835}
836"#
837 );
838 k9::snapshot!(
839 parse("v=spf1 mx -all"),
840 "
841Record {
842 directives: [
843 Directive {
844 qualifier: Pass,
845 mechanism: Mx {
846 domain: None,
847 cidr_len: DualCidrLength {
848 v4: 32,
849 v6: 128,
850 },
851 },
852 },
853 Directive {
854 qualifier: Fail,
855 mechanism: All,
856 },
857 ],
858 redirect: None,
859 explanation: None,
860}
861"
862 );
863 k9::snapshot!(
864 parse("v=spf1 mx:example.org -all"),
865 r#"
866Record {
867 directives: [
868 Directive {
869 qualifier: Pass,
870 mechanism: Mx {
871 domain: Some(
872 MacroSpec {
873 elements: [
874 Literal(
875 "example.org",
876 ),
877 ],
878 },
879 ),
880 cidr_len: DualCidrLength {
881 v4: 32,
882 v6: 128,
883 },
884 },
885 },
886 Directive {
887 qualifier: Fail,
888 mechanism: All,
889 },
890 ],
891 redirect: None,
892 explanation: None,
893}
894"#
895 );
896 k9::snapshot!(
897 parse("v=spf1 mx mx:example.org -all"),
898 r#"
899Record {
900 directives: [
901 Directive {
902 qualifier: Pass,
903 mechanism: Mx {
904 domain: None,
905 cidr_len: DualCidrLength {
906 v4: 32,
907 v6: 128,
908 },
909 },
910 },
911 Directive {
912 qualifier: Pass,
913 mechanism: Mx {
914 domain: Some(
915 MacroSpec {
916 elements: [
917 Literal(
918 "example.org",
919 ),
920 ],
921 },
922 ),
923 cidr_len: DualCidrLength {
924 v4: 32,
925 v6: 128,
926 },
927 },
928 },
929 Directive {
930 qualifier: Fail,
931 mechanism: All,
932 },
933 ],
934 redirect: None,
935 explanation: None,
936}
937"#
938 );
939 k9::snapshot!(
940 parse("v=spf1 mx/30 -all"),
941 "
942Record {
943 directives: [
944 Directive {
945 qualifier: Pass,
946 mechanism: Mx {
947 domain: None,
948 cidr_len: DualCidrLength {
949 v4: 30,
950 v6: 128,
951 },
952 },
953 },
954 Directive {
955 qualifier: Fail,
956 mechanism: All,
957 },
958 ],
959 redirect: None,
960 explanation: None,
961}
962"
963 );
964 k9::snapshot!(
965 parse("v=spf1 mx/30 mx:example.org/30 -all"),
966 r#"
967Record {
968 directives: [
969 Directive {
970 qualifier: Pass,
971 mechanism: Mx {
972 domain: None,
973 cidr_len: DualCidrLength {
974 v4: 30,
975 v6: 128,
976 },
977 },
978 },
979 Directive {
980 qualifier: Pass,
981 mechanism: Mx {
982 domain: Some(
983 MacroSpec {
984 elements: [
985 Literal(
986 "example.org",
987 ),
988 ],
989 },
990 ),
991 cidr_len: DualCidrLength {
992 v4: 30,
993 v6: 128,
994 },
995 },
996 },
997 Directive {
998 qualifier: Fail,
999 mechanism: All,
1000 },
1001 ],
1002 redirect: None,
1003 explanation: None,
1004}
1005"#
1006 );
1007 k9::snapshot!(
1008 parse("v=spf1 ptr -all"),
1009 "
1010Record {
1011 directives: [
1012 Directive {
1013 qualifier: Pass,
1014 mechanism: Ptr {
1015 domain: None,
1016 },
1017 },
1018 Directive {
1019 qualifier: Fail,
1020 mechanism: All,
1021 },
1022 ],
1023 redirect: None,
1024 explanation: None,
1025}
1026"
1027 );
1028 k9::snapshot!(
1029 parse("v=spf1 ip4:192.0.2.128/28 -all"),
1030 "
1031Record {
1032 directives: [
1033 Directive {
1034 qualifier: Pass,
1035 mechanism: Ip4 {
1036 ip4_network: 192.0.2.128,
1037 cidr_len: 28,
1038 },
1039 },
1040 Directive {
1041 qualifier: Fail,
1042 mechanism: All,
1043 },
1044 ],
1045 redirect: None,
1046 explanation: None,
1047}
1048"
1049 );
1050 k9::snapshot!(
1051 parse("v=spf1 include:example.com include:example.net -all"),
1052 r#"
1053Record {
1054 directives: [
1055 Directive {
1056 qualifier: Pass,
1057 mechanism: Include {
1058 domain: MacroSpec {
1059 elements: [
1060 Literal(
1061 "example.com",
1062 ),
1063 ],
1064 },
1065 },
1066 },
1067 Directive {
1068 qualifier: Pass,
1069 mechanism: Include {
1070 domain: MacroSpec {
1071 elements: [
1072 Literal(
1073 "example.net",
1074 ),
1075 ],
1076 },
1077 },
1078 },
1079 Directive {
1080 qualifier: Fail,
1081 mechanism: All,
1082 },
1083 ],
1084 redirect: None,
1085 explanation: None,
1086}
1087"#
1088 );
1089 k9::snapshot!(
1090 parse("v=spf1 redirect=example.org"),
1091 r#"
1092Record {
1093 directives: [],
1094 redirect: Some(
1095 MacroSpec {
1096 elements: [
1097 Literal(
1098 "example.org",
1099 ),
1100 ],
1101 },
1102 ),
1103 explanation: None,
1104}
1105"#
1106 );
1107 }
1108}