Skip to main content

dmarc_report_parser/
lib.rs

1//! DMARC aggregate report parser (RFC 7489).
2//!
3//! Parse DMARC aggregate feedback reports from their XML representation as
4//! defined in [RFC 7489 Appendix C](https://www.rfc-editor.org/rfc/rfc7489#appendix-C).
5//!
6#![doc = include_str!("../docs/library-usage.md")]
7//!
8//! # CLI
9//!
10#![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            // quick-xml represents <adkim></adkim> as {"$text": ""} rather than a plain string
47            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
61// ──────────────────────────────────────────────────────────────────────────────
62// Public API
63// ──────────────────────────────────────────────────────────────────────────────
64
65/// Parse a DMARC aggregate report from an XML string.
66///
67/// # Errors
68///
69/// Returns [`Error::Parse`] if the XML is invalid or does not conform to the
70/// DMARC aggregate report schema (RFC 7489 Appendix C).
71pub fn parse(xml: &str) -> Result<Report, Error> {
72    quick_xml::de::from_str(xml).map_err(Error::from)
73}
74
75/// Parse a DMARC aggregate report from a byte slice.
76///
77/// # Errors
78///
79/// Returns [`Error::Utf8`] if the bytes are not valid UTF-8, or
80/// [`Error::Parse`] if the XML is invalid or non-conformant.
81pub fn parse_bytes(bytes: &[u8]) -> Result<Report, Error> {
82    let xml = std::str::from_utf8(bytes)?;
83    parse(xml)
84}
85
86// ──────────────────────────────────────────────────────────────────────────────
87// RFC 7489 Appendix C — DMARC XML schema types
88// ──────────────────────────────────────────────────────────────────────────────
89
90/// Top-level DMARC aggregate feedback report (`<feedback>`).
91///
92/// Defined as the root element in RFC 7489 Appendix C.
93#[derive(Debug, Clone, PartialEq, Deserialize)]
94#[serde(rename = "feedback")]
95pub struct Report {
96    /// Report format version (optional, xs:decimal).
97    #[serde(default)]
98    pub version: Option<String>,
99
100    /// Metadata about the report generator.
101    pub report_metadata: ReportMetadata,
102
103    /// The DMARC policy published for the domain covered by this report.
104    pub policy_published: PolicyPublished,
105
106    /// One or more individual message records.
107    #[serde(rename = "record")]
108    pub records: Vec<Record>,
109}
110
111/// Report generator metadata (`ReportMetadataType`).
112#[derive(Debug, Clone, PartialEq, Deserialize)]
113pub struct ReportMetadata {
114    /// The name of the organization generating the report.
115    pub org_name: String,
116
117    /// Contact email address for the report generator.
118    pub email: String,
119
120    /// Additional contact information (optional).
121    #[serde(default)]
122    pub extra_contact_info: Option<String>,
123
124    /// Unique identifier for this report.
125    pub report_id: String,
126
127    /// The UTC time range covered by the messages in this report.
128    pub date_range: DateRange,
129
130    /// Any errors encountered during report generation.
131    #[serde(rename = "error", default)]
132    pub errors: Vec<String>,
133}
134
135/// UTC time range covered by a report, expressed as Unix timestamps.
136#[derive(Debug, Clone, PartialEq, Deserialize)]
137pub struct DateRange {
138    /// Start of the time range (seconds since Unix epoch).
139    pub begin: i64,
140
141    /// End of the time range (seconds since Unix epoch).
142    pub end: i64,
143}
144
145/// The DMARC policy published for the organizational domain (`PolicyPublishedType`).
146#[derive(Debug, Clone, PartialEq, Deserialize)]
147pub struct PolicyPublished {
148    /// The domain to which the DMARC policy applies.
149    pub domain: String,
150
151    /// DKIM alignment mode (`r` = relaxed, `s` = strict). Defaults to relaxed when absent.
152    #[serde(default, deserialize_with = "deserialize_optional_alignment")]
153    pub adkim: Option<AlignmentMode>,
154
155    /// SPF alignment mode (`r` = relaxed, `s` = strict). Defaults to relaxed when absent.
156    #[serde(default, deserialize_with = "deserialize_optional_alignment")]
157    pub aspf: Option<AlignmentMode>,
158
159    /// Domain-level policy action.
160    pub p: Disposition,
161
162    /// Subdomain policy action.
163    pub sp: Disposition,
164
165    /// Percentage of messages to which the policy is applied (0–100).
166    pub pct: u32,
167
168    /// Failure reporting options (colon-separated list of `0`, `1`, `d`, `s`).
169    #[serde(default)]
170    pub fo: Option<String>,
171}
172
173/// DKIM / SPF alignment mode (`AlignmentType`).
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
175pub enum AlignmentMode {
176    /// Relaxed alignment (default). Organisational domain match is sufficient.
177    #[serde(rename = "r")]
178    Relaxed,
179
180    /// Strict alignment. Exact domain match is required.
181    #[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/// Policy action applied to a message (`DispositionType`).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum Disposition {
198    /// No action taken; the message is delivered normally.
199    None,
200    /// The message is treated as suspicious and may be quarantined.
201    Quarantine,
202    /// The message is rejected.
203    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/// A single message record within a feedback report (`RecordType`).
217#[derive(Debug, Clone, PartialEq, Deserialize)]
218pub struct Record {
219    /// Per-message row data.
220    pub row: Row,
221
222    /// Identifiers extracted from the message.
223    pub identifiers: Identifiers,
224
225    /// Authentication results for the message.
226    pub auth_results: AuthResults,
227}
228
229/// Per-message data row (`RowType`).
230#[derive(Debug, Clone, PartialEq, Deserialize)]
231pub struct Row {
232    /// The IP address of the sending mail server.
233    pub source_ip: String,
234
235    /// The number of messages covered by this row.
236    pub count: u64,
237
238    /// The applied DMARC policy evaluation results.
239    pub policy_evaluated: PolicyEvaluated,
240}
241
242/// Results of applying DMARC to the messages in this row (`PolicyEvaluatedType`).
243#[derive(Debug, Clone, PartialEq, Deserialize)]
244pub struct PolicyEvaluated {
245    /// The final policy action applied.
246    pub disposition: Disposition,
247
248    /// Whether the message passed DKIM alignment.
249    pub dkim: DmarcResult,
250
251    /// Whether the message passed SPF alignment.
252    pub spf: DmarcResult,
253
254    /// Reasons that may have altered the evaluated disposition.
255    #[serde(rename = "reason", default)]
256    pub reasons: Vec<PolicyOverrideReason>,
257}
258
259/// The DMARC-aligned authentication result (`DMARCResultType`).
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
261#[serde(rename_all = "lowercase")]
262pub enum DmarcResult {
263    /// Authentication passed.
264    Pass,
265    /// Authentication failed.
266    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/// A reason why the applied policy may differ from the published policy
279/// (`PolicyOverrideReasonType`).
280#[derive(Debug, Clone, PartialEq, Deserialize)]
281pub struct PolicyOverrideReason {
282    /// The type of policy override.
283    #[serde(rename = "type")]
284    pub reason_type: PolicyOverride,
285
286    /// An optional human-readable comment about the override.
287    #[serde(default)]
288    pub comment: Option<String>,
289}
290
291/// Reason type for a policy override (`PolicyOverrideType`).
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum PolicyOverride {
295    /// Message was forwarded and could not be authenticated.
296    Forwarded,
297    /// Message was sampled out of the policy percentage.
298    SampledOut,
299    /// Message was from a trusted forwarder.
300    TrustedForwarder,
301    /// Message was processed by a mailing list.
302    MailingList,
303    /// Local policy overrode the published DMARC policy.
304    LocalPolicy,
305    /// Some other reason.
306    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/// Message identifiers (`IdentifierType`).
323#[derive(Debug, Clone, PartialEq, Deserialize)]
324pub struct Identifiers {
325    /// The RFC 5321 `RCPT TO` domain, if available.
326    #[serde(default)]
327    pub envelope_to: Option<String>,
328
329    /// The RFC 5321 `MAIL FROM` domain, if available.
330    #[serde(default)]
331    pub envelope_from: Option<String>,
332
333    /// The RFC 5322 `From:` header domain.
334    pub header_from: String,
335}
336
337/// Authentication results for a message (`AuthResultType`).
338#[derive(Debug, Clone, PartialEq, Deserialize)]
339pub struct AuthResults {
340    /// DKIM signature evaluation results (zero or more).
341    #[serde(rename = "dkim", default)]
342    pub dkim: Vec<DkimAuthResult>,
343
344    /// SPF evaluation results (one or more per RFC 7489).
345    #[serde(rename = "spf")]
346    pub spf: Vec<SpfAuthResult>,
347}
348
349/// Result of evaluating a single DKIM signature (`DKIMAuthResultType`).
350#[derive(Debug, Clone, PartialEq, Deserialize)]
351pub struct DkimAuthResult {
352    /// The `d=` domain from the DKIM signature.
353    pub domain: String,
354
355    /// The `s=` selector from the DKIM signature.
356    #[serde(default)]
357    pub selector: Option<String>,
358
359    /// The DKIM verification result.
360    pub result: DkimResult,
361
362    /// A human-readable result string.
363    #[serde(default)]
364    pub human_result: Option<String>,
365}
366
367/// DKIM verification result (`DKIMResultType`), per RFC 5451.
368#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
369#[serde(rename_all = "lowercase")]
370pub enum DkimResult {
371    /// No DKIM signature was found.
372    None,
373    /// The DKIM signature verified successfully.
374    Pass,
375    /// The DKIM signature failed verification.
376    Fail,
377    /// The DKIM signature was rejected for policy reasons.
378    Policy,
379    /// The DKIM verification result was neutral.
380    Neutral,
381    /// A transient error occurred during DKIM verification.
382    Temperror,
383    /// A permanent error occurred during DKIM verification.
384    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/// Result of an SPF check (`SPFAuthResultType`).
402#[derive(Debug, Clone, PartialEq, Deserialize)]
403pub struct SpfAuthResult {
404    /// The domain used for SPF evaluation.
405    pub domain: String,
406
407    /// The identity that was checked (HELO or MAIL FROM).
408    #[serde(default)]
409    pub scope: Option<SpfDomainScope>,
410
411    /// The SPF evaluation result.
412    pub result: SpfResult,
413}
414
415/// SPF identity scope (`SPFDomainScope`).
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
417#[serde(rename_all = "lowercase")]
418pub enum SpfDomainScope {
419    /// The SMTP `HELO`/`EHLO` identity.
420    Helo,
421    /// The SMTP `MAIL FROM` identity.
422    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/// SPF evaluation result (`SPFResultType`), per RFC 7208.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
436#[serde(rename_all = "lowercase")]
437pub enum SpfResult {
438    /// No SPF record was found.
439    None,
440    /// The SPF check returned a neutral result.
441    Neutral,
442    /// The SPF check passed.
443    Pass,
444    /// The SPF check failed.
445    Fail,
446    /// The SPF check returned a soft-fail result.
447    Softfail,
448    /// A transient error occurred during SPF evaluation.
449    Temperror,
450    /// A permanent error occurred during SPF evaluation.
451    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// ──────────────────────────────────────────────────────────────────────────────
469// Aggregate view across multiple reports
470// ──────────────────────────────────────────────────────────────────────────────
471
472/// A combined view across multiple DMARC aggregate reports.
473///
474/// Each underlying [`Report`] retains its own metadata and `policy_published`
475/// — there is intentionally no synthetic merged report, since fields like
476/// `org_name`, `report_id`, and `date_range` cannot be honestly combined.
477/// Use [`Aggregate::records`] to iterate every record paired with the report
478/// it came from.
479#[derive(Debug, Clone, PartialEq)]
480pub struct Aggregate {
481    /// The reports that make up the aggregate, in the order they were added.
482    pub reports: Vec<Report>,
483}
484
485impl Aggregate {
486    /// Build an aggregate from a collection of reports.
487    pub fn from_reports(reports: Vec<Report>) -> Self {
488        Self { reports }
489    }
490
491    /// Iterator over every record across every report, paired with the
492    /// [`Report`] it came from.
493    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    /// Sum of `row.count` across every record.
500    pub fn total_messages(&self) -> u64 {
501        self.records().map(|(_, rec)| rec.row.count).sum()
502    }
503
504    /// Earliest `begin` and latest `end` across all reports' date ranges.
505    /// Returns `None` if the aggregate contains no reports.
506    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
527// ──────────────────────────────────────────────────────────────────────────────
528// Trait implementations for idiomatic Rust usage
529// ──────────────────────────────────────────────────────────────────────────────
530
531impl 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// ──────────────────────────────────────────────────────────────────────────────
556// Unit tests
557// ──────────────────────────────────────────────────────────────────────────────
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    // Minimal valid report — only required fields present
564    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        // metadata
609        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        // policy published
618        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        // records
626        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        // identifiers
636        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        // auth results
644        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        // optional version
714        assert_eq!(report.version, Some("1.0".to_string()));
715
716        // metadata extras
717        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        // policy published optional fields
728        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        // policy override reason
738        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        // identifiers with envelope_to
744        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        // DKIM auth result
754        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        // SPF auth result with scope
765        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    // ──────────────────────────────────────────────────────────────────────────
1312    // Aggregate
1313    // ──────────────────────────────────────────────────────────────────────────
1314
1315    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}