1use super::types::{
2 AlignmentMode, DmarcRecord, FailureOption, Policy, ReportFormat, ReportUri,
3};
4
5#[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 pub fn parse(record: &str) -> Result<Self, DmarcParseError> {
22 let tags = parse_tag_list(record)?;
23
24 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 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 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" => {} "v" => {} "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 _ => {} }
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
133fn 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, };
145 if tag.is_empty() {
146 continue;
147 }
148 tags.push((tag.to_string(), val.to_string()));
149 }
150 Ok(tags)
151}
152
153fn 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, }
163 }
164 None => 100,
165 }
166}
167
168fn 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
186fn parse_ri(val: Option<&str>) -> u32 {
188 match val {
189 Some(v) => v.parse::<u32>().unwrap_or(86400),
190 None => 86400,
191 }
192}
193
194fn 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
207fn 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..]; 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
237fn 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 #[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); 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[test]
649 fn empty_record() {
650 let result = DmarcRecord::parse("");
651 assert!(result.is_err());
652 }
653
654 #[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 #[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 #[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 #[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}