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
12mod error;
13pub use error::Error;
14
15use serde::Deserialize;
16
17fn deserialize_optional_alignment<'de, D>(
18    deserializer: D,
19) -> Result<Option<AlignmentMode>, D::Error>
20where
21    D: serde::Deserializer<'de>,
22{
23    struct Visitor;
24
25    impl<'de> serde::de::Visitor<'de> for Visitor {
26        type Value = Option<AlignmentMode>;
27
28        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29            f.write_str("alignment mode 'r', 's', or empty")
30        }
31
32        fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
33            match v {
34                "" => Ok(None),
35                "r" => Ok(Some(AlignmentMode::Relaxed)),
36                "s" => Ok(Some(AlignmentMode::Strict)),
37                other => Err(E::unknown_variant(other, &["r", "s"])),
38            }
39        }
40
41        fn visit_map<A: serde::de::MapAccess<'de>>(
42            self,
43            mut map: A,
44        ) -> Result<Self::Value, A::Error> {
45            // quick-xml represents <adkim></adkim> as {"$text": ""} rather than a plain string
46            let mut text = String::new();
47            while let Some(key) = map.next_key::<String>()? {
48                let val: String = map.next_value()?;
49                if key == "$text" {
50                    text = val;
51                }
52            }
53            self.visit_str(&text)
54        }
55    }
56
57    deserializer.deserialize_any(Visitor)
58}
59
60// ──────────────────────────────────────────────────────────────────────────────
61// Public API
62// ──────────────────────────────────────────────────────────────────────────────
63
64/// Parse a DMARC aggregate report from an XML string.
65///
66/// # Errors
67///
68/// Returns [`Error::Parse`] if the XML is invalid or does not conform to the
69/// DMARC aggregate report schema (RFC 7489 Appendix C).
70pub fn parse(xml: &str) -> Result<Report, Error> {
71    quick_xml::de::from_str(xml).map_err(Error::from)
72}
73
74/// Parse a DMARC aggregate report from a byte slice.
75///
76/// # Errors
77///
78/// Returns [`Error::Utf8`] if the bytes are not valid UTF-8, or
79/// [`Error::Parse`] if the XML is invalid or non-conformant.
80pub fn parse_bytes(bytes: &[u8]) -> Result<Report, Error> {
81    let xml = std::str::from_utf8(bytes)?;
82    parse(xml)
83}
84
85// ──────────────────────────────────────────────────────────────────────────────
86// RFC 7489 Appendix C — DMARC XML schema types
87// ──────────────────────────────────────────────────────────────────────────────
88
89/// Top-level DMARC aggregate feedback report (`<feedback>`).
90///
91/// Defined as the root element in RFC 7489 Appendix C.
92#[derive(Debug, Clone, PartialEq, Deserialize)]
93#[serde(rename = "feedback")]
94pub struct Report {
95    /// Report format version (optional, xs:decimal).
96    #[serde(default)]
97    pub version: Option<String>,
98
99    /// Metadata about the report generator.
100    pub report_metadata: ReportMetadata,
101
102    /// The DMARC policy published for the domain covered by this report.
103    pub policy_published: PolicyPublished,
104
105    /// One or more individual message records.
106    #[serde(rename = "record")]
107    pub records: Vec<Record>,
108}
109
110/// Report generator metadata (`ReportMetadataType`).
111#[derive(Debug, Clone, PartialEq, Deserialize)]
112pub struct ReportMetadata {
113    /// The name of the organization generating the report.
114    pub org_name: String,
115
116    /// Contact email address for the report generator.
117    pub email: String,
118
119    /// Additional contact information (optional).
120    #[serde(default)]
121    pub extra_contact_info: Option<String>,
122
123    /// Unique identifier for this report.
124    pub report_id: String,
125
126    /// The UTC time range covered by the messages in this report.
127    pub date_range: DateRange,
128
129    /// Any errors encountered during report generation.
130    #[serde(rename = "error", default)]
131    pub errors: Vec<String>,
132}
133
134/// UTC time range covered by a report, expressed as Unix timestamps.
135#[derive(Debug, Clone, PartialEq, Deserialize)]
136pub struct DateRange {
137    /// Start of the time range (seconds since Unix epoch).
138    pub begin: i64,
139
140    /// End of the time range (seconds since Unix epoch).
141    pub end: i64,
142}
143
144/// The DMARC policy published for the organizational domain (`PolicyPublishedType`).
145#[derive(Debug, Clone, PartialEq, Deserialize)]
146pub struct PolicyPublished {
147    /// The domain to which the DMARC policy applies.
148    pub domain: String,
149
150    /// DKIM alignment mode (`r` = relaxed, `s` = strict). Defaults to relaxed when absent.
151    #[serde(default, deserialize_with = "deserialize_optional_alignment")]
152    pub adkim: Option<AlignmentMode>,
153
154    /// SPF alignment mode (`r` = relaxed, `s` = strict). Defaults to relaxed when absent.
155    #[serde(default, deserialize_with = "deserialize_optional_alignment")]
156    pub aspf: Option<AlignmentMode>,
157
158    /// Domain-level policy action.
159    pub p: Disposition,
160
161    /// Subdomain policy action.
162    pub sp: Disposition,
163
164    /// Percentage of messages to which the policy is applied (0–100).
165    pub pct: u32,
166
167    /// Failure reporting options (colon-separated list of `0`, `1`, `d`, `s`).
168    #[serde(default)]
169    pub fo: Option<String>,
170}
171
172/// DKIM / SPF alignment mode (`AlignmentType`).
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
174pub enum AlignmentMode {
175    /// Relaxed alignment (default). Organisational domain match is sufficient.
176    #[serde(rename = "r")]
177    Relaxed,
178
179    /// Strict alignment. Exact domain match is required.
180    #[serde(rename = "s")]
181    Strict,
182}
183
184impl std::fmt::Display for AlignmentMode {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        match self {
187            AlignmentMode::Relaxed => f.write_str("r"),
188            AlignmentMode::Strict => f.write_str("s"),
189        }
190    }
191}
192
193/// Policy action applied to a message (`DispositionType`).
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum Disposition {
197    /// No action taken; the message is delivered normally.
198    None,
199    /// The message is treated as suspicious and may be quarantined.
200    Quarantine,
201    /// The message is rejected.
202    Reject,
203}
204
205impl std::fmt::Display for Disposition {
206    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        match self {
208            Disposition::None => f.write_str("none"),
209            Disposition::Quarantine => f.write_str("quarantine"),
210            Disposition::Reject => f.write_str("reject"),
211        }
212    }
213}
214
215/// A single message record within a feedback report (`RecordType`).
216#[derive(Debug, Clone, PartialEq, Deserialize)]
217pub struct Record {
218    /// Per-message row data.
219    pub row: Row,
220
221    /// Identifiers extracted from the message.
222    pub identifiers: Identifiers,
223
224    /// Authentication results for the message.
225    pub auth_results: AuthResults,
226}
227
228/// Per-message data row (`RowType`).
229#[derive(Debug, Clone, PartialEq, Deserialize)]
230pub struct Row {
231    /// The IP address of the sending mail server.
232    pub source_ip: String,
233
234    /// The number of messages covered by this row.
235    pub count: u64,
236
237    /// The applied DMARC policy evaluation results.
238    pub policy_evaluated: PolicyEvaluated,
239}
240
241/// Results of applying DMARC to the messages in this row (`PolicyEvaluatedType`).
242#[derive(Debug, Clone, PartialEq, Deserialize)]
243pub struct PolicyEvaluated {
244    /// The final policy action applied.
245    pub disposition: Disposition,
246
247    /// Whether the message passed DKIM alignment.
248    pub dkim: DmarcResult,
249
250    /// Whether the message passed SPF alignment.
251    pub spf: DmarcResult,
252
253    /// Reasons that may have altered the evaluated disposition.
254    #[serde(rename = "reason", default)]
255    pub reasons: Vec<PolicyOverrideReason>,
256}
257
258/// The DMARC-aligned authentication result (`DMARCResultType`).
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
260#[serde(rename_all = "lowercase")]
261pub enum DmarcResult {
262    /// Authentication passed.
263    Pass,
264    /// Authentication failed.
265    Fail,
266}
267
268impl std::fmt::Display for DmarcResult {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        match self {
271            DmarcResult::Pass => f.write_str("pass"),
272            DmarcResult::Fail => f.write_str("fail"),
273        }
274    }
275}
276
277/// A reason why the applied policy may differ from the published policy
278/// (`PolicyOverrideReasonType`).
279#[derive(Debug, Clone, PartialEq, Deserialize)]
280pub struct PolicyOverrideReason {
281    /// The type of policy override.
282    #[serde(rename = "type")]
283    pub reason_type: PolicyOverride,
284
285    /// An optional human-readable comment about the override.
286    #[serde(default)]
287    pub comment: Option<String>,
288}
289
290/// Reason type for a policy override (`PolicyOverrideType`).
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum PolicyOverride {
294    /// Message was forwarded and could not be authenticated.
295    Forwarded,
296    /// Message was sampled out of the policy percentage.
297    SampledOut,
298    /// Message was from a trusted forwarder.
299    TrustedForwarder,
300    /// Message was processed by a mailing list.
301    MailingList,
302    /// Local policy overrode the published DMARC policy.
303    LocalPolicy,
304    /// Some other reason.
305    Other,
306}
307
308impl std::fmt::Display for PolicyOverride {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        match self {
311            PolicyOverride::Forwarded => f.write_str("forwarded"),
312            PolicyOverride::SampledOut => f.write_str("sampled_out"),
313            PolicyOverride::TrustedForwarder => f.write_str("trusted_forwarder"),
314            PolicyOverride::MailingList => f.write_str("mailing_list"),
315            PolicyOverride::LocalPolicy => f.write_str("local_policy"),
316            PolicyOverride::Other => f.write_str("other"),
317        }
318    }
319}
320
321/// Message identifiers (`IdentifierType`).
322#[derive(Debug, Clone, PartialEq, Deserialize)]
323pub struct Identifiers {
324    /// The RFC 5321 `RCPT TO` domain, if available.
325    #[serde(default)]
326    pub envelope_to: Option<String>,
327
328    /// The RFC 5321 `MAIL FROM` domain, if available.
329    #[serde(default)]
330    pub envelope_from: Option<String>,
331
332    /// The RFC 5322 `From:` header domain.
333    pub header_from: String,
334}
335
336/// Authentication results for a message (`AuthResultType`).
337#[derive(Debug, Clone, PartialEq, Deserialize)]
338pub struct AuthResults {
339    /// DKIM signature evaluation results (zero or more).
340    #[serde(rename = "dkim", default)]
341    pub dkim: Vec<DkimAuthResult>,
342
343    /// SPF evaluation results (one or more per RFC 7489).
344    #[serde(rename = "spf")]
345    pub spf: Vec<SpfAuthResult>,
346}
347
348/// Result of evaluating a single DKIM signature (`DKIMAuthResultType`).
349#[derive(Debug, Clone, PartialEq, Deserialize)]
350pub struct DkimAuthResult {
351    /// The `d=` domain from the DKIM signature.
352    pub domain: String,
353
354    /// The `s=` selector from the DKIM signature.
355    #[serde(default)]
356    pub selector: Option<String>,
357
358    /// The DKIM verification result.
359    pub result: DkimResult,
360
361    /// A human-readable result string.
362    #[serde(default)]
363    pub human_result: Option<String>,
364}
365
366/// DKIM verification result (`DKIMResultType`), per RFC 5451.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
368#[serde(rename_all = "lowercase")]
369pub enum DkimResult {
370    /// No DKIM signature was found.
371    None,
372    /// The DKIM signature verified successfully.
373    Pass,
374    /// The DKIM signature failed verification.
375    Fail,
376    /// The DKIM signature was rejected for policy reasons.
377    Policy,
378    /// The DKIM verification result was neutral.
379    Neutral,
380    /// A transient error occurred during DKIM verification.
381    Temperror,
382    /// A permanent error occurred during DKIM verification.
383    Permerror,
384}
385
386impl std::fmt::Display for DkimResult {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        match self {
389            DkimResult::None => f.write_str("none"),
390            DkimResult::Pass => f.write_str("pass"),
391            DkimResult::Fail => f.write_str("fail"),
392            DkimResult::Policy => f.write_str("policy"),
393            DkimResult::Neutral => f.write_str("neutral"),
394            DkimResult::Temperror => f.write_str("temperror"),
395            DkimResult::Permerror => f.write_str("permerror"),
396        }
397    }
398}
399
400/// Result of an SPF check (`SPFAuthResultType`).
401#[derive(Debug, Clone, PartialEq, Deserialize)]
402pub struct SpfAuthResult {
403    /// The domain used for SPF evaluation.
404    pub domain: String,
405
406    /// The identity that was checked (HELO or MAIL FROM).
407    #[serde(default)]
408    pub scope: Option<SpfDomainScope>,
409
410    /// The SPF evaluation result.
411    pub result: SpfResult,
412}
413
414/// SPF identity scope (`SPFDomainScope`).
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
416#[serde(rename_all = "lowercase")]
417pub enum SpfDomainScope {
418    /// The SMTP `HELO`/`EHLO` identity.
419    Helo,
420    /// The SMTP `MAIL FROM` identity.
421    Mfrom,
422}
423
424impl std::fmt::Display for SpfDomainScope {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        match self {
427            SpfDomainScope::Helo => f.write_str("helo"),
428            SpfDomainScope::Mfrom => f.write_str("mfrom"),
429        }
430    }
431}
432
433/// SPF evaluation result (`SPFResultType`), per RFC 7208.
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
435#[serde(rename_all = "lowercase")]
436pub enum SpfResult {
437    /// No SPF record was found.
438    None,
439    /// The SPF check returned a neutral result.
440    Neutral,
441    /// The SPF check passed.
442    Pass,
443    /// The SPF check failed.
444    Fail,
445    /// The SPF check returned a soft-fail result.
446    Softfail,
447    /// A transient error occurred during SPF evaluation.
448    Temperror,
449    /// A permanent error occurred during SPF evaluation.
450    Permerror,
451}
452
453impl std::fmt::Display for SpfResult {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        match self {
456            SpfResult::None => f.write_str("none"),
457            SpfResult::Neutral => f.write_str("neutral"),
458            SpfResult::Pass => f.write_str("pass"),
459            SpfResult::Fail => f.write_str("fail"),
460            SpfResult::Softfail => f.write_str("softfail"),
461            SpfResult::Temperror => f.write_str("temperror"),
462            SpfResult::Permerror => f.write_str("permerror"),
463        }
464    }
465}
466
467// ──────────────────────────────────────────────────────────────────────────────
468// Aggregate view across multiple reports
469// ──────────────────────────────────────────────────────────────────────────────
470
471/// A combined view across multiple DMARC aggregate reports.
472///
473/// Each underlying [`Report`] retains its own metadata and `policy_published`
474/// — there is intentionally no synthetic merged report, since fields like
475/// `org_name`, `report_id`, and `date_range` cannot be honestly combined.
476/// Use [`Aggregate::records`] to iterate every record paired with the report
477/// it came from.
478#[derive(Debug, Clone, PartialEq)]
479pub struct Aggregate {
480    /// The reports that make up the aggregate, in the order they were added.
481    pub reports: Vec<Report>,
482}
483
484impl Aggregate {
485    /// Build an aggregate from a collection of reports.
486    pub fn from_reports(reports: Vec<Report>) -> Self {
487        Self { reports }
488    }
489
490    /// Iterator over every record across every report, paired with the
491    /// [`Report`] it came from.
492    pub fn records(&self) -> impl Iterator<Item = (&Report, &Record)> {
493        self.reports
494            .iter()
495            .flat_map(|r| r.records.iter().map(move |rec| (r, rec)))
496    }
497
498    /// Sum of `row.count` across every record.
499    pub fn total_messages(&self) -> u64 {
500        self.records().map(|(_, rec)| rec.row.count).sum()
501    }
502
503    /// Earliest `begin` and latest `end` across all reports' date ranges.
504    /// Returns `None` if the aggregate contains no reports.
505    pub fn date_span(&self) -> Option<(i64, i64)> {
506        let begin = self
507            .reports
508            .iter()
509            .map(|r| r.report_metadata.date_range.begin)
510            .min()?;
511        let end = self
512            .reports
513            .iter()
514            .map(|r| r.report_metadata.date_range.end)
515            .max()?;
516        Some((begin, end))
517    }
518}
519
520impl From<Vec<Report>> for Aggregate {
521    fn from(reports: Vec<Report>) -> Self {
522        Self::from_reports(reports)
523    }
524}
525
526// ──────────────────────────────────────────────────────────────────────────────
527// Trait implementations for idiomatic Rust usage
528// ──────────────────────────────────────────────────────────────────────────────
529
530impl std::str::FromStr for Report {
531    type Err = Error;
532
533    fn from_str(s: &str) -> Result<Self, Self::Err> {
534        parse(s)
535    }
536}
537
538impl TryFrom<&str> for Report {
539    type Error = Error;
540
541    fn try_from(s: &str) -> Result<Self, Self::Error> {
542        parse(s)
543    }
544}
545
546impl TryFrom<&[u8]> for Report {
547    type Error = Error;
548
549    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
550        parse_bytes(bytes)
551    }
552}
553
554// ──────────────────────────────────────────────────────────────────────────────
555// Unit tests
556// ──────────────────────────────────────────────────────────────────────────────
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    // Minimal valid report — only required fields present
563    const MINIMAL_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
564<feedback>
565  <report_metadata>
566    <org_name>Acme</org_name>
567    <email>postmaster@acme.example</email>
568    <report_id>20130901.r.acme.example</report_id>
569    <date_range>
570      <begin>1377993600</begin>
571      <end>1378080000</end>
572    </date_range>
573  </report_metadata>
574  <policy_published>
575    <domain>acme.example</domain>
576    <p>none</p>
577    <sp>none</sp>
578    <pct>100</pct>
579  </policy_published>
580  <record>
581    <row>
582      <source_ip>192.0.2.1</source_ip>
583      <count>2</count>
584      <policy_evaluated>
585        <disposition>none</disposition>
586        <dkim>pass</dkim>
587        <spf>pass</spf>
588      </policy_evaluated>
589    </row>
590    <identifiers>
591      <envelope_from>acme.example</envelope_from>
592      <header_from>acme.example</header_from>
593    </identifiers>
594    <auth_results>
595      <spf>
596        <domain>acme.example</domain>
597        <result>pass</result>
598      </spf>
599    </auth_results>
600  </record>
601</feedback>"#;
602
603    #[test]
604    fn parse_minimal_report() {
605        let report = parse(MINIMAL_XML).unwrap();
606
607        // metadata
608        assert_eq!(report.report_metadata.org_name, "Acme");
609        assert_eq!(report.report_metadata.email, "postmaster@acme.example");
610        assert_eq!(report.report_metadata.report_id, "20130901.r.acme.example");
611        assert_eq!(report.report_metadata.date_range.begin, 1_377_993_600);
612        assert_eq!(report.report_metadata.date_range.end, 1_378_080_000);
613        assert!(report.report_metadata.extra_contact_info.is_none());
614        assert!(report.report_metadata.errors.is_empty());
615
616        // policy published
617        assert_eq!(report.policy_published.domain, "acme.example");
618        assert_eq!(report.policy_published.p, Disposition::None);
619        assert_eq!(report.policy_published.sp, Disposition::None);
620        assert_eq!(report.policy_published.pct, 100);
621        assert!(report.policy_published.adkim.is_none());
622        assert!(report.policy_published.aspf.is_none());
623
624        // records
625        assert_eq!(report.records.len(), 1);
626        let record = &report.records[0];
627        assert_eq!(record.row.source_ip, "192.0.2.1");
628        assert_eq!(record.row.count, 2);
629        assert_eq!(record.row.policy_evaluated.disposition, Disposition::None);
630        assert_eq!(record.row.policy_evaluated.dkim, DmarcResult::Pass);
631        assert_eq!(record.row.policy_evaluated.spf, DmarcResult::Pass);
632        assert!(record.row.policy_evaluated.reasons.is_empty());
633
634        // identifiers
635        assert!(record.identifiers.envelope_to.is_none());
636        assert_eq!(
637            record.identifiers.envelope_from.as_deref(),
638            Some("acme.example")
639        );
640        assert_eq!(record.identifiers.header_from, "acme.example");
641
642        // auth results
643        assert!(record.auth_results.dkim.is_empty());
644        assert_eq!(record.auth_results.spf.len(), 1);
645        assert_eq!(record.auth_results.spf[0].domain, "acme.example");
646        assert_eq!(record.auth_results.spf[0].result, SpfResult::Pass);
647    }
648
649    #[test]
650    fn parse_full_report_all_optional_fields() {
651        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
652<feedback>
653  <version>1.0</version>
654  <report_metadata>
655    <org_name>Mail Service Provider</org_name>
656    <email>dmarc-reports@msp.example</email>
657    <extra_contact_info>https://msp.example/dmarc-info</extra_contact_info>
658    <report_id>report-001</report_id>
659    <date_range>
660      <begin>1609459200</begin>
661      <end>1609545600</end>
662    </date_range>
663    <error>Lookup for example.com failed transiently</error>
664    <error>DNS timeout for subdomain.example.com</error>
665  </report_metadata>
666  <policy_published>
667    <domain>example.com</domain>
668    <adkim>s</adkim>
669    <aspf>s</aspf>
670    <p>reject</p>
671    <sp>quarantine</sp>
672    <pct>50</pct>
673    <fo>1</fo>
674  </policy_published>
675  <record>
676    <row>
677      <source_ip>198.51.100.42</source_ip>
678      <count>10</count>
679      <policy_evaluated>
680        <disposition>reject</disposition>
681        <dkim>fail</dkim>
682        <spf>fail</spf>
683        <reason>
684          <type>forwarded</type>
685          <comment>Known forwarder</comment>
686        </reason>
687      </policy_evaluated>
688    </row>
689    <identifiers>
690      <envelope_to>example.com</envelope_to>
691      <envelope_from>sender.example</envelope_from>
692      <header_from>example.com</header_from>
693    </identifiers>
694    <auth_results>
695      <dkim>
696        <domain>example.com</domain>
697        <selector>selector1</selector>
698        <result>fail</result>
699        <human_result>signature did not verify</human_result>
700      </dkim>
701      <spf>
702        <domain>sender.example</domain>
703        <scope>mfrom</scope>
704        <result>fail</result>
705      </spf>
706    </auth_results>
707  </record>
708</feedback>"#;
709
710        let report = parse(xml).unwrap();
711
712        // optional version
713        assert_eq!(report.version, Some("1.0".to_string()));
714
715        // metadata extras
716        assert_eq!(
717            report.report_metadata.extra_contact_info,
718            Some("https://msp.example/dmarc-info".to_string())
719        );
720        assert_eq!(report.report_metadata.errors.len(), 2);
721        assert_eq!(
722            report.report_metadata.errors[0],
723            "Lookup for example.com failed transiently"
724        );
725
726        // policy published optional fields
727        assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
728        assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
729        assert_eq!(report.policy_published.p, Disposition::Reject);
730        assert_eq!(report.policy_published.sp, Disposition::Quarantine);
731        assert_eq!(report.policy_published.pct, 50);
732        assert_eq!(report.policy_published.fo, Some("1".to_string()));
733
734        let record = &report.records[0];
735
736        // policy override reason
737        assert_eq!(record.row.policy_evaluated.reasons.len(), 1);
738        let reason = &record.row.policy_evaluated.reasons[0];
739        assert_eq!(reason.reason_type, PolicyOverride::Forwarded);
740        assert_eq!(reason.comment, Some("Known forwarder".to_string()));
741
742        // identifiers with envelope_to
743        assert_eq!(
744            record.identifiers.envelope_to,
745            Some("example.com".to_string())
746        );
747        assert_eq!(
748            record.identifiers.envelope_from.as_deref(),
749            Some("sender.example")
750        );
751
752        // DKIM auth result
753        assert_eq!(record.auth_results.dkim.len(), 1);
754        let dkim = &record.auth_results.dkim[0];
755        assert_eq!(dkim.domain, "example.com");
756        assert_eq!(dkim.selector, Some("selector1".to_string()));
757        assert_eq!(dkim.result, DkimResult::Fail);
758        assert_eq!(
759            dkim.human_result,
760            Some("signature did not verify".to_string())
761        );
762
763        // SPF auth result with scope
764        assert_eq!(
765            record.auth_results.spf[0].scope,
766            Some(SpfDomainScope::Mfrom)
767        );
768        assert_eq!(record.auth_results.spf[0].result, SpfResult::Fail);
769    }
770
771    #[test]
772    fn parse_multiple_records() {
773        let xml = r#"<?xml version="1.0"?>
774<feedback>
775  <report_metadata>
776    <org_name>Reporter</org_name>
777    <email>r@reporter.example</email>
778    <report_id>multi-001</report_id>
779    <date_range><begin>0</begin><end>86400</end></date_range>
780  </report_metadata>
781  <policy_published>
782    <domain>sender.example</domain>
783    <p>quarantine</p>
784    <sp>quarantine</sp>
785    <pct>100</pct>
786  </policy_published>
787  <record>
788    <row>
789      <source_ip>192.0.2.1</source_ip>
790      <count>1</count>
791      <policy_evaluated>
792        <disposition>none</disposition>
793        <dkim>pass</dkim>
794        <spf>pass</spf>
795      </policy_evaluated>
796    </row>
797    <identifiers>
798      <envelope_from>sender.example</envelope_from>
799      <header_from>sender.example</header_from>
800    </identifiers>
801    <auth_results>
802      <spf>
803        <domain>sender.example</domain>
804        <result>pass</result>
805      </spf>
806    </auth_results>
807  </record>
808  <record>
809    <row>
810      <source_ip>203.0.113.7</source_ip>
811      <count>3</count>
812      <policy_evaluated>
813        <disposition>quarantine</disposition>
814        <dkim>fail</dkim>
815        <spf>fail</spf>
816      </policy_evaluated>
817    </row>
818    <identifiers>
819      <envelope_from>attacker.example</envelope_from>
820      <header_from>sender.example</header_from>
821    </identifiers>
822    <auth_results>
823      <spf>
824        <domain>attacker.example</domain>
825        <result>fail</result>
826      </spf>
827    </auth_results>
828  </record>
829</feedback>"#;
830
831        let report = parse(xml).unwrap();
832
833        assert_eq!(report.records.len(), 2);
834        assert_eq!(report.records[0].row.source_ip, "192.0.2.1");
835        assert_eq!(report.records[0].row.count, 1);
836        assert_eq!(
837            report.records[0].row.policy_evaluated.disposition,
838            Disposition::None
839        );
840
841        assert_eq!(report.records[1].row.source_ip, "203.0.113.7");
842        assert_eq!(report.records[1].row.count, 3);
843        assert_eq!(
844            report.records[1].row.policy_evaluated.disposition,
845            Disposition::Quarantine
846        );
847        assert_eq!(
848            report.records[1].row.policy_evaluated.dkim,
849            DmarcResult::Fail
850        );
851    }
852
853    #[test]
854    fn parse_multiple_dkim_spf_auth_results() {
855        let xml = r#"<?xml version="1.0"?>
856<feedback>
857  <report_metadata>
858    <org_name>Reporter</org_name>
859    <email>r@reporter.example</email>
860    <report_id>multi-auth-001</report_id>
861    <date_range><begin>0</begin><end>86400</end></date_range>
862  </report_metadata>
863  <policy_published>
864    <domain>example.com</domain>
865    <p>none</p>
866    <sp>none</sp>
867    <pct>100</pct>
868  </policy_published>
869  <record>
870    <row>
871      <source_ip>192.0.2.1</source_ip>
872      <count>1</count>
873      <policy_evaluated>
874        <disposition>none</disposition>
875        <dkim>pass</dkim>
876        <spf>pass</spf>
877      </policy_evaluated>
878    </row>
879    <identifiers>
880      <envelope_from>example.com</envelope_from>
881      <header_from>example.com</header_from>
882    </identifiers>
883    <auth_results>
884      <dkim>
885        <domain>example.com</domain>
886        <selector>key1</selector>
887        <result>pass</result>
888      </dkim>
889      <dkim>
890        <domain>example.com</domain>
891        <selector>key2</selector>
892        <result>fail</result>
893      </dkim>
894      <spf>
895        <domain>example.com</domain>
896        <scope>helo</scope>
897        <result>pass</result>
898      </spf>
899      <spf>
900        <domain>example.com</domain>
901        <scope>mfrom</scope>
902        <result>pass</result>
903      </spf>
904    </auth_results>
905  </record>
906</feedback>"#;
907
908        let report = parse(xml).unwrap();
909        let auth = &report.records[0].auth_results;
910
911        assert_eq!(auth.dkim.len(), 2);
912        assert_eq!(auth.dkim[0].selector, Some("key1".to_string()));
913        assert_eq!(auth.dkim[0].result, DkimResult::Pass);
914        assert_eq!(auth.dkim[1].selector, Some("key2".to_string()));
915        assert_eq!(auth.dkim[1].result, DkimResult::Fail);
916
917        assert_eq!(auth.spf.len(), 2);
918        assert_eq!(auth.spf[0].scope, Some(SpfDomainScope::Helo));
919        assert_eq!(auth.spf[1].scope, Some(SpfDomainScope::Mfrom));
920    }
921
922    #[test]
923    fn parse_alignment_modes() {
924        let xml_relaxed = r#"<?xml version="1.0"?>
925<feedback>
926  <report_metadata>
927    <org_name>R</org_name><email>r@r.example</email>
928    <report_id>r1</report_id>
929    <date_range><begin>0</begin><end>1</end></date_range>
930  </report_metadata>
931  <policy_published>
932    <domain>example.com</domain>
933    <adkim>r</adkim>
934    <aspf>r</aspf>
935    <p>none</p><sp>none</sp><pct>100</pct>
936  </policy_published>
937  <record>
938    <row><source_ip>192.0.2.1</source_ip><count>1</count>
939      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
940    </row>
941    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
942    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
943  </record>
944</feedback>"#;
945
946        let report = parse(xml_relaxed).unwrap();
947        assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Relaxed));
948        assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Relaxed));
949
950        let xml_strict = xml_relaxed
951            .replace("<adkim>r</adkim>", "<adkim>s</adkim>")
952            .replace("<aspf>r</aspf>", "<aspf>s</aspf>");
953
954        let report = parse(&xml_strict).unwrap();
955        assert_eq!(report.policy_published.adkim, Some(AlignmentMode::Strict));
956        assert_eq!(report.policy_published.aspf, Some(AlignmentMode::Strict));
957    }
958
959    #[test]
960    fn parse_empty_alignment_modes() {
961        let xml = r#"<?xml version="1.0"?>
962<feedback>
963  <report_metadata>
964    <org_name>R</org_name><email>r@r.example</email>
965    <report_id>r1</report_id>
966    <date_range><begin>0</begin><end>1</end></date_range>
967  </report_metadata>
968  <policy_published>
969    <domain>example.com</domain>
970    <adkim></adkim>
971    <aspf></aspf>
972    <p>none</p><sp>none</sp><pct>100</pct>
973  </policy_published>
974  <record>
975    <row><source_ip>192.0.2.1</source_ip><count>1</count>
976      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
977    </row>
978    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
979    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
980  </record>
981</feedback>"#;
982
983        let report = parse(xml).unwrap();
984        assert!(report.policy_published.adkim.is_none());
985        assert!(report.policy_published.aspf.is_none());
986    }
987
988    #[test]
989    fn parse_all_dkim_results() {
990        let results = [
991            ("none", DkimResult::None),
992            ("pass", DkimResult::Pass),
993            ("fail", DkimResult::Fail),
994            ("policy", DkimResult::Policy),
995            ("neutral", DkimResult::Neutral),
996            ("temperror", DkimResult::Temperror),
997            ("permerror", DkimResult::Permerror),
998        ];
999
1000        for (s, expected) in results {
1001            let xml = format!(
1002                r#"<?xml version="1.0"?>
1003<feedback>
1004  <report_metadata>
1005    <org_name>R</org_name><email>r@r.example</email>
1006    <report_id>r1</report_id>
1007    <date_range><begin>0</begin><end>1</end></date_range>
1008  </report_metadata>
1009  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1010  <record>
1011    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1012      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1013    </row>
1014    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1015    <auth_results>
1016      <dkim><domain>example.com</domain><result>{s}</result></dkim>
1017      <spf><domain>example.com</domain><result>pass</result></spf>
1018    </auth_results>
1019  </record>
1020</feedback>"#
1021            );
1022            let report = parse(&xml).unwrap();
1023            assert_eq!(
1024                report.records[0].auth_results.dkim[0].result, expected,
1025                "failed for DKIM result '{s}'"
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn parse_all_spf_results() {
1032        let results = [
1033            ("none", SpfResult::None),
1034            ("neutral", SpfResult::Neutral),
1035            ("pass", SpfResult::Pass),
1036            ("fail", SpfResult::Fail),
1037            ("softfail", SpfResult::Softfail),
1038            ("temperror", SpfResult::Temperror),
1039            ("permerror", SpfResult::Permerror),
1040        ];
1041
1042        for (s, expected) in results {
1043            let xml = format!(
1044                r#"<?xml version="1.0"?>
1045<feedback>
1046  <report_metadata>
1047    <org_name>R</org_name><email>r@r.example</email>
1048    <report_id>r1</report_id>
1049    <date_range><begin>0</begin><end>1</end></date_range>
1050  </report_metadata>
1051  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1052  <record>
1053    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1054      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1055    </row>
1056    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1057    <auth_results>
1058      <spf><domain>example.com</domain><result>{s}</result></spf>
1059    </auth_results>
1060  </record>
1061</feedback>"#
1062            );
1063            let report = parse(&xml).unwrap();
1064            assert_eq!(
1065                report.records[0].auth_results.spf[0].result, expected,
1066                "failed for SPF result '{s}'"
1067            );
1068        }
1069    }
1070
1071    #[test]
1072    fn parse_all_policy_overrides() {
1073        let overrides = [
1074            ("forwarded", PolicyOverride::Forwarded),
1075            ("sampled_out", PolicyOverride::SampledOut),
1076            ("trusted_forwarder", PolicyOverride::TrustedForwarder),
1077            ("mailing_list", PolicyOverride::MailingList),
1078            ("local_policy", PolicyOverride::LocalPolicy),
1079            ("other", PolicyOverride::Other),
1080        ];
1081
1082        for (s, expected) in overrides {
1083            let xml = format!(
1084                r#"<?xml version="1.0"?>
1085<feedback>
1086  <report_metadata>
1087    <org_name>R</org_name><email>r@r.example</email>
1088    <report_id>r1</report_id>
1089    <date_range><begin>0</begin><end>1</end></date_range>
1090  </report_metadata>
1091  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1092  <record>
1093    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1094      <policy_evaluated>
1095        <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
1096        <reason><type>{s}</type></reason>
1097      </policy_evaluated>
1098    </row>
1099    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1100    <auth_results>
1101      <spf><domain>example.com</domain><result>pass</result></spf>
1102    </auth_results>
1103  </record>
1104</feedback>"#
1105            );
1106            let report = parse(&xml).unwrap();
1107            assert_eq!(
1108                report.records[0].row.policy_evaluated.reasons[0].reason_type, expected,
1109                "failed for policy override '{s}'"
1110            );
1111        }
1112    }
1113
1114    #[test]
1115    fn parse_all_dispositions() {
1116        for (s, expected) in [
1117            ("none", Disposition::None),
1118            ("quarantine", Disposition::Quarantine),
1119            ("reject", Disposition::Reject),
1120        ] {
1121            let xml = format!(
1122                r#"<?xml version="1.0"?>
1123<feedback>
1124  <report_metadata>
1125    <org_name>R</org_name><email>r@r.example</email>
1126    <report_id>r1</report_id>
1127    <date_range><begin>0</begin><end>1</end></date_range>
1128  </report_metadata>
1129  <policy_published><domain>example.com</domain><p>{s}</p><sp>{s}</sp><pct>100</pct></policy_published>
1130  <record>
1131    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1132      <policy_evaluated><disposition>{s}</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1133    </row>
1134    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1135    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1136  </record>
1137</feedback>"#
1138            );
1139            let report = parse(&xml).unwrap();
1140            assert_eq!(report.policy_published.p, expected, "failed for '{s}'");
1141            assert_eq!(
1142                report.records[0].row.policy_evaluated.disposition, expected,
1143                "failed for '{s}'"
1144            );
1145        }
1146    }
1147
1148    #[test]
1149    fn parse_multiple_policy_override_reasons() {
1150        let xml = r#"<?xml version="1.0"?>
1151<feedback>
1152  <report_metadata>
1153    <org_name>R</org_name><email>r@r.example</email>
1154    <report_id>r1</report_id>
1155    <date_range><begin>0</begin><end>1</end></date_range>
1156  </report_metadata>
1157  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1158  <record>
1159    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1160      <policy_evaluated>
1161        <disposition>none</disposition><dkim>pass</dkim><spf>pass</spf>
1162        <reason><type>forwarded</type><comment>via list</comment></reason>
1163        <reason><type>mailing_list</type></reason>
1164      </policy_evaluated>
1165    </row>
1166    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1167    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1168  </record>
1169</feedback>"#;
1170
1171        let report = parse(xml).unwrap();
1172        let reasons = &report.records[0].row.policy_evaluated.reasons;
1173        assert_eq!(reasons.len(), 2);
1174        assert_eq!(reasons[0].reason_type, PolicyOverride::Forwarded);
1175        assert_eq!(reasons[0].comment, Some("via list".to_string()));
1176        assert_eq!(reasons[1].reason_type, PolicyOverride::MailingList);
1177        assert!(reasons[1].comment.is_none());
1178    }
1179
1180    #[test]
1181    fn parse_ipv6_source_ip() {
1182        let xml = r#"<?xml version="1.0"?>
1183<feedback>
1184  <report_metadata>
1185    <org_name>R</org_name><email>r@r.example</email>
1186    <report_id>r1</report_id>
1187    <date_range><begin>0</begin><end>1</end></date_range>
1188  </report_metadata>
1189  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1190  <record>
1191    <row><source_ip>2001:db8::1</source_ip><count>1</count>
1192      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1193    </row>
1194    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1195    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1196  </record>
1197</feedback>"#;
1198
1199        let report = parse(xml).unwrap();
1200        assert_eq!(report.records[0].row.source_ip, "2001:db8::1");
1201    }
1202
1203    #[test]
1204    fn parse_missing_envelope_from() {
1205        let xml = r#"<?xml version="1.0"?>
1206<feedback>
1207  <report_metadata>
1208    <org_name>R</org_name><email>r@r.example</email>
1209    <report_id>r1</report_id>
1210    <date_range><begin>0</begin><end>1</end></date_range>
1211  </report_metadata>
1212  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1213  <record>
1214    <row><source_ip>192.0.2.1</source_ip><count>1</count>
1215      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1216    </row>
1217    <identifiers><header_from>example.com</header_from></identifiers>
1218    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1219  </record>
1220</feedback>"#;
1221
1222        let report = parse(xml).unwrap();
1223        assert!(report.records[0].identifiers.envelope_from.is_none());
1224        assert_eq!(report.records[0].identifiers.header_from, "example.com");
1225    }
1226
1227    #[test]
1228    fn from_str_trait() {
1229        let report: Report = MINIMAL_XML.parse().unwrap();
1230        assert_eq!(report.report_metadata.org_name, "Acme");
1231    }
1232
1233    #[test]
1234    fn try_from_str_trait() {
1235        let report = Report::try_from(MINIMAL_XML).unwrap();
1236        assert_eq!(report.report_metadata.org_name, "Acme");
1237    }
1238
1239    #[test]
1240    fn try_from_bytes_trait() {
1241        let report = Report::try_from(MINIMAL_XML.as_bytes()).unwrap();
1242        assert_eq!(report.report_metadata.org_name, "Acme");
1243    }
1244
1245    #[test]
1246    fn parse_bytes_function() {
1247        let report = parse_bytes(MINIMAL_XML.as_bytes()).unwrap();
1248        assert_eq!(report.report_metadata.org_name, "Acme");
1249    }
1250
1251    #[test]
1252    fn error_on_invalid_xml() {
1253        let result = parse("<not-valid-dmarc/>");
1254        assert!(result.is_err());
1255    }
1256
1257    #[test]
1258    fn error_on_invalid_utf8_bytes() {
1259        let result = parse_bytes(&[0xFF, 0xFE]);
1260        assert!(matches!(result, Err(Error::Utf8(_))));
1261    }
1262
1263    #[test]
1264    fn display_alignment_mode() {
1265        assert_eq!(AlignmentMode::Relaxed.to_string(), "r");
1266        assert_eq!(AlignmentMode::Strict.to_string(), "s");
1267    }
1268
1269    #[test]
1270    fn display_disposition() {
1271        assert_eq!(Disposition::None.to_string(), "none");
1272        assert_eq!(Disposition::Quarantine.to_string(), "quarantine");
1273        assert_eq!(Disposition::Reject.to_string(), "reject");
1274    }
1275
1276    #[test]
1277    fn display_dmarc_result() {
1278        assert_eq!(DmarcResult::Pass.to_string(), "pass");
1279        assert_eq!(DmarcResult::Fail.to_string(), "fail");
1280    }
1281
1282    #[test]
1283    fn display_dkim_result() {
1284        assert_eq!(DkimResult::None.to_string(), "none");
1285        assert_eq!(DkimResult::Pass.to_string(), "pass");
1286        assert_eq!(DkimResult::Fail.to_string(), "fail");
1287        assert_eq!(DkimResult::Policy.to_string(), "policy");
1288        assert_eq!(DkimResult::Neutral.to_string(), "neutral");
1289        assert_eq!(DkimResult::Temperror.to_string(), "temperror");
1290        assert_eq!(DkimResult::Permerror.to_string(), "permerror");
1291    }
1292
1293    #[test]
1294    fn display_spf_result() {
1295        assert_eq!(SpfResult::None.to_string(), "none");
1296        assert_eq!(SpfResult::Neutral.to_string(), "neutral");
1297        assert_eq!(SpfResult::Pass.to_string(), "pass");
1298        assert_eq!(SpfResult::Fail.to_string(), "fail");
1299        assert_eq!(SpfResult::Softfail.to_string(), "softfail");
1300        assert_eq!(SpfResult::Temperror.to_string(), "temperror");
1301        assert_eq!(SpfResult::Permerror.to_string(), "permerror");
1302    }
1303
1304    #[test]
1305    fn display_spf_domain_scope() {
1306        assert_eq!(SpfDomainScope::Helo.to_string(), "helo");
1307        assert_eq!(SpfDomainScope::Mfrom.to_string(), "mfrom");
1308    }
1309
1310    // ──────────────────────────────────────────────────────────────────────────
1311    // Aggregate
1312    // ──────────────────────────────────────────────────────────────────────────
1313
1314    fn report_with(report_id: &str, begin: i64, end: i64, counts: &[u64]) -> Report {
1315        let records: String = counts
1316            .iter()
1317            .map(|c| {
1318                format!(
1319                    r#"<record>
1320    <row><source_ip>192.0.2.1</source_ip><count>{c}</count>
1321      <policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated>
1322    </row>
1323    <identifiers><envelope_from>example.com</envelope_from><header_from>example.com</header_from></identifiers>
1324    <auth_results><spf><domain>example.com</domain><result>pass</result></spf></auth_results>
1325  </record>"#
1326                )
1327            })
1328            .collect();
1329
1330        let xml = format!(
1331            r#"<?xml version="1.0"?>
1332<feedback>
1333  <report_metadata>
1334    <org_name>R</org_name><email>r@r.example</email>
1335    <report_id>{report_id}</report_id>
1336    <date_range><begin>{begin}</begin><end>{end}</end></date_range>
1337  </report_metadata>
1338  <policy_published><domain>example.com</domain><p>none</p><sp>none</sp><pct>100</pct></policy_published>
1339  {records}
1340</feedback>"#
1341        );
1342        parse(&xml).unwrap()
1343    }
1344
1345    #[test]
1346    fn aggregate_empty() {
1347        let agg = Aggregate::from_reports(vec![]);
1348        assert_eq!(agg.records().count(), 0);
1349        assert_eq!(agg.total_messages(), 0);
1350        assert_eq!(agg.date_span(), None);
1351    }
1352
1353    #[test]
1354    fn aggregate_single_report() {
1355        let agg = Aggregate::from_reports(vec![report_with("r1", 100, 200, &[3, 5])]);
1356        assert_eq!(agg.records().count(), 2);
1357        assert_eq!(agg.total_messages(), 8);
1358        assert_eq!(agg.date_span(), Some((100, 200)));
1359    }
1360
1361    #[test]
1362    fn aggregate_total_messages_sums_across_reports() {
1363        let agg = Aggregate::from_reports(vec![
1364            report_with("r1", 0, 1, &[1, 2]),
1365            report_with("r2", 0, 1, &[4]),
1366            report_with("r3", 0, 1, &[10, 20, 30]),
1367        ]);
1368        assert_eq!(agg.total_messages(), 1 + 2 + 4 + 10 + 20 + 30);
1369    }
1370
1371    #[test]
1372    fn aggregate_date_span_picks_earliest_begin_and_latest_end() {
1373        let agg = Aggregate::from_reports(vec![
1374            report_with("r1", 500, 600, &[1]),
1375            report_with("r2", 100, 200, &[1]),
1376            report_with("r3", 300, 900, &[1]),
1377        ]);
1378        assert_eq!(agg.date_span(), Some((100, 900)));
1379    }
1380
1381    #[test]
1382    fn aggregate_records_pair_with_source_report() {
1383        let agg = Aggregate::from_reports(vec![
1384            report_with("r1", 0, 1, &[1, 2]),
1385            report_with("r2", 0, 1, &[3]),
1386        ]);
1387        let pairs: Vec<(&str, u64)> = agg
1388            .records()
1389            .map(|(r, rec)| (r.report_metadata.report_id.as_str(), rec.row.count))
1390            .collect();
1391        assert_eq!(pairs, vec![("r1", 1), ("r1", 2), ("r2", 3)]);
1392    }
1393
1394    #[test]
1395    fn aggregate_from_vec_via_into() {
1396        let reports = vec![report_with("r1", 0, 1, &[7])];
1397        let agg: Aggregate = reports.into();
1398        assert_eq!(agg.total_messages(), 7);
1399    }
1400
1401    #[test]
1402    fn display_policy_override() {
1403        assert_eq!(PolicyOverride::Forwarded.to_string(), "forwarded");
1404        assert_eq!(PolicyOverride::SampledOut.to_string(), "sampled_out");
1405        assert_eq!(
1406            PolicyOverride::TrustedForwarder.to_string(),
1407            "trusted_forwarder"
1408        );
1409        assert_eq!(PolicyOverride::MailingList.to_string(), "mailing_list");
1410        assert_eq!(PolicyOverride::LocalPolicy.to_string(), "local_policy");
1411        assert_eq!(PolicyOverride::Other.to_string(), "other");
1412    }
1413}