oxihuman_core/
multipart_parser.rs1#![allow(dead_code)]
4
5#[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#[derive(Clone, Debug, PartialEq)]
18pub struct MultipartBody {
19 pub boundary: String,
20 pub parts: Vec<MultipartPart>,
21}
22
23#[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
41pub 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
50pub 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 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
121pub fn total_body_bytes(body: &MultipartBody) -> usize {
123 body.parts.iter().map(|p| p.data.len()).sum()
124}
125
126pub 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}