use bytes::Bytes;
use http::StatusCode;
use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
use mime::{BOUNDARY, Mime};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct Response {
pub(crate) url: String,
pub(crate) status: StatusCode,
pub(crate) headers: HeaderMap,
pub(crate) body: Bytes,
}
#[derive(Debug, Clone)]
pub struct ResponsePart {
pub name: String,
pub filename: Option<String>,
pub content_type: Option<Mime>,
pub body: Bytes,
}
impl Response {
pub fn url(&self) -> &str {
&self.url
}
pub fn status(&self) -> StatusCode {
self.status
}
pub fn headers(&self) -> &HeaderMap {
&self.headers
}
pub fn body(&self) -> &Bytes {
&self.body
}
pub fn into_body(self) -> Bytes {
self.body
}
pub fn text(&self) -> Result<&str> {
Ok(std::str::from_utf8(&self.body)?)
}
pub fn content_type(&self) -> Result<Option<Mime>> {
parse_content_type(&self.headers)
}
pub fn parts(&self) -> Result<Vec<ResponsePart>> {
split_parts(self.content_type()?.as_ref(), &self.body)
}
pub fn error_for_status(self) -> Result<Self> {
if self.status.is_client_error() || self.status.is_server_error() {
Err(Error::Status(Box::new(self)))
} else {
Ok(self)
}
}
#[cfg(feature = "json")]
pub fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
Ok(serde_json::from_slice(&self.body)?)
}
pub fn form(&self) -> Vec<(String, String)> {
form_urlencoded::parse(&self.body)
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
}
impl ResponsePart {
pub fn parts(&self) -> Result<Vec<ResponsePart>> {
split_parts(self.content_type.as_ref(), &self.body)
}
}
fn split_parts(content_type: Option<&Mime>, body: &Bytes) -> Result<Vec<ResponsePart>> {
let content_type = content_type
.ok_or_else(|| Error::HeaderParse("multipart body without Content-Type".to_owned()))?;
if content_type.type_() != mime::MULTIPART {
return Err(Error::HeaderParse(format!(
"expected a multipart Content-Type, got {content_type}"
)));
}
let boundary = content_type
.get_param(BOUNDARY)
.ok_or_else(|| Error::HeaderParse("multipart Content-Type without boundary".to_owned()))?;
parse_multipart(body, boundary.as_str())
}
pub(crate) fn parse_content_type(headers: &HeaderMap) -> Result<Option<Mime>> {
headers
.get(CONTENT_TYPE)
.map(|value| Ok(value.to_str()?.trim().parse::<Mime>()?))
.transpose()
}
pub(crate) fn parse_multipart(body: &Bytes, boundary: &str) -> Result<Vec<ResponsePart>> {
let delimiter = format!("--{boundary}").into_bytes();
let truncated = || Error::Multipart("body ends inside a part".to_owned());
let malformed = |what: &dyn std::fmt::Display| Error::Multipart(what.to_string());
let mut parts = Vec::new();
let mut pos = find_delimiter(body, 0, &delimiter, |i| i == 0 || body[i - 1] == b'\n')
.ok_or_else(|| Error::Multipart(format!("boundary {boundary:?} not found")))?;
loop {
pos += delimiter.len();
if body[pos..].starts_with(b"--") {
return Ok(parts);
}
pos = find(body, pos, b"\n").ok_or_else(truncated)? + 1;
let mut headers = HeaderMap::new();
loop {
let line_end = find(body, pos, b"\n").ok_or_else(truncated)?;
let line = &body[pos..line_end];
let line = line.strip_suffix(b"\r").unwrap_or(line);
pos = line_end + 1;
if line.is_empty() {
break;
}
let Some(colon) = line.iter().position(|&b| b == b':') else {
return Err(Error::Multipart(format!(
"malformed part header {:?}",
String::from_utf8_lossy(line)
)));
};
headers.append(
HeaderName::from_bytes(line[..colon].trim_ascii()).map_err(|e| malformed(&e))?,
HeaderValue::from_bytes(line[colon + 1..].trim_ascii())
.map_err(|e| malformed(&e))?,
);
}
let next = find_delimiter(body, pos - 1, &delimiter, |i| i > 0 && body[i - 1] == b'\n')
.ok_or_else(truncated)?
- 1;
let mut end = next;
if end > pos && body[end - 1] == b'\r' {
end -= 1;
}
let end = end.max(pos);
let disposition = headers
.get(CONTENT_DISPOSITION)
.map(|v| String::from_utf8_lossy(v.as_bytes()));
parts.push(ResponsePart {
name: disposition
.as_deref()
.and_then(|d| disposition_param(d, "name"))
.unwrap_or_default(),
filename: disposition
.as_deref()
.and_then(|d| disposition_param(d, "filename")),
content_type: parse_content_type(&headers).map_err(|e| malformed(&e))?,
body: body.slice(pos..end),
});
pos = next + 1;
}
}
fn find(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
haystack
.get(from..)?
.windows(needle.len())
.position(|window| window == needle)
.map(|i| i + from)
}
fn find_delimiter(
body: &[u8],
from: usize,
delimiter: &[u8],
at_line_start: impl Fn(usize) -> bool,
) -> Option<usize> {
let mut from = from;
loop {
let i = find(body, from, delimiter)?;
let after = body.get(i + delimiter.len());
if at_line_start(i) && matches!(after, None | Some(b'-' | b' ' | b'\t' | b'\r' | b'\n')) {
return Some(i);
}
from = i + 1;
}
}
fn disposition_param(value: &str, key: &str) -> Option<String> {
let mut rest = value.split_once(';')?.1;
loop {
let (k, after_eq) = rest.split_once('=')?;
if let Some(i) = k.find(';') {
rest = &rest[i + 1..];
continue;
}
let k = k.trim();
let (v, next) = match after_eq.trim_start().strip_prefix('"') {
Some(quoted) => {
let mut v = String::new();
let mut chars = quoted.char_indices();
let mut close = None;
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
if let Some((_, escaped)) = chars.next() {
v.push(escaped);
}
}
'"' => {
close = Some(i);
break;
}
c => v.push(c),
}
}
let next = close.and_then(|i| quoted[i + 1..].split_once(';').map(|(_, n)| n));
(v, next)
}
None => match after_eq.split_once(';') {
Some((v, next)) => (v.trim().to_owned(), Some(next)),
None => (after_eq.trim().to_owned(), None),
},
};
if k.eq_ignore_ascii_case(key) {
return Some(v);
}
rest = next?;
}
}
pub(crate) fn process_header_line(
line: &[u8],
headers: &mut HeaderMap,
status: &mut Option<StatusCode>,
) -> Result<()> {
let line = line.trim_ascii_end();
if line.is_empty() {
return Ok(());
}
if let Some(rest) = line.strip_prefix(b"HTTP/") {
headers.clear();
let code = std::str::from_utf8(rest)
.ok()
.and_then(|rest| rest.split_whitespace().nth(1)?.parse::<u16>().ok())
.ok_or_else(|| {
Error::HeaderParse(format!(
"malformed status line {:?}",
String::from_utf8_lossy(line)
))
})?;
*status = Some(StatusCode::from_u16(code)?);
return Ok(());
}
if let Some(colon) = line.iter().position(|&b| b == b':')
&& let Ok(name) = HeaderName::from_bytes(line[..colon].trim_ascii())
&& let Ok(value) = HeaderValue::from_bytes(line[colon + 1..].trim_ascii())
{
headers.append(name, value);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_lines() {
let mut headers = HeaderMap::new();
let mut status = None;
let mut line = |l: &[u8]| process_header_line(l, &mut headers, &mut status);
line(b"HTTP/1.1 100 Continue\r\n").unwrap();
line(b"X-Early: 1\r\n").unwrap();
line(b"\r\n").unwrap();
assert!(line(b"HTTP/1.1 abc\r\n").is_err());
line(b"HTTP/2 200\r\n").unwrap();
line(b"Content-Type: text/plain\r\n").unwrap();
line(b"X-Empty:\r\n").unwrap();
line(b"garbage line\r\n").unwrap();
line(b"Bad Name: x\r\n").unwrap();
line(b"X-Ctl: a\x01b\r\n").unwrap();
line(b"X-Latin1: M\xfcller\r\n").unwrap();
assert_eq!(status, Some(StatusCode::OK));
assert!(!headers.contains_key("x-early"));
assert_eq!(headers[CONTENT_TYPE], "text/plain");
assert_eq!(headers["x-empty"], "");
assert_eq!(headers["x-latin1"].as_bytes(), b"M\xfcller");
assert_eq!(headers.len(), 3);
}
#[test]
fn multipart_parsing() {
let body = Bytes::from_static(
b"preamble\r\n--B\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n\
--B \r\nContent-Disposition: form-data; name=\"f\"; filename=\"x \\\"y\\\".bin\"\r\n\
Content-Type: application/octet-stream\r\n\r\n\x00--B\x01\r\n\
--B\r\n\r\n\r\n\
--B\r\nContent-Type: text/plain\r\n\r\n\r\n\
--B--\r\nepilogue",
);
let parts = parse_multipart(&body, "B").unwrap();
assert_eq!(parts.len(), 4);
assert_eq!(parts[0].name, "a");
assert_eq!(parts[0].body, "1");
assert_eq!(parts[0].content_type, None);
assert_eq!(parts[1].name, "f");
assert_eq!(parts[1].filename.as_deref(), Some("x \"y\".bin"));
assert_eq!(parts[1].content_type, Some(mime::APPLICATION_OCTET_STREAM));
assert_eq!(parts[1].body, &b"\x00--B\x01"[..]);
assert_eq!(parts[2].name, "");
assert_eq!(parts[2].body, "");
assert_eq!(parts[3].content_type, Some(mime::TEXT_PLAIN));
assert_eq!(parts[3].body, "");
assert_eq!(
parts[0].body.as_ptr(),
body[body.len() - body.len()..]
.as_ptr()
.wrapping_add(find(&body, 0, b"1\r\n").unwrap())
);
}
#[test]
fn nested_parts() {
let body = Bytes::from_static(
b"--outer\r\nContent-Type: multipart/mixed; boundary=inner\r\n\r\n\
--inner\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n\
--inner--\r\n\r\n\
--outer\r\nContent-Type: text/plain\r\n\r\nflat\r\n\
--outer--\r\n",
);
let parts = parse_multipart(&body, "outer").unwrap();
assert_eq!(parts.len(), 2);
let inner = parts[0].parts().unwrap();
assert_eq!(inner.len(), 1);
assert_eq!(inner[0].name, "a");
assert_eq!(inner[0].body, "1");
assert!(body.as_ptr_range().contains(&inner[0].body.as_ptr()));
assert!(matches!(
parts[1].parts().unwrap_err(),
Error::HeaderParse(_)
));
}
#[test]
fn disposition_params() {
let d = r#"form-data; name="a;b"; filename="x \"y\" \\ z;.txt"; token=plain ; last=1"#;
assert_eq!(disposition_param(d, "name").as_deref(), Some("a;b"));
assert_eq!(
disposition_param(d, "FILENAME").as_deref(),
Some("x \"y\" \\ z;.txt")
);
assert_eq!(disposition_param(d, "token").as_deref(), Some("plain"));
assert_eq!(disposition_param(d, "last").as_deref(), Some("1"));
assert_eq!(disposition_param(d, "missing"), None);
assert_eq!(disposition_param("form-data", "name"), None);
assert_eq!(
disposition_param(r#"form-data; name="a"; flag; filename="x""#, "filename").as_deref(),
Some("x")
);
assert_eq!(
disposition_param("form-data; name=\"open", "name").as_deref(),
Some("open")
);
assert_eq!(
disposition_param("form-data; name=grüße", "name").as_deref(),
Some("grüße")
);
}
#[test]
fn encoder_output_round_trips_through_the_parser() {
let segments = crate::Multipart::new()
.text("grüße", "ä;ö")
.file(
"f;g",
"ré;sumé \"1\".pdf",
mime::APPLICATION_PDF,
&b"%PDF"[..],
)
.into_segments("xyz");
let body = Bytes::from(segments.concat());
let parts = parse_multipart(&body, "xyz").unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].name, "grüße");
assert_eq!(parts[0].body, "ä;ö");
assert_eq!(parts[1].name, "f;g");
assert_eq!(parts[1].filename.as_deref(), Some("ré;sumé %221%22.pdf"));
assert_eq!(parts[1].body, "%PDF");
}
#[test]
fn boundary_prefix_is_not_a_delimiter() {
let body = Bytes::from_static(
b"--B\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nline\r\n--Bnot\r\n\r\nmore\r\n--B--\r\n",
);
let parts = parse_multipart(&body, "B").unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].body, &b"line\r\n--Bnot\r\n\r\nmore"[..]);
let body = Bytes::from_static(b"x--B\r\n\r\n1\r\n--B\r\n\r\n2\r\n--B--");
let parts = parse_multipart(&body, "B").unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].body, "2");
let body = Bytes::from_static(b"--B\r\nBad Name: x\r\n\r\n\r\n--B--");
assert!(matches!(
parse_multipart(&body, "B").unwrap_err(),
Error::Multipart(_)
));
}
#[test]
fn multipart_bare_newlines_and_errors() {
let body = Bytes::from_static(b"--B\nContent-Disposition: form-data; name=a\n\nx\n--B--\n");
let parts = parse_multipart(&body, "B").unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].name, "a");
assert_eq!(parts[0].body, "x");
let truncated = Bytes::from_static(b"--B\r\nContent-Type: text/plain\r\n\r\nx\r\n");
assert!(matches!(
parse_multipart(&truncated, "B").unwrap_err(),
Error::Multipart(_)
));
assert!(matches!(
parse_multipart(&Bytes::from_static(b"nothing"), "B").unwrap_err(),
Error::Multipart(_)
));
assert!(
parse_multipart(&Bytes::from_static(b"--B--"), "B")
.unwrap()
.is_empty()
);
}
}