Skip to main content

oxihuman_core/
multipart_parser.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Multipart/form-data parser stub — splits parts by boundary.
6
7/// A single multipart form part.
8#[derive(Clone, Debug, PartialEq)]
9pub struct MultipartPart {
10    pub name: Option<String>,
11    pub filename: Option<String>,
12    pub content_type: String,
13    pub data: Vec<u8>,
14}
15
16/// Result of parsing a multipart body.
17#[derive(Clone, Debug, PartialEq)]
18pub struct MultipartBody {
19    pub boundary: String,
20    pub parts: Vec<MultipartPart>,
21}
22
23/// Errors from multipart parsing.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum MultipartError {
26    MissingBoundary,
27    MalformedPart,
28    EmptyBody,
29}
30
31impl std::fmt::Display for MultipartError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            MultipartError::MissingBoundary => write!(f, "missing boundary"),
35            MultipartError::MalformedPart => write!(f, "malformed part"),
36            MultipartError::EmptyBody => write!(f, "empty body"),
37        }
38    }
39}
40
41/// Extracts the boundary from a Content-Type header value.
42pub fn extract_boundary(content_type: &str) -> Option<String> {
43    content_type.split(';').find_map(|seg| {
44        let seg = seg.trim();
45        seg.strip_prefix("boundary=")
46            .map(|b| b.trim_matches('"').to_owned())
47    })
48}
49
50/// Parses a raw multipart body string given a boundary.
51pub fn parse_multipart(body: &str, boundary: &str) -> Result<MultipartBody, MultipartError> {
52    if body.is_empty() {
53        return Err(MultipartError::EmptyBody);
54    }
55    if boundary.is_empty() {
56        return Err(MultipartError::MissingBoundary);
57    }
58    let delimiter = format!("--{}", boundary);
59    let mut parts = Vec::new();
60
61    for raw_part in body.split(&delimiter) {
62        let part = raw_part.trim();
63        if part.is_empty() || part == "--" {
64            continue;
65        }
66        let parsed = parse_single_part(part)?;
67        parts.push(parsed);
68    }
69
70    Ok(MultipartBody {
71        boundary: boundary.into(),
72        parts,
73    })
74}
75
76fn parse_single_part(raw: &str) -> Result<MultipartPart, MultipartError> {
77    /* find blank line separating headers from body */
78    let sep = if raw.contains("\r\n\r\n") {
79        "\r\n\r\n"
80    } else {
81        "\n\n"
82    };
83    let idx = raw.find(sep).ok_or(MultipartError::MalformedPart)?;
84    let header_section = &raw[..idx];
85    let body_section = &raw[idx + sep.len()..];
86
87    let mut name = None;
88    let mut filename = None;
89    let mut content_type = "text/plain".to_owned();
90
91    for line in header_section.lines() {
92        let lower = line.to_lowercase();
93        if lower.starts_with("content-disposition:") {
94            name = extract_field(line, "name");
95            filename = extract_field(line, "filename");
96        } else if lower.starts_with("content-type:") {
97            content_type = line
98                .split_once(':')
99                .map(|x| x.1)
100                .unwrap_or("")
101                .trim()
102                .to_owned();
103        }
104    }
105
106    Ok(MultipartPart {
107        name,
108        filename,
109        content_type,
110        data: body_section.as_bytes().to_vec(),
111    })
112}
113
114fn extract_field(header_line: &str, field: &str) -> Option<String> {
115    let pattern = format!("{}=\"", field);
116    let start = header_line.find(&pattern)? + pattern.len();
117    let end = header_line[start..].find('"')? + start;
118    Some(header_line[start..end].to_owned())
119}
120
121/// Returns the total byte size of all parts.
122pub fn total_body_bytes(body: &MultipartBody) -> usize {
123    body.parts.iter().map(|p| p.data.len()).sum()
124}
125
126/// Finds a part by its name field.
127pub fn find_part_by_name<'a>(body: &'a MultipartBody, name: &str) -> Option<&'a MultipartPart> {
128    body.parts.iter().find(|p| p.name.as_deref() == Some(name))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_extract_boundary_from_content_type() {
137        let ct = "multipart/form-data; boundary=abc123";
138        assert_eq!(extract_boundary(ct), Some("abc123".into()));
139    }
140
141    #[test]
142    fn test_extract_boundary_quoted() {
143        let ct = "multipart/form-data; boundary=\"mybound\"";
144        assert_eq!(extract_boundary(ct), Some("mybound".into()));
145    }
146
147    #[test]
148    fn test_parse_empty_body_error() {
149        assert_eq!(parse_multipart("", "bound"), Err(MultipartError::EmptyBody));
150    }
151
152    #[test]
153    fn test_parse_empty_boundary_error() {
154        assert_eq!(
155            parse_multipart("data", ""),
156            Err(MultipartError::MissingBoundary)
157        );
158    }
159
160    #[test]
161    fn test_parse_single_part() {
162        let body = "--bound\nContent-Disposition: form-data; name=\"field\"\n\nhello\n--bound--";
163        let result = parse_multipart(body, "bound").expect("should succeed");
164        assert_eq!(result.parts.len(), 1);
165    }
166
167    #[test]
168    fn test_find_part_by_name() {
169        let body = "--b\nContent-Disposition: form-data; name=\"username\"\n\nalice\n--b--";
170        let result = parse_multipart(body, "b").expect("should succeed");
171        let part = find_part_by_name(&result, "username");
172        assert!(part.is_some());
173    }
174
175    #[test]
176    fn test_total_body_bytes_sums_parts() {
177        let body = MultipartBody {
178            boundary: "b".into(),
179            parts: vec![
180                MultipartPart {
181                    name: None,
182                    filename: None,
183                    content_type: "text/plain".into(),
184                    data: vec![1, 2, 3],
185                },
186                MultipartPart {
187                    name: None,
188                    filename: None,
189                    content_type: "text/plain".into(),
190                    data: vec![4, 5],
191                },
192            ],
193        };
194        assert_eq!(total_body_bytes(&body), 5);
195    }
196
197    #[test]
198    fn test_boundary_appears_in_parsed_body() {
199        let body = "--mybound\nContent-Disposition: form-data; name=\"x\"\n\nval\n--mybound--";
200        let result = parse_multipart(body, "mybound").expect("should succeed");
201        assert_eq!(result.boundary, "mybound");
202    }
203
204    #[test]
205    fn test_no_boundary_in_content_type_returns_none() {
206        let ct = "application/json";
207        assert!(extract_boundary(ct).is_none());
208    }
209}