Skip to content

Commit 0f696ce

Browse files
authored
Merge pull request #755 from hatoo/form
feat: add curl-compatible multipart form data support (-F option)
2 parents 99c69f6 + 6bea4ae commit 0f696ce

File tree

2 files changed

+411
-12
lines changed

2 files changed

+411
-12
lines changed

src/curl_compat.rs

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
//! Curl compatibility utilities
2+
use std::str::FromStr;
3+
4+
pub struct Form {
5+
pub boundary: String,
6+
pub parts: Vec<FormPart>,
7+
}
8+
9+
pub struct FormPart {
10+
pub name: String,
11+
pub filename: Option<String>,
12+
pub content_type: Option<String>,
13+
pub data: Vec<u8>,
14+
}
15+
16+
impl Form {
17+
pub fn new() -> Self {
18+
Self {
19+
boundary: Self::generate_boundary(),
20+
parts: Vec::new(),
21+
}
22+
}
23+
24+
pub fn add_part(&mut self, part: FormPart) {
25+
self.parts.push(part);
26+
}
27+
28+
pub fn content_type(&self) -> String {
29+
format!("multipart/form-data; boundary={}", self.boundary)
30+
}
31+
32+
pub fn body(&self) -> Vec<u8> {
33+
let mut body = Vec::new();
34+
35+
for part in &self.parts {
36+
// Add boundary separator
37+
body.extend_from_slice(b"--");
38+
body.extend_from_slice(self.boundary.as_bytes());
39+
body.extend_from_slice(b"\r\n");
40+
41+
// Add Content-Disposition header
42+
body.extend_from_slice(b"Content-Disposition: form-data; name=\"");
43+
body.extend_from_slice(part.name.as_bytes());
44+
body.extend_from_slice(b"\"");
45+
46+
// Add filename if present
47+
if let Some(filename) = &part.filename {
48+
body.extend_from_slice(b"; filename=\"");
49+
body.extend_from_slice(filename.as_bytes());
50+
body.extend_from_slice(b"\"");
51+
}
52+
body.extend_from_slice(b"\r\n");
53+
54+
// Add Content-Type header if present
55+
if let Some(content_type) = &part.content_type {
56+
body.extend_from_slice(b"Content-Type: ");
57+
body.extend_from_slice(content_type.as_bytes());
58+
body.extend_from_slice(b"\r\n");
59+
}
60+
61+
// Empty line before data
62+
body.extend_from_slice(b"\r\n");
63+
64+
// Add the actual data
65+
body.extend_from_slice(&part.data);
66+
body.extend_from_slice(b"\r\n");
67+
}
68+
69+
// Add final boundary
70+
body.extend_from_slice(b"--");
71+
body.extend_from_slice(self.boundary.as_bytes());
72+
body.extend_from_slice(b"--\r\n");
73+
74+
body
75+
}
76+
fn generate_boundary() -> String {
77+
use rand::Rng;
78+
79+
let mut rng = rand::rng();
80+
let random_bytes: [u8; 16] = rng.random();
81+
82+
// Convert to hex string manually to avoid external hex dependency
83+
let hex_string = random_bytes
84+
.iter()
85+
.map(|b| format!("{b:02x}"))
86+
.collect::<String>();
87+
88+
format!("----formdata-oha-{hex_string}")
89+
}
90+
}
91+
92+
impl FromStr for FormPart {
93+
type Err = anyhow::Error;
94+
95+
/// Parse curl's -F format string
96+
/// Supports formats like:
97+
/// - `name=value`
98+
/// - `name=@filename` (file upload with filename)
99+
/// - `name=<filename` (file upload without filename)
100+
/// - `name=@filename;type=content-type`
101+
/// - `name=value;filename=name`
102+
fn from_str(s: &str) -> Result<Self, Self::Err> {
103+
// Split on first '=' to separate name from value/options
104+
let (name, rest) = s
105+
.split_once('=')
106+
.ok_or_else(|| anyhow::anyhow!("Invalid form format: missing '=' in '{}'", s))?;
107+
108+
let name = name.to_string();
109+
110+
// Parse the value part which may contain semicolon-separated options
111+
let parts: Vec<&str> = rest.split(';').collect();
112+
let value_part = parts[0];
113+
114+
let mut filename = None;
115+
let mut content_type = None;
116+
let data;
117+
118+
// Check if this is a file upload (@filename or <filename)
119+
if let Some(file_path) = value_part.strip_prefix('@') {
120+
// Remove '@' prefix
121+
122+
// Read file content
123+
data = std::fs::read(file_path)
124+
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", file_path, e))?;
125+
126+
// Extract filename from path
127+
filename = std::path::Path::new(file_path)
128+
.file_name()
129+
.and_then(|name| name.to_str())
130+
.map(|s| s.to_string());
131+
} else if let Some(file_path) = value_part.strip_prefix('<') {
132+
// Remove '<' prefix
133+
134+
// Read file content
135+
data = std::fs::read(file_path)
136+
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", file_path, e))?;
137+
138+
// Do not set filename for '<' format (curl behavior)
139+
} else {
140+
// Regular form field with string value
141+
data = value_part.as_bytes().to_vec();
142+
}
143+
144+
// Parse additional options (filename, type, etc.)
145+
for part in parts.iter().skip(1) {
146+
if let Some((key, value)) = part.split_once('=') {
147+
match key.trim() {
148+
"filename" => {
149+
filename = Some(value.trim().to_string());
150+
}
151+
"type" => {
152+
content_type = Some(value.trim().to_string());
153+
}
154+
_ => {
155+
// Ignore unknown options for compatibility
156+
}
157+
}
158+
}
159+
}
160+
161+
Ok(FormPart {
162+
name,
163+
filename,
164+
content_type,
165+
data,
166+
})
167+
}
168+
}
169+
170+
#[cfg(test)]
171+
mod tests {
172+
use super::*;
173+
174+
#[test]
175+
fn test_parse_simple_field() {
176+
let part: FormPart = "name=value".parse().unwrap();
177+
assert_eq!(part.name, "name");
178+
assert_eq!(part.data, b"value");
179+
assert_eq!(part.filename, None);
180+
assert_eq!(part.content_type, None);
181+
}
182+
183+
#[test]
184+
fn test_parse_field_with_filename() {
185+
let part: FormPart = "upload=data;filename=test.txt".parse().unwrap();
186+
assert_eq!(part.name, "upload");
187+
assert_eq!(part.data, b"data");
188+
assert_eq!(part.filename, Some("test.txt".to_string()));
189+
assert_eq!(part.content_type, None);
190+
}
191+
192+
#[test]
193+
fn test_parse_field_with_type() {
194+
let part: FormPart = "data=content;type=text/plain".parse().unwrap();
195+
assert_eq!(part.name, "data");
196+
assert_eq!(part.data, b"content");
197+
assert_eq!(part.filename, None);
198+
assert_eq!(part.content_type, Some("text/plain".to_string()));
199+
}
200+
201+
#[test]
202+
fn test_parse_field_with_filename_and_type() {
203+
let part: FormPart = "file=content;filename=test.txt;type=text/plain"
204+
.parse()
205+
.unwrap();
206+
assert_eq!(part.name, "file");
207+
assert_eq!(part.data, b"content");
208+
assert_eq!(part.filename, Some("test.txt".to_string()));
209+
assert_eq!(part.content_type, Some("text/plain".to_string()));
210+
}
211+
212+
#[test]
213+
fn test_parse_invalid_format() {
214+
let result: Result<FormPart, _> = "invalid".parse();
215+
assert!(result.is_err());
216+
}
217+
218+
#[test]
219+
fn test_parse_file_upload() {
220+
// Create a temporary file for testing
221+
let temp_dir = std::env::temp_dir();
222+
let test_file = temp_dir.join("test_form_upload.txt");
223+
std::fs::write(&test_file, b"test file content").unwrap();
224+
225+
let form_str = format!("upload=@{}", test_file.display());
226+
let part: FormPart = form_str.parse().unwrap();
227+
228+
assert_eq!(part.name, "upload");
229+
assert_eq!(part.data, b"test file content");
230+
assert_eq!(part.filename, Some("test_form_upload.txt".to_string()));
231+
assert_eq!(part.content_type, None);
232+
233+
// Clean up
234+
std::fs::remove_file(&test_file).ok();
235+
}
236+
237+
#[test]
238+
fn test_parse_file_upload_without_filename() {
239+
// Create a temporary file for testing
240+
let temp_dir = std::env::temp_dir();
241+
let test_file = temp_dir.join("test_form_upload_no_filename.txt");
242+
std::fs::write(&test_file, b"test file content without filename").unwrap();
243+
244+
let form_str = format!("upload=<{}", test_file.display());
245+
let part: FormPart = form_str.parse().unwrap();
246+
247+
assert_eq!(part.name, "upload");
248+
assert_eq!(part.data, b"test file content without filename");
249+
assert_eq!(part.filename, None); // No filename set for '<' format
250+
assert_eq!(part.content_type, None);
251+
252+
// Clean up
253+
std::fs::remove_file(&test_file).ok();
254+
}
255+
256+
#[test]
257+
fn test_form_creation_and_body_generation() {
258+
let mut form = Form::new();
259+
260+
// Add a simple text field
261+
let text_part: FormPart = "name=John".parse().unwrap();
262+
form.add_part(text_part);
263+
264+
// Add a field with filename
265+
let file_part: FormPart = "file=content;filename=test.txt;type=text/plain"
266+
.parse()
267+
.unwrap();
268+
form.add_part(file_part);
269+
270+
let body = form.body();
271+
let body_str = String::from_utf8_lossy(&body);
272+
273+
// Check that boundary is present
274+
assert!(body_str.contains(&format!("--{}", form.boundary)));
275+
276+
// Check Content-Disposition headers
277+
assert!(body_str.contains("Content-Disposition: form-data; name=\"name\""));
278+
assert!(
279+
body_str
280+
.contains("Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"")
281+
);
282+
283+
// Check Content-Type header
284+
assert!(body_str.contains("Content-Type: text/plain"));
285+
286+
// Check data content
287+
assert!(body_str.contains("John"));
288+
assert!(body_str.contains("content"));
289+
290+
// Check final boundary
291+
assert!(body_str.ends_with(&format!("--{}--\r\n", form.boundary)));
292+
}
293+
294+
#[test]
295+
fn test_form_content_type() {
296+
let form = Form::new();
297+
let content_type = form.content_type();
298+
299+
assert!(content_type.starts_with("multipart/form-data; boundary="));
300+
assert!(content_type.contains(&form.boundary));
301+
}
302+
303+
#[test]
304+
fn test_empty_form_body() {
305+
let form = Form::new();
306+
let body = form.body();
307+
let body_str = String::from_utf8_lossy(&body);
308+
309+
// Should only contain final boundary for empty form
310+
assert_eq!(body_str, format!("--{}--\r\n", form.boundary));
311+
}
312+
313+
#[test]
314+
fn test_form_with_file_upload() {
315+
// Create a temporary file for testing
316+
let temp_dir = std::env::temp_dir();
317+
let test_file = temp_dir.join("form_test_upload.txt");
318+
std::fs::write(&test_file, b"file content for form").unwrap();
319+
320+
let mut form = Form::new();
321+
322+
// Parse and add file upload part
323+
let form_str = format!("upload=@{}", test_file.display());
324+
let file_part: FormPart = form_str.parse().unwrap();
325+
form.add_part(file_part);
326+
327+
let body = form.body();
328+
let body_str = String::from_utf8_lossy(&body);
329+
330+
// Check file upload formatting
331+
assert!(body_str.contains(
332+
"Content-Disposition: form-data; name=\"upload\"; filename=\"form_test_upload.txt\""
333+
));
334+
assert!(body_str.contains("file content for form"));
335+
336+
// Clean up
337+
std::fs::remove_file(&test_file).ok();
338+
}
339+
340+
#[test]
341+
fn test_boundary_generation_is_random() {
342+
let form1 = Form::new();
343+
let form2 = Form::new();
344+
345+
// Boundaries should be different for different forms
346+
assert_ne!(form1.boundary, form2.boundary);
347+
348+
// Boundaries should follow the expected format
349+
assert!(form1.boundary.starts_with("----formdata-oha-"));
350+
assert!(form2.boundary.starts_with("----formdata-oha-"));
351+
352+
// Boundaries should have the expected length (prefix + 32 hex chars)
353+
assert_eq!(form1.boundary.len(), "----formdata-oha-".len() + 32);
354+
assert_eq!(form2.boundary.len(), "----formdata-oha-".len() + 32);
355+
}
356+
}

0 commit comments

Comments
 (0)