Skip to main content

email_auth/dkim/
parser.rs

1use std::collections::HashSet;
2
3use base64::Engine;
4
5use super::types::{Algorithm, CanonicalizationMethod, DkimSignature, PermFailKind};
6
7/// Error from DKIM signature or key parsing.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct DkimParseError {
10    pub kind: PermFailKind,
11    pub detail: String,
12}
13
14impl std::fmt::Display for DkimParseError {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        write!(f, "{:?}: {}", self.kind, self.detail)
17    }
18}
19
20impl std::error::Error for DkimParseError {}
21
22fn malformed(detail: impl Into<String>) -> DkimParseError {
23    DkimParseError {
24        kind: PermFailKind::MalformedSignature,
25        detail: detail.into(),
26    }
27}
28
29fn domain_mismatch(detail: impl Into<String>) -> DkimParseError {
30    DkimParseError {
31        kind: PermFailKind::DomainMismatch,
32        detail: detail.into(),
33    }
34}
35
36/// Parse tag=value pairs from a DKIM header or key record string.
37/// Handles folded headers (CRLF+WSP) and whitespace around tags/values.
38pub fn parse_tag_list(input: &str) -> Vec<(String, String)> {
39    // Unfold: remove CRLF followed by whitespace
40    let unfolded = unfold(input);
41
42    let mut tags = Vec::new();
43    for part in unfolded.split(';') {
44        let trimmed = part.trim();
45        if trimmed.is_empty() {
46            continue;
47        }
48        if let Some((name, value)) = trimmed.split_once('=') {
49            tags.push((name.trim().to_string(), value.trim().to_string()));
50        }
51    }
52    tags
53}
54
55/// Unfold headers: remove CRLF followed by whitespace.
56fn unfold(s: &str) -> String {
57    let mut result = String::with_capacity(s.len());
58    let bytes = s.as_bytes();
59    let mut i = 0;
60    while i < bytes.len() {
61        if i + 1 < bytes.len() && bytes[i] == b'\r' && bytes[i + 1] == b'\n' {
62            // Check if next char after CRLF is whitespace
63            if i + 2 < bytes.len() && (bytes[i + 2] == b' ' || bytes[i + 2] == b'\t') {
64                // Skip CRLF, keep the whitespace
65                i += 2;
66                continue;
67            }
68        }
69        result.push(bytes[i] as char);
70        i += 1;
71    }
72    result
73}
74
75/// Decode base64 with whitespace stripped.
76fn decode_base64(value: &str) -> Result<Vec<u8>, DkimParseError> {
77    let cleaned: String = value.chars().filter(|c| !c.is_ascii_whitespace()).collect();
78    base64::engine::general_purpose::STANDARD
79        .decode(&cleaned)
80        .map_err(|e| malformed(format!("invalid base64: {}", e)))
81}
82
83impl DkimSignature {
84    /// Parse a DKIM-Signature header value into a DkimSignature.
85    /// The input is the header value (everything after "DKIM-Signature:").
86    pub fn parse(header_value: &str) -> Result<Self, DkimParseError> {
87        let raw_header = header_value.to_string();
88        let tags = parse_tag_list(header_value);
89
90        // Check for duplicate tags
91        let mut seen = HashSet::new();
92        for (name, _) in &tags {
93            if !seen.insert(name.as_str()) {
94                return Err(malformed(format!("duplicate tag: {}", name)));
95            }
96        }
97
98        let get = |name: &str| -> Option<&str> {
99            tags.iter()
100                .find(|(n, _)| n == name)
101                .map(|(_, v)| v.as_str())
102        };
103
104        // Required tags
105        let version_str = get("v").ok_or_else(|| malformed("missing required tag: v"))?;
106        let version: u8 = version_str
107            .parse()
108            .map_err(|_| malformed(format!("invalid version: {}", version_str)))?;
109        if version != 1 {
110            return Err(malformed(format!("unsupported version: {}", version)));
111        }
112
113        let algo_str = get("a").ok_or_else(|| malformed("missing required tag: a"))?;
114        let algorithm = Algorithm::parse(algo_str)
115            .ok_or_else(|| malformed(format!("unknown algorithm: {}", algo_str)))?;
116
117        let b_raw = get("b").ok_or_else(|| malformed("missing required tag: b"))?;
118        let signature = decode_base64(b_raw)?;
119
120        let bh_raw = get("bh").ok_or_else(|| malformed("missing required tag: bh"))?;
121        let body_hash = decode_base64(bh_raw)?;
122
123        let domain = get("d")
124            .ok_or_else(|| malformed("missing required tag: d"))?
125            .to_string();
126
127        let h_raw = get("h").ok_or_else(|| malformed("missing required tag: h"))?;
128        let signed_headers: Vec<String> = h_raw
129            .split(':')
130            .map(|s| s.trim().to_string())
131            .filter(|s| !s.is_empty())
132            .collect();
133
134        // h= must include "from" (case-insensitive)
135        if !signed_headers.iter().any(|h| h.eq_ignore_ascii_case("from")) {
136            return Err(malformed("h= tag must include \"from\""));
137        }
138
139        let selector = get("s")
140            .ok_or_else(|| malformed("missing required tag: s"))?
141            .to_string();
142
143        // Optional tags
144        let (header_canonicalization, body_canonicalization) = if let Some(c_val) = get("c") {
145            parse_canonicalization(c_val)?
146        } else {
147            (CanonicalizationMethod::Simple, CanonicalizationMethod::Simple)
148        };
149
150        let auid = if let Some(i_val) = get("i") {
151            i_val.to_string()
152        } else {
153            format!("@{}", domain)
154        };
155
156        // Validate i= is subdomain of or equal to d=
157        validate_auid_domain(&auid, &domain)?;
158
159        let body_length = if let Some(l_val) = get("l") {
160            Some(
161                l_val
162                    .parse::<u64>()
163                    .map_err(|_| malformed(format!("invalid l= value: {}", l_val)))?,
164            )
165        } else {
166            None
167        };
168
169        // q= is parsed but only dns/txt is defined; we just accept it
170        // (CHK-328)
171
172        let timestamp = if let Some(t_val) = get("t") {
173            Some(
174                t_val
175                    .parse::<u64>()
176                    .map_err(|_| malformed(format!("invalid t= value: {}", t_val)))?,
177            )
178        } else {
179            None
180        };
181
182        let expiration = if let Some(x_val) = get("x") {
183            Some(
184                x_val
185                    .parse::<u64>()
186                    .map_err(|_| malformed(format!("invalid x= value: {}", x_val)))?,
187            )
188        } else {
189            None
190        };
191
192        let copied_headers = get("z").map(|z_val| {
193            z_val
194                .split('|')
195                .map(|s| s.trim().to_string())
196                .collect::<Vec<_>>()
197        });
198
199        Ok(DkimSignature {
200            version,
201            algorithm,
202            signature,
203            body_hash,
204            header_canonicalization,
205            body_canonicalization,
206            domain,
207            signed_headers,
208            auid,
209            body_length,
210            selector,
211            timestamp,
212            expiration,
213            copied_headers,
214            raw_header,
215        })
216    }
217}
218
219/// Parse c= tag value into (header, body) canonicalization methods.
220fn parse_canonicalization(
221    value: &str,
222) -> Result<(CanonicalizationMethod, CanonicalizationMethod), DkimParseError> {
223    if let Some((header, body)) = value.split_once('/') {
224        let h = CanonicalizationMethod::parse(header.trim())
225            .ok_or_else(|| malformed(format!("unknown header canonicalization: {}", header)))?;
226        let b = CanonicalizationMethod::parse(body.trim())
227            .ok_or_else(|| malformed(format!("unknown body canonicalization: {}", body)))?;
228        Ok((h, b))
229    } else {
230        let h = CanonicalizationMethod::parse(value.trim())
231            .ok_or_else(|| malformed(format!("unknown canonicalization: {}", value)))?;
232        // Body defaults to Simple when only header is specified
233        Ok((h, CanonicalizationMethod::Simple))
234    }
235}
236
237/// Validate that i= AUID domain is subdomain of or equal to d=.
238fn validate_auid_domain(auid: &str, domain: &str) -> Result<(), DkimParseError> {
239    // Extract domain part from i= (everything after @)
240    let i_domain = if let Some(at_pos) = auid.rfind('@') {
241        &auid[at_pos + 1..]
242    } else {
243        auid
244    };
245
246    let i_lower = i_domain.to_ascii_lowercase();
247    let d_lower = domain.to_ascii_lowercase();
248
249    if i_lower == d_lower {
250        return Ok(());
251    }
252
253    // i= domain must be subdomain of d=
254    if i_lower.ends_with(&format!(".{}", d_lower)) {
255        return Ok(());
256    }
257
258    Err(domain_mismatch(format!(
259        "i= domain '{}' is not subdomain of d= '{}'",
260        i_domain, domain
261    )))
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::dkim::types::*;
268
269    // Helper: build a minimal valid DKIM-Signature header value
270    fn minimal_sig() -> String {
271        let b = base64::engine::general_purpose::STANDARD.encode(b"fakesig");
272        let bh = base64::engine::general_purpose::STANDARD.encode(b"fakehash");
273        format!(
274            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1",
275            b, bh
276        )
277    }
278
279    // CHK-450: Minimal valid signature
280    #[test]
281    fn parse_minimal_signature() {
282        let sig = DkimSignature::parse(&minimal_sig()).unwrap();
283        assert_eq!(sig.version, 1);
284        assert_eq!(sig.algorithm, Algorithm::RsaSha256);
285        assert_eq!(sig.domain, "example.com");
286        assert_eq!(sig.selector, "sel1");
287        assert_eq!(sig.signed_headers, vec!["from"]);
288        assert_eq!(sig.auid, "@example.com"); // default
289        assert_eq!(
290            sig.header_canonicalization,
291            CanonicalizationMethod::Simple
292        );
293        assert_eq!(sig.body_canonicalization, CanonicalizationMethod::Simple);
294        assert!(sig.body_length.is_none());
295        assert!(sig.timestamp.is_none());
296        assert!(sig.expiration.is_none());
297        assert!(sig.copied_headers.is_none());
298    }
299
300    // CHK-254..CHK-269: DkimSignature struct fields
301    #[test]
302    fn signature_has_all_fields() {
303        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
304        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
305        let input = format!(
306            "v=1; a=ed25519-sha256; b={}; bh={}; d=example.com; h=from:to:subject; \
307             s=sel1; c=relaxed/relaxed; i=user@example.com; l=100; t=1000; x=2000; \
308             z=From:user@example.com|To:dest@example.com",
309            b, bh
310        );
311        let sig = DkimSignature::parse(&input).unwrap();
312        assert_eq!(sig.version, 1);
313        assert_eq!(sig.algorithm, Algorithm::Ed25519Sha256);
314        assert_eq!(sig.signature, b"sig");
315        assert_eq!(sig.body_hash, b"hash");
316        assert_eq!(
317            sig.header_canonicalization,
318            CanonicalizationMethod::Relaxed
319        );
320        assert_eq!(sig.body_canonicalization, CanonicalizationMethod::Relaxed);
321        assert_eq!(sig.domain, "example.com");
322        assert_eq!(sig.signed_headers, vec!["from", "to", "subject"]);
323        assert_eq!(sig.auid, "user@example.com");
324        assert_eq!(sig.body_length, Some(100));
325        assert_eq!(sig.selector, "sel1");
326        assert_eq!(sig.timestamp, Some(1000));
327        assert_eq!(sig.expiration, Some(2000));
328        assert_eq!(
329            sig.copied_headers,
330            Some(vec![
331                "From:user@example.com".to_string(),
332                "To:dest@example.com".to_string()
333            ])
334        );
335    }
336
337    // CHK-451: All optional tags present
338    #[test]
339    fn parse_all_optional_tags() {
340        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
341        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
342        let input = format!(
343            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; \
344             c=relaxed/simple; i=user@sub.example.com; l=500; q=dns/txt; t=12345; x=99999; \
345             z=From:test",
346            b, bh
347        );
348        let sig = DkimSignature::parse(&input).unwrap();
349        assert_eq!(
350            sig.header_canonicalization,
351            CanonicalizationMethod::Relaxed
352        );
353        assert_eq!(sig.body_canonicalization, CanonicalizationMethod::Simple);
354        assert_eq!(sig.auid, "user@sub.example.com");
355        assert_eq!(sig.body_length, Some(500));
356        assert_eq!(sig.timestamp, Some(12345));
357        assert_eq!(sig.expiration, Some(99999));
358        assert_eq!(sig.copied_headers, Some(vec!["From:test".to_string()]));
359    }
360
361    // CHK-452: Folded header value
362    #[test]
363    fn parse_folded_header() {
364        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
365        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
366        let input = format!(
367            "v=1; a=rsa-sha256;\r\n b={};\r\n\tbh={}; d=example.com;\r\n h=from; s=sel1",
368            b, bh
369        );
370        let sig = DkimSignature::parse(&input).unwrap();
371        assert_eq!(sig.algorithm, Algorithm::RsaSha256);
372        assert_eq!(sig.domain, "example.com");
373    }
374
375    // CHK-453: Base64 with embedded whitespace
376    #[test]
377    fn parse_base64_with_whitespace() {
378        let raw_b = base64::engine::general_purpose::STANDARD.encode(b"signaturedata");
379        let raw_bh = base64::engine::general_purpose::STANDARD.encode(b"bodyhashdata");
380        // Insert spaces in the middle of base64
381        let spaced_b = format!(
382            "{} {}",
383            &raw_b[..raw_b.len() / 2],
384            &raw_b[raw_b.len() / 2..]
385        );
386        let input = format!(
387            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1",
388            spaced_b, raw_bh
389        );
390        let sig = DkimSignature::parse(&input).unwrap();
391        assert_eq!(sig.signature, b"signaturedata");
392    }
393
394    // CHK-454: Missing required tag → PermFail
395    #[test]
396    fn parse_missing_required_tag_v() {
397        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
398        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
399        let input = format!(
400            "a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1",
401            b, bh
402        );
403        let err = DkimSignature::parse(&input).unwrap_err();
404        assert_eq!(err.kind, PermFailKind::MalformedSignature);
405        assert!(err.detail.contains("v"));
406    }
407
408    #[test]
409    fn parse_missing_required_tag_b() {
410        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
411        let input = format!(
412            "v=1; a=rsa-sha256; bh={}; d=example.com; h=from; s=sel1",
413            bh
414        );
415        let err = DkimSignature::parse(&input).unwrap_err();
416        assert_eq!(err.kind, PermFailKind::MalformedSignature);
417    }
418
419    #[test]
420    fn parse_missing_required_tag_d() {
421        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
422        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
423        let input = format!("v=1; a=rsa-sha256; b={}; bh={}; h=from; s=sel1", b, bh);
424        let err = DkimSignature::parse(&input).unwrap_err();
425        assert_eq!(err.kind, PermFailKind::MalformedSignature);
426        assert!(err.detail.contains("d"));
427    }
428
429    #[test]
430    fn parse_missing_required_tag_h() {
431        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
432        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
433        let input = format!(
434            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; s=sel1",
435            b, bh
436        );
437        let err = DkimSignature::parse(&input).unwrap_err();
438        assert_eq!(err.kind, PermFailKind::MalformedSignature);
439        assert!(err.detail.contains("h"));
440    }
441
442    #[test]
443    fn parse_missing_required_tag_s() {
444        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
445        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
446        let input = format!(
447            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from",
448            b, bh
449        );
450        let err = DkimSignature::parse(&input).unwrap_err();
451        assert_eq!(err.kind, PermFailKind::MalformedSignature);
452        assert!(err.detail.contains("s"));
453    }
454
455    // CHK-455: Duplicate tag → PermFail
456    #[test]
457    fn parse_duplicate_tag() {
458        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
459        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
460        let input = format!(
461            "v=1; v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1",
462            b, bh
463        );
464        let err = DkimSignature::parse(&input).unwrap_err();
465        assert_eq!(err.kind, PermFailKind::MalformedSignature);
466        assert!(err.detail.contains("duplicate"));
467    }
468
469    // CHK-456: Unknown tag → ignored
470    #[test]
471    fn parse_unknown_tag_ignored() {
472        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
473        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
474        let input = format!(
475            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; x_custom=hello",
476            b, bh
477        );
478        let sig = DkimSignature::parse(&input).unwrap();
479        assert_eq!(sig.domain, "example.com");
480    }
481
482    // CHK-457: Invalid algorithm → PermFail
483    #[test]
484    fn parse_invalid_algorithm() {
485        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
486        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
487        let input = format!(
488            "v=1; a=rsa-md5; b={}; bh={}; d=example.com; h=from; s=sel1",
489            b, bh
490        );
491        let err = DkimSignature::parse(&input).unwrap_err();
492        assert_eq!(err.kind, PermFailKind::MalformedSignature);
493        assert!(err.detail.contains("unknown algorithm"));
494    }
495
496    // CHK-458: Case-insensitive algorithm
497    #[test]
498    fn parse_case_insensitive_algorithm() {
499        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
500        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
501        let input = format!(
502            "v=1; a=RSA-SHA256; b={}; bh={}; d=example.com; h=from; s=sel1",
503            b, bh
504        );
505        let sig = DkimSignature::parse(&input).unwrap();
506        assert_eq!(sig.algorithm, Algorithm::RsaSha256);
507    }
508
509    // CHK-459: h= missing "from" → PermFail
510    #[test]
511    fn parse_h_missing_from() {
512        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
513        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
514        let input = format!(
515            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=to:subject; s=sel1",
516            b, bh
517        );
518        let err = DkimSignature::parse(&input).unwrap_err();
519        assert_eq!(err.kind, PermFailKind::MalformedSignature);
520        assert!(err.detail.contains("from"));
521    }
522
523    // CHK-460: i= not subdomain of d= → PermFail
524    #[test]
525    fn parse_i_not_subdomain() {
526        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
527        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
528        let input = format!(
529            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; i=user@other.com",
530            b, bh
531        );
532        let err = DkimSignature::parse(&input).unwrap_err();
533        assert_eq!(err.kind, PermFailKind::DomainMismatch);
534    }
535
536    // CHK-461: c= parsing variants
537    #[test]
538    fn parse_c_relaxed_relaxed() {
539        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
540        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
541        let input = format!(
542            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; c=relaxed/relaxed",
543            b, bh
544        );
545        let sig = DkimSignature::parse(&input).unwrap();
546        assert_eq!(
547            sig.header_canonicalization,
548            CanonicalizationMethod::Relaxed
549        );
550        assert_eq!(sig.body_canonicalization, CanonicalizationMethod::Relaxed);
551    }
552
553    #[test]
554    fn parse_c_simple_only() {
555        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
556        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
557        let input = format!(
558            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; c=simple",
559            b, bh
560        );
561        let sig = DkimSignature::parse(&input).unwrap();
562        assert_eq!(sig.header_canonicalization, CanonicalizationMethod::Simple);
563        assert_eq!(sig.body_canonicalization, CanonicalizationMethod::Simple);
564    }
565
566    #[test]
567    fn parse_c_relaxed_only_body_defaults_simple() {
568        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
569        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
570        let input = format!(
571            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; c=relaxed",
572            b, bh
573        );
574        let sig = DkimSignature::parse(&input).unwrap();
575        assert_eq!(
576            sig.header_canonicalization,
577            CanonicalizationMethod::Relaxed
578        );
579        assert_eq!(sig.body_canonicalization, CanonicalizationMethod::Simple);
580    }
581
582    // CHK-270..CHK-275: Algorithm enum
583    #[test]
584    fn algorithm_parse_all_variants() {
585        assert_eq!(Algorithm::parse("rsa-sha1"), Some(Algorithm::RsaSha1));
586        assert_eq!(Algorithm::parse("rsa-sha256"), Some(Algorithm::RsaSha256));
587        assert_eq!(
588            Algorithm::parse("ed25519-sha256"),
589            Some(Algorithm::Ed25519Sha256)
590        );
591        assert_eq!(Algorithm::parse("RSA-SHA256"), Some(Algorithm::RsaSha256));
592        assert!(Algorithm::parse("unknown").is_none());
593    }
594
595    // CHK-276..CHK-280: CanonicalizationMethod enum
596    #[test]
597    fn canonicalization_parse() {
598        assert_eq!(
599            CanonicalizationMethod::parse("simple"),
600            Some(CanonicalizationMethod::Simple)
601        );
602        assert_eq!(
603            CanonicalizationMethod::parse("relaxed"),
604            Some(CanonicalizationMethod::Relaxed)
605        );
606        assert_eq!(
607            CanonicalizationMethod::parse("SIMPLE"),
608            Some(CanonicalizationMethod::Simple)
609        );
610        assert!(CanonicalizationMethod::parse("unknown").is_none());
611    }
612
613    // CHK-292..CHK-310: Result types exist
614    #[test]
615    fn result_types_exist() {
616        let _pass = DkimResult::Pass {
617            domain: "example.com".into(),
618            selector: "sel1".into(),
619            testing: false,
620        };
621        let _fail = DkimResult::Fail {
622            kind: FailureKind::BodyHashMismatch,
623            detail: "test".into(),
624        };
625        let _permfail = DkimResult::PermFail {
626            kind: PermFailKind::MalformedSignature,
627            detail: "test".into(),
628        };
629        let _tempfail = DkimResult::TempFail {
630            reason: "dns".into(),
631        };
632        let _none = DkimResult::None;
633
634        // FailureKind variants
635        let _ = FailureKind::BodyHashMismatch;
636        let _ = FailureKind::SignatureVerificationFailed;
637
638        // PermFailKind variants
639        let _ = PermFailKind::MalformedSignature;
640        let _ = PermFailKind::KeyRevoked;
641        let _ = PermFailKind::KeyNotFound;
642        let _ = PermFailKind::ExpiredSignature;
643        let _ = PermFailKind::AlgorithmMismatch;
644        let _ = PermFailKind::HashNotPermitted;
645        let _ = PermFailKind::ServiceTypeMismatch;
646        let _ = PermFailKind::StrictModeViolation;
647        let _ = PermFailKind::DomainMismatch;
648    }
649
650    // CHK-311: Tag=value pairs
651    #[test]
652    fn tag_list_parsing() {
653        let tags = parse_tag_list("a=b; c=d; e=f");
654        assert_eq!(tags.len(), 3);
655        assert_eq!(tags[0], ("a".into(), "b".into()));
656        assert_eq!(tags[1], ("c".into(), "d".into()));
657        assert_eq!(tags[2], ("e".into(), "f".into()));
658    }
659
660    // CHK-312: Folded headers
661    #[test]
662    fn unfold_crlf_space() {
663        let input = "hello\r\n world";
664        let result = unfold(input);
665        assert_eq!(result, "hello world");
666    }
667
668    #[test]
669    fn unfold_crlf_tab() {
670        let input = "hello\r\n\tworld";
671        let result = unfold(input);
672        assert_eq!(result, "hello\tworld");
673    }
674
675    // CHK-313: Strip whitespace
676    #[test]
677    fn tag_list_strips_whitespace() {
678        let tags = parse_tag_list("  a = b ; c = d  ");
679        assert_eq!(tags[0], ("a".into(), "b".into()));
680        assert_eq!(tags[1], ("c".into(), "d".into()));
681    }
682
683    // CHK-314: Base64 whitespace handling
684    #[test]
685    fn decode_base64_with_spaces() {
686        let encoded = base64::engine::general_purpose::STANDARD.encode(b"test data");
687        let spaced = format!("{} {}", &encoded[..4], &encoded[4..]);
688        let decoded = decode_base64(&spaced).unwrap();
689        assert_eq!(decoded, b"test data");
690    }
691
692    // CHK-315: Version must be 1
693    #[test]
694    fn parse_version_not_1() {
695        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
696        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
697        let input = format!(
698            "v=2; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1",
699            b, bh
700        );
701        let err = DkimSignature::parse(&input).unwrap_err();
702        assert!(err.detail.contains("version"));
703    }
704
705    // CHK-325: i= default to @d=
706    #[test]
707    fn parse_i_defaults_to_at_domain() {
708        let sig = DkimSignature::parse(&minimal_sig()).unwrap();
709        assert_eq!(sig.auid, "@example.com");
710    }
711
712    // CHK-326: i= subdomain valid
713    #[test]
714    fn parse_i_subdomain_valid() {
715        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
716        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
717        let input = format!(
718            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; i=user@sub.example.com",
719            b, bh
720        );
721        let sig = DkimSignature::parse(&input).unwrap();
722        assert_eq!(sig.auid, "user@sub.example.com");
723    }
724
725    // CHK-337: raw_header stored
726    #[test]
727    fn parse_stores_raw_header() {
728        let input = minimal_sig();
729        let sig = DkimSignature::parse(&input).unwrap();
730        assert_eq!(sig.raw_header, input);
731    }
732
733    // CHK-320: h= colon-separated
734    #[test]
735    fn parse_h_multiple_headers() {
736        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
737        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
738        let input = format!(
739            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from:to:subject:date; s=sel1",
740            b, bh
741        );
742        let sig = DkimSignature::parse(&input).unwrap();
743        assert_eq!(
744            sig.signed_headers,
745            vec!["from", "to", "subject", "date"]
746        );
747    }
748
749    // CHK-331: z= pipe-separated
750    #[test]
751    fn parse_z_copied_headers() {
752        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
753        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
754        let input = format!(
755            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; z=From:a|To:b|Cc:c",
756            b, bh
757        );
758        let sig = DkimSignature::parse(&input).unwrap();
759        assert_eq!(
760            sig.copied_headers,
761            Some(vec![
762                "From:a".to_string(),
763                "To:b".to_string(),
764                "Cc:c".to_string()
765            ])
766        );
767    }
768
769    // CHK-521: All types defined with typed enums
770    #[test]
771    fn all_types_are_typed_enums() {
772        // Algorithm
773        let a = Algorithm::RsaSha256;
774        assert_eq!(a.hash_algorithm(), HashAlgorithm::Sha256);
775        let a = Algorithm::RsaSha1;
776        assert_eq!(a.hash_algorithm(), HashAlgorithm::Sha1);
777
778        // CanonicalizationMethod
779        let _ = CanonicalizationMethod::Simple;
780        let _ = CanonicalizationMethod::Relaxed;
781
782        // KeyType
783        let _ = KeyType::Rsa;
784        let _ = KeyType::Ed25519;
785
786        // HashAlgorithm
787        let _ = HashAlgorithm::Sha1;
788        let _ = HashAlgorithm::Sha256;
789
790        // KeyFlag
791        let _ = KeyFlag::Testing;
792        let _ = KeyFlag::Strict;
793    }
794
795    // CHK-522: Signature parsing complete
796    #[test]
797    fn parse_rsa_sha1_signature() {
798        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
799        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
800        let input = format!(
801            "v=1; a=rsa-sha1; b={}; bh={}; d=example.com; h=from; s=sel1",
802            b, bh
803        );
804        let sig = DkimSignature::parse(&input).unwrap();
805        assert_eq!(sig.algorithm, Algorithm::RsaSha1);
806    }
807
808    #[test]
809    fn parse_ed25519_signature() {
810        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
811        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
812        let input = format!(
813            "v=1; a=ed25519-sha256; b={}; bh={}; d=example.com; h=from; s=sel1",
814            b, bh
815        );
816        let sig = DkimSignature::parse(&input).unwrap();
817        assert_eq!(sig.algorithm, Algorithm::Ed25519Sha256);
818    }
819
820    // CHK-334: Missing required → PermFail
821    #[test]
822    fn parse_missing_bh() {
823        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
824        let input = format!(
825            "v=1; a=rsa-sha256; b={}; d=example.com; h=from; s=sel1",
826            b
827        );
828        let err = DkimSignature::parse(&input).unwrap_err();
829        assert_eq!(err.kind, PermFailKind::MalformedSignature);
830        assert!(err.detail.contains("bh"));
831    }
832
833    // CHK-333: Duplicate tags PermFail
834    #[test]
835    fn parse_duplicate_d_tag() {
836        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
837        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
838        let input = format!(
839            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; d=other.com; h=from; s=sel1",
840            b, bh
841        );
842        let err = DkimSignature::parse(&input).unwrap_err();
843        assert_eq!(err.kind, PermFailKind::MalformedSignature);
844        assert!(err.detail.contains("duplicate"));
845    }
846
847    // CHK-335: h= must include from (case-insensitive)
848    #[test]
849    fn parse_h_from_case_insensitive() {
850        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
851        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
852        let input = format!(
853            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=From:To; s=sel1",
854            b, bh
855        );
856        let sig = DkimSignature::parse(&input).unwrap();
857        assert_eq!(sig.signed_headers[0], "From");
858    }
859
860    // CHK-336: i= not subdomain → PermFail
861    #[test]
862    fn validate_auid_domain_equal() {
863        assert!(validate_auid_domain("user@example.com", "example.com").is_ok());
864    }
865
866    #[test]
867    fn validate_auid_domain_subdomain() {
868        assert!(validate_auid_domain("user@sub.example.com", "example.com").is_ok());
869    }
870
871    #[test]
872    fn validate_auid_domain_different() {
873        assert!(validate_auid_domain("user@other.com", "example.com").is_err());
874    }
875
876    #[test]
877    fn validate_auid_domain_case_insensitive() {
878        assert!(validate_auid_domain("user@EXAMPLE.COM", "example.com").is_ok());
879    }
880
881    // CHK-327: l= body length
882    #[test]
883    fn parse_l_body_length() {
884        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
885        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
886        let input = format!(
887            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; l=12345",
888            b, bh
889        );
890        let sig = DkimSignature::parse(&input).unwrap();
891        assert_eq!(sig.body_length, Some(12345));
892    }
893
894    // CHK-329, CHK-330: t= and x=
895    #[test]
896    fn parse_timestamp_expiration() {
897        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
898        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
899        let input = format!(
900            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; t=1000000; x=2000000",
901            b, bh
902        );
903        let sig = DkimSignature::parse(&input).unwrap();
904        assert_eq!(sig.timestamp, Some(1000000));
905        assert_eq!(sig.expiration, Some(2000000));
906    }
907
908    // CHK-332: Unknown tags ignored
909    #[test]
910    fn parse_multiple_unknown_tags() {
911        let b = base64::engine::general_purpose::STANDARD.encode(b"sig");
912        let bh = base64::engine::general_purpose::STANDARD.encode(b"hash");
913        let input = format!(
914            "v=1; a=rsa-sha256; b={}; bh={}; d=example.com; h=from; s=sel1; foo=bar; baz=qux",
915            b, bh
916        );
917        let sig = DkimSignature::parse(&input).unwrap();
918        assert_eq!(sig.selector, "sel1");
919    }
920}