Skip to main content

oxihttp_core/
multipart.rs

1//! Multipart form-data body builder (RFC 7578).
2//!
3//! Provides [`MultipartBuilder`] for constructing `multipart/form-data` bodies
4//! and [`Part`] for individual MIME parts.
5
6#![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
15/// Generate a unique boundary string using nanosecond timestamp + atomic counter.
16fn 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/// A single MIME part in a multipart body.
26///
27/// Parts consist of headers (name-value pairs) and a binary body.
28#[derive(Debug, Clone)]
29pub struct Part {
30    headers: Vec<(String, String)>,
31    body: Bytes,
32}
33
34impl Part {
35    /// Create a text part with a `Content-Disposition: form-data; name=...` header.
36    ///
37    /// Per RFC 7578, text fields do not need an explicit `Content-Type`; the
38    /// receiver treats them as `text/plain`.
39    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    /// Create a file/binary part with `Content-Disposition` and `Content-Type` headers.
50    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    /// Create a part with fully custom headers and body.
64    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/// Builder for `multipart/form-data` bodies per RFC 7578.
73///
74/// # Example
75///
76/// ```rust
77/// use oxihttp_core::multipart::MultipartBuilder;
78///
79/// let builder = MultipartBuilder::new()
80///     .add_text("username", "alice")
81///     .add_file("avatar", "pic.png", "image/png", b"PNG\r\n".as_ref());
82///
83/// let content_type = builder.content_type();
84/// let body_bytes = builder.build();
85/// ```
86#[derive(Debug, Clone)]
87pub struct MultipartBuilder {
88    boundary: String,
89    parts: Vec<Part>,
90}
91
92impl MultipartBuilder {
93    /// Create a new builder with an auto-generated boundary.
94    pub fn new() -> Self {
95        Self {
96            boundary: generate_boundary(),
97            parts: Vec::new(),
98        }
99    }
100
101    /// Return the boundary string (without leading `--`).
102    pub fn boundary(&self) -> &str {
103        &self.boundary
104    }
105
106    /// Return the full `Content-Type` header value including the boundary parameter.
107    ///
108    /// Set this as the `Content-Type` header when sending the body.
109    pub fn content_type(&self) -> String {
110        format!("multipart/form-data; boundary={}", self.boundary)
111    }
112
113    /// Add a text field part.
114    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    /// Add a file/binary part.
120    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    /// Add a pre-constructed [`Part`].
133    pub fn add_part(mut self, part: Part) -> Self {
134        self.parts.push(part);
135        self
136    }
137
138    /// Serialise to a [`Bytes`] buffer containing the complete multipart wire format.
139    ///
140    /// Automatically handles boundary collision: if the boundary string occurs literally
141    /// inside any part body, a numeric suffix is appended and the check repeats until
142    /// the boundary is guaranteed unique across all part bodies.
143    ///
144    /// Note: the uniqueness check searches for the bare boundary string (conservative —
145    /// no false negatives). The actual wire delimiter is `--<boundary>`, but matching
146    /// the bare string is safe because any occurrence of the bare string would also
147    /// produce a collision in the delimiter form.
148    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    /// Find a boundary string guaranteed not to occur in any part body.
173    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        // The raw bytes may not be valid UTF-8 (PNG magic bytes), so search byte-by-byte.
240        let bytes_vec = bytes.to_vec();
241        let header_section = &bytes_vec[..];
242        // The headers and text fields are valid ASCII; convert the header region for inspection.
243        // We search for the known ASCII patterns in the byte slice directly.
244        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        // Inject the boundary string literally into a part body.
266        let boundary_clone = builder.boundary().to_owned();
267        builder = builder.add_text("field", boundary_clone.as_str());
268        // build() must resolve the collision and produce valid output.
269        let bytes = builder.build();
270        let s = String::from_utf8(bytes.to_vec()).unwrap();
271        // Final boundary marker must be present.
272        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        // Headers end in CRLF; blank line (CRLF) before body.
288        assert!(s.contains("\r\n\r\n"));
289        // Part body ends in CRLF before next boundary.
290        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}