Skip to main content

email_auth/dmarc/
parser.rs

1use super::types::{
2    AlignmentMode, DmarcRecord, FailureOption, Policy, ReportFormat, ReportUri,
3};
4
5/// DMARC record parse error.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct DmarcParseError {
8    pub detail: String,
9}
10
11impl std::fmt::Display for DmarcParseError {
12    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13        f.write_str(&self.detail)
14    }
15}
16
17impl std::error::Error for DmarcParseError {}
18
19impl DmarcRecord {
20    /// Parse a DMARC TXT record string into a DmarcRecord.
21    pub fn parse(record: &str) -> Result<Self, DmarcParseError> {
22        let tags = parse_tag_list(record)?;
23
24        // v= MUST be first tag
25        if tags.is_empty() {
26            return Err(DmarcParseError { detail: "empty record".into() });
27        }
28        let (first_tag, first_val) = &tags[0];
29        if !first_tag.eq_ignore_ascii_case("v") {
30            return Err(DmarcParseError {
31                detail: format!("v= must be first tag, found '{}='", first_tag),
32            });
33        }
34        if !first_val.eq_ignore_ascii_case("DMARC1") {
35            return Err(DmarcParseError {
36                detail: format!("invalid version: '{}', expected 'DMARC1'", first_val),
37            });
38        }
39
40        // Find p= (required). Use first occurrence.
41        let policy_val = tags.iter()
42            .find(|(t, _)| t.eq_ignore_ascii_case("p"))
43            .map(|(_, v)| v.as_str());
44        let policy = match policy_val {
45            Some(v) => Policy::parse(v).ok_or_else(|| DmarcParseError {
46                detail: format!("invalid p= value: '{}'", v),
47            })?,
48            None => return Err(DmarcParseError { detail: "missing required p= tag".into() }),
49        };
50
51        // Optional tags — use first occurrence for each
52        let mut sp = None;
53        let mut np = None;
54        let mut adkim = None;
55        let mut aspf = None;
56        let mut pct = None;
57        let mut fo = None;
58        let mut rf = None;
59        let mut ri = None;
60        let mut rua = None;
61        let mut ruf = None;
62
63        for (tag, val) in &tags[1..] {
64            let tag_lower = tag.to_ascii_lowercase();
65            match tag_lower.as_str() {
66                "p" => {} // already handled, skip duplicates
67                "v" => {} // skip duplicate v=
68                "sp" if sp.is_none() => sp = Some(val.as_str()),
69                "np" if np.is_none() => np = Some(val.as_str()),
70                "adkim" if adkim.is_none() => adkim = Some(val.as_str()),
71                "aspf" if aspf.is_none() => aspf = Some(val.as_str()),
72                "pct" if pct.is_none() => pct = Some(val.as_str()),
73                "fo" if fo.is_none() => fo = Some(val.as_str()),
74                "rf" if rf.is_none() => rf = Some(val.as_str()),
75                "ri" if ri.is_none() => ri = Some(val.as_str()),
76                "rua" if rua.is_none() => rua = Some(val.as_str()),
77                "ruf" if ruf.is_none() => ruf = Some(val.as_str()),
78                _ => {} // unknown tags ignored
79            }
80        }
81
82        let subdomain_policy = sp
83            .and_then(|v| Policy::parse(v))
84            .unwrap_or(policy);
85
86        let non_existent_subdomain_policy = np
87            .and_then(|v| Policy::parse(v));
88
89        let dkim_alignment = adkim
90            .and_then(|v| AlignmentMode::parse(v))
91            .unwrap_or(AlignmentMode::Relaxed);
92
93        let spf_alignment = aspf
94            .and_then(|v| AlignmentMode::parse(v))
95            .unwrap_or(AlignmentMode::Relaxed);
96
97        let percent = parse_pct(pct);
98
99        let failure_options = parse_fo(fo);
100
101        let report_format = rf
102            .and_then(|v| ReportFormat::parse(v))
103            .unwrap_or(ReportFormat::Afrf);
104
105        let report_interval = parse_ri(ri);
106
107        let rua_uris = rua
108            .map(|v| parse_uri_list(v))
109            .transpose()?
110            .unwrap_or_default();
111
112        let ruf_uris = ruf
113            .map(|v| parse_uri_list(v))
114            .transpose()?
115            .unwrap_or_default();
116
117        Ok(DmarcRecord {
118            policy,
119            subdomain_policy,
120            non_existent_subdomain_policy,
121            dkim_alignment,
122            spf_alignment,
123            percent,
124            failure_options,
125            report_format,
126            report_interval,
127            rua: rua_uris,
128            ruf: ruf_uris,
129        })
130    }
131}
132
133/// Parse tag=value pairs from a semicolon-separated record.
134fn parse_tag_list(record: &str) -> Result<Vec<(String, String)>, DmarcParseError> {
135    let mut tags = Vec::new();
136    for part in record.split(';') {
137        let trimmed = part.trim();
138        if trimmed.is_empty() {
139            continue;
140        }
141        let (tag, val) = match trimmed.find('=') {
142            Some(pos) => (trimmed[..pos].trim(), trimmed[pos + 1..].trim()),
143            None => continue, // no = sign, skip
144        };
145        if tag.is_empty() {
146            continue;
147        }
148        tags.push((tag.to_string(), val.to_string()));
149    }
150    Ok(tags)
151}
152
153/// Parse pct= value. Clamp to 0-100, non-numeric → default 100.
154fn parse_pct(val: Option<&str>) -> u8 {
155    match val {
156        Some(v) => {
157            match v.parse::<i64>() {
158                Ok(n) if n > 100 => 100,
159                Ok(n) if n < 0 => 0,
160                Ok(n) => n as u8,
161                Err(_) => 100, // non-numeric → default
162            }
163        }
164        None => 100,
165    }
166}
167
168/// Parse fo= value. Colon-separated, unknown options ignored. Default: [Zero].
169fn parse_fo(val: Option<&str>) -> Vec<FailureOption> {
170    match val {
171        Some(v) => {
172            let opts: Vec<FailureOption> = v
173                .split(':')
174                .filter_map(|s| FailureOption::parse(s.trim()))
175                .collect();
176            if opts.is_empty() {
177                vec![FailureOption::Zero]
178            } else {
179                opts
180            }
181        }
182        None => vec![FailureOption::Zero],
183    }
184}
185
186/// Parse ri= value. Non-numeric → default 86400.
187fn parse_ri(val: Option<&str>) -> u32 {
188    match val {
189        Some(v) => v.parse::<u32>().unwrap_or(86400),
190        None => 86400,
191    }
192}
193
194/// Parse a comma-separated list of report URIs.
195fn parse_uri_list(val: &str) -> Result<Vec<ReportUri>, DmarcParseError> {
196    let mut uris = Vec::new();
197    for part in val.split(',') {
198        let trimmed = part.trim();
199        if trimmed.is_empty() {
200            continue;
201        }
202        uris.push(parse_report_uri(trimmed)?);
203    }
204    Ok(uris)
205}
206
207/// Parse a single report URI: `mailto:address[!size[unit]]`.
208fn parse_report_uri(uri: &str) -> Result<ReportUri, DmarcParseError> {
209    let lower = uri.to_ascii_lowercase();
210    if !lower.starts_with("mailto:") {
211        return Err(DmarcParseError {
212            detail: format!("unsupported URI scheme (only mailto: accepted): '{}'", uri),
213        });
214    }
215
216    let after_scheme = &uri[7..]; // skip "mailto:"
217
218    // Check for size suffix: address!size[unit]
219    let (address, max_size) = if let Some(bang_pos) = after_scheme.rfind('!') {
220        let addr = &after_scheme[..bang_pos];
221        let size_str = &after_scheme[bang_pos + 1..];
222        let max = parse_size_suffix(size_str)?;
223        (addr.to_string(), Some(max))
224    } else {
225        (after_scheme.to_string(), None)
226    };
227
228    if address.is_empty() {
229        return Err(DmarcParseError {
230            detail: "empty mailto: address".into(),
231        });
232    }
233
234    Ok(ReportUri { address, max_size })
235}
236
237/// Parse size suffix: number + optional unit (k/m/g/t).
238fn parse_size_suffix(s: &str) -> Result<u64, DmarcParseError> {
239    if s.is_empty() {
240        return Err(DmarcParseError { detail: "empty size suffix".into() });
241    }
242
243    let s_lower = s.to_ascii_lowercase();
244    let (num_str, multiplier) = if s_lower.ends_with('k') {
245        (&s_lower[..s_lower.len() - 1], 1024u64)
246    } else if s_lower.ends_with('m') {
247        (&s_lower[..s_lower.len() - 1], 1024u64 * 1024)
248    } else if s_lower.ends_with('g') {
249        (&s_lower[..s_lower.len() - 1], 1024u64 * 1024 * 1024)
250    } else if s_lower.ends_with('t') {
251        (&s_lower[..s_lower.len() - 1], 1024u64 * 1024 * 1024 * 1024)
252    } else {
253        (s_lower.as_str(), 1u64)
254    };
255
256    let num: u64 = num_str.parse().map_err(|_| DmarcParseError {
257        detail: format!("invalid size number: '{}'", num_str),
258    })?;
259
260    Ok(num * multiplier)
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::dmarc::types::*;
267
268    // ─── CHK-681: Minimal valid ──────────────────────────────────────
269
270    #[test]
271    fn minimal_valid_record() {
272        let r = DmarcRecord::parse("v=DMARC1; p=none").unwrap();
273        assert_eq!(r.policy, Policy::None);
274        assert_eq!(r.subdomain_policy, Policy::None); // defaults to p=
275        assert_eq!(r.dkim_alignment, AlignmentMode::Relaxed);
276        assert_eq!(r.spf_alignment, AlignmentMode::Relaxed);
277        assert_eq!(r.percent, 100);
278        assert_eq!(r.failure_options, vec![FailureOption::Zero]);
279        assert_eq!(r.report_format, ReportFormat::Afrf);
280        assert_eq!(r.report_interval, 86400);
281        assert!(r.rua.is_empty());
282        assert!(r.ruf.is_empty());
283        assert!(r.non_existent_subdomain_policy.is_none());
284    }
285
286    // ─── CHK-682: Full record ────────────────────────────────────────
287
288    #[test]
289    fn full_record_all_tags() {
290        let record = "v=DMARC1; p=reject; sp=quarantine; np=none; \
291            adkim=s; aspf=s; pct=50; fo=0:1:d:s; rf=afrf; ri=3600; \
292            rua=mailto:agg@example.com!10m; ruf=mailto:fail@example.com";
293        let r = DmarcRecord::parse(record).unwrap();
294        assert_eq!(r.policy, Policy::Reject);
295        assert_eq!(r.subdomain_policy, Policy::Quarantine);
296        assert_eq!(r.non_existent_subdomain_policy, Some(Policy::None));
297        assert_eq!(r.dkim_alignment, AlignmentMode::Strict);
298        assert_eq!(r.spf_alignment, AlignmentMode::Strict);
299        assert_eq!(r.percent, 50);
300        assert_eq!(r.failure_options, vec![
301            FailureOption::Zero,
302            FailureOption::One,
303            FailureOption::D,
304            FailureOption::S,
305        ]);
306        assert_eq!(r.report_format, ReportFormat::Afrf);
307        assert_eq!(r.report_interval, 3600);
308        assert_eq!(r.rua.len(), 1);
309        assert_eq!(r.rua[0].address, "agg@example.com");
310        assert_eq!(r.rua[0].max_size, Some(10 * 1024 * 1024));
311        assert_eq!(r.ruf.len(), 1);
312        assert_eq!(r.ruf[0].address, "fail@example.com");
313        assert_eq!(r.ruf[0].max_size, None);
314    }
315
316    // ─── CHK-683: Missing v= ─────────────────────────────────────────
317
318    #[test]
319    fn missing_v_tag() {
320        let result = DmarcRecord::parse("p=none");
321        assert!(result.is_err());
322        assert!(result.unwrap_err().detail.contains("v="));
323    }
324
325    // ─── CHK-684: v= not first ──────────────────────────────────────
326
327    #[test]
328    fn v_not_first_tag() {
329        let result = DmarcRecord::parse("p=none; v=DMARC1");
330        assert!(result.is_err());
331        assert!(result.unwrap_err().detail.contains("v="));
332    }
333
334    // ─── CHK-685: Invalid p= value ──────────────────────────────────
335
336    #[test]
337    fn invalid_policy_value() {
338        let result = DmarcRecord::parse("v=DMARC1; p=invalid");
339        assert!(result.is_err());
340        assert!(result.unwrap_err().detail.contains("p="));
341    }
342
343    // ─── CHK-686: Unknown tags ignored ──────────────────────────────
344
345    #[test]
346    fn unknown_tags_ignored() {
347        let r = DmarcRecord::parse("v=DMARC1; p=none; x=unknown; y=other").unwrap();
348        assert_eq!(r.policy, Policy::None);
349    }
350
351    // ─── CHK-687: Case insensitivity ─────────────────────────────────
352
353    #[test]
354    fn case_insensitive_tags_and_values() {
355        let r = DmarcRecord::parse("v=dmarc1; p=Quarantine; ADKIM=S; ASPF=R").unwrap();
356        assert_eq!(r.policy, Policy::Quarantine);
357        assert_eq!(r.dkim_alignment, AlignmentMode::Strict);
358        assert_eq!(r.spf_alignment, AlignmentMode::Relaxed);
359    }
360
361    // ─── CHK-688: URI size limits ────────────────────────────────────
362
363    #[test]
364    fn uri_size_limits() {
365        let r = DmarcRecord::parse(
366            "v=DMARC1; p=none; rua=mailto:a@b.com!100k,mailto:c@d.com!5m"
367        ).unwrap();
368        assert_eq!(r.rua.len(), 2);
369        assert_eq!(r.rua[0].max_size, Some(100 * 1024));
370        assert_eq!(r.rua[1].max_size, Some(5 * 1024 * 1024));
371    }
372
373    #[test]
374    fn uri_size_bare_bytes() {
375        let r = DmarcRecord::parse(
376            "v=DMARC1; p=none; rua=mailto:a@b.com!5000"
377        ).unwrap();
378        assert_eq!(r.rua[0].max_size, Some(5000));
379    }
380
381    #[test]
382    fn uri_size_gigabytes() {
383        let r = DmarcRecord::parse(
384            "v=DMARC1; p=none; rua=mailto:a@b.com!2g"
385        ).unwrap();
386        assert_eq!(r.rua[0].max_size, Some(2 * 1024 * 1024 * 1024));
387    }
388
389    #[test]
390    fn uri_size_terabytes() {
391        let r = DmarcRecord::parse(
392            "v=DMARC1; p=none; rua=mailto:a@b.com!1t"
393        ).unwrap();
394        assert_eq!(r.rua[0].max_size, Some(1024u64 * 1024 * 1024 * 1024));
395    }
396
397    // ─── CHK-689: Multiple URIs ──────────────────────────────────────
398
399    #[test]
400    fn multiple_rua_uris() {
401        let r = DmarcRecord::parse(
402            "v=DMARC1; p=none; rua=mailto:a@b.com,mailto:c@d.com,mailto:e@f.com"
403        ).unwrap();
404        assert_eq!(r.rua.len(), 3);
405        assert_eq!(r.rua[0].address, "a@b.com");
406        assert_eq!(r.rua[1].address, "c@d.com");
407        assert_eq!(r.rua[2].address, "e@f.com");
408    }
409
410    // ─── CHK-690: Non-mailto URI ─────────────────────────────────────
411
412    #[test]
413    fn non_mailto_uri_rejected() {
414        let result = DmarcRecord::parse(
415            "v=DMARC1; p=none; rua=https://example.com/report"
416        );
417        assert!(result.is_err());
418        assert!(result.unwrap_err().detail.contains("mailto"));
419    }
420
421    // ─── CHK-691: Trailing semicolons ────────────────────────────────
422
423    #[test]
424    fn trailing_semicolons_valid() {
425        let r = DmarcRecord::parse("v=DMARC1; p=reject;").unwrap();
426        assert_eq!(r.policy, Policy::Reject);
427    }
428
429    #[test]
430    fn multiple_trailing_semicolons() {
431        let r = DmarcRecord::parse("v=DMARC1; p=reject;;;").unwrap();
432        assert_eq!(r.policy, Policy::Reject);
433    }
434
435    // ─── CHK-692: Whitespace variations ──────────────────────────────
436
437    #[test]
438    fn whitespace_around_tags() {
439        let r = DmarcRecord::parse("  v = DMARC1 ; p = none ; pct = 75  ").unwrap();
440        assert_eq!(r.policy, Policy::None);
441        assert_eq!(r.percent, 75);
442    }
443
444    // ─── CHK-693: No spaces around semicolons ────────────────────────
445
446    #[test]
447    fn no_spaces_around_semicolons() {
448        let r = DmarcRecord::parse("v=DMARC1;p=none;pct=75").unwrap();
449        assert_eq!(r.policy, Policy::None);
450        assert_eq!(r.percent, 75);
451    }
452
453    // ─── CHK-694: Duplicate p= → first wins ──────────────────────────
454
455    #[test]
456    fn duplicate_p_first_wins() {
457        let r = DmarcRecord::parse("v=DMARC1; p=reject; p=none").unwrap();
458        assert_eq!(r.policy, Policy::Reject);
459    }
460
461    // ─── CHK-695: pct > 100 → clamp ─────────────────────────────────
462
463    #[test]
464    fn pct_greater_than_100_clamped() {
465        let r = DmarcRecord::parse("v=DMARC1; p=none; pct=200").unwrap();
466        assert_eq!(r.percent, 100);
467    }
468
469    // ─── CHK-696: pct < 0 → clamp ───────────────────────────────────
470
471    #[test]
472    fn pct_negative_clamped() {
473        let r = DmarcRecord::parse("v=DMARC1; p=none; pct=-5").unwrap();
474        assert_eq!(r.percent, 0);
475    }
476
477    // ─── CHK-697: pct non-numeric → default ──────────────────────────
478
479    #[test]
480    fn pct_non_numeric_default() {
481        let r = DmarcRecord::parse("v=DMARC1; p=none; pct=abc").unwrap();
482        assert_eq!(r.percent, 100);
483    }
484
485    // ─── CHK-698: fo= multiple options ───────────────────────────────
486
487    #[test]
488    fn fo_multiple_options() {
489        let r = DmarcRecord::parse("v=DMARC1; p=none; fo=0:1:d:s").unwrap();
490        assert_eq!(r.failure_options, vec![
491            FailureOption::Zero,
492            FailureOption::One,
493            FailureOption::D,
494            FailureOption::S,
495        ]);
496    }
497
498    // ─── CHK-699: fo= unknown options ignored ────────────────────────
499
500    #[test]
501    fn fo_unknown_options_ignored() {
502        let r = DmarcRecord::parse("v=DMARC1; p=none; fo=0:x:d:z").unwrap();
503        assert_eq!(r.failure_options, vec![FailureOption::Zero, FailureOption::D]);
504    }
505
506    // ─── CHK-700: np= parsing (RFC 9091) ─────────────────────────────
507
508    #[test]
509    fn np_parsing() {
510        let r = DmarcRecord::parse("v=DMARC1; p=reject; np=quarantine").unwrap();
511        assert_eq!(r.non_existent_subdomain_policy, Some(Policy::Quarantine));
512    }
513
514    #[test]
515    fn np_absent() {
516        let r = DmarcRecord::parse("v=DMARC1; p=reject").unwrap();
517        assert!(r.non_existent_subdomain_policy.is_none());
518    }
519
520    // ─── CHK-701: sp= defaults to p= ────────────────────────────────
521
522    #[test]
523    fn sp_defaults_to_p() {
524        let r = DmarcRecord::parse("v=DMARC1; p=reject").unwrap();
525        assert_eq!(r.subdomain_policy, Policy::Reject);
526    }
527
528    #[test]
529    fn sp_overrides_default() {
530        let r = DmarcRecord::parse("v=DMARC1; p=reject; sp=none").unwrap();
531        assert_eq!(r.subdomain_policy, Policy::None);
532    }
533
534    // ─── CHK-702: ri= non-numeric → default ─────────────────────────
535
536    #[test]
537    fn ri_non_numeric_default() {
538        let r = DmarcRecord::parse("v=DMARC1; p=none; ri=abc").unwrap();
539        assert_eq!(r.report_interval, 86400);
540    }
541
542    #[test]
543    fn ri_custom_value() {
544        let r = DmarcRecord::parse("v=DMARC1; p=none; ri=7200").unwrap();
545        assert_eq!(r.report_interval, 7200);
546    }
547
548    // ─── CHK-760/761: Completeness checks ────────────────────────────
549
550    #[test]
551    fn all_policy_variants() {
552        assert_eq!(Policy::parse("none"), Some(Policy::None));
553        assert_eq!(Policy::parse("quarantine"), Some(Policy::Quarantine));
554        assert_eq!(Policy::parse("reject"), Some(Policy::Reject));
555        assert_eq!(Policy::parse("NONE"), Some(Policy::None));
556        assert_eq!(Policy::parse("invalid"), Option::None);
557    }
558
559    #[test]
560    fn all_alignment_variants() {
561        assert_eq!(AlignmentMode::parse("r"), Some(AlignmentMode::Relaxed));
562        assert_eq!(AlignmentMode::parse("s"), Some(AlignmentMode::Strict));
563        assert_eq!(AlignmentMode::parse("R"), Some(AlignmentMode::Relaxed));
564        assert_eq!(AlignmentMode::parse("x"), Option::None);
565    }
566
567    #[test]
568    fn all_failure_option_variants() {
569        assert_eq!(FailureOption::parse("0"), Some(FailureOption::Zero));
570        assert_eq!(FailureOption::parse("1"), Some(FailureOption::One));
571        assert_eq!(FailureOption::parse("d"), Some(FailureOption::D));
572        assert_eq!(FailureOption::parse("s"), Some(FailureOption::S));
573        assert_eq!(FailureOption::parse("D"), Some(FailureOption::D));
574        assert_eq!(FailureOption::parse("x"), Option::None);
575    }
576
577    #[test]
578    fn disposition_enum_exists() {
579        // Verify all variants exist and are usable
580        let _pass = Disposition::Pass;
581        let _quarantine = Disposition::Quarantine;
582        let _reject = Disposition::Reject;
583        let _none = Disposition::None;
584        let _tf = Disposition::TempFail;
585    }
586
587    #[test]
588    fn dmarc_result_struct() {
589        let r = DmarcResult {
590            disposition: Disposition::Pass,
591            dkim_aligned: true,
592            spf_aligned: false,
593            applied_policy: Some(Policy::Reject),
594            record: None,
595        };
596        assert_eq!(r.disposition, Disposition::Pass);
597        assert!(r.dkim_aligned);
598        assert!(!r.spf_aligned);
599    }
600
601    #[test]
602    fn report_uri_no_size() {
603        let uri = parse_report_uri("mailto:dmarc@example.com").unwrap();
604        assert_eq!(uri.address, "dmarc@example.com");
605        assert!(uri.max_size.is_none());
606    }
607
608    #[test]
609    fn report_uri_with_size_k() {
610        let uri = parse_report_uri("mailto:dmarc@example.com!100k").unwrap();
611        assert_eq!(uri.address, "dmarc@example.com");
612        assert_eq!(uri.max_size, Some(100 * 1024));
613    }
614
615    #[test]
616    fn report_uri_with_bare_size() {
617        let uri = parse_report_uri("mailto:dmarc@example.com!5000").unwrap();
618        assert_eq!(uri.address, "dmarc@example.com");
619        assert_eq!(uri.max_size, Some(5000));
620    }
621
622    #[test]
623    fn report_uri_non_mailto() {
624        let result = parse_report_uri("https://example.com");
625        assert!(result.is_err());
626    }
627
628    // ─── Missing p= tag ─────────────────────────────────────────────
629
630    #[test]
631    fn missing_p_tag() {
632        let result = DmarcRecord::parse("v=DMARC1; sp=none");
633        assert!(result.is_err());
634        assert!(result.unwrap_err().detail.contains("p="));
635    }
636
637    // ─── Version validation ──────────────────────────────────────────
638
639    #[test]
640    fn wrong_version() {
641        let result = DmarcRecord::parse("v=DMARC2; p=none");
642        assert!(result.is_err());
643        assert!(result.unwrap_err().detail.contains("version"));
644    }
645
646    // ─── Empty record ───────────────────────────────────────────────
647
648    #[test]
649    fn empty_record() {
650        let result = DmarcRecord::parse("");
651        assert!(result.is_err());
652    }
653
654    // ─── fo= all unknown → default Zero ──────────────────────────────
655
656    #[test]
657    fn fo_all_unknown_default() {
658        let r = DmarcRecord::parse("v=DMARC1; p=none; fo=x:y:z").unwrap();
659        assert_eq!(r.failure_options, vec![FailureOption::Zero]);
660    }
661
662    // ─── rf= unknown → default afrf ──────────────────────────────────
663
664    #[test]
665    fn rf_unknown_default() {
666        let r = DmarcRecord::parse("v=DMARC1; p=none; rf=iodef").unwrap();
667        assert_eq!(r.report_format, ReportFormat::Afrf);
668    }
669
670    // ─── Duplicate tags: first wins (consistent) ─────────────────────
671
672    #[test]
673    fn duplicate_sp_first_wins() {
674        let r = DmarcRecord::parse("v=DMARC1; p=reject; sp=none; sp=quarantine").unwrap();
675        assert_eq!(r.subdomain_policy, Policy::None);
676    }
677
678    // ─── Size suffix parsing edge cases ──────────────────────────────
679
680    #[test]
681    fn size_suffix_case_insensitive() {
682        assert_eq!(parse_size_suffix("10K").unwrap(), 10 * 1024);
683        assert_eq!(parse_size_suffix("10M").unwrap(), 10 * 1024 * 1024);
684        assert_eq!(parse_size_suffix("10G").unwrap(), 10 * 1024 * 1024 * 1024);
685    }
686}