Skip to main content

guard_core_engine/
multipart_scan.rs

1//! Line-based multipart scanner mirroring the extracted-value semantics of
2//! Python's email feedparser for multipart/form-data bodies.
3//!
4//! Source: `guard_core/_utils/body_form_scan._multipart_text_parts`, a direct
5//! port of the Go engine's `multipartscan.go`.
6//!
7//! - preamble lines before the opening boundary are discarded, epilogue after
8//!   the final boundary is discarded;
9//! - part headers keep their raw name case and wire order; a folded
10//!   continuation line is appended to the previous value with its original
11//!   line break preserved (compat32 keeps the raw fold);
12//! - the first colonless line inside a part ends the header block and that
13//!   line and everything after it up to the next boundary is the payload
14//!   (`MissingHeaderBodySeparatorDefect`);
15//! - the line terminator preceding a boundary line belongs to the delimiter,
16//!   everything else (including bare newlines) stays in the payload;
17//! - a part whose Content-Type is multipart with a boundary parameter is a
18//!   container: its leaf parts are walked in place (`message.walk`), the
19//!   container itself produces no entries; a container without a boundary is
20//!   an ordinary leaf.
21//!
22//! No closing boundary is tolerated: the part parsed so far is kept, matching
23//! the email parser's `CloseBoundaryNotFoundDefect` behavior. The scanner is
24//! line-based over the already-capped body string and part payloads are
25//! borrowed slices of it (the engine body is always valid UTF-8, decoded
26//! lossily at the adapter boundary).
27
28/// One raw part header, in wire order and raw name case.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct MimeHeaderEntry {
31    pub name: String,
32    pub value: String,
33}
34
35/// One leaf part: raw headers plus the payload bytes as a slice of the body.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct MultipartPart<'a> {
38    pub headers: Vec<MimeHeaderEntry>,
39    pub payload: &'a str,
40}
41
42/// Split a multipart body into its leaf parts (nested multipart containers
43/// expanded in place).
44///
45/// An empty boundary yields no parts, which routes the caller to the
46/// whole-body blob fallback like the Python `is_multipart() == False` path.
47#[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    // Preamble: skip lines until the opening boundary (or the final boundary,
61    // which means zero parts).
62    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            // Missing closing boundary (payload ran to the end of the input,
81            // CloseBoundaryNotFoundDefect) or the multipart body was closed:
82            // any epilogue is discarded.
83            return parts;
84        }
85        pos = next;
86    }
87    parts
88}
89
90/// Read one part (headers plus payload) starting at `pos`. Reports whether a
91/// boundary line terminated the part (`closed`) and whether that boundary was
92/// the final one; a missing closing boundary ends the multipart body with the
93/// payload running to the end of the input.
94type ReadPart<'a> = (Vec<MimeHeaderEntry>, &'a str, usize, bool, bool);
95
96#[allow(clippy::too_many_lines)] // faithful line-by-line port of readPart
97fn 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                // Blank line: headers end, payload starts after it.
108                in_headers = false;
109                payload_start = Some(line_next);
110            } else if !headers.is_empty() && (content.starts_with(' ') || content.starts_with('\t'))
111            {
112                // Folded continuation: keep the raw break and the line.
113                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                // Colonless line: the header block ends and the line itself
126                // opens the payload (compat32 defect behavior).
127                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    // No closing boundary: the payload runs to the end of the body.
146    let payload = payload_start.map_or(body, |start| &body[start..]);
147    (headers, payload, body.len() + 1, false, false)
148}
149
150/// Extract the payload bytes between `payload_start` and the start of the
151/// boundary line; the terminator immediately before the boundary line belongs
152/// to the delimiter, not the payload.
153fn 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))] // unreachable: the CRLF strip guards keep
166    // `end >= start`, so the slice never inverts
167    if end < start {
168        return "";
169    }
170    &body[start..end]
171}
172
173/// Strip the trailing CR and transport padding (trailing spaces and tabs) the
174/// boundary comparison ignores.
175fn 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
180/// Split the next line: `(line_start, line_end_excluding_newline, next_pos)`.
181/// A final line without a terminator ends at the body end.
182fn 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
192/// Either expand a nested multipart container (`message.walk` descends; the
193/// container itself produces no entries) or append the part as a leaf.
194fn 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    // `get_content_maintype() == "multipart"`: the piece before the "/".
202    let boundary = if main_type.split('/').next() == Some("multipart") {
203        crate::body_scan::param_lookup(&params, "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        // compat32 defect behavior: the colonless line itself and everything
273        // after it up to the next boundary is the payload.
274        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        // The same fold over bare-LF breaks keeps the LF break verbatim.
287        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        // the terminator immediately before the boundary line is the
344        // delimiter's: a lone `\n` strip lands exactly on the payload start
345        assert_eq!(payload_slice("\nZ", Some(0), 1), "");
346        // a CRLF terminator strips both bytes
347        assert_eq!(payload_slice("x\r\nBOUND", Some(0), 3), "x");
348        // a payload byte that is no terminator keeps the whole span up to
349        // the boundary line
350        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        // the blank header terminator sits directly against the final boundary
361        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        // the payload's last line ends in a bare LF (no CR), so the delimiter
370        // strips only the LF
371        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        // a payload of exactly one LF whose preceding line ended in CRLF:
380        // both decrement below the payload start
381        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}