Skip to main content

ferrijs_fetch/
multipart.rs

1//! `multipart/form-data` serialization, shared by the request-option
2//! lowering and the JS `FormData` body path so both express multipart
3//! identically. Mirrors Playwright's `FormField`.
4
5/// One field of a `multipart/form-data` body — a plain text value or an
6/// uploaded file part. Mirrors Playwright's `FormField`
7/// (`multipartData: { name, value } | { name, file: { name, mimeType, buffer } }`).
8#[derive(Debug, Clone)]
9pub struct MultipartField {
10  pub name: String,
11  pub value: MultipartValue,
12}
13
14#[derive(Debug, Clone)]
15pub enum MultipartValue {
16  /// A scalar text field.
17  Text(String),
18  /// A file part with an explicit filename + content type.
19  File {
20    filename: String,
21    content_type: String,
22    bytes: Vec<u8>,
23  },
24}
25
26impl MultipartField {
27  /// Lower Playwright's `multipart` option bag — a map of
28  /// `string | number | boolean | { name, mimeType, buffer }` — into
29  /// fields.
30  ///
31  /// `buffer` accepts a byte array or a string (the natural JSON shape of
32  /// a `Buffer` / `Uint8Array` crossing either binding boundary). Both
33  /// bindings call this so a `multipart` bag means exactly one thing.
34  ///
35  /// # Errors
36  ///
37  /// Returns a message naming the offending key when a value is neither
38  /// a scalar nor a well-formed file descriptor.
39  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/// Serialize `multipart/form-data` fields into a body + the matching
86/// `content-type` header value (with the boundary). Field names /
87/// filenames are written into the part headers verbatim (the caller
88/// controls them).
89#[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/// The `boundary` parameter of a `multipart/form-data` content type, or
121/// `None` when the type is not multipart or declares no boundary.
122/// Handles the quoted form (`boundary="ab cd"`) and is case-insensitive
123/// on both the type and the parameter name.
124#[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/// Parse a `multipart/form-data` body back into fields — the inverse of
145/// [`serialize_multipart`], backing the WHATWG `formData()` body mixin.
146///
147/// A part with a `filename` parameter becomes [`MultipartValue::File`]
148/// (defaulting to `application/octet-stream` when it declares no type),
149/// anything else becomes [`MultipartValue::Text`]. Malformed parts are
150/// skipped rather than failing the whole parse: browsers are lenient
151/// here, and a body that round-trips through a server may lose the
152/// preamble/epilogue.
153#[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    // A part starts after the delimiter's CRLF and ends before the CRLF
160    // that precedes the next one; the closing delimiter carries a
161    // trailing `--`.
162    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
204/// A quoted parameter of a header value (`name="file"` -> `file`).
205fn 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
226/// The slices between each occurrence of `sep` (the leading segment
227/// before the first separator is dropped — it is the multipart preamble).
228fn 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/// A process-unique multipart boundary. Deterministic construction (no
241/// RNG dependency): a fixed prefix + a monotonic counter.
242#[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 that are not valid UTF-8 must survive verbatim.
296          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}