use crate::courierust_body::Body;
use crate::courierust_bytes::Bytes;
use crate::courierust_client::Client;
use crate::courierust_error::{Error, Result};
use crate::courierust_http::header::{HeaderName, HeaderValue};
use crate::courierust_http::method::Method;
use crate::courierust_http::request::Request;
use crate::courierust_http::response::Response;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
#[derive(Debug, Clone)]
pub struct Part {
name: String,
filename: Option<String>,
content_type: Option<String>,
body: Vec<u8>,
}
impl Part {
pub fn text(name: impl Into<String>, value: impl Into<String>) -> Self {
Self {
name: name.into(),
filename: None,
content_type: None,
body: value.into().into_bytes(),
}
}
pub fn file(
name: impl Into<String>,
filename: impl Into<String>,
content_type: Option<&str>,
body: impl Into<Vec<u8>>,
) -> Self {
Self {
name: name.into(),
filename: Some(filename.into()),
content_type: content_type.map(str::to_string),
body: body.into(),
}
}
pub fn bytes(name: impl Into<String>, content_type: &str, body: impl Into<Vec<u8>>) -> Self {
Self {
name: name.into(),
filename: None,
content_type: Some(content_type.to_string()),
body: body.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct Multipart {
boundary: String,
parts: Vec<Part>,
}
impl Multipart {
pub fn new() -> Result<Self> {
let mut entropy = [0u8; 24];
if !crate::courierust_tls::crypto::rng::fill_random(&mut entropy) {
return Err(Error::protocol(
"multipart: the platform entropy source is unavailable, so no boundary can be made",
));
}
let mut boundary = String::with_capacity(49);
for byte in entropy {
boundary.push_str(&format!("{byte:02x}"));
}
Ok(Self::with_boundary(boundary))
}
pub fn with_boundary(boundary: impl Into<String>) -> Self {
Self {
boundary: boundary.into(),
parts: Vec::new(),
}
}
pub fn part(mut self, part: Part) -> Self {
self.parts.push(part);
self
}
pub fn text(self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.part(Part::text(name, value))
}
pub fn file(
self,
name: impl Into<String>,
filename: impl Into<String>,
content_type: Option<&str>,
body: impl Into<Vec<u8>>,
) -> Self {
self.part(Part::file(name, filename, content_type, body))
}
pub fn boundary(&self) -> &str {
&self.boundary
}
pub fn content_type(&self) -> String {
format!("multipart/form-data; boundary={}", self.boundary)
}
pub fn len(&self) -> usize {
self.parts.len()
}
pub fn is_empty(&self) -> bool {
self.parts.is_empty()
}
pub fn encode(&self) -> Result<Vec<u8>> {
if !is_bchars(&self.boundary) {
return Err(Error::protocol(
"multipart: boundary must be 1..=70 characters of RFC 2046 bcharsnospace",
));
}
let delimiter = format!("\r\n--{}", self.boundary);
let mut out = Vec::new();
for part in &self.parts {
if part.name.is_empty() {
return Err(Error::protocol("multipart: a part needs a name"));
}
check_parameter(&part.name, "name")?;
if let Some(filename) = &part.filename {
check_parameter(filename, "filename")?;
}
if let Some(content_type) = &part.content_type {
check_parameter(content_type, "content-type")?;
}
if contains(&part.body, delimiter.as_bytes()) {
return Err(Error::protocol(format!(
"multipart: part `{}` contains the boundary",
part.name
)));
}
out.extend_from_slice(b"--");
out.extend_from_slice(self.boundary.as_bytes());
out.extend_from_slice(b"\r\nContent-Disposition: form-data; name=\"");
out.extend_from_slice(part.name.as_bytes());
out.push(b'"');
if let Some(filename) = &part.filename {
out.extend_from_slice(b"; filename=\"");
out.extend_from_slice(filename.as_bytes());
out.push(b'"');
}
out.extend_from_slice(b"\r\nContent-Type: ");
match &part.content_type {
Some(content_type) => out.extend_from_slice(content_type.as_bytes()),
None if part.filename.is_some() => {
out.extend_from_slice(b"application/octet-stream")
}
None => out.extend_from_slice(b"text/plain"),
}
out.extend_from_slice(b"\r\n\r\n");
out.extend_from_slice(&part.body);
out.extend_from_slice(b"\r\n");
}
out.extend_from_slice(b"--");
out.extend_from_slice(self.boundary.as_bytes());
out.extend_from_slice(b"--\r\n");
Ok(out)
}
}
fn is_bchars(boundary: &str) -> bool {
!boundary.is_empty()
&& boundary.len() <= 70
&& boundary.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'\''
| b'('
| b')'
| b'+'
| b'_'
| b','
| b'-'
| b'.'
| b'/'
| b':'
| b'='
| b'?'
)
})
}
fn check_parameter(value: &str, what: &str) -> Result<()> {
if value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
return Err(Error::protocol(format!(
"multipart: a {what} may not contain CR, LF or NUL"
)));
}
Ok(())
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack.windows(needle.len()).any(|w| w == needle)
}
impl Client {
pub fn execute_multipart(
&self,
url: &str,
method: Method,
form: &Multipart,
) -> Result<Response<Body>> {
let mut req = Request::<Body>::new(method, "/");
req.headers.insert(
HeaderName::from_lowercase("content-type"),
HeaderValue::from_bytes(form.content_type().as_bytes())?,
);
req.body = Body::Bytes(Bytes::from(form.encode()?));
self.execute(url, req)
}
pub fn post_multipart(&self, url: &str, form: &Multipart) -> Result<Response<Body>> {
self.execute_multipart(url, Method::POST, form)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encoded(form: &Multipart) -> String {
String::from_utf8(form.encode().expect("encode")).expect("utf8")
}
#[test]
fn the_wire_format_is_what_rfc_7578_says() {
let form = Multipart::with_boundary("B").text("field", "value").file(
"upload",
"a.txt",
Some("text/plain"),
b"body".to_vec(),
);
assert_eq!(form.content_type(), "multipart/form-data; boundary=B");
assert_eq!(
encoded(&form),
"--B\r\n\
Content-Disposition: form-data; name=\"field\"\r\n\
Content-Type: text/plain\r\n\
\r\n\
value\r\n\
--B\r\n\
Content-Disposition: form-data; name=\"upload\"; filename=\"a.txt\"\r\n\
Content-Type: text/plain\r\n\
\r\n\
body\r\n\
--B--\r\n"
);
}
#[test]
fn a_missing_content_type_gets_the_rfc_defaults() {
let form = Multipart::with_boundary("B").text("t", "1").file(
"f",
"x.bin",
None,
b"\x00\x01".to_vec(),
);
assert_eq!(
encoded(&form),
"--B\r\n\
Content-Disposition: form-data; name=\"t\"\r\n\
Content-Type: text/plain\r\n\
\r\n\
1\r\n\
--B\r\n\
Content-Disposition: form-data; name=\"f\"; filename=\"x.bin\"\r\n\
Content-Type: application/octet-stream\r\n\
\r\n\
\u{0}\u{1}\r\n\
--B--\r\n"
);
}
#[test]
fn an_empty_form_is_still_a_well_formed_body() {
let form = Multipart::with_boundary("B");
assert_eq!(encoded(&form), "--B--\r\n");
assert!(form.is_empty());
}
#[test]
fn a_body_containing_the_boundary_is_refused_not_misframed() {
let form =
Multipart::with_boundary("B").file("f", "f.bin", None, b"x\r\n--B\r\ny".to_vec());
let error = form.encode().expect_err("must refuse");
assert!(error.to_string().contains("boundary"), "{error}");
}
#[test]
fn header_injection_through_a_name_is_refused() {
for bad in ["a\r\nX: y", "a\nb", "a\u{0}b"] {
let form = Multipart::with_boundary("B").text(bad, "v");
assert!(form.encode().is_err(), "{bad:?} must be refused");
}
let form = Multipart::with_boundary("B").file("ok", "evil\r\nX: y", None, Vec::new());
assert!(form.encode().is_err(), "a filename is a parameter too");
let quoted = Multipart::with_boundary("B").text("ok", "v");
let mut quoted = quoted;
quoted.parts[0].content_type = Some("text/plain\r\nX: y".to_string());
assert!(
quoted.encode().is_err(),
"a content-type is a parameter too"
);
}
#[test]
fn an_illegal_boundary_is_refused() {
for bad in ["", "with space", "quote\"", "line\rbreak", &"x".repeat(71)] {
let form = Multipart::with_boundary(bad).text("a", "b");
assert!(form.encode().is_err(), "{bad:?} must be refused");
}
}
#[test]
fn a_random_boundary_is_unpredictable_and_legal() {
let a = Multipart::new().expect("entropy");
let b = Multipart::new().expect("entropy");
assert_ne!(a.boundary(), b.boundary());
assert_eq!(a.boundary().len(), 48, "24 bytes of hex");
assert!(a.encode().is_ok());
}
}