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