diff --git a/Cargo.toml b/Cargo.toml index b7f3aa1..fe28f71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ maintenance = { status = "passively-maintained" } [dependencies] data-encoding = "2.6.0" +memchr = "2.7.0" quoted_printable = "0.5.0" charset = "0.1.3" diff --git a/src/body.rs b/src/body.rs index dcb6a84..8cbe3de 100644 --- a/src/body.rs +++ b/src/body.rs @@ -137,14 +137,36 @@ impl<'a> BinaryBody<'a> { } fn decode_base64(body: &[u8]) -> Result, MailParseError> { - let cleaned = body - .iter() - .filter(|c| !c.is_ascii_whitespace()) - .cloned() - .collect::>(); + let cleaned = strip_ascii_whitespace(body); Ok(data_encoding::BASE64_MIME_PERMISSIVE.decode(&cleaned)?) } +/// Copy `body` without its ASCII whitespace -- the same bytes `u8::is_ascii_whitespace` +/// names: space, tab, newline, form feed, carriage return. +/// +/// Whitespace is located with a vectorised search and the runs between are copied +/// whole, rather than testing and pushing one byte at a time. A base64 body is +/// almost entirely 76-byte lines ending in CRLF, so the common case is one search +/// and one copy per line. Tabs and form feeds are rare enough that they get a second +/// search over each run instead of a place in the first. +fn strip_ascii_whitespace(body: &[u8]) -> Vec { + let mut cleaned = Vec::with_capacity(body.len()); + let mut rest = body; + loop { + let end = memchr::memchr3(b'\r', b'\n', b' ', rest).unwrap_or(rest.len()); + let mut run = &rest[..end]; + while let Some(j) = memchr::memchr2(b'\t', 0x0c, run) { + cleaned.extend_from_slice(&run[..j]); + run = &run[j + 1..]; + } + cleaned.extend_from_slice(run); + if end == rest.len() { + return cleaned; + } + rest = &rest[end + 1..]; + } +} + fn decode_quoted_printable(body: &[u8]) -> Result, MailParseError> { Ok(quoted_printable::decode( body, @@ -161,3 +183,47 @@ fn get_body_as_string(body: &[u8], ctype: &ParsedContentType) -> Result Vec { + body.iter() + .filter(|c| !c.is_ascii_whitespace()) + .cloned() + .collect() + } + + #[test] + fn matches_the_filter_it_replaces() { + let cases: &[&[u8]] = &[ + b"", + b" ", + b" \t\r\n\x0c", + b"abc", + b" abc ", + b"ab cd\tef\ngh\rij\x0ckl", + b"\x0b", // vertical tab is NOT ascii whitespace; must be kept + b"a\x0bb", + b"\t\tab\x0c\x0ccd", + b"QUJD\r\nREVG\r\n", + b"QUJD REVG\tR0hJ\x0cSktM", + ]; + for case in cases { + assert_eq!(strip_ascii_whitespace(case), reference(case), "{:?}", case); + } + // every byte value, alone and next to whitespace + for b in 0u8..=255 { + let single = [b]; + assert_eq!( + strip_ascii_whitespace(&single), + reference(&single), + "{:?}", + b + ); + let mixed = [b' ', b, b'\t', b, b'\r', b'\n', b]; + assert_eq!(strip_ascii_whitespace(&mixed), reference(&mixed), "{:?}", b); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 6a1653c..6ac294f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -117,27 +117,7 @@ pub(crate) fn find_from(line: &str, ix_start: usize, key: &str) -> Option fn find_from_u8(line: &[u8], ix_start: usize, key: &[u8]) -> Option { assert!(!key.is_empty()); assert!(ix_start <= line.len()); - if line.len() < key.len() { - return None; - } - let ix_end = line.len() - key.len(); - if ix_start <= ix_end { - for i in ix_start..=ix_end { - if line[i] == key[0] { - let mut success = true; - for j in 1..key.len() { - if line[i + j] != key[j] { - success = false; - break; - } - } - if success { - return Some(i); - } - } - } - } - None + memchr::memmem::find(&line[ix_start..], key).map(|v| ix_start + v) } #[test]