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
17pub fn parse(xml: &str) -> Result<Report, Error> {
28 quick_xml::de::from_str(xml).map_err(Error::from)
29}
30
31pub fn parse_bytes(bytes: &[u8]) -> Result<Report, Error> {
38 let xml = std::str::from_utf8(bytes)?;
39 parse(xml)
40}
41
42#[derive(Debug, Clone, PartialEq, Deserialize)]
50#[serde(rename = "feedback")]
51pub struct Report {
52 #[serde(default)]
54 pub version: Option<String>,
55
56 pub report_metadata: ReportMetadata,
58
59 pub policy_published: PolicyPublished,
61
62 #[serde(rename = "record")]
64 pub records: Vec<Record>,
65}
66
67#[derive(Debug, Clone, PartialEq, Deserialize)]
69pub struct ReportMetadata {
70 pub org_name: String,
72
73 pub email: String,
75
76 #[serde(default)]
78 pub extra_contact_info: Option<String>,
79
80 pub report_id: String,
82
83 pub date_range: DateRange,
85
86 #[serde(rename = "error", default)]
88 pub errors: Vec<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Deserialize)]
93pub struct DateRange {
94 pub begin: i64,
96
97 pub end: i64,
99}
100
101#[derive(Debug, Clone, PartialEq, Deserialize)]
103pub struct PolicyPublished {
104 pub domain: String,
106
107 #[serde(default)]
109 pub adkim: Option<AlignmentMode>,
110
111 #[serde(default)]
113 pub aspf: Option<AlignmentMode>,
114
115 pub p: Disposition,
117
118 pub sp: Disposition,
120
121 pub pct: u32,
123
124 #[serde(default)]
126 pub fo: Option<String>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
131pub enum AlignmentMode {
132 #[serde(rename = "r")]
134 Relaxed,
135
136 #[serde(rename = "s")]
138 Strict,
139}
140
141impl std::fmt::Display for AlignmentMode {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 AlignmentMode::Relaxed => f.write_str("r"),
145 AlignmentMode::Strict => f.write_str("s"),
146 }
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum Disposition {
154 None,
156 Quarantine,
158 Reject,
160}
161
162impl std::fmt::Display for Disposition {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 match self {
165 Disposition::None => f.write_str("none"),
166 Disposition::Quarantine => f.write_str("quarantine"),
167 Disposition::Reject => f.write_str("reject"),
168 }
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Deserialize)]
174pub struct Record {
175 pub row: Row,
177
178 pub identifiers: Identifiers,
180
181 pub auth_results: AuthResults,
183}
184
185#[derive(Debug, Clone, PartialEq, Deserialize)]
187pub struct Row {
188 pub source_ip: String,
190
191 pub count: u64,
193
194 pub policy_evaluated: PolicyEvaluated,
196}
197
198#[derive(Debug, Clone, PartialEq, Deserialize)]
200pub struct PolicyEvaluated {
201 pub disposition: Disposition,
203
204 pub dkim: DmarcResult,
206
207 pub spf: DmarcResult,
209
210 #[serde(rename = "reason", default)]
212 pub reasons: Vec<PolicyOverrideReason>,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
217#[serde(rename_all = "lowercase")]
218pub enum DmarcResult {
219 Pass,
221 Fail,
223}
224
225impl std::fmt::Display for DmarcResult {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 match self {
228 DmarcResult::Pass => f.write_str("pass"),
229 DmarcResult::Fail => f.write_str("fail"),
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Deserialize)]
237pub struct PolicyOverrideReason {
238 #[serde(rename = "type")]
240 pub reason_type: PolicyOverride,
241
242 #[serde(default)]
244 pub comment: Option<String>,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum PolicyOverride {
251 Forwarded,
253 SampledOut,
255 TrustedForwarder,
257 MailingList,
259 LocalPolicy,
261 Other,
263}
264
265impl std::fmt::Display for PolicyOverride {
266 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 match self {
268 PolicyOverride::Forwarded => f.write_str("forwarded"),
269 PolicyOverride::SampledOut => f.write_str("sampled_out"),
270 PolicyOverride::TrustedForwarder => f.write_str("trusted_forwarder"),
271 PolicyOverride::MailingList => f.write_str("mailing_list"),
272 PolicyOverride::LocalPolicy => f.write_str("local_policy"),
273 PolicyOverride::Other => f.write_str("other"),
274 }
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Deserialize)]
280pub struct Identifiers {
281 #[serde(default)]
283 pub envelope_to: Option<String>,
284
285 #[serde(default)]
287 pub envelope_from: Option<String>,
288
289 pub header_from: String,
291}
292
293#[derive(Debug, Clone, PartialEq, Deserialize)]
295pub struct AuthResults {
296 #[serde(rename = "dkim", default)]
298 pub dkim: Vec<DkimAuthResult>,
299
300 #[serde(rename = "spf")]
302 pub spf: Vec<SpfAuthResult>,
303}
304
305#[derive(Debug, Clone, PartialEq, Deserialize)]
307pub struct DkimAuthResult {
308 pub domain: String,
310
311 #[serde(default)]
313 pub selector: Option<String>,
314
315 pub result: DkimResult,
317
318 #[serde(default)]
320 pub human_result: Option<String>,
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
325#[serde(rename_all = "lowercase")]
326pub enum DkimResult {
327 None,
329 Pass,
331 Fail,
333 Policy,
335 Neutral,
337 Temperror,
339 Permerror,
341}
342
343impl std::fmt::Display for DkimResult {
344 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345 match self {
346 DkimResult::None => f.write_str("none"),
347 DkimResult::Pass => f.write_str("pass"),
348 DkimResult::Fail => f.write_str("fail"),
349 DkimResult::Policy => f.write_str("policy"),
350 DkimResult::Neutral => f.write_str("neutral"),
351 DkimResult::Temperror => f.write_str("temperror"),
352 DkimResult::Permerror => f.write_str("permerror"),
353 }
354 }
355}
356
357#[derive(Debug, Clone, PartialEq, Deserialize)]
359pub struct SpfAuthResult {
360 pub domain: String,
362
363 #[serde(default)]
365 pub scope: Option<SpfDomainScope>,
366
367 pub result: SpfResult,
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
373#[serde(rename_all = "lowercase")]
374pub enum SpfDomainScope {
375 Helo,
377 Mfrom,
379}
380
381impl std::fmt::Display for SpfDomainScope {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 match self {
384 SpfDomainScope::Helo => f.write_str("helo"),
385 SpfDomainScope::Mfrom => f.write_str("mfrom"),
386 }
387 }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
392#[serde(rename_all = "lowercase")]
393pub enum SpfResult {
394 None,
396 Neutral,
398 Pass,
400 Fail,
402 Softfail,
404 Temperror,
406 Permerror,
408}
409
410impl std::fmt::Display for SpfResult {
411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 match self {
413 SpfResult::None => f.write_str("none"),
414 SpfResult::Neutral => f.write_str("neutral"),
415 SpfResult::Pass => f.write_str("pass"),
416 SpfResult::Fail => f.write_str("fail"),
417 SpfResult::Softfail => f.write_str("softfail"),
418 SpfResult::Temperror => f.write_str("temperror"),
419 SpfResult::Permerror => f.write_str("permerror"),
420 }
421 }
422}
423
424impl std::str::FromStr for Report {
429 type Err = Error;
430
431 fn from_str(s: &str) -> Result<Self, Self::Err> {
432 parse(s)
433 }
434}
435
436impl TryFrom<&str> for Report {
437 type Error = Error;
438
439 fn try_from(s: &str) -> Result<Self, Self::Error> {
440 parse(s)
441 }
442}
443
444impl TryFrom<&[u8]> for Report {
445 type Error = Error;
446
447 fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
448 parse_bytes(bytes)
449 }
450}
451
452#[cfg(test)]
457mod tests {
458 use super::*;
459
460 const MINIMAL_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
462<feedback>
463 <report_metadata>
464 <org_name>Acme</org_name>
465 <email>postmaster@acme.example</email>
466 <report_id>20130901.r.acme.example</report_id>
467 <date_range>
468 <begin>1377993600</begin>
469 <end>1378080000</end>
470 </date_range>
471 </report_metadata>
472 <policy_published>
473 <domain>acme.example</domain>
474 <p>none</p>
475 <sp>none</sp>
476 <pct>100</pct>
477 </policy_published>
478 <record>
479 <row>
480 <source_ip>192.0.2.1</source_ip>
481 <count>2</count>
482 <policy_evaluated>
483 <disposition>none</disposition>
484 <dkim>pass</dkim>
485 <spf>pass</spf>
486 </policy_evaluated>
487 </row>
488 <identifiers>
489 <envelope_from>acme.example</envelope_from>
490 <header_from>acme.example</header_from>
491 </identifiers>
492 <auth_results>
493 <spf>
494 <domain>acme.example</domain>
495 <result>pass</result>
496 </spf>
497 </auth_results>
498 </record>
499</feedback>"#;
500
501 #[test]
502 fn parse_minimal_report() {
503 let report = parse(MINIMAL_XML).unwrap();
504
505 assert_eq!(report.report_metadata.org_name, "Acme");
507 assert_eq!(report.report_metadata.email, "postmaster@acme.example");
508 assert_eq!(report.report_metadata.report_id, "20130901.r.acme.example");
509 assert_eq!(report.report_metadata.date_range.begin, 1_377_993_600);
510 assert_eq!(report.report_metadata.date_range.end, 1_378_080_000);
511 assert!(report.report_metadata.extra_contact_info.is_none());
512 assert!(report.report_metadata.errors.is_empty());
513
514 assert_eq!(report.policy_published.domain, "acme.example");
516 assert_eq!(report.policy_published.p, Disposition::None);
517 assert_eq!(report.policy_published.sp, Disposition::None);
518 assert_eq!(report.policy_published.pct, 100);
519 assert!(report.policy_published.adkim.is_none());
520 assert!(report.policy_published.aspf.is_none());
521
522 assert_eq!(report.records.len(), 1);
524 let record = &report.records[0];
525 assert_eq!(record.row.source_ip, "192.0.2.1");
526 assert_eq!(record.row.count, 2);
527 assert_eq!(record.row.policy_evaluated.disposition, Disposition::None);
528 assert_eq!(record.row.policy_evaluated.dkim, DmarcResult::Pass);
529 assert_eq!(record.row.policy_evaluated.spf, DmarcResult::Pass);
530 assert!(record.row.policy_evaluated.reasons.is_empty());
531
532 assert!(record.identifiers.envelope_to.is_none());
534 assert_eq!(
535 record.identifiers.envelope_from.as_deref(),
536 Some("acme.example")
537 );
538 assert_eq!(record.identifiers.header_from, "acme.example");
539
540 assert!(record.auth_results.dkim.is_empty());
542 assert_eq!(record.auth_results.spf.len(), 1);
543 assert_eq!(record.auth_results.spf[0].domain, "acme.example");
544 assert_eq!(record.auth_results.spf[0].result, SpfResult::Pass);
545 }
546
547 #[test]
548 fn parse_full_report_all_optional_fields() {
549 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
550<feedback>
551 <version>1.0</version>
552 <report_metadata>
553 <org_name>Mail Service Provider</org_name>
554 <email>dmarc-reports@msp.example</email>
555 <extra_contact_info>https://msp.example/dmarc-info</extra_contact_info>
556 <report_id>report-001</report_id>
557 <date_range>
558 <begin>1609459200</begin>
559 <end>1609545600</end>
560 </date_range>
561 <error>Lookup for example.com failed transiently</error>
562 <error>DNS timeout for subdomain.example.com</error>
563 </report_metadata>
564 <policy_published>
565 <domain>example.com</domain>
566 <adkim>s</adkim>
567 <aspf>s</aspf>
568 <p>reject</p>
569 <sp>quarantine</sp>
570 <pct>50</pct>
571 <fo>1</fo>
572 </policy_published>
573 <record>
574 <row>
575 <source_ip>198.51.100.42</source_ip>
576 <count>10</count>
577 <policy_evaluated>
578 <disposition>reject</disposition>
579 <dkim>fail</dkim>
580 <spf>fail</spf>
581 <reason>
582 <type>forwarded</type>
583 <comment>Known forwarder</comment>
584 </reason>
585 </policy_evaluated>
586 </row>
587 <identifiers>
588 <envelope_to>example.com</envelope_to>
589 <envelope_from>sender.example</envelope_from>
590 <header_from>example.com</header_from>
591 </identifiers>
592 <auth_results>
593 <dkim>
594 <domain>example.com</domain>
595 <selector>selector1</selector>
596 <result>fail</result>
597 <human_result>signature did not verify</human_result>
598 </dkim>
599 <spf>
600 <domain>sender.example</domain>
601 <scope>mfrom</scope>
602 <result>fail</result>
603 </spf>
604 </auth_results>
605 </record>
606</feedback>"#;
607
608 let report = parse(xml).unwrap();
609
610 assert_eq!(report.version, Some("1.0".to_string()));
612
613 assert_eq!(
615 report.report_metadata.extra_contact_info,
616 Some("https://msp.example/dmarc-info".to_string())
617 );
618 assert_eq!(report.report_metadata.errors.len(), 2);
619 assert_eq!(
620 report.report_metadata.errors[0],
621 "Lookup for example.com failed transiently"
622 );
623
624 assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
626 assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
627 assert_eq!(report.policy_published.p, Disposition::Reject);
628 assert_eq!(report.policy_published.sp, Disposition::Quarantine);
629 assert_eq!(report.policy_published.pct, 50);
630 assert_eq!(report.policy_published.fo, Some("1".to_string()));
631
632 let record = &report.records[0];
633
634 assert_eq!(record.row.policy_evaluated.reasons.len(), 1);
636 let reason = &record.row.policy_evaluated.reasons[0];
637 assert_eq!(reason.reason_type, PolicyOverride::Forwarded);
638 assert_eq!(reason.comment, Some("Known forwarder".to_string()));
639
640 assert_eq!(
642 record.identifiers.envelope_to,
643 Some("example.com".to_string())
644 );
645 assert_eq!(
646 record.identifiers.envelope_from.as_deref(),
647 Some("sender.example")
648 );
649
650 assert_eq!(record.auth_results.dkim.len(), 1);
652 let dkim = &record.auth_results.dkim[0];
653 assert_eq!(dkim.domain, "example.com");
654 assert_eq!(dkim.selector, Some("selector1".to_string()));
655 assert_eq!(dkim.result, DkimResult::Fail);
656 assert_eq!(
657 dkim.human_result,
658 Some("signature did not verify".to_string())
659 );
660
661 assert_eq!(
663 record.auth_results.spf[0].scope,
664 Some(SpfDomainScope::Mfrom)
665 );
666 assert_eq!(record.auth_results.spf[0].result, SpfResult::Fail);
667 }
668
669 #[test]
670 fn parse_multiple_records() {
671 let xml = r#"<?xml version="1.0"?>
672<feedback>
673 <report_metadata>
674 <org_name>Reporter</org_name>
675 <email>r@reporter.example</email>
676 <report_id>multi-001</report_id>
677 <date_range><begin>0</begin><end>86400</end></date_range>
678 </report_metadata>
679 <policy_published>
680 <domain>sender.example</domain>
681 <p>quarantine</p>
682 <sp>quarantine</sp>
683 <pct>100</pct>
684 </policy_published>
685 <record>
686 <row>
687 <source_ip>192.0.2.1</source_ip>
688 <count>1</count>
689 <policy_evaluated>
690 <disposition>none</disposition>
691 <dkim>pass</dkim>
692 <spf>pass</spf>
693 </policy_evaluated>
694 </row>
695 <identifiers>
696 <envelope_from>sender.example</envelope_from>
697 <header_from>sender.example</header_from>
698 </identifiers>
699 <auth_results>
700 <spf>
701 <domain>sender.example</domain>
702 <result>pass</result>
703 </spf>
704 </auth_results>
705 </record>
706 <record>
707 <row>
708 <source_ip>203.0.113.7</source_ip>
709 <count>3</count>
710 <policy_evaluated>
711 <disposition>quarantine</disposition>
712 <dkim>fail</dkim>
713 <spf>fail</spf>
714 </policy_evaluated>
715 </row>
716 <identifiers>
717 <envelope_from>attacker.example</envelope_from>
718 <header_from>sender.example</header_from>
719 </identifiers>
720 <auth_results>
721 <spf>
722 <domain>attacker.example</domain>
723 <result>fail</result>
724 </spf>
725 </auth_results>
726 </record>
727</feedback>"#;
728
729 let report = parse(xml).unwrap();
730
731 assert_eq!(report.records.len(), 2);
732 assert_eq!(report.records[0].row.source_ip, "192.0.2.1");
733 assert_eq!(report.records[0].row.count, 1);
734 assert_eq!(
735 report.records[0].row.policy_evaluated.disposition,
736 Disposition::None
737 );
738
739 assert_eq!(report.records[1].row.source_ip, "203.0.113.7");
740 assert_eq!(report.records[1].row.count, 3);
741 assert_eq!(
742 report.records[1].row.policy_evaluated.disposition,
743 Disposition::Quarantine
744 );
745 assert_eq!(
746 report.records[1].row.policy_evaluated.dkim,
747 DmarcResult::Fail
748 );
749 }
750
751 #[test]
752 fn parse_multiple_dkim_spf_auth_results() {
753 let xml = r#"<?xml version="1.0"?>
754<feedback>
755 <report_metadata>
756 <org_name>Reporter</org_name>
757 <email>r@reporter.example</email>
758 <report_id>multi-auth-001</report_id>
759 <date_range><begin>0</begin><end>86400</end></date_range>
760 </report_metadata>
761 <policy_published>
762 <domain>example.com</domain>
763 <p>none</p>
764 <sp>none</sp>
765 <pct>100</pct>
766 </policy_published>
767 <record>
768 <row>
769 <source_ip>192.0.2.1</source_ip>
770 <count>1</count>
771 <policy_evaluated>
772 <disposition>none</disposition>
773 <dkim>pass</dkim>
774 <spf>pass</spf>
775 </policy_evaluated>
776 </row>
777 <identifiers>
778 <envelope_from>example.com</envelope_from>
779 <header_from>example.com</header_from>
780 </identifiers>
781 <auth_results>
782 <dkim>
783 <domain>example.com</domain>
784 <selector>key1</selector>
785 <result>pass</result>
786 </dkim>
787 <dkim>
788 <domain>example.com</domain>
789 <selector>key2</selector>
790 <result>fail</result>
791 </dkim>
792 <spf>
793 <domain>example.com</domain>
794 <scope>helo</scope>
795 <result>pass</result>
796 </spf>
797 <spf>
798 <domain>example.com</domain>
799 <scope>mfrom</scope>
800 <result>pass</result>
801 </spf>
802 </auth_results>
803 </record>
804</feedback>"#;
805
806 let report = parse(xml).unwrap();
807 let auth = &report.records[0].auth_results;
808
809 assert_eq!(auth.dkim.len(), 2);
810 assert_eq!(auth.dkim[0].selector, Some("key1".to_string()));
811 assert_eq!(auth.dkim[0].result, DkimResult::Pass);
812 assert_eq!(auth.dkim[1].selector, Some("key2".to_string()));
813 assert_eq!(auth.dkim[1].result, DkimResult::Fail);
814
815 assert_eq!(auth.spf.len(), 2);
816 assert_eq!(auth.spf[0].scope, Some(SpfDomainScope::Helo));
817 assert_eq!(auth.spf[1].scope, Some(SpfDomainScope::Mfrom));
818 }
819
820 #[test]
821 fn parse_alignment_modes() {
822 let xml_relaxed = r#"<?xml version="1.0"?>
823<feedback>
824 <report_metadata>
825 <org_name>R</org_name><email>r@r.example</email>
826 <report_id>r1</report_id>
827 <date_range><begin>0</begin><end>1</end></date_range>
828 </report_metadata>
829 <policy_published>
830 <domain>example.com</domain>
831 <adkim>r</adkim>
832 <aspf>r</aspf>
833 <p>none</p><sp>none</sp><pct>100</pct>
834 </policy_published>
835 <record>
836 <row><source_ip>192.0.2.1</source_ip><count>1</count>
837 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
838 </row>
839 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
840 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
841 </record>
842</feedback>"#;
843
844 let report = parse(xml_relaxed).unwrap();
845 assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Relaxed));
846 assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Relaxed));
847
848 let xml_strict = xml_relaxed
849 .replace("<adkim>r</adkim>", "<adkim>s</adkim>")
850 .replace("<aspf>r</aspf>", "<aspf>s</aspf>");
851
852 let report = parse(&xml_strict).unwrap();
853 assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
854 assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
855 }
856
857 #[test]
858 fn parse_all_dkim_results() {
859 let results = [
860 ("none", DkimResult::None),
861 ("pass", DkimResult::Pass),
862 ("fail", DkimResult::Fail),
863 ("policy", DkimResult::Policy),
864 ("neutral", DkimResult::Neutral),
865 ("temperror", DkimResult::Temperror),
866 ("permerror", DkimResult::Permerror),
867 ];
868
869 for (s, expected) in results {
870 let xml = format!(
871 r#"<?xml version="1.0"?>
872<feedback>
873 <report_metadata>
874 <org_name>R</org_name><email>r@r.example</email>
875 <report_id>r1</report_id>
876 <date_range><begin>0</begin><end>1</end></date_range>
877 </report_metadata>
878 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
879 <record>
880 <row><source_ip>192.0.2.1</source_ip><count>1</count>
881 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
882 </row>
883 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
884 <auth_results>
885 <dkim><domain>example.com</domain><result>{s}</result></dkim>
886 <spf><domain>example.com</domain><result>pass</result></spf>
887 </auth_results>
888 </record>
889</feedback>"#
890 );
891 let report = parse(&xml).unwrap();
892 assert_eq!(
893 report.records[0].auth_results.dkim[0].result, expected,
894 "failed for DKIM result '{s}'"
895 );
896 }
897 }
898
899 #[test]
900 fn parse_all_spf_results() {
901 let results = [
902 ("none", SpfResult::None),
903 ("neutral", SpfResult::Neutral),
904 ("pass", SpfResult::Pass),
905 ("fail", SpfResult::Fail),
906 ("softfail", SpfResult::Softfail),
907 ("temperror", SpfResult::Temperror),
908 ("permerror", SpfResult::Permerror),
909 ];
910
911 for (s, expected) in results {
912 let xml = format!(
913 r#"<?xml version="1.0"?>
914<feedback>
915 <report_metadata>
916 <org_name>R</org_name><email>r@r.example</email>
917 <report_id>r1</report_id>
918 <date_range><begin>0</begin><end>1</end></date_range>
919 </report_metadata>
920 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
921 <record>
922 <row><source_ip>192.0.2.1</source_ip><count>1</count>
923 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
924 </row>
925 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
926 <auth_results>
927 <spf><domain>example.com</domain><result>{s}</result></spf>
928 </auth_results>
929 </record>
930</feedback>"#
931 );
932 let report = parse(&xml).unwrap();
933 assert_eq!(
934 report.records[0].auth_results.spf[0].result, expected,
935 "failed for SPF result '{s}'"
936 );
937 }
938 }
939
940 #[test]
941 fn parse_all_policy_overrides() {
942 let overrides = [
943 ("forwarded", PolicyOverride::Forwarded),
944 ("sampled_out", PolicyOverride::SampledOut),
945 ("trusted_forwarder", PolicyOverride::TrustedForwarder),
946 ("mailing_list", PolicyOverride::MailingList),
947 ("local_policy", PolicyOverride::LocalPolicy),
948 ("other", PolicyOverride::Other),
949 ];
950
951 for (s, expected) in overrides {
952 let xml = format!(
953 r#"<?xml version="1.0"?>
954<feedback>
955 <report_metadata>
956 <org_name>R</org_name><email>r@r.example</email>
957 <report_id>r1</report_id>
958 <date_range><begin>0</begin><end>1</end></date_range>
959 </report_metadata>
960 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
961 <record>
962 <row><source_ip>192.0.2.1</source_ip><count>1</count>
963 <policy_evaluated>
964 <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
965 <reason><type>{s}</type></reason>
966 </policy_evaluated>
967 </row>
968 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
969 <auth_results>
970 <spf><domain>example.com</domain><result>pass</result></spf>
971 </auth_results>
972 </record>
973</feedback>"#
974 );
975 let report = parse(&xml).unwrap();
976 assert_eq!(
977 report.records[0].row.policy_evaluated.reasons[0].reason_type, expected,
978 "failed for policy override '{s}'"
979 );
980 }
981 }
982
983 #[test]
984 fn parse_all_dispositions() {
985 for (s, expected) in [
986 ("none", Disposition::None),
987 ("quarantine", Disposition::Quarantine),
988 ("reject", Disposition::Reject),
989 ] {
990 let xml = format!(
991 r#"<?xml version="1.0"?>
992<feedback>
993 <report_metadata>
994 <org_name>R</org_name><email>r@r.example</email>
995 <report_id>r1</report_id>
996 <date_range><begin>0</begin><end>1</end></date_range>
997 </report_metadata>
998 <policy_published><domain>example.com</domain><p>{s}</p><sp>{s}</sp><pct>100</pct></policy_published>
999 <record>
1000 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1001 <policy_evaluated><disposition>{s}</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1002 </row>
1003 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1004 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1005 </record>
1006</feedback>"#
1007 );
1008 let report = parse(&xml).unwrap();
1009 assert_eq!(report.policy_published.p, expected, "failed for '{s}'");
1010 assert_eq!(
1011 report.records[0].row.policy_evaluated.disposition, expected,
1012 "failed for '{s}'"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn parse_multiple_policy_override_reasons() {
1019 let xml = r#"<?xml version="1.0"?>
1020<feedback>
1021 <report_metadata>
1022 <org_name>R</org_name><email>r@r.example</email>
1023 <report_id>r1</report_id>
1024 <date_range><begin>0</begin><end>1</end></date_range>
1025 </report_metadata>
1026 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1027 <record>
1028 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1029 <policy_evaluated>
1030 <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
1031 <reason><type>forwarded</type><comment>via list</comment></reason>
1032 <reason><type>mailing_list</type></reason>
1033 </policy_evaluated>
1034 </row>
1035 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1036 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1037 </record>
1038</feedback>"#;
1039
1040 let report = parse(xml).unwrap();
1041 let reasons = &report.records[0].row.policy_evaluated.reasons;
1042 assert_eq!(reasons.len(), 2);
1043 assert_eq!(reasons[0].reason_type, PolicyOverride::Forwarded);
1044 assert_eq!(reasons[0].comment, Some("via list".to_string()));
1045 assert_eq!(reasons[1].reason_type, PolicyOverride::MailingList);
1046 assert!(reasons[1].comment.is_none());
1047 }
1048
1049 #[test]
1050 fn parse_ipv6_source_ip() {
1051 let xml = r#"<?xml version="1.0"?>
1052<feedback>
1053 <report_metadata>
1054 <org_name>R</org_name><email>r@r.example</email>
1055 <report_id>r1</report_id>
1056 <date_range><begin>0</begin><end>1</end></date_range>
1057 </report_metadata>
1058 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1059 <record>
1060 <row><source_ip>2001:db8::1</source_ip><count>1</count>
1061 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1062 </row>
1063 <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1064 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1065 </record>
1066</feedback>"#;
1067
1068 let report = parse(xml).unwrap();
1069 assert_eq!(report.records[0].row.source_ip, "2001:db8::1");
1070 }
1071
1072 #[test]
1073 fn parse_missing_envelope_from() {
1074 let xml = r#"<?xml version="1.0"?>
1075<feedback>
1076 <report_metadata>
1077 <org_name>R</org_name><email>r@r.example</email>
1078 <report_id>r1</report_id>
1079 <date_range><begin>0</begin><end>1</end></date_range>
1080 </report_metadata>
1081 <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1082 <record>
1083 <row><source_ip>192.0.2.1</source_ip><count>1</count>
1084 <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1085 </row>
1086 <identifiers><header_from>example.com</header_from></identifiers>
1087 <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1088 </record>
1089</feedback>"#;
1090
1091 let report = parse(xml).unwrap();
1092 assert!(report.records[0].identifiers.envelope_from.is_none());
1093 assert_eq!(report.records[0].identifiers.header_from, "example.com");
1094 }
1095
1096 #[test]
1097 fn from_str_trait() {
1098 let report: Report = MINIMAL_XML.parse().unwrap();
1099 assert_eq!(report.report_metadata.org_name, "Acme");
1100 }
1101
1102 #[test]
1103 fn try_from_str_trait() {
1104 let report = Report::try_from(MINIMAL_XML).unwrap();
1105 assert_eq!(report.report_metadata.org_name, "Acme");
1106 }
1107
1108 #[test]
1109 fn try_from_bytes_trait() {
1110 let report = Report::try_from(MINIMAL_XML.as_bytes()).unwrap();
1111 assert_eq!(report.report_metadata.org_name, "Acme");
1112 }
1113
1114 #[test]
1115 fn parse_bytes_function() {
1116 let report = parse_bytes(MINIMAL_XML.as_bytes()).unwrap();
1117 assert_eq!(report.report_metadata.org_name, "Acme");
1118 }
1119
1120 #[test]
1121 fn error_on_invalid_xml() {
1122 let result = parse("<not-valid-dmarc/>");
1123 assert!(result.is_err());
1124 }
1125
1126 #[test]
1127 fn error_on_invalid_utf8_bytes() {
1128 let result = parse_bytes(&[0xFF, 0xFE]);
1129 assert!(matches!(result, Err(Error::Utf8(_))));
1130 }
1131
1132 #[test]
1133 fn display_alignment_mode() {
1134 assert_eq!(AlignmentMode::Relaxed.to_string(), "r");
1135 assert_eq!(AlignmentMode::Strict.to_string(), "s");
1136 }
1137
1138 #[test]
1139 fn display_disposition() {
1140 assert_eq!(Disposition::None.to_string(), "none");
1141 assert_eq!(Disposition::Quarantine.to_string(), "quarantine");
1142 assert_eq!(Disposition::Reject.to_string(), "reject");
1143 }
1144
1145 #[test]
1146 fn display_dmarc_result() {
1147 assert_eq!(DmarcResult::Pass.to_string(), "pass");
1148 assert_eq!(DmarcResult::Fail.to_string(), "fail");
1149 }
1150
1151 #[test]
1152 fn display_dkim_result() {
1153 assert_eq!(DkimResult::None.to_string(), "none");
1154 assert_eq!(DkimResult::Pass.to_string(), "pass");
1155 assert_eq!(DkimResult::Fail.to_string(), "fail");
1156 assert_eq!(DkimResult::Policy.to_string(), "policy");
1157 assert_eq!(DkimResult::Neutral.to_string(), "neutral");
1158 assert_eq!(DkimResult::Temperror.to_string(), "temperror");
1159 assert_eq!(DkimResult::Permerror.to_string(), "permerror");
1160 }
1161
1162 #[test]
1163 fn display_spf_result() {
1164 assert_eq!(SpfResult::None.to_string(), "none");
1165 assert_eq!(SpfResult::Neutral.to_string(), "neutral");
1166 assert_eq!(SpfResult::Pass.to_string(), "pass");
1167 assert_eq!(SpfResult::Fail.to_string(), "fail");
1168 assert_eq!(SpfResult::Softfail.to_string(), "softfail");
1169 assert_eq!(SpfResult::Temperror.to_string(), "temperror");
1170 assert_eq!(SpfResult::Permerror.to_string(), "permerror");
1171 }
1172
1173 #[test]
1174 fn display_spf_domain_scope() {
1175 assert_eq!(SpfDomainScope::Helo.to_string(), "helo");
1176 assert_eq!(SpfDomainScope::Mfrom.to_string(), "mfrom");
1177 }
1178
1179 #[test]
1180 fn display_policy_override() {
1181 assert_eq!(PolicyOverride::Forwarded.to_string(), "forwarded");
1182 assert_eq!(PolicyOverride::SampledOut.to_string(), "sampled_out");
1183 assert_eq!(
1184 PolicyOverride::TrustedForwarder.to_string(),
1185 "trusted_forwarder"
1186 );
1187 assert_eq!(PolicyOverride::MailingList.to_string(), "mailing_list");
1188 assert_eq!(PolicyOverride::LocalPolicy.to_string(), "local_policy");
1189 assert_eq!(PolicyOverride::Other.to_string(), "other");
1190 }
1191}