ferrijs-fetch 0.1.0

A WHATWG Fetch model and one send engine over reqwest: manual redirects, a cookie bridge, multipart, and the SSRF guard the ferrijs sandbox enforces per hop.
Documentation
//! `multipart/form-data` serialization, shared by the request-option
//! lowering and the JS `FormData` body path so both express multipart
//! identically. Mirrors Playwright's `FormField`.

/// One field of a `multipart/form-data` body — a plain text value or an
/// uploaded file part. Mirrors Playwright's `FormField`
/// (`multipartData: { name, value } | { name, file: { name, mimeType, buffer } }`).
#[derive(Debug, Clone)]
pub struct MultipartField {
  pub name: String,
  pub value: MultipartValue,
}

#[derive(Debug, Clone)]
pub enum MultipartValue {
  /// A scalar text field.
  Text(String),
  /// A file part with an explicit filename + content type.
  File {
    filename: String,
    content_type: String,
    bytes: Vec<u8>,
  },
}

impl MultipartField {
  /// Lower Playwright's `multipart` option bag — a map of
  /// `string | number | boolean | { name, mimeType, buffer }` — into
  /// fields.
  ///
  /// `buffer` accepts a byte array or a string (the natural JSON shape of
  /// a `Buffer` / `Uint8Array` crossing either binding boundary). Both
  /// bindings call this so a `multipart` bag means exactly one thing.
  ///
  /// # Errors
  ///
  /// Returns a message naming the offending key when a value is neither
  /// a scalar nor a well-formed file descriptor.
  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()
  }
}

/// Serialize `multipart/form-data` fields into a body + the matching
/// `content-type` header value (with the boundary). Field names /
/// filenames are written into the part headers verbatim (the caller
/// controls them).
#[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}"))
}

/// The `boundary` parameter of a `multipart/form-data` content type, or
/// `None` when the type is not multipart or declares no boundary.
/// Handles the quoted form (`boundary="ab cd"`) and is case-insensitive
/// on both the type and the parameter name.
#[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
}

/// Parse a `multipart/form-data` body back into fields — the inverse of
/// [`serialize_multipart`], backing the WHATWG `formData()` body mixin.
///
/// A part with a `filename` parameter becomes [`MultipartValue::File`]
/// (defaulting to `application/octet-stream` when it declares no type),
/// anything else becomes [`MultipartValue::Text`]. Malformed parts are
/// skipped rather than failing the whole parse: browsers are lenient
/// here, and a body that round-trips through a server may lose the
/// preamble/epilogue.
#[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()) {
    // A part starts after the delimiter's CRLF and ends before the CRLF
    // that precedes the next one; the closing delimiter carries a
    // trailing `--`.
    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
}

/// A quoted parameter of a header value (`name="file"` -> `file`).
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)
}

/// The slices between each occurrence of `sep` (the leading segment
/// before the first separator is dropped — it is the multipart preamble).
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
}

/// A process-unique multipart boundary. Deterministic construction (no
/// RNG dependency): a fixed prefix + a monotonic counter.
#[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 that are not valid UTF-8 must survive verbatim.
          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);
  }
}