#[derive(Debug, Clone)]
pub struct MultipartField {
pub name: String,
pub value: MultipartValue,
}
#[derive(Debug, Clone)]
pub enum MultipartValue {
Text(String),
File {
filename: String,
content_type: String,
bytes: Vec<u8>,
},
}
impl MultipartField {
pub fn from_json_map<I>(fields: I) -> Result<Vec<Self>, String>
where
I: IntoIterator<Item = (String, serde_json::Value)>,
{
fields
.into_iter()
.map(|(name, value)| {
let value = match value {
serde_json::Value::String(s) => MultipartValue::Text(s),
serde_json::Value::Number(n) => MultipartValue::Text(n.to_string()),
serde_json::Value::Bool(b) => MultipartValue::Text(b.to_string()),
serde_json::Value::Object(obj) => {
let filename = obj
.get("name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("multipart[{name:?}]: a file field needs a string `name`"))?
.to_string();
let content_type = obj
.get("mimeType")
.and_then(serde_json::Value::as_str)
.unwrap_or("application/octet-stream")
.to_string();
let bytes = match obj.get("buffer") {
Some(serde_json::Value::String(s)) => s.clone().into_bytes(),
Some(array @ serde_json::Value::Array(_)) => serde_json::from_value::<Vec<u8>>(array.clone())
.map_err(|_| format!("multipart[{name:?}]: `buffer` must be bytes or a string"))?,
_ => return Err(format!("multipart[{name:?}]: a file field needs a `buffer`")),
};
MultipartValue::File {
filename,
content_type,
bytes,
}
},
other => {
return Err(format!(
"multipart[{name:?}] must be a string, number, boolean, or {{ name, mimeType, buffer }} (got {other})"
));
},
};
Ok(Self { name, value })
})
.collect()
}
}
#[must_use]
pub fn serialize_multipart(fields: &[MultipartField], boundary: &str) -> (Vec<u8>, String) {
let mut body = Vec::new();
for field in fields {
body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
match &field.value {
MultipartValue::Text(text) => {
body.extend_from_slice(format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n", field.name).as_bytes());
body.extend_from_slice(text.as_bytes());
},
MultipartValue::File {
filename,
content_type,
bytes,
} => {
body.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"{}\"; filename=\"{filename}\"\r\nContent-Type: {content_type}\r\n\r\n",
field.name
)
.as_bytes(),
);
body.extend_from_slice(bytes);
},
}
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
(body, format!("multipart/form-data; boundary={boundary}"))
}
#[must_use]
pub fn multipart_boundary_of(content_type: &str) -> Option<String> {
let (mime, params) = content_type.split_once(';')?;
if !mime.trim().eq_ignore_ascii_case("multipart/form-data") {
return None;
}
for param in params.split(';') {
let Some((k, v)) = param.split_once('=') else { continue };
if !k.trim().eq_ignore_ascii_case("boundary") {
continue;
}
let v = v.trim();
let v = v.strip_prefix('"').and_then(|r| r.strip_suffix('"')).unwrap_or(v);
if !v.is_empty() {
return Some(v.to_string());
}
}
None
}
#[must_use]
pub fn parse_multipart(body: &[u8], boundary: &str) -> Vec<MultipartField> {
let delim = format!("--{boundary}");
let mut fields = Vec::new();
for part in split_on(body, delim.as_bytes()) {
let part = part.strip_prefix(b"--".as_slice()).map_or(part, |_| &[][..]);
let part = part.strip_prefix(b"\r\n".as_slice()).unwrap_or(part);
let part = part.strip_suffix(b"\r\n".as_slice()).unwrap_or(part);
if part.is_empty() {
continue;
}
let Some(split) = find(part, b"\r\n\r\n") else { continue };
let (head, rest) = part.split_at(split);
let content = &rest[4..];
let head = String::from_utf8_lossy(head);
let mut name = None;
let mut filename = None;
let mut content_type = None;
for line in head.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
};
if key.trim().eq_ignore_ascii_case("content-type") {
content_type = Some(value.trim().to_string());
} else if key.trim().eq_ignore_ascii_case("content-disposition") {
name = header_param(value, "name");
filename = header_param(value, "filename");
}
}
let Some(name) = name else { continue };
fields.push(MultipartField {
name,
value: match filename {
Some(filename) => MultipartValue::File {
filename,
content_type: content_type.unwrap_or_else(|| "application/octet-stream".to_string()),
bytes: content.to_vec(),
},
None => MultipartValue::Text(String::from_utf8_lossy(content).into_owned()),
},
});
}
fields
}
fn header_param(value: &str, param: &str) -> Option<String> {
for piece in value.split(';') {
let Some((k, v)) = piece.split_once('=') else { continue };
if !k.trim().eq_ignore_ascii_case(param) {
continue;
}
let v = v.trim();
return Some(
v.strip_prefix('"')
.and_then(|r| r.strip_suffix('"'))
.unwrap_or(v)
.to_string(),
);
}
None
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn split_on<'a>(mut haystack: &'a [u8], sep: &[u8]) -> Vec<&'a [u8]> {
let mut out = Vec::new();
let Some(first) = find(haystack, sep) else { return out };
haystack = &haystack[first + sep.len()..];
while let Some(at) = find(haystack, sep) {
out.push(&haystack[..at]);
haystack = &haystack[at + sep.len()..];
}
out.push(haystack);
out
}
#[must_use]
pub fn multipart_boundary() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
format!("----ferridriverBoundary{n:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multipart_serialization_shape() {
let fields = vec![
MultipartField {
name: "text".into(),
value: MultipartValue::Text("val".into()),
},
MultipartField {
name: "file".into(),
value: MultipartValue::File {
filename: "f.bin".into(),
content_type: "application/octet-stream".into(),
bytes: vec![1, 2, 3],
},
},
];
let (body, content_type) = serialize_multipart(&fields, "BOUND");
assert_eq!(content_type, "multipart/form-data; boundary=BOUND");
let text = String::from_utf8_lossy(&body);
assert!(text.contains("--BOUND\r\nContent-Disposition: form-data; name=\"text\"\r\n\r\nval\r\n"));
assert!(text.contains("name=\"file\"; filename=\"f.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n"));
assert!(text.ends_with("--BOUND--\r\n"));
}
#[test]
fn boundaries_are_unique() {
assert_ne!(multipart_boundary(), multipart_boundary());
}
#[test]
fn parse_multipart_round_trips_serialize_multipart() {
let fields = vec![
MultipartField {
name: "text".into(),
value: MultipartValue::Text("val".into()),
},
MultipartField {
name: "file".into(),
value: MultipartValue::File {
filename: "f.bin".into(),
content_type: "text/csv".into(),
bytes: vec![0, 159, 146, 150, b'\r', b'\n'],
},
},
];
let (body, content_type) = serialize_multipart(&fields, "BOUND");
let boundary = multipart_boundary_of(&content_type).expect("boundary");
let parsed = parse_multipart(&body, &boundary);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].name, "text");
assert!(matches!(&parsed[0].value, MultipartValue::Text(t) if t == "val"));
assert_eq!(parsed[1].name, "file");
match &parsed[1].value {
MultipartValue::File {
filename,
content_type,
bytes,
} => {
assert_eq!(filename, "f.bin");
assert_eq!(content_type, "text/csv");
assert_eq!(bytes, &[0, 159, 146, 150, b'\r', b'\n']);
},
MultipartValue::Text(_) => panic!("expected a file part"),
}
}
#[test]
fn parse_multipart_defaults_file_type_and_skips_nameless_parts() {
let body = b"preamble\r\n\
--B\r\nContent-Disposition: form-data; name=\"a\"; filename=\"x\"\r\n\r\nAA\r\n\
--B\r\nContent-Disposition: form-data\r\n\r\nno-name\r\n\
--B\r\ngarbage-with-no-header-separator\r\n\
--B--\r\n";
let parsed = parse_multipart(body, "B");
assert_eq!(parsed.len(), 1, "nameless and malformed parts are skipped");
assert!(matches!(
&parsed[0].value,
MultipartValue::File { content_type, .. } if content_type == "application/octet-stream"
));
}
#[test]
fn multipart_boundary_of_reads_quoted_and_bare_forms() {
assert_eq!(
multipart_boundary_of("multipart/form-data; boundary=abc").as_deref(),
Some("abc")
);
assert_eq!(
multipart_boundary_of("Multipart/Form-Data; charset=utf-8; BOUNDARY=\"a b\"").as_deref(),
Some("a b")
);
assert_eq!(multipart_boundary_of("application/json"), None);
assert_eq!(multipart_boundary_of("multipart/form-data"), None);
}
}