use mime_guess::Mime;
use ureq_proto::http::{self, HeaderValue};
use crate::{util::private::Private, AsSendBody, Error, SendBody};
use std::io::{self, Read};
use std::path::Path;
const BOUNDARY_PREFIX: &str = "----formdata-ureq-";
const BOUNDARY_SUFFIX_LEN: usize = 16;
pub struct Form<'a> {
parts: Vec<(&'a str, Part<'a>)>,
boundary: String,
state: ReadState,
}
pub struct Part<'a> {
inner: PartInner<'a>,
meta: PartMeta,
}
enum PartInner<'a> {
Borrowed(SendBody<'a>),
Owned(SendBody<'static>),
}
struct PartMeta {
mime: Option<Mime>,
file_name: Option<String>,
headers: http::HeaderMap,
}
impl<'a> Default for Form<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> Form<'a> {
pub fn new() -> Self {
let mut random_bytes = [0u8; BOUNDARY_SUFFIX_LEN];
getrandom::getrandom(&mut random_bytes).expect("failed to generate random boundary");
let mut boundary = String::with_capacity(BOUNDARY_PREFIX.len() + BOUNDARY_SUFFIX_LEN * 2);
boundary.push_str(BOUNDARY_PREFIX);
for byte in random_bytes {
boundary.push_str(&format!("{:02x}", byte));
}
Form {
parts: Vec::new(),
boundary,
state: ReadState::default(),
}
}
pub fn boundary(&self) -> &str {
&self.boundary
}
pub fn text(mut self, name: &'a str, value: &'a str) -> Self {
let part = Part::text(value);
self.parts.push((name, part));
self
}
pub fn file<P: AsRef<Path>>(mut self, name: &'a str, path: P) -> std::io::Result<Self> {
let part = Part::file(path)?;
self.parts.push((name, part));
Ok(self)
}
pub fn part(mut self, name: &'a str, part: Part<'a>) -> Self {
self.parts.push((name, part));
self
}
}
impl<'a> Part<'a> {
pub fn text(text: &'a str) -> Self {
Part {
inner: PartInner::Borrowed(SendBody::from_bytes(text.as_bytes())),
meta: PartMeta {
mime: None,
file_name: None,
headers: http::HeaderMap::new(),
},
}
}
pub fn bytes(bytes: &'a [u8]) -> Self {
Part {
inner: PartInner::Borrowed(SendBody::from_bytes(bytes)),
meta: PartMeta {
mime: None,
file_name: None,
headers: http::HeaderMap::new(),
},
}
}
pub fn reader(reader: &'a mut dyn Read) -> Self {
Part {
inner: PartInner::Borrowed(SendBody::from_reader(reader)),
meta: PartMeta {
mime: None,
file_name: None,
headers: http::HeaderMap::new(),
},
}
}
pub fn owned_reader(reader: impl Read + 'static) -> Part<'a> {
Part {
inner: PartInner::Owned(SendBody::from_owned_reader(reader)),
meta: PartMeta {
mime: None,
file_name: None,
headers: http::HeaderMap::new(),
},
}
}
pub fn file<P: AsRef<Path>>(path: P) -> std::io::Result<Part<'a>> {
let mime = mime_guess::from_path(&path).first();
let file_name = path
.as_ref()
.file_name()
.map(|filename| filename.to_string_lossy().into_owned());
let file = std::fs::File::open(path)?;
Ok(Part {
inner: PartInner::Owned(SendBody::from_file(file)),
meta: PartMeta {
mime,
file_name,
headers: http::HeaderMap::new(),
},
})
}
pub fn file_name(mut self, name: &str) -> Self {
self.meta.file_name = Some(name.to_string());
self
}
pub fn mime_str(mut self, mime: &str) -> Result<Self, Error> {
let mime_type = mime.parse().map_err(Error::InvalidMimeType)?;
self.meta.mime = Some(mime_type);
Ok(self)
}
pub fn headers(&self) -> &http::HeaderMap {
&self.meta.headers
}
}
impl<'a> Private for Form<'a> {}
impl<'a> AsSendBody for Form<'a> {
fn as_body(&mut self) -> SendBody {
use crate::send_body::BodyInner;
let size = self.calculate_size();
let content_type = format!("multipart/form-data; boundary={}", self.boundary());
let body: SendBody = (size, BodyInner::Reader(self)).into();
body.with_content_type(HeaderValue::from_str(&content_type).unwrap())
}
}
struct ReadState {
part_idx: usize,
phase: Phase,
phase_offset: usize,
buffer: Vec<u8>,
buffer_pos: usize,
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Phase {
PartBoundary,
PartHeaders,
PartBody,
FinalBoundary,
Done,
}
impl Default for ReadState {
fn default() -> Self {
ReadState {
part_idx: 0,
phase: Phase::PartBoundary,
phase_offset: 0,
buffer: Vec::with_capacity(8192),
buffer_pos: 0,
}
}
}
impl<'a> Form<'a> {
fn calculate_size(&self) -> Option<u64> {
let mut total_size: u64 = 0;
for (idx, (name, part)) in self.parts.iter().enumerate() {
let boundary_size = if idx == 0 {
2 + self.boundary.len() + 2 } else {
2 + 2 + self.boundary.len() + 2 };
total_size += boundary_size as u64;
let mut header_size = 0;
header_size += b"Content-Disposition: form-data; name=\"".len();
header_size += name.len();
header_size += b"\"".len();
if let Some(ref filename) = part.meta.file_name {
header_size += b"; filename=\"".len();
header_size += filename.len();
header_size += b"\"".len();
}
header_size += 2;
if let Some(ref mime) = part.meta.mime {
header_size += b"Content-Type: ".len();
header_size += mime.as_ref().len();
header_size += 2; }
for (name, value) in part.meta.headers.iter() {
header_size += name.as_str().len();
header_size += 2; header_size += value.len();
header_size += 2; }
header_size += 2;
total_size += header_size as u64;
let body_size = match &part.inner {
PartInner::Borrowed(body) => body.size(),
PartInner::Owned(body) => body.size(),
};
total_size += body_size?; }
total_size += (2 + 2 + self.boundary.len() + 2 + 2) as u64;
Some(total_size)
}
fn fill_boundary_buffer(&mut self) -> io::Result<()> {
self.state.buffer.clear();
if self.state.part_idx > 0 {
self.state.buffer.extend_from_slice(b"\r\n");
}
self.state.buffer.extend_from_slice(b"--");
self.state
.buffer
.extend_from_slice(self.boundary.as_bytes());
self.state.buffer.extend_from_slice(b"\r\n");
Ok(())
}
fn fill_headers_buffer(&mut self) -> io::Result<()> {
self.state.buffer.clear();
let (name, part) = &self.parts[self.state.part_idx];
self.state
.buffer
.extend_from_slice(b"Content-Disposition: form-data; name=\"");
self.state.buffer.extend_from_slice(name.as_bytes());
self.state.buffer.extend_from_slice(b"\"");
if let Some(ref filename) = part.meta.file_name {
self.state.buffer.extend_from_slice(b"; filename=\"");
self.state.buffer.extend_from_slice(filename.as_bytes());
self.state.buffer.extend_from_slice(b"\"");
}
self.state.buffer.extend_from_slice(b"\r\n");
if let Some(ref mime) = part.meta.mime {
self.state.buffer.extend_from_slice(b"Content-Type: ");
self.state
.buffer
.extend_from_slice(mime.as_ref().as_bytes());
self.state.buffer.extend_from_slice(b"\r\n");
}
for (name, value) in part.meta.headers.iter() {
self.state
.buffer
.extend_from_slice(name.as_str().as_bytes());
self.state.buffer.extend_from_slice(b": ");
self.state.buffer.extend_from_slice(value.as_bytes());
self.state.buffer.extend_from_slice(b"\r\n");
}
self.state.buffer.extend_from_slice(b"\r\n");
Ok(())
}
fn fill_final_boundary_buffer(&mut self) -> io::Result<()> {
self.state.buffer.clear();
self.state.buffer.extend_from_slice(b"\r\n--");
self.state
.buffer
.extend_from_slice(self.boundary.as_bytes());
self.state.buffer.extend_from_slice(b"--\r\n");
Ok(())
}
fn read_from_part(part: &mut Part<'a>, buf: &mut [u8]) -> io::Result<usize> {
match &mut part.inner {
PartInner::Borrowed(body) => body.read(buf),
PartInner::Owned(body) => body.read(buf),
}
}
}
impl io::Read for Form<'_> {
fn read(&mut self, mut buf: &mut [u8]) -> io::Result<usize> {
if buf.is_empty() {
return Ok(0);
}
if self.parts.is_empty() {
if self.state.phase != Phase::Done {
self.state.phase = Phase::Done;
}
return Ok(0);
}
let original_len = buf.len();
loop {
if self.state.buffer_pos < self.state.buffer.len() {
let available = self.state.buffer.len() - self.state.buffer_pos;
let to_copy = available.min(buf.len());
buf[..to_copy].copy_from_slice(
&self.state.buffer[self.state.buffer_pos..self.state.buffer_pos + to_copy],
);
self.state.buffer_pos += to_copy;
buf = &mut buf[to_copy..];
if buf.is_empty() {
return Ok(original_len);
}
}
self.state.buffer_pos = 0;
self.state.buffer.clear();
match self.state.phase {
Phase::Done => {
return Ok(original_len - buf.len());
}
Phase::PartBoundary => {
self.fill_boundary_buffer()?;
self.state.phase = Phase::PartHeaders;
self.state.phase_offset = 0;
}
Phase::PartHeaders => {
self.fill_headers_buffer()?;
self.state.phase = Phase::PartBody;
self.state.phase_offset = 0;
}
Phase::PartBody => {
let part = &mut self.parts[self.state.part_idx].1;
let n = Self::read_from_part(part, buf)?;
buf = &mut buf[n..];
if n > 0 {
return Ok(original_len - buf.len());
}
self.state.part_idx += 1;
if self.state.part_idx < self.parts.len() {
self.state.phase = Phase::PartBoundary;
} else {
self.state.phase = Phase::FinalBoundary;
}
self.state.phase_offset = 0;
}
Phase::FinalBoundary => {
self.fill_final_boundary_buffer()?;
self.state.phase = Phase::Done;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
#[test]
fn test_form_read_simple() {
let form = Form::new()
.text("field1", "value1")
.text("field2", "value2");
let mut result = Vec::new();
let mut form = form;
form.read_to_end(&mut result).unwrap();
let output = String::from_utf8(result).unwrap();
assert!(output.contains(&form.boundary));
assert!(output.contains("Content-Disposition: form-data; name=\"field1\""));
assert!(output.contains("value1"));
assert!(output.contains("Content-Disposition: form-data; name=\"field2\""));
assert!(output.contains("value2"));
assert!(output.ends_with("--\r\n"));
}
#[test]
fn test_form_read_empty() {
let form = Form::new();
let mut result = Vec::new();
let mut form = form;
form.read_to_end(&mut result).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_form_read_with_filename() {
let form = Form::new().part("upload", Part::text("file content").file_name("test.txt"));
let mut result = Vec::new();
let mut form = form;
form.read_to_end(&mut result).unwrap();
let output = String::from_utf8(result).unwrap();
assert!(output.contains("filename=\"test.txt\""));
assert!(output.contains("file content"));
}
#[test]
fn test_form_read_with_mime() {
let form = Form::new().part(
"data",
Part::bytes(b"binary")
.mime_str("application/octet-stream")
.unwrap(),
);
let mut result = Vec::new();
let mut form = form;
form.read_to_end(&mut result).unwrap();
let output = String::from_utf8(result).unwrap();
assert!(output.contains("Content-Type: application/octet-stream"));
assert!(output.contains("binary"));
}
#[test]
fn test_form_read_incremental() {
let form = Form::new().text("field", "data");
let mut form = form;
let mut result = Vec::new();
let mut buf = [0u8; 16];
loop {
let n = form.read(&mut buf).unwrap();
if n == 0 {
break;
}
result.extend_from_slice(&buf[..n]);
}
let output = String::from_utf8(result).unwrap();
assert!(output.contains("field"));
assert!(output.contains("data"));
}
#[test]
fn test_form_size_calculation() {
let form = Form::new()
.text("field1", "value1")
.text("field2", "value2");
let size = form.calculate_size();
assert!(size.is_some(), "Size should be calculable for text fields");
let mut result = Vec::new();
let mut form = form;
form.read_to_end(&mut result).unwrap();
assert_eq!(
size.unwrap() as usize,
result.len(),
"Calculated size should match actual size"
);
}
#[test]
fn test_form_with_reader_no_size() {
use std::io::Cursor;
let mut data = Cursor::new(b"some data".to_vec());
let form = Form::new().part("file", Part::reader(&mut data));
let size = form.calculate_size();
assert!(
size.is_none(),
"Size should be None for readers without known size"
);
}
#[test]
fn test_invalid_mime_type() {
let result = Part::text("data").mime_str("invalid/mime/type/with/too/many/slashes");
assert!(result.is_err());
assert!(matches!(result, Err(Error::InvalidMimeType(_))));
}
#[test]
fn test_form_sets_content_type() {
let mut form = Form::new().text("field", "value");
let mut body = form.as_body();
let content_type = body.take_content_type();
assert!(content_type.is_some());
let ct = content_type.unwrap();
let ct_str = ct.to_str().unwrap();
assert!(ct_str.starts_with("multipart/form-data; boundary="));
assert!(ct_str.contains(&form.boundary()));
}
}