1use super::{
8 Algorithm, Atps, Canonicalization, DomainKeyReport, Flag, HashAlgorithm, RR_DNS, RR_OTHER,
9 RR_POLICY, Service, Signature, Version,
10};
11use crate::{
12 Error,
13 common::{crypto::VerifyingKeyType, parse::*, verify::DomainKey},
14 dkim::{RR_EXPIRATION, RR_SIGNATURE, RR_UNKNOWN_TAG, RR_VERIFICATION},
15};
16use mail_parser::decoders::base64::base64_decode_stream;
17use std::slice::Iter;
18
19const ATPSH: u64 = (b'a' as u64)
20 | ((b't' as u64) << 8)
21 | ((b'p' as u64) << 16)
22 | ((b's' as u64) << 24)
23 | ((b'h' as u64) << 32);
24const ATPS: u64 =
25 (b'a' as u64) | ((b't' as u64) << 8) | ((b'p' as u64) << 16) | ((b's' as u64) << 24);
26const NONE: u64 =
27 (b'n' as u64) | ((b'o' as u64) << 8) | ((b'n' as u64) << 16) | ((b'e' as u64) << 24);
28const SHA256: u64 = (b's' as u64)
29 | ((b'h' as u64) << 8)
30 | ((b'a' as u64) << 16)
31 | ((b'2' as u64) << 24)
32 | ((b'5' as u64) << 32)
33 | ((b'6' as u64) << 40);
34const SHA1: u64 =
35 (b's' as u64) | ((b'h' as u64) << 8) | ((b'a' as u64) << 16) | ((b'1' as u64) << 24);
36const RA: u64 = (b'r' as u64) | ((b'a' as u64) << 8);
37const RP: u64 = (b'r' as u64) | ((b'p' as u64) << 8);
38const RR: u64 = (b'r' as u64) | ((b'r' as u64) << 8);
39const RS: u64 = (b'r' as u64) | ((b's' as u64) << 8);
40const ALL: u64 = (b'a' as u64) | ((b'l' as u64) << 8) | ((b'l' as u64) << 16);
41
42impl Signature {
43 #[allow(clippy::while_let_on_iterator)]
44 pub fn parse(header: &'_ [u8]) -> crate::Result<Self> {
45 let mut signature = Signature {
46 v: 0,
47 a: Algorithm::RsaSha256,
48 d: "".into(),
49 s: "".into(),
50 i: "".into(),
51 b: Vec::with_capacity(0),
52 bh: Vec::with_capacity(0),
53 h: Vec::with_capacity(0),
54 z: Vec::with_capacity(0),
55 l: 0,
56 x: 0,
57 t: 0,
58 ch: Canonicalization::Simple,
59 cb: Canonicalization::Simple,
60 r: false,
61 atps: None,
62 atpsh: None,
63 };
64 let header_len = header.len();
65 let mut header = header.iter();
66
67 while let Some(key) = header.key() {
68 match key {
69 V => {
70 signature.v = header.number().unwrap_or(0) as u32;
71 if signature.v != 1 {
72 return Err(Error::UnsupportedVersion);
73 }
74 }
75 A => {
76 signature.a = header.algorithm()?;
77 }
78 B => {
79 signature.b =
80 base64_decode_stream(&mut header, header_len, b';').ok_or(Error::Base64)?
81 }
82 BH => {
83 signature.bh =
84 base64_decode_stream(&mut header, header_len, b';').ok_or(Error::Base64)?
85 }
86 C => {
87 let (ch, cb) = header.canonicalization(Canonicalization::Simple)?;
88 signature.ch = ch;
89 signature.cb = cb;
90 }
91 D => signature.d = header.text(true),
92 H => signature.h = header.items(),
93 I => signature.i = header.text_qp(Vec::with_capacity(20), true, false),
94 L => signature.l = header.number().unwrap_or(0),
95 S => signature.s = header.text(true),
96 T => signature.t = header.number().unwrap_or(0),
97 X => signature.x = header.number().unwrap_or(0),
98 Z => signature.z = header.headers_qp(),
99 R => signature.r = header.value() == Y,
100 ATPS => {
101 if signature.atps.is_none() {
102 signature.atps = Some(header.text(true));
103 }
104 }
105 ATPSH => {
106 signature.atpsh = match header.value() {
107 SHA256 => HashAlgorithm::Sha256.into(),
108 SHA1 => HashAlgorithm::Sha1.into(),
109 NONE => None,
110 _ => {
111 signature.atps = Some("".into());
112 None
113 }
114 };
115 }
116 _ => header.ignore(),
117 }
118 }
119
120 if !signature.d.is_empty()
121 && !signature.s.is_empty()
122 && !signature.b.is_empty()
123 && !signature.bh.is_empty()
124 && !signature.h.is_empty()
125 {
126 Ok(signature)
127 } else {
128 Err(Error::MissingParameters)
129 }
130 }
131}
132
133pub(crate) trait SignatureParser: Sized {
134 fn canonicalization(
135 &mut self,
136 default: Canonicalization,
137 ) -> crate::Result<(Canonicalization, Canonicalization)>;
138 fn algorithm(&mut self) -> crate::Result<Algorithm>;
139}
140
141impl SignatureParser for Iter<'_, u8> {
142 fn canonicalization(
143 &mut self,
144 default: Canonicalization,
145 ) -> crate::Result<(Canonicalization, Canonicalization)> {
146 let mut cb = default;
147 let mut ch = default;
148
149 let mut has_header = false;
150 let mut c = None;
151
152 while let Some(char) = self.next() {
153 match (char, c) {
154 (b's' | b'S', None) => {
155 if self.match_bytes(b"imple") {
156 c = Canonicalization::Simple.into();
157 } else {
158 return Err(Error::UnsupportedCanonicalization);
159 }
160 }
161 (b'r' | b'R', None) => {
162 if self.match_bytes(b"elaxed") {
163 c = Canonicalization::Relaxed.into();
164 } else {
165 return Err(Error::UnsupportedCanonicalization);
166 }
167 }
168 (b'/', Some(c_)) => {
169 ch = c_;
170 c = None;
171 has_header = true;
172 }
173 (b';', _) => {
174 break;
175 }
176 (_, _) => {
177 if !char.is_ascii_whitespace() {
178 return Err(Error::UnsupportedCanonicalization);
179 }
180 }
181 }
182 }
183
184 if let Some(c) = c {
185 if has_header {
186 cb = c;
187 } else {
188 ch = c;
189 }
190 }
191
192 Ok((ch, cb))
193 }
194
195 fn algorithm(&mut self) -> crate::Result<Algorithm> {
196 match self.next_skip_whitespaces().unwrap_or(0) {
197 b'r' | b'R' => {
198 if self.match_bytes(b"sa-sha") {
199 let mut algo = 0;
200
201 for ch in self {
202 match ch {
203 b'1' if algo == 0 => algo = 1,
204 b'2' if algo == 0 => algo = 2,
205 b'5' if algo == 2 => algo = 25,
206 b'6' if algo == 25 => algo = 256,
207 b';' => {
208 break;
209 }
210 _ => {
211 if !ch.is_ascii_whitespace() {
212 return Err(Error::UnsupportedAlgorithm);
213 }
214 }
215 }
216 }
217
218 match algo {
219 256 => Ok(Algorithm::RsaSha256),
220 1 => Ok(Algorithm::RsaSha1),
221 _ => Err(Error::UnsupportedAlgorithm),
222 }
223 } else {
224 Err(Error::UnsupportedAlgorithm)
225 }
226 }
227 b'e' | b'E' => {
228 if self.match_bytes(b"d25519-sha256") && self.seek_tag_end() {
229 Ok(Algorithm::Ed25519Sha256)
230 } else {
231 Err(Error::UnsupportedAlgorithm)
232 }
233 }
234 _ => Err(Error::UnsupportedAlgorithm),
235 }
236 }
237}
238
239impl TxtRecordParser for DomainKey {
240 #[allow(clippy::while_let_on_iterator)]
241 fn parse(header: &[u8]) -> crate::Result<Self> {
242 let header_len = header.len();
243 let mut header = header.iter();
244 let mut flags = 0;
245 let mut key_type = VerifyingKeyType::Rsa;
246 let mut public_key = None;
247
248 while let Some(key) = header.key() {
249 match key {
250 V => {
251 if !header.match_bytes(b"DKIM1") || !header.seek_tag_end() {
252 return Err(Error::InvalidRecordType);
253 }
254 }
255 H => flags |= header.flags::<HashAlgorithm>(),
256 P => {
257 if let Some(bytes) = base64_decode_stream(&mut header, header_len, b';') {
258 public_key = Some(bytes);
259 }
260 }
261 S => flags |= header.flags::<Service>(),
262 T => flags |= header.flags::<Flag>(),
263 K => {
264 if let Some(ch) = header.next_skip_whitespaces() {
265 match ch {
266 b'r' | b'R' => {
267 if header.match_bytes(b"sa") && header.seek_tag_end() {
268 key_type = VerifyingKeyType::Rsa;
269 } else {
270 return Err(Error::UnsupportedKeyType);
271 }
272 }
273 b'e' | b'E' => {
274 if header.match_bytes(b"d25519") && header.seek_tag_end() {
275 key_type = VerifyingKeyType::Ed25519;
276 } else {
277 return Err(Error::UnsupportedKeyType);
278 }
279 }
280 b';' => (),
281 _ => {
282 return Err(Error::UnsupportedKeyType);
283 }
284 }
285 }
286 }
287 _ => {
288 header.ignore();
289 }
290 }
291 }
292
293 match public_key {
294 Some(public_key) => Ok(DomainKey {
295 p: key_type.verifying_key(&public_key)?,
296 f: flags,
297 }),
298 _ => Err(Error::InvalidRecordType),
299 }
300 }
301}
302
303impl TxtRecordParser for DomainKeyReport {
304 #[allow(clippy::while_let_on_iterator)]
305 fn parse(header: &[u8]) -> crate::Result<Self> {
306 let mut header = header.iter();
307 let mut record = DomainKeyReport {
308 ra: String::new(),
309 rp: 100,
310 rr: u8::MAX,
311 rs: None,
312 };
313
314 while let Some(key) = header.key() {
315 match key {
316 RA => {
317 record.ra = header.text_qp(Vec::with_capacity(20), true, false);
318 }
319 RP => {
320 record.rp = std::cmp::min(header.number().unwrap_or(0), 100) as u8;
321 }
322 RS => {
323 record.rs = header.text_qp(Vec::with_capacity(20), false, false).into();
324 }
325 RR => {
326 record.rr = 0;
327 loop {
328 let (val, stop_char) = header.flag_value();
329 match val {
330 ALL => {
331 record.rr = u8::MAX;
332 }
333 D => {
334 record.rr |= RR_DNS;
335 }
336 O => {
337 record.rr |= RR_OTHER;
338 }
339 P => {
340 record.rr |= RR_POLICY;
341 }
342 S => {
343 record.rr |= RR_SIGNATURE;
344 }
345 U => {
346 record.rr |= RR_UNKNOWN_TAG;
347 }
348 V => {
349 record.rr |= RR_VERIFICATION;
350 }
351 X => {
352 record.rr |= RR_EXPIRATION;
353 }
354 _ => (),
355 }
356
357 if stop_char != b':' {
358 break;
359 }
360 }
361 }
362
363 _ => {
364 header.ignore();
365 }
366 }
367 }
368
369 if !record.ra.is_empty() {
370 Ok(record)
371 } else {
372 Err(Error::InvalidRecordType)
373 }
374 }
375}
376
377impl TxtRecordParser for Atps {
378 #[allow(clippy::while_let_on_iterator)]
379 fn parse(header: &[u8]) -> crate::Result<Self> {
380 let mut header = header.iter();
381 let mut record = Atps {
382 v: Version::V1,
383 d: None,
384 };
385 let mut has_version = false;
386
387 while let Some(key) = header.key() {
388 match key {
389 V => {
390 if !header.match_bytes(b"ATPS1") || !header.seek_tag_end() {
391 return Err(Error::InvalidRecordType);
392 }
393 has_version = true;
394 }
395 D => {
396 record.d = header.text(true).into();
397 }
398 _ => {
399 header.ignore();
400 }
401 }
402 }
403
404 if !has_version {
405 return Err(Error::InvalidRecordType);
406 }
407
408 Ok(record)
409 }
410}
411
412impl DomainKey {
413 pub fn has_flag(&self, flag: impl Into<u64>) -> bool {
414 (self.f & flag.into()) != 0
415 }
416}
417
418impl ItemParser for HashAlgorithm {
419 fn parse(bytes: &[u8]) -> Option<Self> {
420 if bytes.eq_ignore_ascii_case(b"sha256") {
421 HashAlgorithm::Sha256.into()
422 } else if bytes.eq_ignore_ascii_case(b"sha1") {
423 HashAlgorithm::Sha1.into()
424 } else {
425 None
426 }
427 }
428}
429
430impl ItemParser for Flag {
431 fn parse(bytes: &[u8]) -> Option<Self> {
432 if bytes.eq_ignore_ascii_case(b"y") {
433 Flag::Testing.into()
434 } else if bytes.eq_ignore_ascii_case(b"s") {
435 Flag::MatchDomain.into()
436 } else {
437 None
438 }
439 }
440}
441
442impl ItemParser for Service {
443 fn parse(bytes: &[u8]) -> Option<Self> {
444 if bytes.eq(b"*") {
445 Service::All.into()
446 } else if bytes.eq_ignore_ascii_case(b"email") {
447 Service::Email.into()
448 } else {
449 None
450 }
451 }
452}
453
454#[cfg(test)]
455mod test {
456 use mail_parser::decoders::base64::base64_decode;
457
458 use crate::{
459 common::{
460 crypto::{Algorithm, R_HASH_SHA1, R_HASH_SHA256},
461 parse::TxtRecordParser,
462 verify::DomainKey,
463 },
464 dkim::{
465 Canonicalization, DomainKeyReport, R_FLAG_MATCH_DOMAIN, R_FLAG_TESTING, R_SVC_ALL,
466 R_SVC_EMAIL, RR_DNS, RR_EXPIRATION, RR_OTHER, RR_POLICY, RR_SIGNATURE, RR_UNKNOWN_TAG,
467 RR_VERIFICATION, Signature,
468 },
469 };
470
471 #[test]
472 fn dkim_signature_parse() {
473 for (signature, expected_result) in [
474 (
475 concat!(
476 "v=1; a=rsa-sha256; s=default; d=stalw.art; c=relaxed/relaxed; ",
477 "bh=QoiUNYyUV+1tZ/xUPRcE+gST2zAStvJx1OK078Ylm5s=; ",
478 "b=Du0rvdzNodI6b5bhlUaZZ+gpXJi0VwjY/3qL7lS0wzKutNVCbvdJuZObGdAcv\n",
479 " eVI/RNQh2gxW4H2ynMS3B+Unse1YLJQwdjuGxsCEKBqReKlsEKT8JlO/7b2AvxR\n",
480 "\t9Q+M2aHD5kn9dbNIKnN/PKouutaXmm18QwL5EPEN9DHXSqQ=;",
481 "h=Subject:To:From; t=311923920",
482 ),
483 Signature {
484 v: 1,
485 a: Algorithm::RsaSha256,
486 d: "stalw.art".into(),
487 s: "default".into(),
488 i: "".into(),
489 bh: base64_decode(b"QoiUNYyUV+1tZ/xUPRcE+gST2zAStvJx1OK078Ylm5s=").unwrap(),
490 b: base64_decode(
491 concat!(
492 "Du0rvdzNodI6b5bhlUaZZ+gpXJi0VwjY/3qL7lS0wzKutNVCbvdJuZObGdAcv",
493 "eVI/RNQh2gxW4H2ynMS3B+Unse1YLJQwdjuGxsCEKBqReKlsEKT8JlO/7b2AvxR",
494 "9Q+M2aHD5kn9dbNIKnN/PKouutaXmm18QwL5EPEN9DHXSqQ="
495 )
496 .as_bytes(),
497 )
498 .unwrap(),
499 h: vec!["Subject".into(), "To".into(), "From".into()],
500 z: vec![],
501 l: 0,
502 x: 0,
503 t: 311923920,
504 ch: Canonicalization::Relaxed,
505 cb: Canonicalization::Relaxed,
506 r: false,
507 atps: None,
508 atpsh: None,
509 },
510 ),
511 (
512 concat!(
513 "v=1; a=rsa-sha1; d=example.net; s=brisbane;\r\n",
514 " c=simple; q=dns/txt; i=@eng.example.net;\r\n",
515 " t=1117574938; x=1118006938;\r\n",
516 " h=from:to:subject:date;\r\n",
517 " z=From:foo@eng.example.net|To:joe@example.com|\r\n",
518 " Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;\r\n",
519 " bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;\r\n",
520 " b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZVoG4ZHRNiYzR",
521 ),
522 Signature {
523 v: 1,
524 a: Algorithm::RsaSha1,
525 d: "example.net".into(),
526 s: "brisbane".into(),
527 i: "@eng.example.net".into(),
528 bh: base64_decode(b"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=").unwrap(),
529 b: base64_decode(
530 concat!(
531 "dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGe",
532 "eruD00lszZVoG4ZHRNiYzR"
533 )
534 .as_bytes(),
535 )
536 .unwrap(),
537 h: vec!["from".into(), "to".into(), "subject".into(), "date".into()],
538 z: vec![
539 "From:foo@eng.example.net".into(),
540 "To:joe@example.com".into(),
541 "Subject:demo run".into(),
542 "Date:July 5, 2005 3:44:08 PM -0700".into(),
543 ],
544 l: 0,
545 x: 1118006938,
546 t: 1117574938,
547 ch: Canonicalization::Simple,
548 cb: Canonicalization::Simple,
549 r: false,
550 atps: None,
551 atpsh: None,
552 },
553 ),
554 (
555 concat!(
556 "v=1; a = rsa - sha256; s = brisbane; d = example.com; \r\n",
557 "c = simple / relaxed; q=dns/txt; i = \r\n joe=20@\r\n",
558 " football.example.com; \r\n",
559 "h=Received : From : To :\r\n Subject : : Date : Message-ID::;;;; \r\n",
560 "bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; \r\n",
561 "b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB \r\n",
562 "4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut \r\n",
563 "KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV \r\n",
564 "4bmp/YzhwvcubU4=; l = 123",
565 ),
566 Signature {
567 v: 1,
568 a: Algorithm::RsaSha256,
569 d: "example.com".into(),
570 s: "brisbane".into(),
571 i: "joe @football.example.com".into(),
572 bh: base64_decode(b"2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=").unwrap(),
573 b: base64_decode(
574 concat!(
575 "AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB",
576 "4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut",
577 "KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV",
578 "4bmp/YzhwvcubU4="
579 )
580 .as_bytes(),
581 )
582 .unwrap(),
583 h: vec![
584 "Received".into(),
585 "From".into(),
586 "To".into(),
587 "Subject".into(),
588 "Date".into(),
589 "Message-ID".into(),
590 ],
591 z: vec![],
592 l: 123,
593 x: 0,
594 t: 0,
595 ch: Canonicalization::Simple,
596 cb: Canonicalization::Relaxed,
597 r: false,
598 atps: None,
599 atpsh: None,
600 },
601 ),
602 ] {
603 let result = Signature::parse(signature.as_bytes()).unwrap();
604 assert_eq!(result.v, expected_result.v, "{signature:?}");
605 assert_eq!(result.a, expected_result.a, "{signature:?}");
606 assert_eq!(result.d, expected_result.d, "{signature:?}");
607 assert_eq!(result.s, expected_result.s, "{signature:?}");
608 assert_eq!(result.i, expected_result.i, "{signature:?}");
609 assert_eq!(result.b, expected_result.b, "{signature:?}");
610 assert_eq!(result.bh, expected_result.bh, "{signature:?}");
611 assert_eq!(result.h, expected_result.h, "{signature:?}");
612 assert_eq!(result.z, expected_result.z, "{signature:?}");
613 assert_eq!(result.l, expected_result.l, "{signature:?}");
614 assert_eq!(result.x, expected_result.x, "{signature:?}");
615 assert_eq!(result.t, expected_result.t, "{signature:?}");
616 assert_eq!(result.ch, expected_result.ch, "{signature:?}");
617 assert_eq!(result.cb, expected_result.cb, "{signature:?}");
618 }
619 }
620
621 #[test]
622 fn dkim_record_parse() {
623 for (record, expected_result) in [
624 (
625 concat!(
626 "v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ",
627 "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt",
628 "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v",
629 "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi",
630 "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB",
631 ),
632 0,
633 ),
634 (
635 concat!(
636 "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOC",
637 "AQ8AMIIBCgKCAQEAvzwKQIIWzQXv0nihasFTT3+JO23hXCg",
638 "e+ESWNxCJdVLxKL5edxrumEU3DnrPeGD6q6E/vjoXwBabpm",
639 "8F5o96MEPm7v12O5IIK7wx7gIJiQWvexwh+GJvW4aFFa0g1",
640 "3Ai75UdZjGFNKHAEGeLmkQYybK/EHW5ymRlSg3g8zydJGEc",
641 "I/melLCiBoShHjfZFJEThxLmPHNSi+KOUMypxqYHd7hzg6W",
642 "7qnq6t9puZYXMWj6tEaf6ORWgb7DOXZSTJJjAJPBWa2+Urx",
643 "XX6Ro7L7Xy1zzeYFCk8W5vmn0wMgGpjkWw0ljJWNwIpxZAj9",
644 "p5wMedWasaPS74TZ1b7tI39ncp6QIDAQAB ; t= y : s :yy:x;",
645 "s=*:email;; h= sha1:sha 256:other;; n=ignore these notes "
646 ),
647 R_HASH_SHA1
648 | R_HASH_SHA256
649 | R_SVC_ALL
650 | R_SVC_EMAIL
651 | R_FLAG_MATCH_DOMAIN
652 | R_FLAG_TESTING,
653 ),
654 (
655 concat!(
656 "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCYtb/9Sh8nGKV7exhUFS",
657 "+cBNXlHgO1CxD9zIfQd5ztlq1LO7g38dfmFpQafh9lKgqPBTolFhZxhF1yUNT",
658 "hpV673NdAtaCVGNyx/fTYtvyyFe9DH2tmm/ijLlygDRboSkIJ4NHZjK++48hk",
659 "NP8/htqWHS+CvwWT4Qgs0NtB7Re9bQIDAQAB"
660 ),
661 0,
662 ),
663 ] {
664 assert_eq!(
665 DomainKey::parse(record.as_bytes()).unwrap().f,
666 expected_result
667 );
668 }
669 }
670
671 #[test]
672 fn dkim_report_record_parse() {
673 for (record, expected_result) in [
674 (
675 "ra=dkim-errors; rp=97; rr=v:x",
676 DomainKeyReport {
677 ra: "dkim-errors".to_string(),
678 rp: 97,
679 rr: RR_VERIFICATION | RR_EXPIRATION,
680 rs: None,
681 },
682 ),
683 (
684 "ra=postmaster; rp=1; rr=d:o:p:s:u:v:x; rs=Error=20Message;",
685 DomainKeyReport {
686 ra: "postmaster".to_string(),
687 rp: 1,
688 rr: RR_DNS
689 | RR_OTHER
690 | RR_POLICY
691 | RR_SIGNATURE
692 | RR_UNKNOWN_TAG
693 | RR_VERIFICATION
694 | RR_EXPIRATION,
695 rs: "Error Message".to_string().into(),
696 },
697 ),
698 ] {
699 assert_eq!(
700 DomainKeyReport::parse(record.as_bytes()).unwrap(),
701 expected_result
702 );
703 }
704 }
705}