byte-exact roundtrip

This commit is contained in:
2026-09-13 03:50:32 +02:00
parent 2041f2232c
commit c3b8d56d5a
2 changed files with 138 additions and 12 deletions
+15 -12
View File
@@ -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
View File
@@ -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]