From c3b8d56d5a2a99ca281f4eda516a668c66443c15 Mon Sep 17 00:00:00 2001 From: Thomas Oettli Date: Sun, 13 Sep 2026 03:50:32 +0200 Subject: [PATCH] byte-exact roundtrip --- src/lib.rs | 27 ++++++----- src/tests.rs | 123 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0d07f10..972c5c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -273,7 +273,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { body_bytes: Cow::Borrowed(&[]), body_bytes_orig: &[], subparts: Vec::new(), - end_bytes: b"\r\n", + end_bytes: b"", default_ctype: ContentType::default(), } } @@ -311,6 +311,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { } self.body_bytes = Cow::Owned(bytes); + self.end_bytes = b"\r\n"; } pub fn get_filename(&self) -> Option { @@ -621,11 +622,7 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { pub fn serialize_body(&self) -> Vec> { match self.message_type() { - MessageType::SinglePart => match self.body_bytes { - 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) => { + MessageType::MultiPart(boundary) if !self.subparts.is_empty() => { let delimiter = [b"--", boundary.as_bytes(), b"\r\n"].concat(); let closing = [b"--", boundary.as_bytes(), b"--"].concat(); @@ -642,6 +639,9 @@ impl<'a, H: Header<'a> + 'a> CowMail<'a, H> { .flatten() .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.body_bytes = Cow::Borrowed(&[]); self.subparts.clear(); - self.end_bytes = b"\r\n"; + self.end_bytes = b""; } fn clear_content(&mut self) { self.headers.retain(|h| !h.key_starts_with("content-")); self.body_bytes = Cow::Borrowed(&[]); self.subparts.clear(); - self.end_bytes = b"\r\n"; + self.end_bytes = b""; } fn default_ctype(&self) -> ContentType { @@ -1001,11 +1001,14 @@ fn build_mail<'a, H: Header<'a> + 'a>( 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(); - if closing_idx + 2 <= raw_data.len() && &raw_data[closing_idx..closing_idx + 2] == b"--" { - mail.end_bytes = &raw_data[closing_idx + 2..]; - } + mail.end_bytes = match closing_idx + 2 <= raw_data.len() && &raw_data[closing_idx..closing_idx + 2] == b"--" { + true => &raw_data[closing_idx + 2..], + false => b"", + }; Ok(mail) } diff --git a/src/tests.rs b/src/tests.rs index 456af23..6fec279 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,6 +1,20 @@ //use std::io::{Read, Write}; use crate::{BodyType, CowHeader, CowMail, Header, HeaderMap, Mail, MediaType, names::HTML, names::TEXT}; + fn serialize<'a>(mail: &'a CowMail<'a, CowHeader<'a>>) -> Vec { + 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] fn test_header_mutations() { let mut mail = CowMail::::new(); @@ -743,6 +757,115 @@ 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\" \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", + "

HTML

\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 // CRLF, used to be missed by the hand-rolled boundary search. #[test]