oxihttp_core/
multipart.rs1#![forbid(unsafe_code)]
7
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use bytes::{BufMut, Bytes, BytesMut};
12
13static BOUNDARY_COUNTER: AtomicU64 = AtomicU64::new(0);
14
15fn generate_boundary() -> String {
17 let nanos = SystemTime::now()
18 .duration_since(UNIX_EPOCH)
19 .unwrap_or_default()
20 .subsec_nanos();
21 let counter = BOUNDARY_COUNTER.fetch_add(1, Ordering::Relaxed);
22 format!("----OxiHTTPBoundary{nanos:08x}{counter:04x}")
23}
24
25#[derive(Debug, Clone)]
29pub struct Part {
30 headers: Vec<(String, String)>,
31 body: Bytes,
32}
33
34impl Part {
35 pub fn text(name: &str, value: impl Into<String>) -> Self {
40 Self {
41 headers: vec![(
42 "Content-Disposition".into(),
43 format!("form-data; name=\"{name}\""),
44 )],
45 body: Bytes::from(value.into()),
46 }
47 }
48
49 pub fn file(name: &str, filename: &str, content_type: &str, body: impl Into<Bytes>) -> Self {
51 Self {
52 headers: vec![
53 (
54 "Content-Disposition".into(),
55 format!("form-data; name=\"{name}\"; filename=\"{filename}\""),
56 ),
57 ("Content-Type".into(), content_type.to_owned()),
58 ],
59 body: body.into(),
60 }
61 }
62
63 pub fn custom(headers: Vec<(String, String)>, body: impl Into<Bytes>) -> Self {
65 Self {
66 headers,
67 body: body.into(),
68 }
69 }
70}
71
72#[derive(Debug, Clone)]
87pub struct MultipartBuilder {
88 boundary: String,
89 parts: Vec<Part>,
90}
91
92impl MultipartBuilder {
93 pub fn new() -> Self {
95 Self {
96 boundary: generate_boundary(),
97 parts: Vec::new(),
98 }
99 }
100
101 pub fn boundary(&self) -> &str {
103 &self.boundary
104 }
105
106 pub fn content_type(&self) -> String {
110 format!("multipart/form-data; boundary={}", self.boundary)
111 }
112
113 pub fn add_text(mut self, name: &str, value: impl Into<String>) -> Self {
115 self.parts.push(Part::text(name, value));
116 self
117 }
118
119 pub fn add_file(
121 mut self,
122 name: &str,
123 filename: &str,
124 content_type: &str,
125 body: impl Into<Bytes>,
126 ) -> Self {
127 self.parts
128 .push(Part::file(name, filename, content_type, body));
129 self
130 }
131
132 pub fn add_part(mut self, part: Part) -> Self {
134 self.parts.push(part);
135 self
136 }
137
138 pub fn build(self) -> Bytes {
149 let boundary = self.find_unique_boundary();
150 let dash_boundary = format!("--{boundary}");
151 let final_boundary = format!("--{boundary}--\r\n");
152
153 let mut buf = BytesMut::new();
154
155 for part in &self.parts {
156 buf.put_slice(dash_boundary.as_bytes());
157 buf.put_slice(b"\r\n");
158 for (k, v) in &part.headers {
159 buf.put_slice(k.as_bytes());
160 buf.put_slice(b": ");
161 buf.put_slice(v.as_bytes());
162 buf.put_slice(b"\r\n");
163 }
164 buf.put_slice(b"\r\n");
165 buf.put_slice(&part.body);
166 buf.put_slice(b"\r\n");
167 }
168 buf.put_slice(final_boundary.as_bytes());
169 buf.freeze()
170 }
171
172 fn find_unique_boundary(&self) -> String {
174 let mut boundary = self.boundary.clone();
175 let mut suffix = 0u32;
176 loop {
177 let has_collision = self.parts.iter().any(|p| {
178 p.body
179 .windows(boundary.len())
180 .any(|w| w == boundary.as_bytes())
181 });
182 if !has_collision {
183 return boundary;
184 }
185 suffix += 1;
186 boundary = format!("{}{suffix:04x}", self.boundary);
187 }
188 }
189}
190
191impl Default for MultipartBuilder {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn test_empty_builder() {
203 let builder = MultipartBuilder::new();
204 let ct = builder.content_type();
205 assert!(ct.starts_with("multipart/form-data; boundary="));
206 let bytes = builder.build();
207 let s = String::from_utf8(bytes.to_vec()).unwrap();
208 assert!(s.contains("----OxiHTTPBoundary"));
209 assert!(s.ends_with("--\r\n"));
210 }
211
212 #[test]
213 fn test_text_field() {
214 let bytes = MultipartBuilder::new()
215 .add_text("field1", "hello world")
216 .build();
217 let s = String::from_utf8(bytes.to_vec()).unwrap();
218 assert!(s.contains("name=\"field1\""));
219 assert!(s.contains("hello world"));
220 }
221
222 #[test]
223 fn test_file_part() {
224 let bytes = MultipartBuilder::new()
225 .add_file("upload", "test.txt", "text/plain", "file contents")
226 .build();
227 let s = String::from_utf8(bytes.to_vec()).unwrap();
228 assert!(s.contains("filename=\"test.txt\""));
229 assert!(s.contains("Content-Type: text/plain"));
230 assert!(s.contains("file contents"));
231 }
232
233 #[test]
234 fn test_mixed_parts() {
235 let bytes = MultipartBuilder::new()
236 .add_text("name", "Alice")
237 .add_file("avatar", "pic.png", "image/png", b"\x89PNG\r\n".as_ref())
238 .build();
239 let bytes_vec = bytes.to_vec();
241 let header_section = &bytes_vec[..];
242 assert!(
245 bytes_vec
246 .windows(b"name=\"name\"".len())
247 .any(|w| w == b"name=\"name\""),
248 "missing name field"
249 );
250 assert!(
251 bytes_vec.windows(b"Alice".len()).any(|w| w == b"Alice"),
252 "missing Alice"
253 );
254 assert!(
255 header_section
256 .windows(b"filename=\"pic.png\"".len())
257 .any(|w| w == b"filename=\"pic.png\""),
258 "missing filename"
259 );
260 }
261
262 #[test]
263 fn test_boundary_collision_resolved() {
264 let mut builder = MultipartBuilder::new();
265 let boundary_clone = builder.boundary().to_owned();
267 builder = builder.add_text("field", boundary_clone.as_str());
268 let bytes = builder.build();
270 let s = String::from_utf8(bytes.to_vec()).unwrap();
271 assert!(s.ends_with("--\r\n"));
273 }
274
275 #[test]
276 fn test_content_type_header() {
277 let b = MultipartBuilder::new();
278 let ct = b.content_type();
279 let bnd = b.boundary().to_owned();
280 assert_eq!(ct, format!("multipart/form-data; boundary={bnd}"));
281 }
282
283 #[test]
284 fn test_crlf_format() {
285 let bytes = MultipartBuilder::new().add_text("x", "y").build();
286 let s = String::from_utf8(bytes.to_vec()).unwrap();
287 assert!(s.contains("\r\n\r\n"));
289 assert!(s.contains("y\r\n"));
291 }
292
293 #[test]
294 fn test_boundary_collision_unique() {
295 let mut b = MultipartBuilder::new();
296 let bnd = b.boundary().to_owned();
297 let body_with_boundary = format!("some text {bnd} more text");
298 b = b.add_text("collision_field", &body_with_boundary);
299 let bytes = b.build();
300 let s = String::from_utf8(bytes.to_vec()).unwrap();
301 assert!(s.ends_with("--\r\n"));
302 }
303
304 #[test]
305 fn test_custom_part() {
306 let bytes = MultipartBuilder::new()
307 .add_part(Part::custom(
308 vec![
309 (
310 "Content-Disposition".into(),
311 "form-data; name=\"raw\"".into(),
312 ),
313 ("X-Custom".into(), "header-value".into()),
314 ],
315 Bytes::from("raw body"),
316 ))
317 .build();
318 let s = String::from_utf8(bytes.to_vec()).unwrap();
319 assert!(s.contains("X-Custom: header-value"));
320 assert!(s.contains("raw body"));
321 }
322}