use mailparse to parse headers instead of reimplementing

This commit is contained in:
2026-09-13 17:30:41 +02:00
parent 644d0e813e
commit fcf0b47d7d
4 changed files with 237 additions and 125 deletions
+27 -25
View File
@@ -1,5 +1,7 @@
use super::{Header, parse_header};
use crate::CowMailError;
use mailparse::MailHeader;
use super::{Header, build_header};
use crate::{CowMailError, extend_over_newline};
pub trait HeaderMap<'a, H: Header<'a> + 'a> {
fn iter_headers(&'a self, key: &str) -> impl Iterator<Item = &'a H>;
@@ -93,29 +95,29 @@ impl<'a, H: Header<'a> + 'a> HeaderMap<'a, H> for Vec<H> {
}
}
/// Rebuild the header lines mailparse has already scanned, so that a part's
/// header block is only walked once. `parsed` has to be the headers of
/// `raw_data`, so that their key and value slices point into it. Returns the
/// headers and the index the body starts at.
pub(crate) fn build_headers<'a, H: Header<'a>>(
raw_data: &'a [u8],
parsed: &[MailHeader<'a>],
) -> Result<(Vec<H>, usize), CowMailError> {
let mut headers = Vec::with_capacity(parsed.len());
let mut line_end = 0;
for (ix, header) in parsed.iter().enumerate() {
let (header, end) = build_header(raw_data, header, parsed.get(ix + 1))?;
headers.push(header);
line_end = end;
}
// Whatever terminates the block: the blank line, or nothing at all when the
// message ends with its headers.
Ok((headers, extend_over_newline(raw_data, line_end)))
}
pub fn parse_headers<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(Vec<H>, usize), CowMailError> {
let mut headers: Vec<H> = Vec::new();
let mut ix = 0;
loop {
if ix >= raw_data.len() {
break;
} else if raw_data[ix] == b'\n' {
ix += 1;
break;
} else if raw_data[ix] == b'\r' {
if ix + 1 < raw_data.len() && raw_data[ix + 1] == b'\n' {
ix += 2;
break;
} else {
return Err(CowMailError::Generic(
"Headers were followed by an unexpected lone CR character!".to_string(),
));
}
}
let (header, ix_next) = parse_header(&raw_data[ix..])?;
headers.push(header);
ix += ix_next;
}
Ok((headers, ix))
let (parsed, _) = mailparse::parse_headers(raw_data)?;
build_headers(raw_data, &parsed)
}
+62 -92
View File
@@ -3,15 +3,16 @@ mod headers;
pub use encoding::encode_header_value;
pub use headers::{HeaderMap, parse_headers};
pub(crate) use headers::build_headers;
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt::{self, Display};
use charset::decode_latin1;
use mailparse::parse_content_type;
use mailparse::{MailHeader, parse_content_type};
use crate::CowMailError;
use crate::{CowMailError, offset_in};
pub trait Header<'a>: Clone + Display {
fn new<K: ToString, V: ToString>(key: K, value: V) -> Self;
@@ -222,96 +223,65 @@ impl Display for CowHeader<'_> {
}
}
enum HeaderParseState {
Initial,
Key,
PreValue,
Value,
ValueNewline,
/// End of the — possibly folded — header line whose value ends at `value_end`:
/// past the first newline that is not followed by continuation whitespace. Only
/// `\r`, `\n`, spaces and tabs can appear between the end of a value and the end
/// of its line; anything else would have moved `value_end` along.
fn header_line_end(raw_data: &[u8], value_end: usize) -> usize {
let mut ix = value_end;
loop {
let Some(newline_ix) = raw_data[ix..].iter().position(|&byte| byte == b'\n') else {
return raw_data.len();
};
ix += newline_ix + 1;
if !matches!(raw_data.get(ix), Some(b' ' | b'\t')) {
return ix;
}
}
}
/// Rebuild the header line mailparse scanned into `parsed`, which has to be a
/// header of `raw_data`, so that its key and value slices point into it.
///
/// A `MailHeader` keeps the key and the value, but not the line they came from,
/// which is what we serialize. The key always starts at the first byte of its
/// line, and a line ends where the next one's key begins — hence `next`; the
/// last header of a block has none and needs a look ahead instead. Returns the
/// header and the index just past its line.
fn build_header<'a, H: Header<'a>>(
raw_data: &'a [u8],
parsed: &MailHeader<'a>,
next: Option<&MailHeader<'a>>,
) -> Result<(H, usize), CowMailError> {
let offset_of = |bytes: &[u8]| {
offset_in(raw_data, bytes)
.ok_or_else(|| CowMailError::Generic("header is not a slice of its part".to_string()))
};
let key = parsed.get_key_raw();
let value = parsed.get_value_raw();
let line_start = offset_of(key)?;
let value_start = offset_of(value)?;
let value_end = value_start + value.len();
let line_end = match next {
Some(next) => offset_of(next.get_key_raw())?,
None => header_line_end(raw_data, value_end),
};
let header = H::from(
&raw_data[line_start..line_end],
key.len(),
value_start - line_start,
value_end - line_start,
);
Ok((header, line_end))
}
pub fn parse_header<'a, H: Header<'a>>(raw_data: &'a [u8]) -> Result<(H, usize), CowMailError> {
let mut it = raw_data.iter();
let mut ix = 0;
let mut c = match it.next() {
None => return Err(CowMailError::Generic("Empty string provided".to_string())),
Some(v) => *v,
};
let mut ix_key_end = None;
let mut ix_value_start = 0;
let mut ix_value_end = 0;
let mut state = HeaderParseState::Initial;
loop {
match state {
HeaderParseState::Initial => {
if c == b' ' {
return Err(CowMailError::Generic(
"Header cannot start with a space; it is likely an overhanging line from a previous header"
.to_string(),
));
};
state = HeaderParseState::Key;
continue;
}
HeaderParseState::Key => {
if c == b':' {
ix_key_end = Some(ix);
state = HeaderParseState::PreValue;
} else if c == b'\n' {
// Technically this is invalid. We'll handle it gracefully
// since it does appear to happen in the wild and other
// MTAs deal with it. Our handling is to just treat everything
// encountered so far on this line as the header key, and
// leave the value empty.
ix_key_end = Some(ix);
ix_value_start = ix;
ix_value_end = ix;
ix += 1;
break;
}
}
HeaderParseState::PreValue => {
if c != b' ' {
ix_value_start = ix;
ix_value_end = ix;
state = HeaderParseState::Value;
continue;
}
}
HeaderParseState::Value => {
if c == b'\n' {
state = HeaderParseState::ValueNewline;
} else if c != b'\r' {
ix_value_end = ix + 1;
}
}
HeaderParseState::ValueNewline => {
if c == b' ' || c == b'\t' {
state = HeaderParseState::Value;
continue;
} else {
break;
}
}
}
ix += 1;
c = match it.next() {
None => break,
Some(v) => *v,
};
}
match ix_key_end {
Some(v) => Ok((H::from(&raw_data[0..ix], v, ix_value_start, ix_value_end), ix)),
None => Ok((
// Technically this is invalid. We'll handle it gracefully
// since we handle the analogous situation above. Our handling
// is to just treat everything encountered on this line as
// the header key, and leave the value empty.
H::from(&raw_data[0..ix], ix, ix, ix),
ix,
)),
}
}
let (parsed, _) = mailparse::parse_header(raw_data)?;
build_header(raw_data, &parsed, None)
}
+9 -8
View File
@@ -7,7 +7,7 @@ pub mod header;
mod tests;
pub use body::{BodyContent, ContentTransferEncoding};
use header::CowHeader;
use header::{CowHeader, build_headers};
pub use header::{Header, HeaderMap, parse_headers};
use mailparse::{
@@ -928,15 +928,16 @@ fn line_ending(bytes: &[u8]) -> &'static [u8] {
/// buffer, which holds for every `ParsedMail::raw_bytes` relative to its
/// parent's. Returns `None` instead of panicking, so that a mailparse that
/// stops handing back subslices surfaces as an error, not as a bad index.
fn offset_in(outer: &[u8], inner: &[u8]) -> Option<usize> {
pub(crate) fn offset_in(outer: &[u8], inner: &[u8]) -> Option<usize> {
let ix = (inner.as_ptr() as usize).checked_sub(outer.as_ptr() as usize)?;
(ix + inner.len() <= outer.len()).then_some(ix)
}
/// Undo mailparse's `strip_trailing_crlf`: widen `end` over the newline that
/// separates a part from the delimiter line following it, so that the slice
/// covers everything the part occupied in the message.
fn extend_over_newline(raw_data: &[u8], end: usize) -> usize {
/// Widen `end` over the newline that follows it, if there is one. Used to undo
/// mailparse's `strip_trailing_crlf`, so that a part's slice covers everything
/// it occupied in the message, and to step over the blank line that ends a
/// header block.
pub(crate) fn extend_over_newline(raw_data: &[u8], end: usize) -> usize {
if raw_data[end..].starts_with(b"\r\n") {
end + 2
} else if raw_data[end..].starts_with(b"\n") {
@@ -957,7 +958,7 @@ fn build_mail<'a, H: Header<'a> + 'a>(
parsed: &ParsedMail<'a>,
in_multipart_digest: bool,
) -> Result<CowMail<'a, H>, CowMailError> {
let (headers, body_idx) = parse_headers::<H>(raw_data)?;
let (headers, body_idx) = build_headers::<H>(raw_data, &parsed.headers)?;
let mut body_end_idx = raw_data.len();
let mut end_bytes: &[u8] = &[];
@@ -1042,4 +1043,4 @@ fn build_mail<'a, H: Header<'a> + 'a>(
};
Ok(mail)
}
}
+139
View File
@@ -961,3 +961,142 @@
assert_eq!(mail.subparts[0].end_bytes, b"\r\n");
assert_eq!(mail.end_bytes, b"");
}
// The header lines below are rebuilt from the key/value slices mailparse
// hands back, so each of these pins a case where the line extends past the
// value: folding, a stray CR, an empty value, or the end of the buffer.
#[test]
fn test_roundtrip_folded_header() {
let raw = concat!(
"Subject: a\r\n",
" continued\r\n",
"To: john@example.com\r\n",
"\r\n",
"body\r\n"
);
assert_roundtrip(raw);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(mail.headers.len(), 2);
assert_eq!(mail.headers[0].get_key_raw(), b"Subject");
assert_eq!(mail.headers[0].get_value_raw(), b"a\r\n continued");
assert_eq!(mail.headers[0].get_value(), "a continued");
assert_eq!(mail.headers[1].get_key_raw(), b"To");
}
// A fold whose last line holds nothing but whitespace: the value ends before
// the line does, for the last header as well as for one in the middle.
#[test]
fn test_roundtrip_header_folded_onto_blank_line() {
let raw = concat!("Subject: x\r\n", " \r\n", "To: y\r\n", " \r\n", "\r\n", "body\r\n");
assert_roundtrip(raw);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(mail.headers.len(), 2);
assert_eq!(mail.headers[0].get_key_raw(), b"Subject");
assert_eq!(mail.headers[1].get_key_raw(), b"To");
assert_eq!(mail.raw_body(), b"body");
}
// Invalid, but it happens in the wild: mailparse treats the whole line as the
// key and leaves the value empty. Note that the key keeps the trailing CR.
#[test]
fn test_roundtrip_header_without_colon() {
let raw = concat!("Subject\r\n", "To: y\r\n", "\r\n", "body\r\n");
assert_roundtrip(raw);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(mail.headers.len(), 2);
assert_eq!(mail.headers[0].get_key_raw(), b"Subject\r");
assert_eq!(mail.headers[0].get_value_raw(), b"");
assert_eq!(mail.headers[1].get_value_raw(), b"y");
}
#[test]
fn test_roundtrip_empty_header_value() {
let raw = concat!("X-Empty:\r\n", "Subject: x\r\n", "\r\n", "body\r\n");
assert_roundtrip(raw);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(mail.headers[0].get_key_raw(), b"X-Empty");
assert_eq!(mail.headers[0].get_value_raw(), b"");
assert_eq!(mail.headers[1].get_value_raw(), b"x");
}
#[test]
fn test_roundtrip_no_headers() {
assert_roundtrip("\r\nbody\r\n");
assert_roundtrip("\nbody\n");
let mail = CowMail::try_from("\r\nbody\r\n".as_bytes()).unwrap();
assert!(mail.headers.is_empty());
assert_eq!(mail.raw_body(), b"body");
}
#[test]
fn test_roundtrip_lf_headers() {
let raw = concat!("Subject: a\n", " continued\n", "To: y\n", "\n", "body\n");
assert_roundtrip(raw);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(mail.headers[0].get_value_raw(), b"a\n continued");
assert_eq!(mail.headers[1].get_key_raw(), b"To");
}
// A message that ends with its header block, with and without the final
// newline. The blank line separating headers from body is always written, so
// these are the one case that does not round-trip byte-exact.
#[test]
fn test_header_block_without_blank_line() {
let mail = CowMail::try_from("Subject: x\r\nTo: y\r\n".as_bytes()).unwrap();
assert_eq!(mail.headers.len(), 2);
assert_eq!(mail.headers[1].get_value_raw(), b"y");
assert_eq!(mail.raw_body(), b"");
assert_eq!(
String::from_utf8_lossy(&serialize(&mail)),
"Subject: x\r\nTo: y\r\n\r\n"
);
let mail = CowMail::try_from("Subject: x".as_bytes()).unwrap();
assert_eq!(mail.headers.len(), 1);
assert_eq!(mail.headers[0].get_value_raw(), b"x");
assert_eq!(mail.raw_body(), b"");
assert_eq!(String::from_utf8_lossy(&serialize(&mail)), "Subject: x\r\n");
}
// The header line is rebuilt from pointer offsets, not by searching for the
// key and value bytes, so a value identical to its key still lands at the
// right place.
#[test]
fn test_roundtrip_header_value_equal_to_key() {
for raw in [
"Test: Test\r\n\r\nbody\r\n",
"Test:Test\r\n\r\nbody\r\n",
"Test: Test\r\n Test\r\n\r\nbody\r\n",
"T: T\r\nT: T\r\n\r\nbody\r\n",
] {
assert_roundtrip(raw);
}
let mail = CowMail::try_from("Test: Test\r\nTest:Test\r\n\r\nbody\r\n".as_bytes()).unwrap();
assert_eq!(mail.headers.len(), 2);
for header in &mail.headers {
assert_eq!(header.get_key_raw(), b"Test");
assert_eq!(header.get_value_raw(), b"Test");
}
assert_eq!(mail.headers[0].get_raw().as_ref(), b"Test: Test\r\n");
assert_eq!(mail.headers[1].get_raw().as_ref(), b"Test:Test\r\n");
}
// A header line that is nothing but a colon: both the key and the value are
// empty, and they sit at different offsets.
#[test]
fn test_roundtrip_header_colon_only() {
let raw = ":\r\nTo: y\r\n\r\nbody\r\n";
assert_roundtrip(raw);
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
assert_eq!(mail.headers[0].get_key_raw(), b"");
assert_eq!(mail.headers[0].get_value_raw(), b"");
assert_eq!(mail.headers[0].get_raw().as_ref(), b":\r\n");
}