Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/email/header.rs

21.1 KiB, 1 run

created by r1870400018:61442, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Lifted out of `crate::dkim` on 2026-09-23, so the submission server reads a message's
2//! header fields exactly as the signer that then signs them does.
3//!
4//! Made strict on 2026-09-24 (D-06 audit F4-1). The first reader split lines on LF alone, dropped
5//! a line with no colon before unfolding, and let a colon clear the address before it, so
6//! `From: ceo@x:hello@x;` and `From: ceo@x\r<hello@x>` passed the sender check as `hello@x`
7//! while a receiver's parser read `ceo@x`. Whatever cannot be read as RFC 5322 reads it is now an
8//! error rather than a guess, because the caller is deciding whose name a message goes out under.
9
10use oxedyne_fe2o3_core::prelude::*;
11
12
13/// A message split at its first blank line: the header block, without the blank line, and the
14/// body, untouched. Only `\r\n\r\n` ends the header section. A message without one is all header
15/// block, and [`header_fields`] then refuses the bare line ends it holds, rather than this
16/// guessing at a second rule for where the header stops.
17pub fn split_headers_body(message: &[u8]) -> (&[u8], &[u8]) {
18 match find_subseq(message, b"\r\n\r\n") {
19 Some(i) => (&message[..i], &message[i + 4..]),
20 None => (message, &[]),
21 }
22}
23
24fn find_subseq(hay: &[u8], needle: &[u8]) -> Option<usize> {
25 if needle.is_empty() || needle.len() > hay.len() { return None; }
26 for i in 0..=hay.len() - needle.len() {
27 if &hay[i..i + needle.len()] == needle {
28 return Some(i);
29 }
30 }
31 None
32}
33
34/// Is `c` a line end to some reader somewhere? A control character other than HTAB, or a Unicode
35/// line or paragraph separator.
36fn breaks_lines(c: char) -> bool {
37 (c.is_control() && c != '\t') || c == '\u{2028}' || c == '\u{2029}'
38}
39
40/// `(name, unfolded value)` pairs, in the order they appeared, read as RFC 5322 §2.2 reads them.
41///
42/// Lines end at CRLF. A CR or an LF standing alone, any other control character, and bytes that
43/// are not UTF-8 are refused rather than read one way here and another by the receiver. Folding
44/// is undone before any line is judged, so a field's name cannot hide on a line of its own. An
45/// empty line, a continuation with nothing before it to continue, and a line with no colon or
46/// with a name that is not printable ASCII are all refused. A value keeps its folding WSP, which
47/// DKIM's relaxed canonicalisation collapses later; the obsolete WSP before the colon (§4.5) is
48/// no part of the name.
49pub fn header_fields(headers: &[u8]) -> Outcome<Vec<(String, String)>> {
50 let text = match std::str::from_utf8(headers) {
51 Ok(t) => t,
52 Err(e) => return Err(err!(
53 "The header section is not UTF-8, from byte {}.", e.valid_up_to();
54 Invalid, Input, Decode)),
55 };
56 // Physical lines, each ended by CRLF. A trailing CRLF leaves nothing after it.
57 let mut lines: Vec<&str> = Vec::new();
58 let mut start = 0usize;
59 let mut chars = text.char_indices();
60 while let Some((i, c)) = chars.next() {
61 match c {
62 '\r' => match chars.next() {
63 Some((_, '\n')) => {
64 lines.push(&text[start..i]);
65 start = i + 2;
66 },
67 _ => return Err(err!(
68 "The header section holds a CR not followed by LF, at byte {}.", i;
69 Invalid, Input)),
70 },
71 '\n' => return Err(err!(
72 "The header section holds an LF not preceded by CR, at byte {}.", i;
73 Invalid, Input)),
74 c if breaks_lines(c) => return Err(err!(
75 "The header section holds the control character {:?}, at byte {}.", c, i;
76 Invalid, Input)),
77 _ => {},
78 }
79 }
80 if start < text.len() {
81 lines.push(&text[start..]);
82 }
83 // Unfolded fields: a line starting with WSP continues the one before it.
84 let mut fields: Vec<String> = Vec::new();
85 for (n, line) in lines.iter().enumerate() {
86 if line.is_empty() {
87 return Err(err!(
88 "Header line {} is empty, which would end the header section there.", n + 1;
89 Invalid, Input));
90 }
91 if line.starts_with(' ') || line.starts_with('\t') {
92 match fields.last_mut() {
93 Some(f) => f.push_str(line),
94 None => return Err(err!(
95 "Header line 1 is a continuation, with no field before it to continue.";
96 Invalid, Input)),
97 }
98 } else {
99 fields.push(line.to_string());
100 }
101 }
102 let mut out = Vec::with_capacity(fields.len());
103 for (n, field) in fields.into_iter().enumerate() {
104 let colon = match field.find(':') {
105 Some(c) => c,
106 None => return Err(err!(
107 "Header field {} has no colon, so it is not a field: {:?}.", n + 1, field;
108 Invalid, Input, Missing)),
109 };
110 let name = field[..colon].trim_end_matches([' ', '\t']);
111 if name.is_empty() || !name.bytes().all(|b| (33..=126).contains(&b)) {
112 return Err(err!(
113 "Header field {} has the name {:?}, which is not printable ASCII.", n + 1, name;
114 Invalid, Input));
115 }
116 out.push((name.to_string(), field[colon + 1..].to_string()));
117 }
118 Ok(out)
119}
120
121/// The addresses in an address-list field such as `From`, `Sender` or `To`, each as its bare
122/// addr-spec, in order, with comments and folding white space removed.
123///
124/// RFC 5322 §3.4, with the UTF-8 of RFC 6532: `a@b`, `Name <a@b>`, `"Last, First" <a@b>`,
125/// comments in parentheses, groups (`Team: a@b, c@d;` and `undisclosed-recipients:;`), and the
126/// obsolete empty members and dotted phrases. Anything else is an error rather than a guess --
127/// a member with no address, text after an address, a quote, comment or bracket left open, an
128/// obsolete route -- since the caller is deciding whose name a message goes out under.
129pub fn addresses(value: &str) -> Outcome<Vec<String>> {
130 if let Some(c) = value.chars().find(|c| breaks_lines(*c)) {
131 return Err(err!(
132 "An address list holds the control character {:?}: {:?}.", c, value;
133 Invalid, Input));
134 }
135 let toks = res!(tokens(value));
136 let mut r = Reader { toks: &toks, pos: 0, value };
137 r.address_list()
138}
139
140/// One lexical piece of an address list; comments and white space are gone by now.
141#[derive(Clone, Debug, Eq, PartialEq)]
142enum Tok {
143 Atom(String), // a run of atext
144 Quoted(String), // a quoted-string, quotes and escapes kept as written
145 Literal(String), // a domain-literal, brackets kept
146 Special(char), // one of `< > : ; @ , .`
147}
148
149/// Is `c` RFC 5322 atext? Beyond ASCII, anything printable is, per RFC 6532.
150fn is_atext(c: char) -> bool {
151 c.is_ascii_alphanumeric()
152 || "!#$%&'*+-/=?^_`{|}~".contains(c)
153 || (!c.is_ascii() && !c.is_whitespace() && !breaks_lines(c))
154}
155
156fn tokens(value: &str) -> Outcome<Vec<Tok>> {
157 let mut out = Vec::new();
158 let mut chars = value.chars().peekable();
159 while let Some(c) = chars.next() {
160 match c {
161 ' ' | '\t' => {},
162 '(' => {
163 // A comment, nested comments and quoted pairs included, is white space.
164 let mut depth = 1usize;
165 while depth > 0 {
166 match chars.next() {
167 Some('\\') => { let _ = chars.next(); },
168 Some('(') => depth += 1,
169 Some(')') => depth -= 1,
170 Some(_) => {},
171 None => return Err(err!(
172 "An address list leaves a comment open: {:?}.", value;
173 Invalid, Input)),
174 }
175 }
176 },
177 '"' => {
178 let mut q = String::from('"');
179 loop {
180 match chars.next() {
181 Some('\\') => match chars.next() {
182 Some(e) => {
183 q.push('\\');
184 q.push(e);
185 },
186 None => return Err(err!(
187 "An address list leaves a quoted string open: {:?}.", value;
188 Invalid, Input)),
189 },
190 Some('"') => {
191 q.push('"');
192 break;
193 },
194 Some(c) => q.push(c),
195 None => return Err(err!(
196 "An address list leaves a quoted string open: {:?}.", value;
197 Invalid, Input)),
198 }
199 }
200 out.push(Tok::Quoted(q));
201 },
202 '[' => {
203 let mut l = String::from('[');
204 loop {
205 match chars.next() {
206 Some(']') => {
207 l.push(']');
208 break;
209 },
210 Some(c) if c == '[' || c == '\\' => return Err(err!(
211 "An address list's domain literal holds {:?}: {:?}.", c, value;
212 Invalid, Input)),
213 Some(c) => l.push(c),
214 None => return Err(err!(
215 "An address list leaves a domain literal open: {:?}.", value;
216 Invalid, Input)),
217 }
218 }
219 out.push(Tok::Literal(l));
220 },
221 '<' | '>' | ':' | ';' | '@' | ',' | '.' => out.push(Tok::Special(c)),
222 c if is_atext(c) => {
223 let mut a = String::from(c);
224 while let Some(&n) = chars.peek() {
225 if !is_atext(n) {
226 break;
227 }
228 a.push(n);
229 let _ = chars.next();
230 }
231 out.push(Tok::Atom(a));
232 },
233 c => return Err(err!(
234 "An address list holds {:?}, which may not stand there: {:?}.", c, value;
235 Invalid, Input)),
236 }
237 }
238 Ok(out)
239}
240
241/// A recursive-descent reader of RFC 5322 §3.4 over [`Tok`]s.
242struct Reader<'a> {
243 toks: &'a [Tok],
244 pos: usize,
245 value: &'a str, // the field as written, for the errors
246}
247
248impl<'a> Reader<'a> {
249 fn peek(&self) -> Option<&'a Tok> {
250 self.toks.get(self.pos)
251 }
252
253 fn at(&self, c: char) -> bool {
254 matches!(self.peek(), Some(Tok::Special(s)) if *s == c)
255 }
256
257 fn expect(&mut self, c: char, phase: &str) -> Outcome<()> {
258 if self.at(c) {
259 self.pos += 1;
260 return Ok(());
261 }
262 Err(err!(
263 "An address list wants {:?} {}, and has {:?} at token {}: {:?}.",
264 c, phase, self.peek(), self.pos, self.value;
265 Invalid, Input))
266 }
267
268 /// The first `<`, `:`, `@`, `,` or `;` from here on, which says what the next member is:
269 /// an angle address, a group, a bare addr-spec, or no address at all.
270 fn lookahead(&self) -> Option<char> {
271 self.toks.iter().skip(self.pos).find_map(|t| match t {
272 Tok::Special(c) if matches!(c, '<' | ':' | '@' | ',' | ';') => Some(*c),
273 _ => None,
274 })
275 }
276
277 fn address_list(&mut self) -> Outcome<Vec<String>> {
278 let mut out = Vec::new();
279 while self.peek().is_some() {
280 if self.at(',') {
281 // The obsolete empty member.
282 self.pos += 1;
283 continue;
284 }
285 match self.lookahead() {
286 Some(':') => out.extend(res!(self.group())),
287 _ => out.push(res!(self.mailbox())),
288 }
289 if self.peek().is_some() {
290 res!(self.expect(',', "after an address"));
291 }
292 }
293 Ok(out)
294 }
295
296 /// `display-name ":" [group-list] ";"`, whose members are mailboxes and never groups.
297 fn group(&mut self) -> Outcome<Vec<String>> {
298 res!(self.phrase(true));
299 res!(self.expect(':', "after a group's name"));
300 let mut out = Vec::new();
301 loop {
302 if self.at(';') {
303 self.pos += 1;
304 return Ok(out);
305 }
306 if self.at(',') {
307 self.pos += 1;
308 continue;
309 }
310 if self.peek().is_none() {
311 return Err(err!(
312 "An address list leaves a group without its closing ';': {:?}.", self.value;
313 Invalid, Input, Missing));
314 }
315 out.push(res!(self.mailbox()));
316 if !self.at(';') {
317 res!(self.expect(',', "after a group member"));
318 }
319 }
320 }
321
322 /// `name-addr / addr-spec`.
323 fn mailbox(&mut self) -> Outcome<String> {
324 match self.lookahead() {
325 Some('<') => {
326 res!(self.phrase(false));
327 res!(self.expect('<', "before an address"));
328 if self.at('@') {
329 return Err(err!(
330 "An address list holds an obsolete source route, which is not read: \
331 {:?}.", self.value;
332 Invalid, Input, Unimplemented));
333 }
334 let spec = res!(self.addr_spec());
335 res!(self.expect('>', "after an address"));
336 Ok(spec)
337 },
338 Some('@') => self.addr_spec(),
339 _ => Err(err!(
340 "An address list member at token {} holds no address: {:?}.", self.pos, self.value;
341 Invalid, Input, Missing)),
342 }
343 }
344
345 /// A display name: words, with the obsolete `.` after the first. A group's is required.
346 fn phrase(&mut self, required: bool) -> Outcome<()> {
347 let mut words = 0usize;
348 loop {
349 match self.peek() {
350 Some(Tok::Atom(_)) | Some(Tok::Quoted(_)) => words += 1,
351 Some(Tok::Special('.')) if words > 0 => {},
352 _ => break,
353 }
354 self.pos += 1;
355 }
356 if required && words == 0 {
357 return Err(err!(
358 "An address list holds a group with no name: {:?}.", self.value;
359 Invalid, Input, Missing));
360 }
361 Ok(())
362 }
363
364 /// `local-part "@" domain`, as one string.
365 fn addr_spec(&mut self) -> Outcome<String> {
366 let mut spec = res!(self.dotted(true, "local part"));
367 res!(self.expect('@', "after a local part"));
368 spec.push('@');
369 match self.peek() {
370 Some(Tok::Literal(l)) => {
371 spec.push_str(l);
372 self.pos += 1;
373 },
374 _ => spec.push_str(&res!(self.dotted(false, "domain"))),
375 }
376 Ok(spec)
377 }
378
379 /// `word *("." word)`, where a quoted word may stand in a local part but not in a domain.
380 fn dotted(&mut self, quoted_ok: bool, what: &str) -> Outcome<String> {
381 let mut s = String::new();
382 loop {
383 match self.peek() {
384 Some(Tok::Atom(a)) => s.push_str(a),
385 Some(Tok::Quoted(q)) if quoted_ok => s.push_str(q),
386 other => return Err(err!(
387 "An address's {} wants a word, and has {:?} at token {}: {:?}.",
388 what, other, self.pos, self.value;
389 Invalid, Input)),
390 }
391 self.pos += 1;
392 if !self.at('.') {
393 return Ok(s);
394 }
395 self.pos += 1;
396 s.push('.');
397 }
398 }
399}
400
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 fn addrs(v: &str) -> Vec<String> {
407 match addresses(v) {
408 Ok(a) => a,
409 Err(e) => panic!("{:?} would not read: {}", v, e),
410 }
411 }
412
413 fn fields(head: &[u8]) -> Vec<(String, String)> {
414 match header_fields(head) {
415 Ok(f) => f,
416 Err(e) => panic!("{:?} would not read: {}", String::from_utf8_lossy(head), e),
417 }
418 }
419
420 #[test]
421 fn the_forms_mail_clients_write_are_read_00() {
422 assert_eq!(addrs(" hello@oxegen.io"), vec!["hello@oxegen.io"]);
423 assert_eq!(addrs(" Jason Hoogland <hello@oxegen.io>"), vec!["hello@oxegen.io"]);
424 assert_eq!(addrs(" \"Hoogland, Jason\" <hello@oxegen.io>"), vec!["hello@oxegen.io"],
425 "a comma inside a quoted display name is not a separator");
426 assert_eq!(addrs(" \"Hoogland <Jason>: CEO@x.test\" <hello@oxegen.io>"),
427 vec!["hello@oxegen.io"], "nor is an angle bracket, colon or @ inside one");
428 assert_eq!(addrs(" hello@oxegen.io (the owner)"), vec!["hello@oxegen.io"]);
429 assert_eq!(addrs(" =?UTF-8?Q?Caf=C3=A9?= <news@oxegen.io>"), vec!["news@oxegen.io"]);
430 assert_eq!(addrs(" Café Oxegen <news@oxegen.io>"), vec!["news@oxegen.io"],
431 "RFC 6532 UTF-8 in a display name");
432 assert_eq!(addrs(" a@x.test, \"B\" <b@y.test>"), vec!["a@x.test", "b@y.test"]);
433 assert_eq!(addrs(" Team: a@x.test, B <b@y.test>;"), vec!["a@x.test", "b@y.test"]);
434 assert_eq!(addrs(" undisclosed-recipients:;"), Vec::<String>::new());
435 assert_eq!(addrs(" \"john doe\"@x.test"), vec!["\"john doe\"@x.test"],
436 "a quoted local part is part of the address");
437 assert_eq!(addrs(" Name (a comment, with a comma) <a@x.test>"), vec!["a@x.test"]);
438 assert_eq!(addrs(" J. Hoogland <a@x.test>"), vec!["a@x.test"], "the obsolete dotted phrase");
439 assert_eq!(addrs(" a@x.test,, b@y.test,"), vec!["a@x.test", "b@y.test"],
440 "the obsolete empty members");
441 assert_eq!(addrs(" a@[192.0.2.1]"), vec!["a@[192.0.2.1]"]);
442 }
443
444 /// A member with no address, text after an address, or a bracket left open, is refused
445 /// rather than guessed at.
446 #[test]
447 fn what_cannot_be_read_is_refused_00() {
448 for bad in [
449 " Jason Hoogland",
450 " <>",
451 " Doe, John <j@x.test>", // an unquoted comma splits the name from its address
452 " Name <a@x.test",
453 " \"open quote <a@x.test>",
454 " (open comment a@x.test",
455 " <a@x.test> <b@y.test>",
456 " @x.test",
457 " a@",
458 // D-06 audit F4-1: a standard parser reads each of these as ceo@x.test.
459 " ceo@x.test:hello@x.test;",
460 " ceo@x.test <hello@x.test>",
461 " ceo@x.test\r<hello@x.test>",
462 " ceo@x.test\rX: hello@x.test",
463 // A colon after an addr-spec does not make it a group's name.
464 " ceo@x.test: hello@x.test;",
465 " Team: a@x.test", // a group must be closed
466 " Outer: Inner: a@x.test;;",// and never nests
467 " <@route.test:a@x.test>", // the obsolete source route
468 " hello@x.test;",
469 " a@x.test\u{2028}",
470 ] {
471 assert!(addresses(bad).is_err(), "{:?} was read as {:?}", bad, addresses(bad));
472 }
473 }
474
475 #[test]
476 fn header_fields_unfold_and_keep_their_order_00() {
477 let (head, body) = split_headers_body(
478 b"From: A\r\n <a@x.test>\r\nSubject: hi\r\nFrom: b@y.test\r\n\r\nbody\r\n");
479 let f = fields(head);
480 assert_eq!(f.len(), 3);
481 assert_eq!(f[0].0, "From");
482 assert_eq!(f[0].1, " A <a@x.test>");
483 assert_eq!(f[2], (fmt!("From"), fmt!(" b@y.test")));
484 assert_eq!(body, b"body\r\n");
485 let (head, body) = split_headers_body(b"Subject: no body");
486 assert_eq!(head, b"Subject: no body");
487 assert!(body.is_empty());
488 assert_eq!(fields(b"From : a@x.test"), vec![(fmt!("From"), fmt!(" a@x.test"))],
489 "the obsolete WSP before a colon is no part of the name");
490 }
491
492 /// Lines end at CRLF, and folding is undone before a line is judged, so a name cannot hide
493 /// on a line of its own (D-06 audit F4-1).
494 #[test]
495 fn header_fields_read_lines_as_rfc_5322_does_00() {
496 // Unfolded first, the field is `From : ceo@x.test`, so there are two `From`s.
497 let f = fields(b"From\r\n : ceo@x.test\r\nFrom: hello@x.test");
498 assert_eq!(f.iter().filter(|(n, _)| n == "From").count(), 2, "{:?}", f);
499 for bad in [
500 &b"From: ceo@x.test\rX: hello@x.test"[..],
501 b"From: ceo@x.test\r<hello@x.test>",
502 b"From: hello@x.test\nFrom: ceo@x.test",
503 b"From: hello@x.test\r\nX: a\x00b",
504 b"From: hello@x.test\r\nX: a\x0bb",
505 b"\r\nFrom: hello@x.test", // an empty first line ends the header there
506 b" From: hello@x.test", // a continuation of nothing
507 b"From: hello@x.test\r\nNot a field",
508 b"From: hello@x.test\r\nX Y: z",
509 b"From: \xff@x.test",
510 ] {
511 assert!(header_fields(bad).is_err(), "{:?} was read as {:?}",
512 String::from_utf8_lossy(bad), header_fields(bad));
513 }
514 // With no CRLF CRLF, the whole message is header, and its bare LFs are refused rather
515 // than taken for the end of the header.
516 let (head, body) = split_headers_body(b"From: hello@x.test\n\nX\r\nFrom: ceo@x.test\r\n");
517 assert!(body.is_empty());
518 assert!(header_fields(head).is_err());
519 }
520}