1use std::collections::HashMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::dns::{DnsResolver, RecordData, RecordType};
20use crate::error::Result;
21use crate::validation::{normalize_domain, normalize_host};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum PostureVerdict {
29 Absent,
31 Weak,
33 Moderate,
35 Strict,
37 Present,
39}
40
41impl PostureVerdict {
42 pub fn as_str(self) -> &'static str {
44 match self {
45 PostureVerdict::Absent => "absent",
46 PostureVerdict::Weak => "weak",
47 PostureVerdict::Moderate => "moderate",
48 PostureVerdict::Strict => "strict",
49 PostureVerdict::Present => "present",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct SpfPolicy {
57 pub present: bool,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 pub record: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
63 pub all_qualifier: Option<String>,
64 pub verdict: PostureVerdict,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct DmarcPolicy {
70 pub present: bool,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub record: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub policy: Option<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub subdomain_policy: Option<String>,
79 #[serde(skip_serializing_if = "Vec::is_empty")]
81 pub aggregate_reports: Vec<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub percent: Option<u8>,
85 pub verdict: PostureVerdict,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct MtaStsPolicy {
92 pub present: bool,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub record: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub id: Option<String>,
98 pub verdict: PostureVerdict,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct BimiPolicy {
104 pub present: bool,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub record: Option<String>,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub logo_url: Option<String>,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub authority_url: Option<String>,
113 pub verdict: PostureVerdict,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct TlsaRecord {
119 pub scope: String,
123 pub cert_usage: u8,
124 pub selector: u8,
125 pub matching: u8,
126 pub cert_data: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct DanePolicy {
132 pub present: bool,
133 #[serde(skip_serializing_if = "Vec::is_empty")]
134 pub records: Vec<TlsaRecord>,
135 pub verdict: PostureVerdict,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct EmailPosture {
141 pub domain: String,
142 pub spf: SpfPolicy,
143 pub dmarc: DmarcPolicy,
144 pub mta_sts: MtaStsPolicy,
145 pub bimi: BimiPolicy,
146 pub dane: DanePolicy,
147 pub notes: Vec<String>,
149}
150
151const MAX_SPF_REDIRECTS: usize = 2;
156
157const MAX_DMARC_WALK: usize = 7;
159
160const MAX_DANE_MX_HOSTS: usize = 5;
162
163fn has_version_tag(record: &str, tag: &str, allow_semicolon: bool) -> bool {
170 let record = record.trim_start();
171 let Some(head) = record.get(..tag.len()) else {
172 return false;
173 };
174 if !head.eq_ignore_ascii_case(tag) {
175 return false;
176 }
177 match record[tag.len()..].chars().next() {
178 None => true,
179 Some(c) => c.is_whitespace() || (allow_semicolon && c == ';'),
180 }
181}
182
183#[derive(Debug, PartialEq, Eq)]
185enum Selection<'a> {
186 None,
187 One(&'a str),
188 Multiple(usize),
191}
192
193fn select<'a>(records: &'a [String], tag: &str, allow_semicolon: bool) -> Selection<'a> {
194 let mut matching = records
195 .iter()
196 .filter(|r| has_version_tag(r, tag, allow_semicolon));
197 match (matching.next(), matching.count()) {
198 (None, _) => Selection::None,
199 (Some(record), 0) => Selection::One(record),
200 (Some(_), rest) => Selection::Multiple(rest + 1),
201 }
202}
203
204fn select_spf(records: &[String]) -> Selection<'_> {
205 select(records, "v=spf1", false)
206}
207
208fn select_dmarc(records: &[String]) -> Selection<'_> {
209 select(records, "v=DMARC1", true)
210}
211
212fn parse_spf_all_qualifier(record: &str) -> Option<String> {
215 for token in record.split_whitespace() {
216 let t = token.to_ascii_lowercase();
217 if t == "all" {
218 return Some("+".to_string());
219 }
220 if let Some(qual) = t.strip_suffix("all") {
221 if matches!(qual, "-" | "~" | "?" | "+") {
222 return Some(qual.to_string());
223 }
224 }
225 }
226 None
227}
228
229fn parse_spf_redirect(record: &str) -> Option<String> {
231 record.split_whitespace().find_map(|token| {
232 let (name, value) = token.split_once('=')?;
233 name.eq_ignore_ascii_case("redirect")
234 .then(|| value.to_string())
235 })
236}
237
238#[derive(Debug, PartialEq, Eq)]
240enum SpfTerminal {
241 All(String),
243 Redirect(String),
246 Neither,
248}
249
250fn spf_terminal(record: &str) -> SpfTerminal {
251 match (parse_spf_all_qualifier(record), parse_spf_redirect(record)) {
252 (Some(qualifier), _) => SpfTerminal::All(qualifier),
253 (None, Some(target)) => SpfTerminal::Redirect(target),
254 (None, None) => SpfTerminal::Neither,
255 }
256}
257
258fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
260 match all_qualifier {
261 Some("-") => PostureVerdict::Strict,
262 Some("~") => PostureVerdict::Moderate,
263 Some(_) | None => PostureVerdict::Weak,
266 }
267}
268
269fn parse_tag_value(record: &str) -> HashMap<String, String> {
272 record
273 .split(';')
274 .filter_map(|pair| {
275 let (k, v) = pair.split_once('=')?;
276 Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
277 })
278 .collect()
279}
280
281fn dmarc_verdict(policy: Option<&str>) -> PostureVerdict {
283 match policy.map(|p| p.to_ascii_lowercase()) {
284 Some(ref p) if p == "reject" => PostureVerdict::Strict,
285 Some(ref p) if p == "quarantine" => PostureVerdict::Moderate,
286 Some(ref p) if p == "none" => PostureVerdict::Weak,
287 _ => PostureVerdict::Weak,
288 }
289}
290
291fn downgrade(verdict: PostureVerdict) -> PostureVerdict {
295 match verdict {
296 PostureVerdict::Strict => PostureVerdict::Moderate,
297 PostureVerdict::Moderate => PostureVerdict::Weak,
298 other => other,
299 }
300}
301
302fn txt_strings(records: &[crate::dns::DnsRecord]) -> Vec<String> {
304 records
305 .iter()
306 .filter_map(|r| match &r.data {
307 RecordData::TXT { text } => Some(text.clone()),
308 _ => None,
309 })
310 .collect()
311}
312
313#[derive(Debug)]
317struct Assessed<T> {
318 policy: T,
319 notes: Vec<String>,
320}
321
322#[derive(Debug)]
324enum RedirectOutcome {
325 Resolved {
328 target: String,
329 qualifier: Option<String>,
330 },
331 Failed { target: String, reason: String },
333}
334
335fn spf_verdict_note(verdict: PostureVerdict) -> Option<String> {
336 match verdict {
337 PostureVerdict::Weak => Some(
338 "SPF does not end in -all/~all — it permits unlisted senders (spoofable).".to_string(),
339 ),
340 PostureVerdict::Moderate => {
341 Some("SPF uses ~all (softfail); -all provides stricter enforcement.".to_string())
342 }
343 _ => None,
344 }
345}
346
347fn build_spf(apex_txt: &[String], redirect: Option<&RedirectOutcome>) -> Assessed<SpfPolicy> {
351 let record = match select_spf(apex_txt) {
352 Selection::None => {
353 return Assessed {
354 policy: SpfPolicy {
355 present: false,
356 record: None,
357 all_qualifier: None,
358 verdict: PostureVerdict::Absent,
359 },
360 notes: vec!["No SPF record — sending sources are undeclared.".to_string()],
361 };
362 }
363 Selection::Multiple(count) => {
364 return Assessed {
365 policy: SpfPolicy {
366 present: true,
367 record: None,
368 all_qualifier: None,
369 verdict: PostureVerdict::Weak,
370 },
371 notes: vec![format!(
372 "{count} SPF records are published — SPF evaluation fails with permerror \
373 (RFC 7208 §4.5), so receivers get no SPF result. Merge them into one record."
374 )],
375 };
376 }
377 Selection::One(record) => record,
378 };
379
380 let mut notes = Vec::new();
381 let all_qualifier = match (spf_terminal(record), redirect) {
382 (SpfTerminal::All(qualifier), _) => Some(qualifier),
383 (SpfTerminal::Redirect(_), Some(RedirectOutcome::Resolved { target, qualifier })) => {
384 notes.push(format!(
385 "SPF delegates to {target} via redirect= — the verdict reflects that record."
386 ));
387 qualifier.clone()
388 }
389 (SpfTerminal::Redirect(_), Some(RedirectOutcome::Failed { target, reason })) => {
390 return Assessed {
391 policy: SpfPolicy {
392 present: true,
393 record: Some(record.to_string()),
394 all_qualifier: None,
395 verdict: PostureVerdict::Weak,
396 },
397 notes: vec![format!(
398 "SPF redirect={target} could not be evaluated ({reason}) — receivers get \
399 no usable SPF result."
400 )],
401 };
402 }
403 (SpfTerminal::Redirect(_), None) | (SpfTerminal::Neither, _) => None,
404 };
405 let verdict = spf_verdict(all_qualifier.as_deref());
406 notes.extend(spf_verdict_note(verdict));
407 Assessed {
408 policy: SpfPolicy {
409 present: true,
410 verdict,
411 all_qualifier,
412 record: Some(record.to_string()),
413 },
414 notes,
415 }
416}
417
418#[derive(Debug)]
420struct DmarcSource {
421 txts: Vec<String>,
423 at: String,
425 inherited: bool,
427}
428
429fn absent_dmarc() -> DmarcPolicy {
430 DmarcPolicy {
431 present: false,
432 record: None,
433 policy: None,
434 subdomain_policy: None,
435 aggregate_reports: Vec::new(),
436 percent: None,
437 verdict: PostureVerdict::Absent,
438 }
439}
440
441fn build_dmarc(domain: &str, source: Option<&DmarcSource>) -> Assessed<DmarcPolicy> {
443 let spoofable =
444 || vec!["No DMARC record — the domain is trivially spoofable in phishing.".to_string()];
445 let Some(source) = source else {
446 return Assessed {
447 policy: absent_dmarc(),
448 notes: spoofable(),
449 };
450 };
451 let record = match select_dmarc(&source.txts) {
452 Selection::None => {
453 return Assessed {
454 policy: absent_dmarc(),
455 notes: spoofable(),
456 };
457 }
458 Selection::Multiple(count) => {
459 return Assessed {
460 policy: DmarcPolicy {
461 present: true,
462 ..absent_dmarc()
463 },
464 notes: vec![format!(
465 "{count} DMARC records at _dmarc.{} — receivers do not apply DMARC at all \
466 when more than one is published (RFC 7489 §6.6.3). Publish exactly one.",
467 source.at
468 )],
469 };
470 }
471 Selection::One(record) => record,
472 };
473
474 let tags = parse_tag_value(record);
475 let policy = tags.get("p").cloned();
476 let subdomain_policy = tags.get("sp").cloned();
477 let percent: Option<u8> = tags.get("pct").and_then(|p| p.parse().ok());
478 let aggregate_reports = tags
479 .get("rua")
480 .map(|rua| {
481 rua.split(',')
482 .map(|s| s.trim().to_string())
483 .filter(|s| !s.is_empty())
484 .collect()
485 })
486 .unwrap_or_default();
487
488 let (label, effective) = match (&subdomain_policy, source.inherited) {
491 (Some(sp), true) => ("sp", Some(sp.as_str())),
492 _ => ("p", policy.as_deref()),
493 };
494 let base = dmarc_verdict(effective);
495 let partial_pct = percent.filter(|pct| *pct < 100);
496 let verdict = if partial_pct.is_some() {
497 downgrade(base)
498 } else {
499 base
500 };
501
502 let mut notes = Vec::new();
503 if source.inherited {
504 notes.push(format!(
505 "No DMARC record at _dmarc.{domain} — it inherits the organizational record at \
506 _dmarc.{} (RFC 7489 §6.6.3), whose {label}= policy applies.",
507 source.at
508 ));
509 }
510 let effective_lc = effective.map(str::to_ascii_lowercase);
511 match (base, effective_lc.as_deref()) {
512 (PostureVerdict::Moderate, _) => notes.push(format!(
513 "DMARC policy is {label}=quarantine — spoofed mail is filtered but not rejected."
514 )),
515 (PostureVerdict::Weak, Some("none")) => notes.push(format!(
516 "DMARC policy is {label}=none (monitoring only) — receivers will not reject spoofed mail."
517 )),
518 (PostureVerdict::Weak, _) => notes.push(format!(
519 "DMARC record has no valid {label}= policy — receivers treat it as monitoring only (p=none)."
520 )),
521 _ => {}
522 }
523 if let (Some(pct), Some(value)) = (partial_pct, effective_lc.as_deref()) {
524 if base != PostureVerdict::Weak {
525 notes.push(format!(
526 "DMARC pct={pct} — the {label}={value} policy is applied to only {pct}% of \
527 failing mail (the rest gets the next-weaker treatment), so enforcement is partial."
528 ));
529 }
530 }
531
532 Assessed {
533 policy: DmarcPolicy {
534 present: true,
535 verdict,
536 subdomain_policy,
537 percent,
538 aggregate_reports,
539 policy,
540 record: Some(record.to_string()),
541 },
542 notes,
543 }
544}
545
546fn build_mta_sts(txt: &[String]) -> MtaStsPolicy {
547 match txt.iter().find(|t| has_version_tag(t, "v=STSv1", true)) {
548 Some(record) => {
549 let tags = parse_tag_value(record);
550 MtaStsPolicy {
551 present: true,
552 id: tags.get("id").cloned(),
553 record: Some(record.clone()),
554 verdict: PostureVerdict::Present,
555 }
556 }
557 None => MtaStsPolicy {
558 present: false,
559 record: None,
560 id: None,
561 verdict: PostureVerdict::Absent,
562 },
563 }
564}
565
566fn build_bimi(txt: &[String]) -> BimiPolicy {
567 match txt.iter().find(|t| has_version_tag(t, "v=BIMI1", true)) {
568 Some(record) => {
569 let tags = parse_tag_value(record);
570 BimiPolicy {
571 present: true,
572 logo_url: tags.get("l").filter(|s| !s.is_empty()).cloned(),
573 authority_url: tags.get("a").filter(|s| !s.is_empty()).cloned(),
574 record: Some(record.clone()),
575 verdict: PostureVerdict::Present,
576 }
577 }
578 None => BimiPolicy {
579 present: false,
580 record: None,
581 logo_url: None,
582 authority_url: None,
583 verdict: PostureVerdict::Absent,
584 },
585 }
586}
587
588fn build_dane(
591 smtp: &[(String, Vec<crate::dns::DnsRecord>)],
592 https: &[crate::dns::DnsRecord],
593) -> DanePolicy {
594 let mut records = Vec::new();
595 let mut collect = |set: &[crate::dns::DnsRecord], scope: &str| {
596 for r in set {
597 if let RecordData::TLSA {
598 cert_usage,
599 selector,
600 matching,
601 cert_data,
602 } = &r.data
603 {
604 records.push(TlsaRecord {
605 scope: scope.to_string(),
606 cert_usage: *cert_usage,
607 selector: *selector,
608 matching: *matching,
609 cert_data: cert_data.clone(),
610 });
611 }
612 }
613 };
614 for (scope, set) in smtp {
615 collect(set, scope);
616 }
617 collect(https, "_443._tcp");
618 DanePolicy {
619 present: !records.is_empty(),
620 verdict: if records.is_empty() {
621 PostureVerdict::Absent
622 } else {
623 PostureVerdict::Present
624 },
625 records,
626 }
627}
628
629fn build_notes(
631 spf: &Assessed<SpfPolicy>,
632 dmarc: &Assessed<DmarcPolicy>,
633 mta_sts: &MtaStsPolicy,
634 dane: &DanePolicy,
635) -> Vec<String> {
636 let mut notes = dmarc.notes.clone();
637 notes.extend(spf.notes.iter().cloned());
638 if !mta_sts.present {
639 notes.push("MTA-STS is not configured — SMTP is vulnerable to downgrade.".to_string());
640 }
641 if !dane.present {
642 notes.push("No DANE (TLSA) records — no DNS-based TLS pinning for mail/HTTPS.".to_string());
643 }
644 notes
645}
646
647async fn follow_spf_redirect(resolver: &DnsResolver, first_target: String) -> RedirectOutcome {
651 let mut target = first_target;
652 for _ in 0..MAX_SPF_REDIRECTS {
653 if target.contains('%') {
654 return RedirectOutcome::Failed {
655 target,
656 reason: "it uses SPF macros, which seer does not expand".to_string(),
657 };
658 }
659 let Ok(name) = normalize_host(&target) else {
660 return RedirectOutcome::Failed {
661 target,
662 reason: "not a valid domain name — permerror".to_string(),
663 };
664 };
665 let txts = match resolver.resolve(&name, RecordType::TXT, None).await {
666 Ok(records) => txt_strings(&records),
667 Err(e) => {
668 return RedirectOutcome::Failed {
669 target: name,
670 reason: format!("DNS lookup failed: {}", e.sanitized_message()),
671 }
672 }
673 };
674 match select_spf(&txts) {
675 Selection::None => {
676 return RedirectOutcome::Failed {
677 target: name,
678 reason: "it publishes no SPF record — permerror".to_string(),
679 }
680 }
681 Selection::Multiple(count) => {
682 return RedirectOutcome::Failed {
683 target: name,
684 reason: format!("it publishes {count} SPF records — permerror"),
685 }
686 }
687 Selection::One(record) => match spf_terminal(record) {
688 SpfTerminal::All(qualifier) => {
689 return RedirectOutcome::Resolved {
690 target: name,
691 qualifier: Some(qualifier),
692 }
693 }
694 SpfTerminal::Neither => {
695 return RedirectOutcome::Resolved {
696 target: name,
697 qualifier: None,
698 }
699 }
700 SpfTerminal::Redirect(next) => target = next,
701 },
702 }
703 }
704 RedirectOutcome::Failed {
705 target,
706 reason: format!("the redirect chain is longer than {MAX_SPF_REDIRECTS} hops"),
707 }
708}
709
710async fn assess_spf(resolver: &DnsResolver, apex_txt: &[String]) -> Assessed<SpfPolicy> {
715 let redirect = match select_spf(apex_txt) {
716 Selection::One(record) => match spf_terminal(record) {
717 SpfTerminal::Redirect(target) => Some(follow_spf_redirect(resolver, target).await),
718 _ => None,
719 },
720 _ => None,
721 };
722 build_spf(apex_txt, redirect.as_ref())
723}
724
725async fn discover_dmarc(
733 resolver: &DnsResolver,
734 domain: &str,
735 own: Result<Vec<crate::dns::DnsRecord>>,
736) -> Option<DmarcSource> {
737 let own = txt_strings(&own.ok()?);
738 if select_dmarc(&own) != Selection::None {
739 return Some(DmarcSource {
740 txts: own,
741 at: domain.to_string(),
742 inherited: false,
743 });
744 }
745 let mut current = domain;
746 for _ in 0..MAX_DMARC_WALK {
747 let parent = match current.split_once('.') {
748 Some((_, parent)) if parent.contains('.') => parent,
749 _ => return None,
750 };
751 let records = resolver
752 .resolve(&format!("_dmarc.{parent}"), RecordType::TXT, None)
753 .await
754 .ok()?;
755 let txts = txt_strings(&records);
756 if select_dmarc(&txts) != Selection::None {
757 return Some(DmarcSource {
758 txts,
759 at: parent.to_string(),
760 inherited: true,
761 });
762 }
763 current = parent;
764 }
765 None
766}
767
768async fn smtp_tlsa_records(
774 resolver: &DnsResolver,
775 domain: &str,
776 mx: Result<Vec<crate::dns::DnsRecord>>,
777) -> Vec<(String, Vec<crate::dns::DnsRecord>)> {
778 let mut hosts: Vec<String> = Vec::new();
779 for record in mx.as_deref().unwrap_or_default() {
780 if let RecordData::MX { exchange, .. } = &record.data {
781 let host = exchange.trim_end_matches('.').to_ascii_lowercase();
782 if !hosts.contains(&host) {
783 hosts.push(host);
784 }
785 }
786 }
787 if hosts.iter().any(|h| h.is_empty()) {
788 return Vec::new();
789 }
790
791 let owners: Vec<(String, String)> = if hosts.is_empty() {
792 vec![("_25._tcp".to_string(), format!("_25._tcp.{domain}"))]
793 } else {
794 hosts
795 .into_iter()
796 .take(MAX_DANE_MX_HOSTS)
797 .map(|host| {
798 let owner = format!("_25._tcp.{host}");
799 (owner.clone(), owner)
800 })
801 .collect()
802 };
803 let results = futures::future::join_all(
804 owners
805 .iter()
806 .map(|(_, owner)| Box::pin(resolver.resolve(owner, RecordType::TLSA, None))),
807 )
808 .await;
809 owners
810 .into_iter()
811 .zip(results)
812 .map(|((scope, _), result)| (scope, result.unwrap_or_default()))
813 .collect()
814}
815
816pub async fn lookup_email_posture(resolver: &DnsResolver, domain: &str) -> Result<EmailPosture> {
822 let domain = normalize_domain(domain)?;
823
824 let dmarc_name = format!("_dmarc.{domain}");
825 let mta_sts_name = format!("_mta-sts.{domain}");
826 let bimi_name = format!("default._bimi.{domain}");
827 let https_tlsa_name = format!("_443._tcp.{domain}");
828
829 let (apex, dmarc, mta_sts, bimi, mx, https_tlsa) = tokio::join!(
834 Box::pin(resolver.resolve(&domain, RecordType::TXT, None)),
835 Box::pin(resolver.resolve(&dmarc_name, RecordType::TXT, None)),
836 Box::pin(resolver.resolve(&mta_sts_name, RecordType::TXT, None)),
837 Box::pin(resolver.resolve(&bimi_name, RecordType::TXT, None)),
838 Box::pin(resolver.resolve(&domain, RecordType::MX, None)),
839 Box::pin(resolver.resolve(&https_tlsa_name, RecordType::TLSA, None)),
840 );
841 let apex_txt = txt_strings(&apex.unwrap_or_default());
842
843 let (spf, dmarc_source, smtp_tlsa) = tokio::join!(
846 Box::pin(assess_spf(resolver, &apex_txt)),
847 Box::pin(discover_dmarc(resolver, &domain, dmarc)),
848 Box::pin(smtp_tlsa_records(resolver, &domain, mx)),
849 );
850
851 let dmarc = build_dmarc(&domain, dmarc_source.as_ref());
852 let mta_sts = build_mta_sts(&txt_strings(&mta_sts.unwrap_or_default()));
853 let bimi = build_bimi(&txt_strings(&bimi.unwrap_or_default()));
854 let dane = build_dane(&smtp_tlsa, &https_tlsa.unwrap_or_default());
855 let notes = build_notes(&spf, &dmarc, &mta_sts, &dane);
856
857 Ok(EmailPosture {
858 domain,
859 spf: spf.policy,
860 dmarc: dmarc.policy,
861 mta_sts,
862 bimi,
863 dane,
864 notes,
865 })
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 fn strings(values: &[&str]) -> Vec<String> {
873 values.iter().map(|v| v.to_string()).collect()
874 }
875
876 fn own_dmarc(domain: &str, txts: &[&str]) -> DmarcSource {
877 DmarcSource {
878 txts: strings(txts),
879 at: domain.to_string(),
880 inherited: false,
881 }
882 }
883
884 #[test]
885 fn spf_qualifier_parsing() {
886 assert_eq!(
887 parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
888 Some("-".to_string())
889 );
890 assert_eq!(
891 parse_spf_all_qualifier("v=spf1 mx ~all"),
892 Some("~".to_string())
893 );
894 assert_eq!(
895 parse_spf_all_qualifier("v=spf1 +all"),
896 Some("+".to_string())
897 );
898 assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
899 }
900
901 #[test]
902 fn spf_verdict_bands() {
903 assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
904 assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
905 assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
906 assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
907 assert_eq!(spf_verdict(None), PostureVerdict::Weak);
908 }
909
910 #[test]
911 fn verdict_as_str_matches_serde_label() {
912 for v in [
914 PostureVerdict::Absent,
915 PostureVerdict::Weak,
916 PostureVerdict::Moderate,
917 PostureVerdict::Strict,
918 PostureVerdict::Present,
919 ] {
920 assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
921 }
922 }
923
924 #[test]
925 fn version_tag_must_be_followed_by_a_delimiter() {
926 assert!(!has_version_tag("v=spf10 -all", "v=spf1", false));
928 assert!(has_version_tag("v=spf1 -all", "v=spf1", false));
929 assert!(has_version_tag("V=SPF1 -all", "v=spf1", false));
930 assert!(has_version_tag("v=spf1", "v=spf1", false));
931 assert!(!has_version_tag("v=spf1;-all", "v=spf1", false));
932 assert!(has_version_tag("v=DMARC1; p=reject", "v=DMARC1", true));
933 assert!(has_version_tag("v=DMARC1;p=reject", "v=DMARC1", true));
934 assert!(!has_version_tag("v=DMARC10; p=reject", "v=DMARC1", true));
935 assert!(
936 !has_version_tag("v=é", "v=spf1", false),
937 "no panic on short multibyte"
938 );
939 assert_eq!(select_spf(&strings(&["v=spf10 -all"])), Selection::None);
940 }
941
942 #[test]
943 fn multiple_spf_records_are_a_permerror() {
944 let spf = build_spf(
946 &strings(&["v=spf1 -all", "v=spf1 include:x.test ~all"]),
947 None,
948 );
949 assert!(spf.policy.present);
950 assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
951 assert_eq!(spf.policy.all_qualifier, None);
952 assert!(
953 spf.notes.iter().any(|n| n.contains("permerror")),
954 "{:?}",
955 spf.notes
956 );
957 }
958
959 #[test]
960 fn multiple_dmarc_records_disable_dmarc() {
961 let source = own_dmarc("x.test", &["v=DMARC1; p=reject", "v=DMARC1; p=none"]);
963 let dmarc = build_dmarc("x.test", Some(&source));
964 assert!(dmarc.policy.present);
965 assert_eq!(dmarc.policy.verdict, PostureVerdict::Absent);
966 assert!(
967 dmarc.notes[0].contains("2 DMARC records"),
968 "{:?}",
969 dmarc.notes
970 );
971 }
972
973 #[test]
974 fn dmarc_parsing_and_verdict() {
975 let record =
976 "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
977 let dmarc = build_dmarc("x.com", Some(&own_dmarc("x.com", &[record]))).policy;
978 assert!(dmarc.present);
979 assert_eq!(dmarc.policy.as_deref(), Some("reject"));
980 assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
981 assert_eq!(dmarc.percent, Some(100));
982 assert_eq!(dmarc.aggregate_reports.len(), 2);
983 assert_eq!(dmarc.verdict, PostureVerdict::Strict);
984 }
985
986 #[test]
987 fn dmarc_none_is_weak_and_absent_is_absent() {
988 let weak = build_dmarc("x.test", Some(&own_dmarc("x.test", &["v=DMARC1; p=none"])));
989 assert_eq!(weak.policy.verdict, PostureVerdict::Weak);
990 assert!(weak.notes[0].contains("p=none (monitoring only)"));
991 let absent = build_dmarc("x.test", Some(&own_dmarc("x.test", &["v=spf1 -all"])));
992 assert!(!absent.policy.present);
993 assert_eq!(absent.policy.verdict, PostureVerdict::Absent);
994 }
995
996 #[test]
997 fn dmarc_partial_pct_downgrades_one_band() {
998 let verdict = |record: &str| {
1000 let dmarc = build_dmarc("x.test", Some(&own_dmarc("x.test", &[record])));
1001 (dmarc.policy.verdict, dmarc.notes)
1002 };
1003 let (v, notes) = verdict("v=DMARC1; p=reject; pct=0");
1004 assert_eq!(v, PostureVerdict::Moderate);
1005 assert!(notes.iter().any(|n| n.contains("pct=0")), "{notes:?}");
1006 assert_eq!(
1007 verdict("v=DMARC1; p=quarantine; pct=50").0,
1008 PostureVerdict::Weak
1009 );
1010 assert_eq!(
1011 verdict("v=DMARC1; p=reject; pct=100").0,
1012 PostureVerdict::Strict
1013 );
1014 assert_eq!(verdict("v=DMARC1; p=none; pct=10").0, PostureVerdict::Weak);
1015 }
1016
1017 #[test]
1018 fn inherited_dmarc_applies_sp_then_p() {
1019 let inherited = |txt: &str| DmarcSource {
1020 txts: strings(&[txt]),
1021 at: "org.test".to_string(),
1022 inherited: true,
1023 };
1024 let dmarc = build_dmarc(
1025 "mail.org.test",
1026 Some(&inherited("v=DMARC1; p=none; sp=reject")),
1027 );
1028 assert_eq!(dmarc.policy.verdict, PostureVerdict::Strict);
1029 assert!(dmarc.notes[0].contains("inherits"), "{:?}", dmarc.notes);
1030 assert!(dmarc.notes[0].contains("_dmarc.org.test"));
1031
1032 let dmarc = build_dmarc("mail.org.test", Some(&inherited("v=DMARC1; p=quarantine")));
1033 assert_eq!(dmarc.policy.verdict, PostureVerdict::Moderate);
1034 }
1035
1036 #[test]
1037 fn spf_prefers_the_spf_record_among_txt() {
1038 let txts = strings(&[
1040 "google-site-verification=abc123",
1041 "v=spf1 include:_spf.google.com -all",
1042 ]);
1043 let spf = build_spf(&txts, None).policy;
1044 assert!(spf.present);
1045 assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
1046 assert_eq!(spf.verdict, PostureVerdict::Strict);
1047 }
1048
1049 #[test]
1050 fn spf_redirect_parsing_prefers_all() {
1051 assert_eq!(
1052 spf_terminal("v=spf1 redirect=_spf.x.test"),
1053 SpfTerminal::Redirect("_spf.x.test".to_string())
1054 );
1055 assert_eq!(
1056 spf_terminal("v=spf1 Redirect=_spf.x.test"),
1057 SpfTerminal::Redirect("_spf.x.test".to_string())
1058 );
1059 assert_eq!(
1061 spf_terminal("v=spf1 redirect=_spf.x.test ~all"),
1062 SpfTerminal::All("~".to_string())
1063 );
1064 assert_eq!(spf_terminal("v=spf1 mx"), SpfTerminal::Neither);
1065 }
1066
1067 #[test]
1068 fn bimi_and_mta_sts_parse_tags() {
1069 let bimi =
1070 build_bimi(&["v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into()]);
1071 assert!(bimi.present);
1072 assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
1073 assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
1074
1075 let mta = build_mta_sts(&["v=STSv1; id=20240101T000000".into()]);
1076 assert!(mta.present);
1077 assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
1078 assert_eq!(mta.verdict, PostureVerdict::Present);
1079 }
1080
1081 #[test]
1082 fn notes_flag_a_spoofable_domain() {
1083 let spf = build_spf(&[], None);
1085 let dmarc = build_dmarc("x.test", None);
1086 let mta = build_mta_sts(&[]);
1087 let dane = build_dane(&[], &[]);
1088 let notes = build_notes(&spf, &dmarc, &mta, &dane);
1089 assert!(notes[0].contains("No DMARC"), "DMARC first: {notes:?}");
1090 assert!(notes.iter().any(|n| n.contains("No SPF")));
1091 assert!(notes.iter().any(|n| n.contains("MTA-STS")));
1092 assert!(notes.iter().any(|n| n.contains("DANE")));
1093 }
1094
1095 use hickory_resolver::proto::rr::rdata as wire;
1098 use hickory_resolver::proto::rr::{Name, RData, RecordType as WireType};
1099
1100 use crate::dns::test_support::{mock_dns_resolver_default, spawn_mock_dns_fn, MockReply};
1101
1102 fn txt(value: &str) -> MockReply {
1103 MockReply::Answer(vec![RData::TXT(wire::TXT::new(vec![value.to_string()]))])
1104 }
1105
1106 fn mx(preference: u16, exchange: &str) -> RData {
1107 RData::MX(wire::MX::new(
1108 preference,
1109 Name::from_ascii(exchange).unwrap(),
1110 ))
1111 }
1112
1113 fn tlsa() -> MockReply {
1114 MockReply::Answer(vec![RData::TLSA(wire::TLSA::new(
1115 wire::tlsa::CertUsage::from(3),
1116 wire::tlsa::Selector::from(1),
1117 wire::tlsa::Matching::from(1),
1118 vec![0xAB, 0xCD],
1119 ))])
1120 }
1121
1122 #[tokio::test]
1126 async fn dane_smtp_is_read_at_the_mx_hosts() {
1127 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1128 ("mail.test", WireType::MX) => {
1129 MockReply::Answer(vec![mx(20, "mx2.mail.test."), mx(10, "mx1.mail.test.")])
1130 }
1131 ("_25._tcp.mx2.mail.test", WireType::TLSA) => tlsa(),
1132 _ => MockReply::NxDomain,
1133 })
1134 .await;
1135 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "mail.test")
1136 .await
1137 .unwrap();
1138 assert!(posture.dane.present, "{:?}", posture.dane);
1139 assert_eq!(posture.dane.records.len(), 1);
1140 assert_eq!(posture.dane.records[0].scope, "_25._tcp.mx2.mail.test");
1141 }
1142
1143 #[tokio::test]
1145 async fn dane_smtp_falls_back_to_the_domain_without_mx() {
1146 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1147 ("_25._tcp.nomx.test", WireType::TLSA) => tlsa(),
1148 _ => MockReply::NoData,
1149 })
1150 .await;
1151 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "nomx.test")
1152 .await
1153 .unwrap();
1154 assert_eq!(posture.dane.records.len(), 1, "{:?}", posture.dane);
1155 assert_eq!(posture.dane.records[0].scope, "_25._tcp");
1156 }
1157
1158 #[tokio::test]
1161 async fn dmarc_is_inherited_from_a_parent() {
1162 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1163 ("_dmarc.org.test", WireType::TXT) => txt("v=DMARC1; p=none; sp=reject"),
1164 _ => MockReply::NxDomain,
1165 })
1166 .await;
1167 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "news.mail.org.test")
1168 .await
1169 .unwrap();
1170 assert!(posture.dmarc.present);
1171 assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1172 assert!(
1173 posture
1174 .notes
1175 .iter()
1176 .any(|n| n.contains("inherits") && n.contains("_dmarc.org.test")),
1177 "{:?}",
1178 posture.notes
1179 );
1180 }
1181
1182 #[tokio::test]
1186 async fn spf_redirect_is_followed() {
1187 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1188 ("brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.brand.test"),
1189 ("_spf.brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.esp.test"),
1190 ("_spf.esp.test", WireType::TXT) => txt("v=spf1 ip4:192.0.2.0/24 -all"),
1191 ("broken.test", WireType::TXT) => txt("v=spf1 redirect=_spf.nowhere.test"),
1192 _ => MockReply::NxDomain,
1193 })
1194 .await;
1195 let resolver = mock_dns_resolver_default(port);
1196
1197 let posture = lookup_email_posture(&resolver, "brand.test").await.unwrap();
1198 assert_eq!(
1199 posture.spf.verdict,
1200 PostureVerdict::Strict,
1201 "{:?}",
1202 posture.notes
1203 );
1204 assert_eq!(posture.spf.all_qualifier.as_deref(), Some("-"));
1205 assert!(posture.notes.iter().any(|n| n.contains("_spf.esp.test")));
1206
1207 let posture = lookup_email_posture(&resolver, "broken.test")
1209 .await
1210 .unwrap();
1211 assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1212 assert!(
1213 posture.notes.iter().any(|n| n.contains("permerror")),
1214 "{:?}",
1215 posture.notes
1216 );
1217 }
1218}