email_auth/bimi/
parser.rs1use super::types::{BimiRecord, BimiSelectorHeader};
2
3#[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
17pub fn parse_bimi_record(value: &str) -> Result<BimiRecord, BimiParseError> {
19 let trimmed = value.trim();
20 let parts: Vec<&str> = trimmed.split(';').collect();
21
22 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, };
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 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 }
116 }
117 }
118
119 Ok(BimiRecord {
120 version: "BIMI1".to_string(),
121 logo_uris,
122 authority_uri,
123 })
124}
125
126pub fn is_declination(record: &BimiRecord) -> bool {
128 record.logo_uris.is_empty() && record.authority_uri.is_none()
129}
130
131pub 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 _ => {} }
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
348 fn missing_l_with_a_is_declination() {
349 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}