Skip to main content

email_auth/bimi/
parser.rs

1use super::types::{BimiRecord, BimiSelectorHeader};
2
3/// BIMI parse error.
4#[derive(Debug, Clone)]
5pub struct BimiParseError {
6    pub detail: String,
7}
8
9impl std::fmt::Display for BimiParseError {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        f.write_str(&self.detail)
12    }
13}
14
15impl std::error::Error for BimiParseError {}
16
17/// Parse a BIMI DNS record from a TXT record value.
18pub fn parse_bimi_record(value: &str) -> Result<BimiRecord, BimiParseError> {
19    let trimmed = value.trim();
20    let parts: Vec<&str> = trimmed.split(';').collect();
21
22    // First tag must be v=BIMI1
23    let first = parts
24        .first()
25        .map(|s| s.trim())
26        .unwrap_or("");
27
28    if !first.starts_with("v=") && !first.starts_with("V=") {
29        return Err(BimiParseError {
30            detail: "first tag must be v=BIMI1".into(),
31        });
32    }
33
34    let version_val = first.splitn(2, '=').nth(1).unwrap_or("").trim();
35    if !version_val.eq_ignore_ascii_case("BIMI1") {
36        return Err(BimiParseError {
37            detail: format!("invalid version: expected BIMI1, got '{}'", version_val),
38        });
39    }
40
41    let mut logo_uris: Vec<String> = Vec::new();
42    let mut authority_uri: Option<String> = Option::None;
43    let mut seen_l = false;
44    let mut seen_a = false;
45
46    for part in &parts[1..] {
47        let tag_value = part.trim();
48        if tag_value.is_empty() {
49            continue;
50        }
51
52        let (tag, val) = match tag_value.split_once('=') {
53            Some((t, v)) => (t.trim().to_ascii_lowercase(), v.trim().to_string()),
54            None => continue, // ignore malformed
55        };
56
57        match tag.as_str() {
58            "l" => {
59                if seen_l {
60                    return Err(BimiParseError {
61                        detail: "duplicate l= tag".into(),
62                    });
63                }
64                seen_l = true;
65
66                if val.is_empty() {
67                    // Empty l= → will be declination if no a=
68                    continue;
69                }
70
71                let uris: Vec<&str> = val.split(',').map(|u| u.trim()).filter(|u| !u.is_empty()).collect();
72                if uris.len() > 2 {
73                    return Err(BimiParseError {
74                        detail: format!("l= has {} URIs, max 2", uris.len()),
75                    });
76                }
77
78                for uri in &uris {
79                    if !uri.starts_with("https://") && !uri.starts_with("HTTPS://") {
80                        return Err(BimiParseError {
81                            detail: format!("l= URI must be HTTPS: '{}'", uri),
82                        });
83                    }
84                }
85
86                logo_uris = uris.iter().map(|u| u.to_string()).collect();
87            }
88            "a" => {
89                if seen_a {
90                    return Err(BimiParseError {
91                        detail: "duplicate a= tag".into(),
92                    });
93                }
94                seen_a = true;
95
96                if val.is_empty() {
97                    continue;
98                }
99
100                if !val.starts_with("https://") && !val.starts_with("HTTPS://") {
101                    return Err(BimiParseError {
102                        detail: format!("a= URI must be HTTPS: '{}'", val),
103                    });
104                }
105
106                authority_uri = Some(val);
107            }
108            "v" => {
109                return Err(BimiParseError {
110                    detail: "v= must be first tag".into(),
111                });
112            }
113            _ => {
114                // Unknown tags ignored
115            }
116        }
117    }
118
119    Ok(BimiRecord {
120        version: "BIMI1".to_string(),
121        logo_uris,
122        authority_uri,
123    })
124}
125
126/// Check if a parsed BimiRecord is a declination record.
127pub fn is_declination(record: &BimiRecord) -> bool {
128    record.logo_uris.is_empty() && record.authority_uri.is_none()
129}
130
131/// Parse BIMI-Selector header value.
132/// Format: `v=BIMI1; s=<selector>;`
133pub fn parse_bimi_selector(value: &str) -> Result<BimiSelectorHeader, BimiParseError> {
134    let trimmed = value.trim();
135    let parts: Vec<&str> = trimmed.split(';').collect();
136
137    let mut version = Option::None;
138    let mut selector = Option::None;
139
140    for (idx, part) in parts.iter().enumerate() {
141        let tag_value = part.trim();
142        if tag_value.is_empty() {
143            continue;
144        }
145
146        let (tag, val) = match tag_value.split_once('=') {
147            Some((t, v)) => (t.trim().to_ascii_lowercase(), v.trim().to_string()),
148            None => continue,
149        };
150
151        match tag.as_str() {
152            "v" => {
153                if idx != 0 {
154                    return Err(BimiParseError {
155                        detail: "v= must be first tag".into(),
156                    });
157                }
158                if !val.eq_ignore_ascii_case("BIMI1") {
159                    return Err(BimiParseError {
160                        detail: format!("invalid version: '{}'", val),
161                    });
162                }
163                version = Some(val);
164            }
165            "s" => {
166                if val.is_empty() {
167                    return Err(BimiParseError {
168                        detail: "s= selector must not be empty".into(),
169                    });
170                }
171                selector = Some(val);
172            }
173            _ => {} // ignore unknown
174        }
175    }
176
177    if version.is_none() {
178        return Err(BimiParseError {
179            detail: "missing v= tag".into(),
180        });
181    }
182
183    Ok(BimiSelectorHeader {
184        version: "BIMI1".to_string(),
185        selector: selector.unwrap_or_else(|| "default".to_string()),
186    })
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    // ─── CHK-986: Valid record ───────────────────────────────────────
194
195    #[test]
196    fn parse_valid_record() {
197        let r = parse_bimi_record(
198            "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/cert.pem;",
199        )
200        .unwrap();
201        assert_eq!(r.version, "BIMI1");
202        assert_eq!(r.logo_uris, vec!["https://example.com/logo.svg"]);
203        assert_eq!(
204            r.authority_uri,
205            Some("https://example.com/cert.pem".to_string())
206        );
207    }
208
209    // ─── CHK-987: Multiple logo URIs ─────────────────────────────────
210
211    #[test]
212    fn parse_multiple_logo_uris() {
213        let r = parse_bimi_record(
214            "v=BIMI1; l=https://a.com/1.svg,https://a.com/2.svg;",
215        )
216        .unwrap();
217        assert_eq!(r.logo_uris.len(), 2);
218        assert_eq!(r.logo_uris[0], "https://a.com/1.svg");
219        assert_eq!(r.logo_uris[1], "https://a.com/2.svg");
220    }
221
222    // ─── CHK-988: v= not first → error ──────────────────────────────
223
224    #[test]
225    fn v_not_first_error() {
226        let r = parse_bimi_record("l=https://example.com/logo.svg; v=BIMI1;");
227        assert!(r.is_err());
228    }
229
230    // ─── CHK-989: Non-HTTPS URI → error ──────────────────────────────
231
232    #[test]
233    fn non_https_l_error() {
234        let r = parse_bimi_record("v=BIMI1; l=http://example.com/logo.svg;");
235        assert!(r.is_err());
236        assert!(r.unwrap_err().detail.contains("HTTPS"));
237    }
238
239    #[test]
240    fn non_https_a_error() {
241        let r = parse_bimi_record(
242            "v=BIMI1; l=https://example.com/logo.svg; a=http://example.com/cert.pem;",
243        );
244        assert!(r.is_err());
245    }
246
247    // ─── CHK-990: Unknown tags → ignored ─────────────────────────────
248
249    #[test]
250    fn unknown_tags_ignored() {
251        let r = parse_bimi_record(
252            "v=BIMI1; l=https://example.com/logo.svg; x=foo; z=bar;",
253        )
254        .unwrap();
255        assert_eq!(r.logo_uris, vec!["https://example.com/logo.svg"]);
256    }
257
258    // ─── CHK-991: Declination → Declined ─────────────────────────────
259
260    #[test]
261    fn declination_record() {
262        let r = parse_bimi_record("v=BIMI1;").unwrap();
263        assert!(r.logo_uris.is_empty());
264        assert!(r.authority_uri.is_none());
265        assert!(is_declination(&r));
266    }
267
268    #[test]
269    fn declination_with_empty_l() {
270        let r = parse_bimi_record("v=BIMI1; l=;").unwrap();
271        assert!(is_declination(&r));
272    }
273
274    // ─── CHK-992: More than 2 URIs → error ───────────────────────────
275
276    #[test]
277    fn too_many_uris_error() {
278        let r = parse_bimi_record(
279            "v=BIMI1; l=https://a.com/1.svg,https://a.com/2.svg,https://a.com/3.svg;",
280        );
281        assert!(r.is_err());
282        assert!(r.unwrap_err().detail.contains("max 2"));
283    }
284
285    // ─── CHK-946: v= not BIMI1 → error ──────────────────────────────
286
287    #[test]
288    fn v_not_bimi1_error() {
289        let r = parse_bimi_record("v=BIMI2; l=https://example.com/logo.svg;");
290        assert!(r.is_err());
291    }
292
293    // ─── CHK-940: Semicolon separated ────────────────────────────────
294
295    #[test]
296    fn trailing_semicolons() {
297        let r = parse_bimi_record(
298            "v=BIMI1; l=https://example.com/logo.svg;;;",
299        )
300        .unwrap();
301        assert_eq!(r.logo_uris, vec!["https://example.com/logo.svg"]);
302    }
303
304    // ─── BIMI-Selector parsing ───────────────────────────────────────
305
306    #[test]
307    fn parse_selector_valid() {
308        let s = parse_bimi_selector("v=BIMI1; s=brand;").unwrap();
309        assert_eq!(s.version, "BIMI1");
310        assert_eq!(s.selector, "brand");
311    }
312
313    #[test]
314    fn parse_selector_default() {
315        let s = parse_bimi_selector("v=BIMI1;").unwrap();
316        assert_eq!(s.selector, "default");
317    }
318
319    #[test]
320    fn parse_selector_missing_v() {
321        let r = parse_bimi_selector("s=brand;");
322        assert!(r.is_err());
323    }
324
325    #[test]
326    fn parse_selector_v_not_first() {
327        let r = parse_bimi_selector("s=brand; v=BIMI1;");
328        assert!(r.is_err());
329    }
330
331    // ─── CHK-943: a= single HTTPS URI ───────────────────────────────
332
333    #[test]
334    fn a_tag_valid() {
335        let r = parse_bimi_record(
336            "v=BIMI1; l=https://example.com/logo.svg; a=https://example.com/vmc.pem;",
337        )
338        .unwrap();
339        assert_eq!(
340            r.authority_uri,
341            Some("https://example.com/vmc.pem".to_string())
342        );
343    }
344
345    // ─── CHK-948: Missing l= → error (unless declination) ───────────
346
347    #[test]
348    fn missing_l_with_a_is_declination() {
349        // No l= tag at all, but has a= → still valid, logo_uris empty
350        // Actually, missing l= with a= is not a declination since a= is present
351        // Per spec: declination is empty l= with no a=
352        let r = parse_bimi_record(
353            "v=BIMI1; a=https://example.com/vmc.pem;",
354        )
355        .unwrap();
356        assert!(r.logo_uris.is_empty());
357        assert!(r.authority_uri.is_some());
358        assert!(!is_declination(&r));
359    }
360}