use std::str::FromStr;
pub struct Form {
pub boundary: String,
pub parts: Vec<FormPart>,
}
pub struct FormPart {
pub name: String,
pub filename: Option<String>,
pub content_type: Option<String>,
pub data: Vec<u8>,
}
impl Form {
pub fn new() -> Self {
Self {
boundary: Self::generate_boundary(),
parts: Vec::new(),
}
}
pub fn add_part(&mut self, part: FormPart) {
self.parts.push(part);
}
pub fn content_type(&self) -> String {
format!("multipart/form-data; boundary={}", self.boundary)
}
pub fn body(&self) -> Vec<u8> {
let mut body = Vec::new();
for part in &self.parts {
body.extend_from_slice(b"--");
body.extend_from_slice(self.boundary.as_bytes());
body.extend_from_slice(b"\r\n");
body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
body.extend_from_slice(part.name.as_bytes());
body.extend_from_slice(b"\"");
if let Some(filename) = &part.filename {
body.extend_from_slice(b"; filename=\"");
body.extend_from_slice(filename.as_bytes());
body.extend_from_slice(b"\"");
}
body.extend_from_slice(b"\r\n");
if let Some(content_type) = &part.content_type {
body.extend_from_slice(b"Content-Type: ");
body.extend_from_slice(content_type.as_bytes());
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"\r\n");
body.extend_from_slice(&part.data);
body.extend_from_slice(b"\r\n");
}
body.extend_from_slice(b"--");
body.extend_from_slice(self.boundary.as_bytes());
body.extend_from_slice(b"--\r\n");
body
}
fn generate_boundary() -> String {
let random_bytes: [u8; 16] = rand::random();
let hex_string = random_bytes
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
format!("----formdata-oha-{hex_string}")
}
}
impl FromStr for FormPart {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (name, rest) = s
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("Invalid form format: missing '=' in '{}'", s))?;
let name = name.to_string();
let parts: Vec<&str> = rest.split(';').collect();
let value_part = parts[0];
let mut filename = None;
let mut content_type = None;
let data;
if let Some(file_path) = value_part.strip_prefix('@') {
data = std::fs::read(file_path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", file_path, e))?;
filename = std::path::Path::new(file_path)
.file_name()
.and_then(|name| name.to_str())
.map(|s| s.to_string());
} else if let Some(file_path) = value_part.strip_prefix('<') {
data = std::fs::read(file_path)
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", file_path, e))?;
} else {
data = value_part.as_bytes().to_vec();
}
for part in parts.iter().skip(1) {
if let Some((key, value)) = part.split_once('=') {
match key.trim() {
"filename" => {
filename = Some(value.trim().to_string());
}
"type" => {
content_type = Some(value.trim().to_string());
}
_ => {
}
}
}
}
Ok(FormPart {
name,
filename,
content_type,
data,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_field() {
let part: FormPart = "name=value".parse().unwrap();
assert_eq!(part.name, "name");
assert_eq!(part.data, b"value");
assert_eq!(part.filename, None);
assert_eq!(part.content_type, None);
}
#[test]
fn test_parse_field_with_filename() {
let part: FormPart = "upload=data;filename=test.txt".parse().unwrap();
assert_eq!(part.name, "upload");
assert_eq!(part.data, b"data");
assert_eq!(part.filename, Some("test.txt".to_string()));
assert_eq!(part.content_type, None);
}
#[test]
fn test_parse_field_with_type() {
let part: FormPart = "data=content;type=text/plain".parse().unwrap();
assert_eq!(part.name, "data");
assert_eq!(part.data, b"content");
assert_eq!(part.filename, None);
assert_eq!(part.content_type, Some("text/plain".to_string()));
}
#[test]
fn test_parse_field_with_filename_and_type() {
let part: FormPart = "file=content;filename=test.txt;type=text/plain"
.parse()
.unwrap();
assert_eq!(part.name, "file");
assert_eq!(part.data, b"content");
assert_eq!(part.filename, Some("test.txt".to_string()));
assert_eq!(part.content_type, Some("text/plain".to_string()));
}
#[test]
fn test_parse_invalid_format() {
let result: Result<FormPart, _> = "invalid".parse();
assert!(result.is_err());
}
#[test]
fn test_parse_file_upload() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("test_form_upload.txt");
std::fs::write(&test_file, b"test file content").unwrap();
let form_str = format!("upload=@{}", test_file.display());
let part: FormPart = form_str.parse().unwrap();
assert_eq!(part.name, "upload");
assert_eq!(part.data, b"test file content");
assert_eq!(part.filename, Some("test_form_upload.txt".to_string()));
assert_eq!(part.content_type, None);
std::fs::remove_file(&test_file).ok();
}
#[test]
fn test_parse_file_upload_without_filename() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("test_form_upload_no_filename.txt");
std::fs::write(&test_file, b"test file content without filename").unwrap();
let form_str = format!("upload=<{}", test_file.display());
let part: FormPart = form_str.parse().unwrap();
assert_eq!(part.name, "upload");
assert_eq!(part.data, b"test file content without filename");
assert_eq!(part.filename, None); assert_eq!(part.content_type, None);
std::fs::remove_file(&test_file).ok();
}
#[test]
fn test_form_creation_and_body_generation() {
let mut form = Form::new();
let text_part: FormPart = "name=John".parse().unwrap();
form.add_part(text_part);
let file_part: FormPart = "file=content;filename=test.txt;type=text/plain"
.parse()
.unwrap();
form.add_part(file_part);
let body = form.body();
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains(&format!("--{}", form.boundary)));
assert!(body_str.contains("Content-Disposition: form-data; name=\"name\""));
assert!(
body_str
.contains("Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"")
);
assert!(body_str.contains("Content-Type: text/plain"));
assert!(body_str.contains("John"));
assert!(body_str.contains("content"));
assert!(body_str.ends_with(&format!("--{}--\r\n", form.boundary)));
}
#[test]
fn test_form_content_type() {
let form = Form::new();
let content_type = form.content_type();
assert!(content_type.starts_with("multipart/form-data; boundary="));
assert!(content_type.contains(&form.boundary));
}
#[test]
fn test_empty_form_body() {
let form = Form::new();
let body = form.body();
let body_str = String::from_utf8_lossy(&body);
assert_eq!(body_str, format!("--{}--\r\n", form.boundary));
}
#[test]
fn test_form_with_file_upload() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("form_test_upload.txt");
std::fs::write(&test_file, b"file content for form").unwrap();
let mut form = Form::new();
let form_str = format!("upload=@{}", test_file.display());
let file_part: FormPart = form_str.parse().unwrap();
form.add_part(file_part);
let body = form.body();
let body_str = String::from_utf8_lossy(&body);
assert!(body_str.contains(
"Content-Disposition: form-data; name=\"upload\"; filename=\"form_test_upload.txt\""
));
assert!(body_str.contains("file content for form"));
std::fs::remove_file(&test_file).ok();
}
#[test]
fn test_boundary_generation_is_random() {
let form1 = Form::new();
let form2 = Form::new();
assert_ne!(form1.boundary, form2.boundary);
assert!(form1.boundary.starts_with("----formdata-oha-"));
assert!(form2.boundary.starts_with("----formdata-oha-"));
assert_eq!(form1.boundary.len(), "----formdata-oha-".len() + 32);
assert_eq!(form2.boundary.len(), "----formdata-oha-".len() + 32);
}
}