1#[derive(Debug, Clone)]
9pub struct MultipartField {
10 pub name: String,
11 pub value: MultipartValue,
12}
13
14#[derive(Debug, Clone)]
15pub enum MultipartValue {
16 Text(String),
18 File {
20 filename: String,
21 content_type: String,
22 bytes: Vec<u8>,
23 },
24}
25
26impl MultipartField {
27 pub fn from_json_map<I>(fields: I) -> Result<Vec<Self>, String>
40 where
41 I: IntoIterator<Item = (String, serde_json::Value)>,
42 {
43 fields
44 .into_iter()
45 .map(|(name, value)| {
46 let value = match value {
47 serde_json::Value::String(s) => MultipartValue::Text(s),
48 serde_json::Value::Number(n) => MultipartValue::Text(n.to_string()),
49 serde_json::Value::Bool(b) => MultipartValue::Text(b.to_string()),
50 serde_json::Value::Object(obj) => {
51 let filename = obj
52 .get("name")
53 .and_then(serde_json::Value::as_str)
54 .ok_or_else(|| format!("multipart[{name:?}]: a file field needs a string `name`"))?
55 .to_string();
56 let content_type = obj
57 .get("mimeType")
58 .and_then(serde_json::Value::as_str)
59 .unwrap_or("application/octet-stream")
60 .to_string();
61 let bytes = match obj.get("buffer") {
62 Some(serde_json::Value::String(s)) => s.clone().into_bytes(),
63 Some(array @ serde_json::Value::Array(_)) => serde_json::from_value::<Vec<u8>>(array.clone())
64 .map_err(|_| format!("multipart[{name:?}]: `buffer` must be bytes or a string"))?,
65 _ => return Err(format!("multipart[{name:?}]: a file field needs a `buffer`")),
66 };
67 MultipartValue::File {
68 filename,
69 content_type,
70 bytes,
71 }
72 },
73 other => {
74 return Err(format!(
75 "multipart[{name:?}] must be a string, number, boolean, or {{ name, mimeType, buffer }} (got {other})"
76 ));
77 },
78 };
79 Ok(Self { name, value })
80 })
81 .collect()
82 }
83}
84
85#[must_use]
90pub fn serialize_multipart(fields: &[MultipartField], boundary: &str) -> (Vec<u8>, String) {
91 let mut body = Vec::new();
92 for field in fields {
93 body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
94 match &field.value {
95 MultipartValue::Text(text) => {
96 body.extend_from_slice(format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n", field.name).as_bytes());
97 body.extend_from_slice(text.as_bytes());
98 },
99 MultipartValue::File {
100 filename,
101 content_type,
102 bytes,
103 } => {
104 body.extend_from_slice(
105 format!(
106 "Content-Disposition: form-data; name=\"{}\"; filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n",
107 field.name
108 )
109 .as_bytes(),
110 );
111 body.extend_from_slice(bytes);
112 },
113 }
114 body.extend_from_slice(b"\r\n");
115 }
116 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
117 (body, format!("multipart/form-data; boundary={boundary}"))
118}
119
120#[must_use]
125pub fn multipart_boundary_of(content_type: &str) -> Option<String> {
126 let (mime, params) = content_type.split_once(';')?;
127 if !mime.trim().eq_ignore_ascii_case("multipart/form-data") {
128 return None;
129 }
130 for param in params.split(';') {
131 let Some((k, v)) = param.split_once('=') else { continue };
132 if !k.trim().eq_ignore_ascii_case("boundary") {
133 continue;
134 }
135 let v = v.trim();
136 let v = v.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(v);
137 if !v.is_empty() {
138 return Some(v.to_string());
139 }
140 }
141 None
142}
143
144#[must_use]
154pub fn parse_multipart(body: &[u8], boundary: &str) -> Vec<MultipartField> {
155 let delim = format!("--{boundary}");
156 let mut fields = Vec::new();
157
158 for part in split_on(body, delim.as_bytes()) {
159 let part = part.strip_prefix(b"--".as_slice()).map_or(part, |_| &[][..]);
163 let part = part.strip_prefix(b"\r\n".as_slice()).unwrap_or(part);
164 let part = part.strip_suffix(b"\r\n".as_slice()).unwrap_or(part);
165 if part.is_empty() {
166 continue;
167 }
168 let Some(split) = find(part, b"\r\n\r\n") else { continue };
169 let (head, rest) = part.split_at(split);
170 let content = &rest[4..];
171
172 let head = String::from_utf8_lossy(head);
173 let mut name = None;
174 let mut filename = None;
175 let mut content_type = None;
176 for line in head.lines() {
177 let Some((key, value)) = line.split_once(':') else {
178 continue;
179 };
180 if key.trim().eq_ignore_ascii_case("content-type") {
181 content_type = Some(value.trim().to_string());
182 } else if key.trim().eq_ignore_ascii_case("content-disposition") {
183 name = header_param(value, "name");
184 filename = header_param(value, "filename");
185 }
186 }
187
188 let Some(name) = name else { continue };
189 fields.push(MultipartField {
190 name,
191 value: match filename {
192 Some(filename) => MultipartValue::File {
193 filename,
194 content_type: content_type.unwrap_or_else(|| "application/octet-stream".to_string()),
195 bytes: content.to_vec(),
196 },
197 None => MultipartValue::Text(String::from_utf8_lossy(content).into_owned()),
198 },
199 });
200 }
201 fields
202}
203
204fn header_param(value: &str, param: &str) -> Option<String> {
206 for piece in value.split(';') {
207 let Some((k, v)) = piece.split_once('=') else { continue };
208 if !k.trim().eq_ignore_ascii_case(param) {
209 continue;
210 }
211 let v = v.trim();
212 return Some(
213 v.strip_prefix('"')
214 .and_then(|r| r.strip_suffix('"'))
215 .unwrap_or(v)
216 .to_string(),
217 );
218 }
219 None
220}
221
222fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
223 haystack.windows(needle.len()).position(|w| w == needle)
224}
225
226fn split_on<'a>(mut haystack: &'a [u8], sep: &[u8]) -> Vec<&'a [u8]> {
229 let mut out = Vec::new();
230 let Some(first) = find(haystack, sep) else { return out };
231 haystack = &haystack[first + sep.len()..];
232 while let Some(at) = find(haystack, sep) {
233 out.push(&haystack[..at]);
234 haystack = &haystack[at + sep.len()..];
235 }
236 out.push(haystack);
237 out
238}
239
240#[must_use]
243pub fn multipart_boundary() -> String {
244 use std::sync::atomic::{AtomicU64, Ordering};
245 static SEQ: AtomicU64 = AtomicU64::new(0);
246 let n = SEQ.fetch_add(1, Ordering::Relaxed);
247 format!("----ferridriverBoundary{n:016x}")
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn multipart_serialization_shape() {
256 let fields = vec![
257 MultipartField {
258 name: "text".into(),
259 value: MultipartValue::Text("val".into()),
260 },
261 MultipartField {
262 name: "file".into(),
263 value: MultipartValue::File {
264 filename: "f.bin".into(),
265 content_type: "application/octet-stream".into(),
266 bytes: vec![1, 2, 3],
267 },
268 },
269 ];
270 let (body, content_type) = serialize_multipart(&fields, "BOUND");
271 assert_eq!(content_type, "multipart/form-data; boundary=BOUND");
272 let text = String::from_utf8_lossy(&body);
273 assert!(text.contains("--BOUND\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\nval\r\n"));
274 assert!(text.contains("name=\"file\"; filename=\"f.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n"));
275 assert!(text.ends_with("--BOUND--\r\n"));
276 }
277
278 #[test]
279 fn boundaries_are_unique() {
280 assert_ne!(multipart_boundary(), multipart_boundary());
281 }
282
283 #[test]
284 fn parse_multipart_round_trips_serialize_multipart() {
285 let fields = vec![
286 MultipartField {
287 name: "text".into(),
288 value: MultipartValue::Text("val".into()),
289 },
290 MultipartField {
291 name: "file".into(),
292 value: MultipartValue::File {
293 filename: "f.bin".into(),
294 content_type: "text/csv".into(),
295 bytes: vec![0, 159, 146, 150, b'\r', b'\n'],
297 },
298 },
299 ];
300 let (body, content_type) = serialize_multipart(&fields, "BOUND");
301 let boundary = multipart_boundary_of(&content_type).expect("boundary");
302 let parsed = parse_multipart(&body, &boundary);
303
304 assert_eq!(parsed.len(), 2);
305 assert_eq!(parsed[0].name, "text");
306 assert!(matches!(&parsed[0].value, MultipartValue::Text(t) if t == "val"));
307 assert_eq!(parsed[1].name, "file");
308 match &parsed[1].value {
309 MultipartValue::File {
310 filename,
311 content_type,
312 bytes,
313 } => {
314 assert_eq!(filename, "f.bin");
315 assert_eq!(content_type, "text/csv");
316 assert_eq!(bytes, &[0, 159, 146, 150, b'\r', b'\n']);
317 },
318 MultipartValue::Text(_) => panic!("expected a file part"),
319 }
320 }
321
322 #[test]
323 fn parse_multipart_defaults_file_type_and_skips_nameless_parts() {
324 let body = b"preamble\r\n\
325 --B\r\nContent-Disposition: form-data; name=\"a\"; filename=\"x\"\r\n\r\nAA\r\n\
326 --B\r\nContent-Disposition: form-data\r\n\r\nno-name\r\n\
327 --B\r\ngarbage-with-no-header-separator\r\n\
328 --B--\r\n";
329 let parsed = parse_multipart(body, "B");
330 assert_eq!(parsed.len(), 1, "nameless and malformed parts are skipped");
331 assert!(matches!(
332 &parsed[0].value,
333 MultipartValue::File { content_type, .. } if content_type == "application/octet-stream"
334 ));
335 }
336
337 #[test]
338 fn multipart_boundary_of_reads_quoted_and_bare_forms() {
339 assert_eq!(
340 multipart_boundary_of("multipart/form-data; boundary=abc").as_deref(),
341 Some("abc")
342 );
343 assert_eq!(
344 multipart_boundary_of("Multipart/Form-Data; charset=utf-8; BOUNDARY=\"a b\"").as_deref(),
345 Some("a b")
346 );
347 assert_eq!(multipart_boundary_of("application/json"), None);
348 assert_eq!(multipart_boundary_of("multipart/form-data"), None);
349 }
350}