1#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct MimeHeaderEntry {
31 pub name: String,
32 pub value: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct MultipartPart<'a> {
38 pub headers: Vec<MimeHeaderEntry>,
39 pub payload: &'a str,
40}
41
42#[must_use]
48pub fn parse_multipart_parts<'a>(body: &'a str, boundary: &str) -> Vec<MultipartPart<'a>> {
49 if boundary.is_empty() {
50 return Vec::new();
51 }
52 parse_multipart_level(body, boundary)
53}
54
55fn parse_multipart_level<'a>(body: &'a str, boundary: &str) -> Vec<MultipartPart<'a>> {
56 let delim = format!("--{boundary}");
57 let final_mark = format!("{delim}--");
58 let mut pos = 0_usize;
59
60 while pos <= body.len() {
63 let (line_start, line_end, next) = next_line(body, pos);
64 let bare = trim_cr_and_padding(&body[line_start..line_end]);
65 if bare == delim {
66 pos = next;
67 break;
68 }
69 if bare.starts_with(&final_mark) {
70 return Vec::new();
71 }
72 pos = next;
73 }
74
75 let mut parts = Vec::new();
76 while pos <= body.len() {
77 let (headers, payload, next, closed, was_final) = read_part(body, pos, &delim, &final_mark);
78 parts = append_multipart_leaf_or_container(parts, headers, payload);
79 if !closed || was_final {
80 return parts;
84 }
85 pos = next;
86 }
87 parts
88}
89
90type ReadPart<'a> = (Vec<MimeHeaderEntry>, &'a str, usize, bool, bool);
95
96#[allow(clippy::too_many_lines)] fn read_part<'a>(body: &'a str, mut pos: usize, delim: &str, final_mark: &str) -> ReadPart<'a> {
98 let mut headers: Vec<MimeHeaderEntry> = Vec::new();
99 let mut in_headers = true;
100 let mut payload_start: Option<usize> = None;
101 while pos <= body.len() {
102 let (line_start, line_end, line_next) = next_line(body, pos);
103 let raw = &body[line_start..line_end];
104 let content = trim_cr_and_padding(raw);
105 if in_headers {
106 if content.is_empty() {
107 in_headers = false;
109 payload_start = Some(line_next);
110 } else if !headers.is_empty() && (content.starts_with(' ') || content.starts_with('\t'))
111 {
112 let br = if raw.ends_with('\r') { "\r\n" } else { "\n" };
114 let prev = headers.last_mut().expect("non-empty above");
115 prev.value.push_str(br);
116 prev.value.push_str(content);
117 } else if let Some(idx) = content.find(':') {
118 headers.push(MimeHeaderEntry {
119 name: content[..idx].to_owned(),
120 value: content[idx + 1..]
121 .trim_start_matches([' ', '\t'])
122 .to_owned(),
123 });
124 } else {
125 in_headers = false;
128 payload_start = Some(line_start);
129 }
130 pos = line_next;
131 continue;
132 }
133 if content == delim || content.starts_with(final_mark) {
134 let payload = payload_slice(body, payload_start, line_start);
135 return (
136 headers,
137 payload,
138 line_next,
139 true,
140 content.starts_with(final_mark),
141 );
142 }
143 pos = line_next;
144 }
145 let payload = payload_start.map_or(body, |start| &body[start..]);
147 (headers, payload, body.len() + 1, false, false)
148}
149
150fn payload_slice(body: &str, payload_start: Option<usize>, boundary_line_start: usize) -> &str {
154 let start = payload_start.unwrap_or(boundary_line_start);
155 if boundary_line_start <= start {
156 return "";
157 }
158 let mut end = boundary_line_start;
159 if end > start && body.as_bytes()[end - 1] == b'\n' {
160 end -= 1;
161 if end > start && body.as_bytes()[end - 1] == b'\r' {
162 end -= 1;
163 }
164 }
165 if end < start {
166 return "";
167 }
168 &body[start..end]
169}
170
171fn trim_cr_and_padding(line: &str) -> &str {
174 let line = line.strip_suffix('\r').unwrap_or(line);
175 line.trim_end_matches([' ', '\t'])
176}
177
178fn next_line(body: &str, pos: usize) -> (usize, usize, usize) {
181 let line_start = pos;
182 body.as_bytes()[pos..]
183 .iter()
184 .position(|b| *b == b'\n')
185 .map_or((line_start, body.len(), body.len() + 1), |idx| {
186 (line_start, pos + idx, pos + idx + 1)
187 })
188}
189
190fn append_multipart_leaf_or_container<'a>(
193 mut parts: Vec<MultipartPart<'a>>,
194 headers: Vec<MimeHeaderEntry>,
195 payload: &'a str,
196) -> Vec<MultipartPart<'a>> {
197 let content_type = first_header_value_or_empty(&headers, "content-type");
198 let (main_type, params) = crate::body_scan::parse_media_type_params(&content_type);
199 let boundary = if main_type.split('/').next() == Some("multipart") {
201 crate::body_scan::param_lookup(¶ms, "boundary").unwrap_or_default()
202 } else {
203 ""
204 };
205 if !boundary.is_empty() {
206 parts.extend(parse_multipart_level(payload, boundary));
207 return parts;
208 }
209 parts.push(MultipartPart { headers, payload });
210 parts
211}
212
213fn first_header_value_or_empty(headers: &[MimeHeaderEntry], lower_name: &str) -> String {
214 headers
215 .iter()
216 .find(|h| h.name.eq_ignore_ascii_case(lower_name))
217 .map_or_else(String::new, |h| h.value.clone())
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn splits_two_text_parts() {
226 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";
227 let parts = parse_multipart_parts(body, "B0");
228 assert_eq!(parts.len(), 2);
229 assert_eq!(parts[0].payload, "hello");
230 assert_eq!(parts[1].payload, "there");
231 assert_eq!(
232 parts[0].headers,
233 vec![MimeHeaderEntry {
234 name: "Content-Disposition".to_owned(),
235 value: "form-data; name=\"note\"".to_owned()
236 }]
237 );
238 }
239
240 #[test]
241 fn empty_boundary_yields_no_parts() {
242 assert!(parse_multipart_parts("--B0\r\n\r\nx\r\n--B0--", "").is_empty());
243 }
244
245 #[test]
246 fn preamble_and_epilogue_discarded() {
247 let body = "preamble\r\n--B0\r\n\r\npayload\r\n--B0--\r\nepilogue\r\n";
248 let parts = parse_multipart_parts(body, "B0");
249 assert_eq!(parts.len(), 1);
250 assert_eq!(parts[0].payload, "payload");
251 }
252
253 #[test]
254 fn final_boundary_first_means_no_parts() {
255 let body = "--B0--\r\nx\r\n";
256 assert!(parse_multipart_parts(body, "B0").is_empty());
257 }
258
259 #[test]
260 fn missing_closing_boundary_keeps_part() {
261 let body =
262 "--B0\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\npayload-without-close";
263 let parts = parse_multipart_parts(body, "B0");
264 assert_eq!(parts.len(), 1);
265 assert_eq!(parts[0].payload, "payload-without-close");
266 }
267
268 #[test]
269 fn colonless_line_opens_the_payload() {
270 let body = "--B0\r\nnot-a-header\r\npayload line\r\n--B0--\r\n";
273 let parts = parse_multipart_parts(body, "B0");
274 assert_eq!(parts.len(), 1);
275 assert!(parts[0].headers.is_empty());
276 assert_eq!(parts[0].payload, "not-a-header\r\npayload line");
277 }
278
279 #[test]
280 fn folded_header_keeps_raw_break() {
281 let body = "--B0\r\nContent-Disposition: form-data;\r\n name=\"a\"\r\n\r\nx\r\n--B0--\r\n";
282 let parts = parse_multipart_parts(body, "B0");
283 assert_eq!(parts[0].headers[0].value, "form-data;\r\n name=\"a\"");
284 }
285
286 #[test]
287 fn bare_newlines_stay_in_payload() {
288 let body = "--B0\r\n\r\nline1\nline2\r\n--B0--\r\n";
289 let parts = parse_multipart_parts(body, "B0");
290 assert_eq!(parts[0].payload, "line1\nline2");
291 }
292
293 #[test]
294 fn nested_multipart_container_expands_in_place() {
295 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";
296 let parts = parse_multipart_parts(body, "B0");
297 assert_eq!(parts.len(), 1, "the container itself produces no entry");
298 assert_eq!(parts[0].payload, "leaf-bytes");
299 assert_eq!(parts[0].headers[0].value, "attachment; filename=\"a.bin\"");
300 }
301
302 #[test]
303 fn container_without_boundary_is_an_ordinary_leaf() {
304 let body = "--B0\r\nContent-Type: multipart/mixed\r\n\r\nnot-really-nested\r\n--B0--\r\n";
305 let parts = parse_multipart_parts(body, "B0");
306 assert_eq!(parts.len(), 1);
307 assert_eq!(parts[0].payload, "not-really-nested");
308 }
309
310 #[test]
311 fn transport_padding_on_boundary_lines_is_ignored() {
312 let body = "--B0 \r\n\r\npayload\r\n--B0-- \t\r\n";
313 let parts = parse_multipart_parts(body, "B0");
314 assert_eq!(parts.len(), 1);
315 assert_eq!(parts[0].payload, "payload");
316 }
317}