#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MimeHeaderEntry {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultipartPart<'a> {
pub headers: Vec<MimeHeaderEntry>,
pub payload: &'a str,
}
#[must_use]
pub fn parse_multipart_parts<'a>(body: &'a str, boundary: &str) -> Vec<MultipartPart<'a>> {
if boundary.is_empty() {
return Vec::new();
}
parse_multipart_level(body, boundary)
}
fn parse_multipart_level<'a>(body: &'a str, boundary: &str) -> Vec<MultipartPart<'a>> {
let delim = format!("--{boundary}");
let final_mark = format!("{delim}--");
let mut pos = 0_usize;
while pos <= body.len() {
let (line_start, line_end, next) = next_line(body, pos);
let bare = trim_cr_and_padding(&body[line_start..line_end]);
if bare == delim {
pos = next;
break;
}
if bare.starts_with(&final_mark) {
return Vec::new();
}
pos = next;
}
let mut parts = Vec::new();
while pos <= body.len() {
let (headers, payload, next, closed, was_final) = read_part(body, pos, &delim, &final_mark);
parts = append_multipart_leaf_or_container(parts, headers, payload);
if !closed || was_final {
return parts;
}
pos = next;
}
parts
}
type ReadPart<'a> = (Vec<MimeHeaderEntry>, &'a str, usize, bool, bool);
#[allow(clippy::too_many_lines)] fn read_part<'a>(body: &'a str, mut pos: usize, delim: &str, final_mark: &str) -> ReadPart<'a> {
let mut headers: Vec<MimeHeaderEntry> = Vec::new();
let mut in_headers = true;
let mut payload_start: Option<usize> = None;
while pos <= body.len() {
let (line_start, line_end, line_next) = next_line(body, pos);
let raw = &body[line_start..line_end];
let content = trim_cr_and_padding(raw);
if in_headers {
if content.is_empty() {
in_headers = false;
payload_start = Some(line_next);
} else if !headers.is_empty() && (content.starts_with(' ') || content.starts_with('\t'))
{
let br = if raw.ends_with('\r') { "\r\n" } else { "\n" };
let prev = headers.last_mut().expect("non-empty above");
prev.value.push_str(br);
prev.value.push_str(content);
} else if let Some(idx) = content.find(':') {
headers.push(MimeHeaderEntry {
name: content[..idx].to_owned(),
value: content[idx + 1..]
.trim_start_matches([' ', '\t'])
.to_owned(),
});
} else {
in_headers = false;
payload_start = Some(line_start);
}
pos = line_next;
continue;
}
if content == delim || content.starts_with(final_mark) {
let payload = payload_slice(body, payload_start, line_start);
return (
headers,
payload,
line_next,
true,
content.starts_with(final_mark),
);
}
pos = line_next;
}
let payload = payload_start.map_or(body, |start| &body[start..]);
(headers, payload, body.len() + 1, false, false)
}
fn payload_slice(body: &str, payload_start: Option<usize>, boundary_line_start: usize) -> &str {
let start = payload_start.unwrap_or(boundary_line_start);
if boundary_line_start <= start {
return "";
}
let mut end = boundary_line_start;
if end > start && body.as_bytes()[end - 1] == b'\n' {
end -= 1;
if end > start && body.as_bytes()[end - 1] == b'\r' {
end -= 1;
}
}
#[cfg(not(coverage))] if end < start {
return "";
}
&body[start..end]
}
fn trim_cr_and_padding(line: &str) -> &str {
let line = line.strip_suffix('\r').unwrap_or(line);
line.trim_end_matches([' ', '\t'])
}
fn next_line(body: &str, pos: usize) -> (usize, usize, usize) {
let line_start = pos;
body.as_bytes()[pos..]
.iter()
.position(|b| *b == b'\n')
.map_or((line_start, body.len(), body.len() + 1), |idx| {
(line_start, pos + idx, pos + idx + 1)
})
}
fn append_multipart_leaf_or_container<'a>(
mut parts: Vec<MultipartPart<'a>>,
headers: Vec<MimeHeaderEntry>,
payload: &'a str,
) -> Vec<MultipartPart<'a>> {
let content_type = first_header_value_or_empty(&headers, "content-type");
let (main_type, params) = crate::body_scan::parse_media_type_params(&content_type);
let boundary = if main_type.split('/').next() == Some("multipart") {
crate::body_scan::param_lookup(¶ms, "boundary").unwrap_or_default()
} else {
""
};
if !boundary.is_empty() {
parts.extend(parse_multipart_level(payload, boundary));
return parts;
}
parts.push(MultipartPart { headers, payload });
parts
}
fn first_header_value_or_empty(headers: &[MimeHeaderEntry], lower_name: &str) -> String {
headers
.iter()
.find(|h| h.name.eq_ignore_ascii_case(lower_name))
.map_or_else(String::new, |h| h.value.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_two_text_parts() {
let body = "--B0\r\nContent-Disposition: form-data; name=\"note\"\r\n\r\nhello\r\n--B0\r\nContent-Disposition: form-data; name=\"n2\"\r\n\r\nthere\r\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].payload, "hello");
assert_eq!(parts[1].payload, "there");
assert_eq!(
parts[0].headers,
vec![MimeHeaderEntry {
name: "Content-Disposition".to_owned(),
value: "form-data; name=\"note\"".to_owned()
}]
);
}
#[test]
fn empty_boundary_yields_no_parts() {
assert!(parse_multipart_parts("--B0\r\n\r\nx\r\n--B0--", "").is_empty());
}
#[test]
fn preamble_and_epilogue_discarded() {
let body = "preamble\r\n--B0\r\n\r\npayload\r\n--B0--\r\nepilogue\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].payload, "payload");
}
#[test]
fn final_boundary_first_means_no_parts() {
let body = "--B0--\r\nx\r\n";
assert!(parse_multipart_parts(body, "B0").is_empty());
}
#[test]
fn missing_closing_boundary_keeps_part() {
let body =
"--B0\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\npayload-without-close";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].payload, "payload-without-close");
}
#[test]
fn colonless_line_opens_the_payload() {
let body = "--B0\r\nnot-a-header\r\npayload line\r\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert!(parts[0].headers.is_empty());
assert_eq!(parts[0].payload, "not-a-header\r\npayload line");
}
#[test]
fn folded_header_keeps_raw_break() {
let body = "--B0\r\nContent-Disposition: form-data;\r\n name=\"a\"\r\n\r\nx\r\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts[0].headers[0].value, "form-data;\r\n name=\"a\"");
let body = "--B0\nContent-Disposition: form-data;\n name=\"a\"\n\nx\n--B0--\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts[0].headers[0].value, "form-data;\n name=\"a\"");
}
#[test]
fn bare_newlines_stay_in_payload() {
let body = "--B0\r\n\r\nline1\nline2\r\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts[0].payload, "line1\nline2");
}
#[test]
fn nested_multipart_container_expands_in_place() {
let body = "--B0\r\nContent-Disposition: form-data; name=\"files\"\r\nContent-Type: multipart/mixed; boundary=INNER\r\n\r\n--INNER\r\nContent-Disposition: attachment; filename=\"a.bin\"\r\n\r\nleaf-bytes\r\n--INNER--\r\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1, "the container itself produces no entry");
assert_eq!(parts[0].payload, "leaf-bytes");
assert_eq!(parts[0].headers[0].value, "attachment; filename=\"a.bin\"");
}
#[test]
fn container_without_boundary_is_an_ordinary_leaf() {
let body = "--B0\r\nContent-Type: multipart/mixed\r\n\r\nnot-really-nested\r\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].payload, "not-really-nested");
}
#[test]
fn transport_padding_on_boundary_lines_is_ignored() {
let body = "--B0 \r\n\r\npayload\r\n--B0-- \t\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].payload, "payload");
}
}
#[cfg(test)]
mod coverage_tests {
use super::*;
#[test]
fn a_missing_boundary_yields_no_parts() {
let body = "Content-Disposition: form-data; name=\"f\"\r\n\r\nbody";
let parts = parse_multipart_parts(body, "NO_SUCH_BOUNDARY");
assert!(parts.is_empty());
}
}
#[cfg(test)]
mod unit_twins {
use super::*;
#[test]
fn a_payload_newline_at_the_boundary_strips_down_to_the_start() {
assert_eq!(payload_slice("\nZ", Some(0), 1), "");
assert_eq!(payload_slice("x\r\nBOUND", Some(0), 3), "x");
assert_eq!(payload_slice("xZ", Some(0), 1), "x");
}
}
#[cfg(test)]
mod gap_tests {
use super::*;
#[test]
fn a_part_with_no_payload_lines_yields_an_empty_payload() {
let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].payload, "");
}
#[test]
fn a_bare_newline_before_the_boundary_stays_in_the_payload() {
let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\nabc\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].payload, "abc");
}
#[test]
fn a_lone_newline_payload_collapses_to_empty() {
let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\n\n--B0--\r\n";
let parts = parse_multipart_parts(body, "B0");
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].payload, "");
}
}