1#![doc = include_str!("../docs/library-usage.md")]
7#![doc = include_str!("../docs/cli-usage.md")]
11
12mod error;
13pub use error::Error;
14
15use serde::Deserialize;
16
17fn deserialize_optional_alignment<'de, D>(
18 deserializer: D,
19) -> Result<Option<AlignmentMode>, D::Error>
20where
21 D: serde::Deserializer<'de>,
22{
23 struct Visitor;
24
25 impl<'de> serde::de::Visitor<'de> for Visitor {
26 type Value = Option<AlignmentMode>;
27
28 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.write_str("alignment mode 'r', 's', or empty")
30 }
31
32 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
33 match v {
34 "" => Ok(None),
35 "r" => Ok(Some(AlignmentMode::Relaxed)),
36 "s" => Ok(Some(AlignmentMode::Strict)),
37 other => Err(E::unknown_variant(other, &["r", "s"])),
38 }
39 }
40
41 fn visit_map<A: serde::de::MapAccess<'de>>(
42 self,
43 mut map: A,
44 ) -> Result<Self::Value, A::Error> {
45 let mut text = String::new();
47 while let Some(key) = map.next_key::<String>()? {
48 let val: String = map.next_value()?;
49 if key == "$text" {
50 text = val;
51 }
52 }
53 self.visit_str(&text)
54 }
55 }
56
57 deserializer.deserialize_any(Visitor)
58}
59
60pub fn parse(xml: &str) -> Result<Report, Error> {
71 quick_xml::de::from_str(xml).map_err(Error::from)
72}
73
74pub fn parse_bytes(bytes: &[u8]) -> Result<Report, Error> {
81 let xml = std::str::from_utf8(bytes)?;
82 parse(xml)
83}
84
85#[derive(Debug, Clone, PartialEq, Deserialize)]
93#[serde(rename = "feedback")]
94pub struct Report {
95 #[serde(default)]
97 pub version: Option<String>,
98
99 pub report_metadata: ReportMetadata,
101
102 pub policy_published: PolicyPublished,
104
105 #[serde(rename = "record")]
107 pub records: Vec<Record>,
108}
109
110#[derive(Debug, Clone, PartialEq, Deserialize)]
112pub struct ReportMetadata {
113 pub org_name: String,
115
116 pub email: String,
118
119 #[serde(default)]
121 pub extra_contact_info: Option<String>,
122
123 pub report_id: String,
125
126 pub date_range: DateRange,
128
129 #[serde(rename = "error", default)]
131 pub errors: Vec<String>,
132}
133
134#[derive(Debug, Clone, PartialEq, Deserialize)]
136pub struct DateRange {
137 pub begin: i64,
139
140 pub end: i64,
142}
143
144#[derive(Debug, Clone, PartialEq, Deserialize)]
146pub struct PolicyPublished {
147 pub domain: String,
149
150 #[serde(default, deserialize_with = "deserialize_optional_alignment")]
152 pub adkim: Option<AlignmentMode>,
153
154 #[serde(default, deserialize_with = "deserialize_optional_alignment")]
156 pub aspf: Option<AlignmentMode>,
157
158 pub p: Disposition,
160
161 pub sp: Disposition,
163
164 pub pct: u32,
166
167 #[serde(default)]
169 pub fo: Option<String>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
174pub enum AlignmentMode {
175 #[serde(rename = "r")]
177 Relaxed,
178
179 #[serde(rename = "s")]
181 Strict,
182}
183
184impl std::fmt::Display for AlignmentMode {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 match self {
187 AlignmentMode::Relaxed => f.write_str("r"),
188 AlignmentMode::Strict => f.write_str("s"),
189 }
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum Disposition {
197 None,
199 Quarantine,
201 Reject,
203}
204
205impl std::fmt::Display for Disposition {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 Disposition::None => f.write_str("none"),
209 Disposition::Quarantine => f.write_str("quarantine"),
210 Disposition::Reject => f.write_str("reject"),
211 }
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Deserialize)]
217pub struct Record {
218 pub row: Row,
220
221 pub identifiers: Identifiers,
223
224 pub auth_results: AuthResults,
226}
227
228#[derive(Debug, Clone, PartialEq, Deserialize)]
230pub struct Row {
231 pub source_ip: String,
233
234 pub count: u64,
236
237 pub policy_evaluated: PolicyEvaluated,
239}
240
241#[derive(Debug, Clone, PartialEq, Deserialize)]
243pub struct PolicyEvaluated {
244 pub disposition: Disposition,
246
247 pub dkim: DmarcResult,
249
250 pub spf: DmarcResult,
252
253 #[serde(rename = "reason", default)]
255 pub reasons: Vec<PolicyOverrideReason>,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum DmarcResult {
262 Pass,
264 Fail,
266}
267
268impl std::fmt::Display for DmarcResult {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 match self {
271 DmarcResult::Pass => f.write_str("pass"),
272 DmarcResult::Fail => f.write_str("fail"),
273 }
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Deserialize)]
280pub struct PolicyOverrideReason {
281 #[serde(rename = "type")]
283 pub reason_type: PolicyOverride,
284
285 #[serde(default)]
287 pub comment: Option<String>,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum PolicyOverride {
294 Forwarded,
296 SampledOut,
298 TrustedForwarder,
300 MailingList,
302 LocalPolicy,
304 Other,
306}
307
308impl std::fmt::Display for PolicyOverride {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 match self {
311 PolicyOverride::Forwarded => f.write_str("forwarded"),
312 PolicyOverride::SampledOut => f.write_str("sampled_out"),
313 PolicyOverride::TrustedForwarder => f.write_str("trusted_forwarder"),
314 PolicyOverride::MailingList => f.write_str("mailing_list"),
315 PolicyOverride::LocalPolicy => f.write_str("local_policy"),
316 PolicyOverride::Other => f.write_str("other"),
317 }
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Deserialize)]
323pub struct Identifiers {
324 #[serde(default)]
326 pub envelope_to: Option<String>,
327
328 #[serde(default)]
330 pub envelope_from: Option<String>,
331
332 pub header_from: String,
334}
335
336#[derive(Debug, Clone, PartialEq, Deserialize)]
338pub struct AuthResults {
339 #[serde(rename = "dkim", default)]
341 pub dkim: Vec<DkimAuthResult>,
342
343 #[serde(rename = "spf")]
345 pub spf: Vec<SpfAuthResult>,
346}
347
348#[derive(Debug, Clone, PartialEq, Deserialize)]
350pub struct DkimAuthResult {
351 pub domain: String,
353
354 #[serde(default)]
356 pub selector: Option<String>,
357
358 pub result: DkimResult,
360
361 #[serde(default)]
363 pub human_result: Option<String>,
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
368#[serde(rename_all = "lowercase")]
369pub enum DkimResult {
370 None,
372 Pass,
374 Fail,
376 Policy,
378 Neutral,
380 Temperror,
382 Permerror,
384}
385
386impl std::fmt::Display for DkimResult {
387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 match self {
389 DkimResult::None => f.write_str("none"),
390 DkimResult::Pass => f.write_str("pass"),
391 DkimResult::Fail => f.write_str("fail"),
392 DkimResult::Policy => f.write_str("policy"),
393 DkimResult::Neutral => f.write_str("neutral"),
394 DkimResult::Temperror => f.write_str("temperror"),
395 DkimResult::Permerror => f.write_str("permerror"),
396 }
397 }
398}
399
400#[derive(Debug, Clone, PartialEq, Deserialize)]
402pub struct SpfAuthResult {
403 pub domain: String,
405
406 #[serde(default)]
408 pub scope: Option<SpfDomainScope>,
409
410 pub result: SpfResult,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
416#[serde(rename_all = "lowercase")]
417pub enum SpfDomainScope {
418 Helo,
420 Mfrom,
422}
423
424impl std::fmt::Display for SpfDomainScope {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 match self {
427 SpfDomainScope::Helo => f.write_str("helo"),
428 SpfDomainScope::Mfrom => f.write_str("mfrom"),
429 }
430 }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
435#[serde(rename_all = "lowercase")]
436pub enum SpfResult {
437 None,
439 Neutral,
441 Pass,
443 Fail,
445 Softfail,
447 Temperror,
449 Permerror,
451}
452
453impl std::fmt::Display for SpfResult {
454 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 match self {
456 SpfResult::None => f.write_str("none"),
457 SpfResult::Neutral => f.write_str("neutral"),
458 SpfResult::Pass => f.write_str("pass"),
459 SpfResult::Fail => f.write_str("fail"),
460 SpfResult::Softfail => f.write_str("softfail"),
461 SpfResult::Temperror => f.write_str("temperror"),
462 SpfResult::Permerror => f.write_str("permerror"),
463 }
464 }
465}
466
467#[derive(Debug, Clone, PartialEq)]
479pub struct Aggregate {
480 pub reports: Vec<Report>,
482}
483
484impl Aggregate {
485 pub fn from_reports(reports: Vec<Report>) -> Self {
487 Self { reports }
488 }
489
490 pub fn records(&self) -> impl Iterator<Item = (&Report, &Record)> {
493 self.reports
494 .iter()
495 .flat_map(|r| r.records.iter().map(move |rec| (r, rec)))
496 }
497
498 pub fn total_messages(&self) -> u64 {
500 self.records().map(|(_, rec)| rec.row.count).sum()
501 }
502
503 pub fn date_span(&self) -> Option<(i64, i64)> {
506 let begin = self
507 .reports
508 .iter()
509 .map(|r| r.report_metadata.date_range.begin)
510 .min()?;
511 let end = self
512 .reports
513 .iter()
514 .map(|r| r.report_metadata.date_range.end)
515 .max()?;
516 Some((begin, end))
517 }
518}
519
520impl From<Vec<Report>> for Aggregate {
521 fn from(reports: Vec<Report>) -> Self {
522 Self::from_reports(reports)
523 }
524}
525
526impl std::str::FromStr for Report {
531 type Err = Error;
532
533 fn from_str(s: &str) -> Result<Self, Self::Err> {
534 parse(s)
535 }
536}
537
538impl TryFrom<&str> for Report {
539 type Error = Error;
540
541 fn try_from(s: &str) -> Result<Self, Self::Error> {
542 parse(s)
543 }
544}
545
546impl TryFrom<&[u8]> for Report {
547 type Error = Error;
548
549 fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
550 parse_bytes(bytes)
551 }
552}
553
554#[cfg(test)]
559mod tests {
560 use super::*;
561
562 const MINIMAL_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
564<feedback>
565 <report_metadata>
566 <org_name>Acme</org_name>
567 <email>postmaster@acme.example</email>
568 <report_id>20130901.r.acme.example</report_id>
569 <date_range>
570 <begin>1377993600</begin>
571 <end>1378080000</end>
572 </date_range>
573 </report_metadata>
574 <policy_published>
575 <domain>acme.example</domain>
576 <p>none</p>
577 <sp>none</sp>
578 <pct>100</pct>
579 </policy_published>
580 <record>
581 <row>
582 <source_ip>192.0.2.1</source_ip>
583 <count>2</count>
584 <policy_evaluated>
585 <disposition>none</disposition>
586 <dkim>pass</dkim>
587 <spf>pass</spf>
588 </policy_evaluated>
589 </row>
590 <identifiers>
591 <envelope_from>acme.example</envelope_from>
592 <header_from>acme.example</header_from>
593 </identifiers>
594 <auth_results>
595 <spf>
596 <domain>acme.example</domain>
597 <result>pass</result>
598 </spf>
599 </auth_results>
600 </record>
601</feedback>"#;
602
603 #[test]
604 fn parse_minimal_report() {
605 let report = parse(MINIMAL_XML).unwrap();
606
607 assert_eq!(report.report_metadata.org_name, "Acme");
609 assert_eq!(report.report_metadata.email, "postmaster@acme.example");
610 assert_eq!(report.report_metadata.report_id, "20130901.r.acme.example");
611 assert_eq!(report.report_metadata.date_range.begin, 1_377_993_600);
612 assert_eq!(report.report_metadata.date_range.end, 1_378_080_000);
613 assert!(report.report_metadata.extra_contact_info.is_none());
614 assert!(report.report_metadata.errors.is_empty());
615
616 assert_eq!(report.policy_published.domain, "acme.example");
618 assert_eq!(report.policy_published.p, Disposition::None);
619 assert_eq!(report.policy_published.sp, Disposition::None);
620 assert_eq!(report.policy_published.pct, 100);
621 assert!(report.policy_published.adkim.is_none());
622 assert!(report.policy_published.aspf.is_none());
623
624 assert_eq!(report.records.len(), 1);
626 let record = &report.records[0];
627 assert_eq!(record.row.source_ip, "192.0.2.1");
628 assert_eq!(record.row.count, 2);
629 assert_eq!(record.row.policy_evaluated.disposition, Disposition::None);
630 assert_eq!(record.row.policy_evaluated.dkim, DmarcResult::Pass);
631 assert_eq!(record.row.policy_evaluated.spf, DmarcResult::Pass);
632 assert!(record.row.policy_evaluated.reasons.is_empty());
633
634 assert!(record.identifiers.envelope_to.is_none());
636 assert_eq!(
637 record.identifiers.envelope_from.as_deref(),
638 Some("acme.example")
639 );
640 assert_eq!(record.identifiers.header_from, "acme.example");
641
642 assert!(record.auth_results.dkim.is_empty());
644 assert_eq!(record.auth_results.spf.len(), 1);
645 assert_eq!(record.auth_results.spf[0].domain, "acme.example");
646 assert_eq!(record.auth_results.spf[0].result, SpfResult::Pass);
647 }
648
649 #[test]
650 fn parse_full_report_all_optional_fields() {
651 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
652<feedback>
653 <version>1.0</version>
654 <report_metadata>
655 <org_name>Mail Service Provider</org_name>
656 <email>dmarc-reports@msp.example</email>
657 <extra_contact_info>https://msp.example/dmarc-info</extra_contact_info>
658 <report_id>report-001</report_id>
659 <date_range>
660 <begin>1609459200</begin>
661 <end>1609545600</end>
662 </date_range>
663 <error>Lookup for example.com failed transiently</error>
664 <error>DNS timeout for subdomain.example.com</error>
665 </report_metadata>
666 <policy_published>
667 <domain>example.com</domain>
668 <adkim>s</adkim>
669 <aspf>s</aspf>
670 <p>reject</p>
671 <sp>quarantine</sp>
672 <pct>50</pct>
673 <fo>1</fo>
674 </policy_published>
675 <record>
676 <row>
677 <source_ip>198.51.100.42</source_ip>
678 <count>10</count>
679 <policy_evaluated>
680 <disposition>reject</disposition>
681 <dkim>fail</dkim>
682 <spf>fail</spf>
683 <reason>
684 <type>forwarded</type>
685 <comment>Known forwarder</comment>
686 </reason>
687 </policy_evaluated>
688 </row>
689 <identifiers>
690 <envelope_to>example.com</envelope_to>
691 <envelope_from>sender.example</envelope_from>
692 <header_from>example.com</header_from>
693 </identifiers>
694 <auth_results>
695 <dkim>
696 <domain>example.com</domain>
697 <selector>selector1</selector>
698 <result>fail</result>
699 <human_result>signature did not verify</human_result>
700 </dkim>
701 <spf>
702 <domain>sender.example</domain>
703 <scope>mfrom</scope>
704 <result>fail</result>
705 </spf>
706 </auth_results>
707 </record>
708</feedback>"#;
709
710 let report = parse(xml).unwrap();
711
712 assert_eq!(report.version, Some("1.0".to_string()));
714
715 assert_eq!(
717 report.report_metadata.extra_contact_info,
718 Some("https://msp.example/dmarc-info".to_string())
719 );
720 assert_eq!(report.report_metadata.errors.len(), 2);
721 assert_eq!(
722 report.report_metadata.errors[0],
723 "Lookup for example.com failed transiently"
724 );
725
726 assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
728 assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
729 assert_eq!(report.policy_published.p, Disposition::Reject);
730 assert_eq!(report.policy_published.sp, Disposition::Quarantine);
731 assert_eq!(report.policy_published.pct, 50);
732 assert_eq!(report.policy_published.fo, Some("1".to_string()));
733
734 let record = &report.records[0];
735
736 assert_eq!(record.row.policy_evaluated.reasons.len(), 1);
738 let reason = &record.row.policy_evaluated.reasons[0];
739 assert_eq!(reason.reason_type, PolicyOverride::Forwarded);
740 assert_eq!(reason.comment, Some("Known forwarder".to_string()));
741
742 assert_eq!(
744 record.identifiers.envelope_to,
745 Some("example.com".to_string())
746 );
747 assert_eq!(
748 record.identifiers.envelope_from.as_deref(),
749 Some("sender.example")
750 );
751
752 assert_eq!(record.auth_results.dkim.len(), 1);
754 let dkim = &record.auth_results.dkim[0];
755 assert_eq!(dkim.domain, "example.com");
756 assert_eq!(dkim.selector, Some("selector1".to_string()));
757 assert_eq!(dkim.result, DkimResult::Fail);
758 assert_eq!(
759 dkim.human_result,
760 Some("signature did not verify".to_string())
761 );
762
763 assert_eq!(
765 record.auth_results.spf[0].scope,
766 Some(SpfDomainScope::Mfrom)
767 );
768 assert_eq!(record.auth_results.spf[0].result, SpfResult::Fail);
769 }
770
771 #[test]
772 fn parse_multiple_records() {
773 let xml = r#"<?xml version="1.0"?>
774<feedback>
775 <report_metadata>
776 <org_name>Reporter</org_name>
777 <email>r@reporter.example</email>
778 <report_id>multi-001</report_id>
779 <date_range><begin>0</begin><end>86400</end></date_range>
780 </report_metadata>
781 <policy_published>
782 <domain>sender.example</domain>
783 <p>quarantine</p>
784 <sp>quarantine</sp>
785 <pct>100</pct>
786 </policy_published>
787 <record>
788 <row>
789 <source_ip>192.0.2.1</source_ip>
790 <count>1</count>
791 <policy_evaluated>
792 <disposition>none</disposition>
793 <dkim>pass</dkim>
794 <spf>pass</spf>
795 </policy_evaluated>
796 </row>
797 <identifiers>
798 <envelope_from>sender.example</envelope_from>
799 <header_from>sender.example</header_from>
800 </identifiers>
801 <auth_results>
802 <spf>
803 <domain>sender.example</domain>
804 <result>pass</result>
805 </spf>
806 </auth_results>
807 </record>
808 <record>
809 <row>
810 <source_ip>203.0.113.7</source_ip>
811 <count>3</count>
812 <policy_evaluated>
813 <disposition>quarantine</disposition>
814 <dkim>fail</dkim>
815 <spf>fail</spf>
816 </policy_evaluated>
817 </row>
818 <identifiers>
819 <envelope_from>attacker.example</envelope_from>
820 <header_from>sender.example</header_from>
821 </identifiers>
822 <auth_results>
823 <spf>
824 <domain>attacker.example</domain>
825 <result>fail</result>
826 </spf>
827 </auth_results>
828 </record>
829</feedback>"#;
830
831 let report = parse(xml).unwrap();
832
833 assert_eq!(report.records.len(), 2);
834 assert_eq!(report.records[0].row.source_ip, "192.0.2.1");
835 assert_eq!(report.records[0].row.count, 1);
836 assert_eq!(
837 report.records[0].row.policy_evaluated.disposition,
838 Disposition::None
839 );
840
841 assert_eq!(report.records[1].row.source_ip, "203.0.113.7");
842 assert_eq!(report.records[1].row.count, 3);
843 assert_eq!(
844 report.records[1].row.policy_evaluated.disposition,
845 Disposition::Quarantine
846 );
847 assert_eq!(
848 report.records[1].row.policy_evaluated.dkim,
849 DmarcResult::Fail
850 );
851 }
852
853 #[test]
854 fn parse_multiple_dkim_spf_auth_results() {
855 let xml = r#"<?xml version="1.0"?>
856<feedback>
857 <report_metadata>
858 <org_name>Reporter</org_name>
859 <email>r@reporter.example</email>
860 <report_id>multi-auth-001</report_id>
861 <date_range><begin>0</begin><end>86400</end></date_range>
862 </report_metadata>
863 <policy_published>
864 <domain>example.com</domain>
865 <p>none</p>
866 <sp>none</sp>
867 <pct>100</pct>
868 </policy_published>
869 <record>
870 <row>
871 <source_ip>192.0.2.1</source_ip>
872 <count>1</count>
873 <policy_evaluated>
874 <disposition>none</disposition>
875 <dkim>pass</dkim>
876 <spf>pass</spf>
877 </policy_evaluated>
878 </row>
879 <identifiers>
880 <envelope_from>example.com</envelope_from>
881 <header_from>example.com</header_from>
882 </identifiers>
883 <auth_results>
884 <dkim>
885 <domain>example.com</domain>
886 <selector>key1</selector>
887 <result>pass</result>
888 </dkim>
889 <dkim>
890 <domain>example.com</domain>
891 <selector>key2</selector>
892 <result>fail</result>
893 </dkim>
894 <spf>
895 <domain>example.com</domain>
896 <scope>helo</scope>
897 <result>pass</result>
898 </spf>
899 <spf>
900 <domain>example.com</domain>
901 <scope>mfrom</scope>
902 <result>pass</result>
903 </spf>
904 </auth_results>
905 </record>
906</feedback>"#;
907
908 let report = parse(xml).unwrap();
909 let auth = &report.records[0].auth_results;
910
911 assert_eq!(auth.dkim.len(), 2);
912 assert_eq!(auth.dkim[0].selector, Some("key1".to_string()));
913 assert_eq!(auth.dkim[0].result, DkimResult::Pass);
914 assert_eq!(auth.dkim[1].selector, Some("key2".to_string()));
915 assert_eq!(auth.dkim[1].result, DkimResult::Fail);
916
917 assert_eq!(auth.spf.len(), 2);
918 assert_eq!(auth.spf[0].scope, Some(SpfDomainScope::Helo));
919 assert_eq!(auth.spf[1].scope, Some(SpfDomainScope::Mfrom));
920 }
921
922 #[test]
923 fn parse_alignment_modes() {
924 let xml_relaxed = r#"<?xml version="1.0"?>
925<feedback>
926 <report_metadata>
927 <org_name>R</org_name><email>r@r.example</email>
928 <report_id>r1</report_id>
929 <date_range><begin>0</begin><end>1</end></date_range>
930 </report_metadata>
931 <policy_published>
932 <domain>example.com</domain>
933 <adkim>r</adkim>
934 <aspf>r</aspf>
935 <p>none</p><sp>none</sp><pct>100</pct>
936 </policy_published>
937 <record>
938 <row><source_ip>192.0.2.1</source_ip><count>1</count>
939 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
940 </row>
941 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
942 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
943 </record>
944</feedback>"#;
945
946 let report = parse(xml_relaxed).unwrap();
947 assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Relaxed));
948 assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Relaxed));
949
950 let xml_strict = xml_relaxed
951 .replace("<adkim>r</adkim>", "<adkim>s</adkim>")
952 .replace("<aspf>r</aspf>", "<aspf>s</aspf>");
953
954 let report = parse(&xml_strict).unwrap();
955 assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
956 assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
957 }
958
959 #[test]
960 fn parse_empty_alignment_modes() {
961 let xml = r#"<?xml version="1.0"?>
962<feedback>
963 <report_metadata>
964 <org_name>R</org_name><email>r@r.example</email>
965 <report_id>r1</report_id>
966 <date_range><begin>0</begin><end>1</end></date_range>
967 </report_metadata>
968 <policy_published>
969 <domain>example.com</domain>
970 <adkim></adkim>
971 <aspf></aspf>
972 <p>none</p><sp>none</sp><pct>100</pct>
973 </policy_published>
974 <record>
975 <row><source_ip>192.0.2.1</source_ip><count>1</count>
976 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
977 </row>
978 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
979 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
980 </record>
981</feedback>"#;
982
983 let report = parse(xml).unwrap();
984 assert!(report.policy_published.adkim.is_none());
985 assert!(report.policy_published.aspf.is_none());
986 }
987
988 #[test]
989 fn parse_all_dkim_results() {
990 let results = [
991 ("none", DkimResult::None),
992 ("pass", DkimResult::Pass),
993 ("fail", DkimResult::Fail),
994 ("policy", DkimResult::Policy),
995 ("neutral", DkimResult::Neutral),
996 ("temperror", DkimResult::Temperror),
997 ("permerror", DkimResult::Permerror),
998 ];
999
1000 for (s, expected) in results {
1001 let xml = format!(
1002 r#"<?xml version="1.0"?>
1003<feedback>
1004 <report_metadata>
1005 <org_name>R</org_name><email>r@r.example</email>
1006 <report_id>r1</report_id>
1007 <date_range><begin>0</begin><end>1</end></date_range>
1008 </report_metadata>
1009 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1010 <record>
1011 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1012 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1013 </row>
1014 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1015 <auth_results>
1016 <dkim><domain>example.com</domain><result>{s}</result></dkim>
1017 <spf><domain>example.com</domain><result>pass</result></spf>
1018 </auth_results>
1019 </record>
1020</feedback>"#
1021 );
1022 let report = parse(&xml).unwrap();
1023 assert_eq!(
1024 report.records[0].auth_results.dkim[0].result, expected,
1025 "failed for DKIM result '{s}'"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn parse_all_spf_results() {
1032 let results = [
1033 ("none", SpfResult::None),
1034 ("neutral", SpfResult::Neutral),
1035 ("pass", SpfResult::Pass),
1036 ("fail", SpfResult::Fail),
1037 ("softfail", SpfResult::Softfail),
1038 ("temperror", SpfResult::Temperror),
1039 ("permerror", SpfResult::Permerror),
1040 ];
1041
1042 for (s, expected) in results {
1043 let xml = format!(
1044 r#"<?xml version="1.0"?>
1045<feedback>
1046 <report_metadata>
1047 <org_name>R</org_name><email>r@r.example</email>
1048 <report_id>r1</report_id>
1049 <date_range><begin>0</begin><end>1</end></date_range>
1050 </report_metadata>
1051 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1052 <record>
1053 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1054 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1055 </row>
1056 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1057 <auth_results>
1058 <spf><domain>example.com</domain><result>{s}</result></spf>
1059 </auth_results>
1060 </record>
1061</feedback>"#
1062 );
1063 let report = parse(&xml).unwrap();
1064 assert_eq!(
1065 report.records[0].auth_results.spf[0].result, expected,
1066 "failed for SPF result '{s}'"
1067 );
1068 }
1069 }
1070
1071 #[test]
1072 fn parse_all_policy_overrides() {
1073 let overrides = [
1074 ("forwarded", PolicyOverride::Forwarded),
1075 ("sampled_out", PolicyOverride::SampledOut),
1076 ("trusted_forwarder", PolicyOverride::TrustedForwarder),
1077 ("mailing_list", PolicyOverride::MailingList),
1078 ("local_policy", PolicyOverride::LocalPolicy),
1079 ("other", PolicyOverride::Other),
1080 ];
1081
1082 for (s, expected) in overrides {
1083 let xml = format!(
1084 r#"<?xml version="1.0"?>
1085<feedback>
1086 <report_metadata>
1087 <org_name>R</org_name><email>r@r.example</email>
1088 <report_id>r1</report_id>
1089 <date_range><begin>0</begin><end>1</end></date_range>
1090 </report_metadata>
1091 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1092 <record>
1093 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1094 <policy_evaluated>
1095 <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
1096 <reason><type>{s}</type></reason>
1097 </policy_evaluated>
1098 </row>
1099 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1100 <auth_results>
1101 <spf><domain>example.com</domain><result>pass</result></spf>
1102 </auth_results>
1103 </record>
1104</feedback>"#
1105 );
1106 let report = parse(&xml).unwrap();
1107 assert_eq!(
1108 report.records[0].row.policy_evaluated.reasons[0].reason_type, expected,
1109 "failed for policy override '{s}'"
1110 );
1111 }
1112 }
1113
1114 #[test]
1115 fn parse_all_dispositions() {
1116 for (s, expected) in [
1117 ("none", Disposition::None),
1118 ("quarantine", Disposition::Quarantine),
1119 ("reject", Disposition::Reject),
1120 ] {
1121 let xml = format!(
1122 r#"<?xml version="1.0"?>
1123<feedback>
1124 <report_metadata>
1125 <org_name>R</org_name><email>r@r.example</email>
1126 <report_id>r1</report_id>
1127 <date_range><begin>0</begin><end>1</end></date_range>
1128 </report_metadata>
1129 <policy_published><domain>example.com</domain><p>{s}</p><sp>{s}</sp><pct>100</pct></policy_published>
1130 <record>
1131 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1132 <policy_evaluated><disposition>{s}</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1133 </row>
1134 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1135 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1136 </record>
1137</feedback>"#
1138 );
1139 let report = parse(&xml).unwrap();
1140 assert_eq!(report.policy_published.p, expected, "failed for '{s}'");
1141 assert_eq!(
1142 report.records[0].row.policy_evaluated.disposition, expected,
1143 "failed for '{s}'"
1144 );
1145 }
1146 }
1147
1148 #[test]
1149 fn parse_multiple_policy_override_reasons() {
1150 let xml = r#"<?xml version="1.0"?>
1151<feedback>
1152 <report_metadata>
1153 <org_name>R</org_name><email>r@r.example</email>
1154 <report_id>r1</report_id>
1155 <date_range><begin>0</begin><end>1</end></date_range>
1156 </report_metadata>
1157 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1158 <record>
1159 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1160 <policy_evaluated>
1161 <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
1162 <reason><type>forwarded</type><comment>via list</comment></reason>
1163 <reason><type>mailing_list</type></reason>
1164 </policy_evaluated>
1165 </row>
1166 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1167 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1168 </record>
1169</feedback>"#;
1170
1171 let report = parse(xml).unwrap();
1172 let reasons = &report.records[0].row.policy_evaluated.reasons;
1173 assert_eq!(reasons.len(), 2);
1174 assert_eq!(reasons[0].reason_type, PolicyOverride::Forwarded);
1175 assert_eq!(reasons[0].comment, Some("via list".to_string()));
1176 assert_eq!(reasons[1].reason_type, PolicyOverride::MailingList);
1177 assert!(reasons[1].comment.is_none());
1178 }
1179
1180 #[test]
1181 fn parse_ipv6_source_ip() {
1182 let xml = r#"<?xml version="1.0"?>
1183<feedback>
1184 <report_metadata>
1185 <org_name>R</org_name><email>r@r.example</email>
1186 <report_id>r1</report_id>
1187 <date_range><begin>0</begin><end>1</end></date_range>
1188 </report_metadata>
1189 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1190 <record>
1191 <row><source_ip>2001:db8::1</source_ip><count>1</count>
1192 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1193 </row>
1194 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1195 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1196 </record>
1197</feedback>"#;
1198
1199 let report = parse(xml).unwrap();
1200 assert_eq!(report.records[0].row.source_ip, "2001:db8::1");
1201 }
1202
1203 #[test]
1204 fn parse_missing_envelope_from() {
1205 let xml = r#"<?xml version="1.0"?>
1206<feedback>
1207 <report_metadata>
1208 <org_name>R</org_name><email>r@r.example</email>
1209 <report_id>r1</report_id>
1210 <date_range><begin>0</begin><end>1</end></date_range>
1211 </report_metadata>
1212 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1213 <record>
1214 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1215 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1216 </row>
1217 <identifiers><header_from>example.com</header_from></identifiers>
1218 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1219 </record>
1220</feedback>"#;
1221
1222 let report = parse(xml).unwrap();
1223 assert!(report.records[0].identifiers.envelope_from.is_none());
1224 assert_eq!(report.records[0].identifiers.header_from, "example.com");
1225 }
1226
1227 #[test]
1228 fn from_str_trait() {
1229 let report: Report = MINIMAL_XML.parse().unwrap();
1230 assert_eq!(report.report_metadata.org_name, "Acme");
1231 }
1232
1233 #[test]
1234 fn try_from_str_trait() {
1235 let report = Report::try_from(MINIMAL_XML).unwrap();
1236 assert_eq!(report.report_metadata.org_name, "Acme");
1237 }
1238
1239 #[test]
1240 fn try_from_bytes_trait() {
1241 let report = Report::try_from(MINIMAL_XML.as_bytes()).unwrap();
1242 assert_eq!(report.report_metadata.org_name, "Acme");
1243 }
1244
1245 #[test]
1246 fn parse_bytes_function() {
1247 let report = parse_bytes(MINIMAL_XML.as_bytes()).unwrap();
1248 assert_eq!(report.report_metadata.org_name, "Acme");
1249 }
1250
1251 #[test]
1252 fn error_on_invalid_xml() {
1253 let result = parse("<not-valid-dmarc/>");
1254 assert!(result.is_err());
1255 }
1256
1257 #[test]
1258 fn error_on_invalid_utf8_bytes() {
1259 let result = parse_bytes(&[0xFF, 0xFE]);
1260 assert!(matches!(result, Err(Error::Utf8(_))));
1261 }
1262
1263 #[test]
1264 fn display_alignment_mode() {
1265 assert_eq!(AlignmentMode::Relaxed.to_string(), "r");
1266 assert_eq!(AlignmentMode::Strict.to_string(), "s");
1267 }
1268
1269 #[test]
1270 fn display_disposition() {
1271 assert_eq!(Disposition::None.to_string(), "none");
1272 assert_eq!(Disposition::Quarantine.to_string(), "quarantine");
1273 assert_eq!(Disposition::Reject.to_string(), "reject");
1274 }
1275
1276 #[test]
1277 fn display_dmarc_result() {
1278 assert_eq!(DmarcResult::Pass.to_string(), "pass");
1279 assert_eq!(DmarcResult::Fail.to_string(), "fail");
1280 }
1281
1282 #[test]
1283 fn display_dkim_result() {
1284 assert_eq!(DkimResult::None.to_string(), "none");
1285 assert_eq!(DkimResult::Pass.to_string(), "pass");
1286 assert_eq!(DkimResult::Fail.to_string(), "fail");
1287 assert_eq!(DkimResult::Policy.to_string(), "policy");
1288 assert_eq!(DkimResult::Neutral.to_string(), "neutral");
1289 assert_eq!(DkimResult::Temperror.to_string(), "temperror");
1290 assert_eq!(DkimResult::Permerror.to_string(), "permerror");
1291 }
1292
1293 #[test]
1294 fn display_spf_result() {
1295 assert_eq!(SpfResult::None.to_string(), "none");
1296 assert_eq!(SpfResult::Neutral.to_string(), "neutral");
1297 assert_eq!(SpfResult::Pass.to_string(), "pass");
1298 assert_eq!(SpfResult::Fail.to_string(), "fail");
1299 assert_eq!(SpfResult::Softfail.to_string(), "softfail");
1300 assert_eq!(SpfResult::Temperror.to_string(), "temperror");
1301 assert_eq!(SpfResult::Permerror.to_string(), "permerror");
1302 }
1303
1304 #[test]
1305 fn display_spf_domain_scope() {
1306 assert_eq!(SpfDomainScope::Helo.to_string(), "helo");
1307 assert_eq!(SpfDomainScope::Mfrom.to_string(), "mfrom");
1308 }
1309
1310 fn report_with(report_id: &str, begin: i64, end: i64, counts: &[u64]) -> Report {
1315 let records: String = counts
1316 .iter()
1317 .map(|c| {
1318 format!(
1319 r#"<record>
1320 <row><source_ip>192.0.2.1</source_ip><count>{c}</count>
1321 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1322 </row>
1323 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1324 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1325 </record>"#
1326 )
1327 })
1328 .collect();
1329
1330 let xml = format!(
1331 r#"<?xml version="1.0"?>
1332<feedback>
1333 <report_metadata>
1334 <org_name>R</org_name><email>r@r.example</email>
1335 <report_id>{report_id}</report_id>
1336 <date_range><begin>{begin}</begin><end>{end}</end></date_range>
1337 </report_metadata>
1338 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1339 {records}
1340</feedback>"#
1341 );
1342 parse(&xml).unwrap()
1343 }
1344
1345 #[test]
1346 fn aggregate_empty() {
1347 let agg = Aggregate::from_reports(vec![]);
1348 assert_eq!(agg.records().count(), 0);
1349 assert_eq!(agg.total_messages(), 0);
1350 assert_eq!(agg.date_span(), None);
1351 }
1352
1353 #[test]
1354 fn aggregate_single_report() {
1355 let agg = Aggregate::from_reports(vec![report_with("r1", 100, 200, &[3, 5])]);
1356 assert_eq!(agg.records().count(), 2);
1357 assert_eq!(agg.total_messages(), 8);
1358 assert_eq!(agg.date_span(), Some((100, 200)));
1359 }
1360
1361 #[test]
1362 fn aggregate_total_messages_sums_across_reports() {
1363 let agg = Aggregate::from_reports(vec![
1364 report_with("r1", 0, 1, &[1, 2]),
1365 report_with("r2", 0, 1, &[4]),
1366 report_with("r3", 0, 1, &[10, 20, 30]),
1367 ]);
1368 assert_eq!(agg.total_messages(), 1 + 2 + 4 + 10 + 20 + 30);
1369 }
1370
1371 #[test]
1372 fn aggregate_date_span_picks_earliest_begin_and_latest_end() {
1373 let agg = Aggregate::from_reports(vec![
1374 report_with("r1", 500, 600, &[1]),
1375 report_with("r2", 100, 200, &[1]),
1376 report_with("r3", 300, 900, &[1]),
1377 ]);
1378 assert_eq!(agg.date_span(), Some((100, 900)));
1379 }
1380
1381 #[test]
1382 fn aggregate_records_pair_with_source_report() {
1383 let agg = Aggregate::from_reports(vec![
1384 report_with("r1", 0, 1, &[1, 2]),
1385 report_with("r2", 0, 1, &[3]),
1386 ]);
1387 let pairs: Vec<(&str, u64)> = agg
1388 .records()
1389 .map(|(r, rec)| (r.report_metadata.report_id.as_str(), rec.row.count))
1390 .collect();
1391 assert_eq!(pairs, vec![("r1", 1), ("r1", 2), ("r2", 3)]);
1392 }
1393
1394 #[test]
1395 fn aggregate_from_vec_via_into() {
1396 let reports = vec![report_with("r1", 0, 1, &[7])];
1397 let agg: Aggregate = reports.into();
1398 assert_eq!(agg.total_messages(), 7);
1399 }
1400
1401 #[test]
1402 fn display_policy_override() {
1403 assert_eq!(PolicyOverride::Forwarded.to_string(), "forwarded");
1404 assert_eq!(PolicyOverride::SampledOut.to_string(), "sampled_out");
1405 assert_eq!(
1406 PolicyOverride::TrustedForwarder.to_string(),
1407 "trusted_forwarder"
1408 );
1409 assert_eq!(PolicyOverride::MailingList.to_string(), "mailing_list");
1410 assert_eq!(PolicyOverride::LocalPolicy.to_string(), "local_policy");
1411 assert_eq!(PolicyOverride::Other.to_string(), "other");
1412 }
1413}