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 #[cfg(not(coverage))] if end < start {
168 return "";
169 }
170 &body[start..end]
171}
172
173fn trim_cr_and_padding(line: &str) -> &str {
176 let line = line.strip_suffix('\r').unwrap_or(line);
177 line.trim_end_matches([' ', '\t'])
178}
179
180fn next_line(body: &str, pos: usize) -> (usize, usize, usize) {
183 let line_start = pos;
184 body.as_bytes()[pos..]
185 .iter()
186 .position(|b| *b == b'\n')
187 .map_or((line_start, body.len(), body.len() + 1), |idx| {
188 (line_start, pos + idx, pos + idx + 1)
189 })
190}
191
192fn append_multipart_leaf_or_container<'a>(
195 mut parts: Vec<MultipartPart<'a>>,
196 headers: Vec<MimeHeaderEntry>,
197 payload: &'a str,
198) -> Vec<MultipartPart<'a>> {
199 let content_type = first_header_value_or_empty(&headers, "content-type");
200 let (main_type, params) = crate::body_scan::parse_media_type_params(&content_type);
201 let boundary = if main_type.split('/').next() == Some("multipart") {
203 crate::body_scan::param_lookup(¶ms, "boundary").unwrap_or_default()
204 } else {
205 ""
206 };
207 if !boundary.is_empty() {
208 parts.extend(parse_multipart_level(payload, boundary));
209 return parts;
210 }
211 parts.push(MultipartPart { headers, payload });
212 parts
213}
214
215fn first_header_value_or_empty(headers: &[MimeHeaderEntry], lower_name: &str) -> String {
216 headers
217 .iter()
218 .find(|h| h.name.eq_ignore_ascii_case(lower_name))
219 .map_or_else(String::new, |h| h.value.clone())
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn splits_two_text_parts() {
228 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";
229 let parts = parse_multipart_parts(body, "B0");
230 assert_eq!(parts.len(), 2);
231 assert_eq!(parts[0].payload, "hello");
232 assert_eq!(parts[1].payload, "there");
233 assert_eq!(
234 parts[0].headers,
235 vec![MimeHeaderEntry {
236 name: "Content-Disposition".to_owned(),
237 value: "form-data; name=\"note\"".to_owned()
238 }]
239 );
240 }
241
242 #[test]
243 fn empty_boundary_yields_no_parts() {
244 assert!(parse_multipart_parts("--B0\r\n\r\nx\r\n--B0--", "").is_empty());
245 }
246
247 #[test]
248 fn preamble_and_epilogue_discarded() {
249 let body = "preamble\r\n--B0\r\n\r\npayload\r\n--B0--\r\nepilogue\r\n";
250 let parts = parse_multipart_parts(body, "B0");
251 assert_eq!(parts.len(), 1);
252 assert_eq!(parts[0].payload, "payload");
253 }
254
255 #[test]
256 fn final_boundary_first_means_no_parts() {
257 let body = "--B0--\r\nx\r\n";
258 assert!(parse_multipart_parts(body, "B0").is_empty());
259 }
260
261 #[test]
262 fn missing_closing_boundary_keeps_part() {
263 let body =
264 "--B0\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\npayload-without-close";
265 let parts = parse_multipart_parts(body, "B0");
266 assert_eq!(parts.len(), 1);
267 assert_eq!(parts[0].payload, "payload-without-close");
268 }
269
270 #[test]
271 fn colonless_line_opens_the_payload() {
272 let body = "--B0\r\nnot-a-header\r\npayload line\r\n--B0--\r\n";
275 let parts = parse_multipart_parts(body, "B0");
276 assert_eq!(parts.len(), 1);
277 assert!(parts[0].headers.is_empty());
278 assert_eq!(parts[0].payload, "not-a-header\r\npayload line");
279 }
280
281 #[test]
282 fn folded_header_keeps_raw_break() {
283 let body = "--B0\r\nContent-Disposition: form-data;\r\n name=\"a\"\r\n\r\nx\r\n--B0--\r\n";
284 let parts = parse_multipart_parts(body, "B0");
285 assert_eq!(parts[0].headers[0].value, "form-data;\r\n name=\"a\"");
286 let body = "--B0\nContent-Disposition: form-data;\n name=\"a\"\n\nx\n--B0--\n";
288 let parts = parse_multipart_parts(body, "B0");
289 assert_eq!(parts[0].headers[0].value, "form-data;\n name=\"a\"");
290 }
291
292 #[test]
293 fn bare_newlines_stay_in_payload() {
294 let body = "--B0\r\n\r\nline1\nline2\r\n--B0--\r\n";
295 let parts = parse_multipart_parts(body, "B0");
296 assert_eq!(parts[0].payload, "line1\nline2");
297 }
298
299 #[test]
300 fn nested_multipart_container_expands_in_place() {
301 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";
302 let parts = parse_multipart_parts(body, "B0");
303 assert_eq!(parts.len(), 1, "the container itself produces no entry");
304 assert_eq!(parts[0].payload, "leaf-bytes");
305 assert_eq!(parts[0].headers[0].value, "attachment; filename=\"a.bin\"");
306 }
307
308 #[test]
309 fn container_without_boundary_is_an_ordinary_leaf() {
310 let body = "--B0\r\nContent-Type: multipart/mixed\r\n\r\nnot-really-nested\r\n--B0--\r\n";
311 let parts = parse_multipart_parts(body, "B0");
312 assert_eq!(parts.len(), 1);
313 assert_eq!(parts[0].payload, "not-really-nested");
314 }
315
316 #[test]
317 fn transport_padding_on_boundary_lines_is_ignored() {
318 let body = "--B0 \r\n\r\npayload\r\n--B0-- \t\r\n";
319 let parts = parse_multipart_parts(body, "B0");
320 assert_eq!(parts.len(), 1);
321 assert_eq!(parts[0].payload, "payload");
322 }
323}
324
325#[cfg(test)]
326mod coverage_tests {
327 use super::*;
328
329 #[test]
330 fn a_missing_boundary_yields_no_parts() {
331 let body = "Content-Disposition: form-data; name=\"f\"\r\n\r\nbody";
332 let parts = parse_multipart_parts(body, "NO_SUCH_BOUNDARY");
333 assert!(parts.is_empty());
334 }
335}
336
337#[cfg(test)]
338mod unit_twins {
339 use super::*;
340
341 #[test]
342 fn a_payload_newline_at_the_boundary_strips_down_to_the_start() {
343 assert_eq!(payload_slice("\nZ", Some(0), 1), "");
346 assert_eq!(payload_slice("x\r\nBOUND", Some(0), 3), "x");
348 assert_eq!(payload_slice("xZ", Some(0), 1), "x");
351 }
352}
353
354#[cfg(test)]
355mod gap_tests {
356 use super::*;
357
358 #[test]
359 fn a_part_with_no_payload_lines_yields_an_empty_payload() {
360 let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\n--B0--\r\n";
362 let parts = parse_multipart_parts(body, "B0");
363 assert_eq!(parts.len(), 1);
364 assert_eq!(parts[0].payload, "");
365 }
366
367 #[test]
368 fn a_bare_newline_before_the_boundary_stays_in_the_payload() {
369 let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\nabc\n--B0--\r\n";
372 let parts = parse_multipart_parts(body, "B0");
373 assert_eq!(parts.len(), 1);
374 assert_eq!(parts[0].payload, "abc");
375 }
376
377 #[test]
378 fn a_lone_newline_payload_collapses_to_empty() {
379 let body = "--B0\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\n\n--B0--\r\n";
382 let parts = parse_multipart_parts(body, "B0");
383 assert_eq!(parts.len(), 1);
384 assert_eq!(parts[0].payload, "");
385 }
386}