byte-exact roundtrip
This commit is contained in:
+15
-12
@@ -273,7 +273,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
|
|||||||
body_bytes: Cow::Borrowed(&[]),
|
body_bytes: Cow::Borrowed(&[]),
|
||||||
body_bytes_orig: &[],
|
body_bytes_orig: &[],
|
||||||
subparts: Vec::new(),
|
subparts: Vec::new(),
|
||||||
end_bytes: b"\r\n",
|
end_bytes: b"",
|
||||||
default_ctype: ContentType::default(),
|
default_ctype: ContentType::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,6 +311,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.body_bytes = Cow::Owned(bytes);
|
self.body_bytes = Cow::Owned(bytes);
|
||||||
|
self.end_bytes = b"\r\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_filename(&self) -> Option<String> {
|
pub fn get_filename(&self) -> Option<String> {
|
||||||
@@ -621,11 +622,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
|
|||||||
|
|
||||||
pub fn serialize_body(&self) -> Vec<Cow<'_, [u8]>> {
|
pub fn serialize_body(&self) -> Vec<Cow<'_, [u8]>> {
|
||||||
match self.message_type() {
|
match self.message_type() {
|
||||||
MessageType::SinglePart => match self.body_bytes {
|
MessageType::MultiPart(boundary) if !self.subparts.is_empty() => {
|
||||||
Cow::Borrowed(body_bytes) => vec![Cow::Borrowed(body_bytes)],
|
|
||||||
Cow::Owned(_) => vec![Cow::Borrowed(&self.body_bytes), Cow::Borrowed(self.end_bytes)],
|
|
||||||
},
|
|
||||||
MessageType::MultiPart(boundary) => {
|
|
||||||
let delimiter = [b"--", boundary.as_bytes(), b"\r\n"].concat();
|
let delimiter = [b"--", boundary.as_bytes(), b"\r\n"].concat();
|
||||||
let closing = [b"--", boundary.as_bytes(), b"--"].concat();
|
let closing = [b"--", boundary.as_bytes(), b"--"].concat();
|
||||||
|
|
||||||
@@ -642,6 +639,9 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> {
|
|||||||
.flatten()
|
.flatten()
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
// Single part — or a part declaring a boundary that never appeared in
|
||||||
|
// the message, which keeps the body it was parsed with.
|
||||||
|
_ => vec![Cow::Borrowed(&self.body_bytes), Cow::Borrowed(self.end_bytes)],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,14 +812,14 @@ impl<'a, H: Header<'a> + 'a> Mail<'a, H> for CowMail<'a, H> {
|
|||||||
self.headers.clear();
|
self.headers.clear();
|
||||||
self.body_bytes = Cow::Borrowed(&[]);
|
self.body_bytes = Cow::Borrowed(&[]);
|
||||||
self.subparts.clear();
|
self.subparts.clear();
|
||||||
self.end_bytes = b"\r\n";
|
self.end_bytes = b"";
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clear_content(&mut self) {
|
fn clear_content(&mut self) {
|
||||||
self.headers.retain(|h| !h.key_starts_with("content-"));
|
self.headers.retain(|h| !h.key_starts_with("content-"));
|
||||||
self.body_bytes = Cow::Borrowed(&[]);
|
self.body_bytes = Cow::Borrowed(&[]);
|
||||||
self.subparts.clear();
|
self.subparts.clear();
|
||||||
self.end_bytes = b"\r\n";
|
self.end_bytes = b"";
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_ctype(&self) -> ContentType {
|
fn default_ctype(&self) -> ContentType {
|
||||||
@@ -1001,11 +1001,14 @@ fn build_mail<'a, H: Header<'a> + 'a>(
|
|||||||
delimiter_idx = part_end_idx;
|
delimiter_idx = part_end_idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Everything after the closing delimiter is the epilogue.
|
// Everything after the closing delimiter is the epilogue. Without a closing
|
||||||
|
// delimiter there is nothing left over: the trailing newline the split above
|
||||||
|
// found belongs to the last part, not to this one.
|
||||||
let closing_idx = delimiter_idx + 2 + boundary.len();
|
let closing_idx = delimiter_idx + 2 + boundary.len();
|
||||||
if closing_idx + 2 <= raw_data.len() && &raw_data[closing_idx..closing_idx + 2] == b"--" {
|
mail.end_bytes = match closing_idx + 2 <= raw_data.len() && &raw_data[closing_idx..closing_idx + 2] == b"--" {
|
||||||
mail.end_bytes = &raw_data[closing_idx + 2..];
|
true => &raw_data[closing_idx + 2..],
|
||||||
}
|
false => b"",
|
||||||
|
};
|
||||||
|
|
||||||
Ok(mail)
|
Ok(mail)
|
||||||
}
|
}
|
||||||
|
|||||||
+123
@@ -1,6 +1,20 @@
|
|||||||
//use std::io::{Read, Write};
|
//use std::io::{Read, Write};
|
||||||
use crate::{BodyType, CowHeader, CowMail, Header, HeaderMap, Mail, MediaType, names::HTML, names::TEXT};
|
use crate::{BodyType, CowHeader, CowMail, Header, HeaderMap, Mail, MediaType, names::HTML, names::TEXT};
|
||||||
|
|
||||||
|
fn serialize<'a>(mail: &'a CowMail<'a, CowHeader<'a>>) -> Vec<u8> {
|
||||||
|
mail.serialize().iter().flat_map(|p| p.iter()).copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `raw` and assert it serializes back to exactly the same bytes.
|
||||||
|
fn assert_roundtrip(raw: &str) {
|
||||||
|
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&serialize(&mail)),
|
||||||
|
raw,
|
||||||
|
"round-trip is not byte-exact"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_header_mutations() {
|
fn test_header_mutations() {
|
||||||
let mut mail = CowMail::<CowHeader>::new();
|
let mut mail = CowMail::<CowHeader>::new();
|
||||||
@@ -743,6 +757,115 @@
|
|||||||
assert_eq!(mail.subparts[0].content_type().mimetype, "message/rfc822");
|
assert_eq!(mail.subparts[0].content_type().mimetype, "message/rfc822");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test: a nested multipart with a preamble and an epilogue round-trips
|
||||||
|
// byte-exact.
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_nested_multipart() {
|
||||||
|
assert_roundtrip(concat!(
|
||||||
|
"From: \"John Doe\" <john.doe@example.com>\r\n",
|
||||||
|
"MIME-Version: 1.0\r\n",
|
||||||
|
"Content-Type: multipart/mixed;\r\n boundary=\"outer\"\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"This is a MIME message.\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--outer\r\n",
|
||||||
|
"Content-Type: multipart/alternative; boundary=\"inner\"\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--inner\r\n",
|
||||||
|
"Content-Type: text/plain\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"Plain text\r\n",
|
||||||
|
"--inner\r\n",
|
||||||
|
"Content-Type: text/html\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"<p>HTML</p>\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--inner--\r\n",
|
||||||
|
"--outer\r\n",
|
||||||
|
"Content-Type: application/pdf\r\n",
|
||||||
|
"Content-Disposition: attachment; filename=\"test.pdf\"\r\n",
|
||||||
|
"Content-Transfer-Encoding: base64\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"JVBERi0xLjQK\r\n",
|
||||||
|
"--outer--\r\n",
|
||||||
|
"epilogue\r\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_single_part() {
|
||||||
|
assert_roundtrip("Subject: Test\r\n\r\nThis is a test message\r\n");
|
||||||
|
assert_roundtrip("Subject: Test\r\n\r\nThis is a test message");
|
||||||
|
assert_roundtrip("Subject: Test\r\n\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_edge_parts() {
|
||||||
|
// Empty part, and a part whose body ends in a blank line.
|
||||||
|
assert_roundtrip(concat!(
|
||||||
|
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--bound\r\n",
|
||||||
|
"Subject: empty\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--bound\r\n",
|
||||||
|
"Content-Type: text/plain\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"Plain text\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--bound--\r\n"
|
||||||
|
));
|
||||||
|
// Closing delimiter at the end of the buffer, no trailing CRLF.
|
||||||
|
assert_roundtrip(concat!(
|
||||||
|
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--bound\r\n",
|
||||||
|
"Content-Type: text/plain\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"Plain text\r\n",
|
||||||
|
"--bound--"
|
||||||
|
));
|
||||||
|
// Declared boundary that never appears: the body is kept as it is.
|
||||||
|
assert_roundtrip(concat!(
|
||||||
|
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"no boundary here\r\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: a message cut off before its closing delimiter keeps every byte it
|
||||||
|
// had; serialization completes it with the delimiter that is missing.
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_truncated_message() {
|
||||||
|
let raw = concat!(
|
||||||
|
"Content-Type: multipart/mixed; boundary=\"bound\"\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"--bound\r\n",
|
||||||
|
"Content-Type: text/plain\r\n",
|
||||||
|
"\r\n",
|
||||||
|
"Plain text\r\n"
|
||||||
|
);
|
||||||
|
let mail = CowMail::try_from(raw.as_bytes()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&serialize(&mail)),
|
||||||
|
format!("{raw}--bound--")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test: an empty body emits no terminator of its own, content supplies one.
|
||||||
|
#[test]
|
||||||
|
fn test_empty_body_has_no_terminator() {
|
||||||
|
let mut mail = CowMail::default();
|
||||||
|
mail.add_header("Subject", "Test");
|
||||||
|
assert_eq!(String::from_utf8_lossy(&serialize(&mail)), "Subject: Test\r\n\r\n");
|
||||||
|
|
||||||
|
mail.set_text_body("Plain text").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8_lossy(&serialize(&mail)),
|
||||||
|
"Subject: Test\r\nContent-Type: text/plain\r\n\r\nPlain text\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Test: a closing delimiter at the very end of the buffer, with no trailing
|
// Test: a closing delimiter at the very end of the buffer, with no trailing
|
||||||
// CRLF, used to be missed by the hand-rolled boundary search.
|
// CRLF, used to be missed by the hand-rolled boundary search.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user