Skip to main content

rightkit_http/
multipart.rs

1//! `multipart/form-data` request bodies with exact, testable framing.
2//!
3//! Every part carries `Content-Disposition`; only file parts carry `Content-Type`.
4//! Lines end in CRLF. Names, filenames and content types have `"`, CR and LF
5//! replaced with `_` so a caller cannot inject headers or break quoting.
6
7use std::path::Path;
8
9use crate::client::Request;
10use crate::error::HttpError;
11
12#[derive(Debug, Clone)]
13pub struct Multipart {
14    boundary: String,
15    parts: Vec<Part>,
16}
17
18#[derive(Debug, Clone)]
19struct Part {
20    name: String,
21    filename: Option<String>,
22    content_type: Option<String>,
23    body: Vec<u8>,
24}
25
26impl Multipart {
27    /// A form with a random boundary.
28    pub fn new() -> Self {
29        Self {
30            boundary: random_boundary(),
31            parts: Vec::new(),
32        }
33    }
34    /// A form with a pinned boundary (for byte-exact tests and replays). The
35    /// boundary must be 1 to 70 characters from ASCII letters, digits, `-`, `_`, `.`, `+`.
36    pub fn with_boundary(boundary: impl Into<String>) -> Self {
37        Self {
38            boundary: boundary.into(),
39            parts: Vec::new(),
40        }
41    }
42    /// Add a text field.
43    pub fn text(mut self, name: &str, value: &str) -> Self {
44        self.parts.push(Part {
45            name: sanitize(name),
46            filename: None,
47            content_type: None,
48            body: value.as_bytes().to_vec(),
49        });
50        self
51    }
52    /// Add a file field from in-memory bytes.
53    pub fn file_bytes(
54        mut self,
55        name: &str,
56        filename: &str,
57        content_type: &str,
58        bytes: impl Into<Vec<u8>>,
59    ) -> Self {
60        self.parts.push(Part {
61            name: sanitize(name),
62            filename: Some(sanitize(filename)),
63            content_type: Some(sanitize(content_type)),
64            body: bytes.into(),
65        });
66        self
67    }
68    /// Add a file field read from `path`. The file name becomes the part's filename.
69    pub fn file_path(self, name: &str, path: &Path, content_type: &str) -> Result<Self, HttpError> {
70        let filename = path
71            .file_name()
72            .and_then(|f| f.to_str())
73            .ok_or_else(|| {
74                HttpError::InvalidRequest("multipart file path has no UTF-8 file name".into())
75            })?
76            .to_string();
77        let bytes = std::fs::read(path)
78            .map_err(|e| HttpError::InvalidRequest(format!("cannot read multipart file: {e}")))?;
79        Ok(self.file_bytes(name, &filename, content_type, bytes))
80    }
81    /// The `Content-Type` header value for this form.
82    pub fn content_type(&self) -> String {
83        format!("multipart/form-data; boundary={}", self.boundary)
84    }
85    /// Encode the body. Fails if the boundary is invalid or occurs inside a part.
86    pub fn into_body(self) -> Result<Vec<u8>, HttpError> {
87        validate_boundary(&self.boundary)?;
88        let boundary = self.boundary.as_bytes();
89        let delimiter = format!("--{}", self.boundary);
90        let mut out = Vec::new();
91        for part in &self.parts {
92            let mut head = format!("Content-Disposition: form-data; name=\"{}\"", part.name);
93            if let Some(filename) = &part.filename {
94                head.push_str(&format!("; filename=\"{filename}\""));
95            }
96            head.push_str("\r\n");
97            if let Some(content_type) = &part.content_type {
98                head.push_str(&format!("Content-Type: {content_type}\r\n"));
99            }
100            if contains(head.as_bytes(), boundary) || contains(&part.body, boundary) {
101                return Err(HttpError::InvalidRequest(
102                    "multipart boundary occurs inside a part; use another boundary".into(),
103                ));
104            }
105            out.extend_from_slice(delimiter.as_bytes());
106            out.extend_from_slice(b"\r\n");
107            out.extend_from_slice(head.as_bytes());
108            out.extend_from_slice(b"\r\n");
109            out.extend_from_slice(&part.body);
110            out.extend_from_slice(b"\r\n");
111        }
112        out.extend_from_slice(delimiter.as_bytes());
113        out.extend_from_slice(b"--\r\n");
114        Ok(out)
115    }
116}
117
118impl Default for Multipart {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl Request {
125    /// Set the body and `Content-Type` from a multipart form (replacing any existing Content-Type).
126    pub fn multipart(self, form: Multipart) -> Result<Self, HttpError> {
127        let content_type = form.content_type();
128        let body = form.into_body()?;
129        let mut req = self.set_header("Content-Type", content_type);
130        req.body = body;
131        Ok(req)
132    }
133}
134
135/// A `multipart/related` body (RFC 2387): an ordered list of parts, each with its own
136/// `Content-Type` and no `Content-Disposition`. A Google-style media upload is a JSON metadata
137/// part followed by the media part.
138#[derive(Debug, Clone)]
139pub struct MultipartRelated {
140    boundary: String,
141    parts: Vec<(String, Vec<u8>)>,
142}
143
144impl MultipartRelated {
145    /// A related body with a random boundary.
146    pub fn new() -> Self {
147        Self {
148            boundary: random_boundary(),
149            parts: Vec::new(),
150        }
151    }
152    /// A related body with a pinned boundary; same rules as [`Multipart::with_boundary`].
153    pub fn with_boundary(boundary: impl Into<String>) -> Self {
154        Self {
155            boundary: boundary.into(),
156            parts: Vec::new(),
157        }
158    }
159    /// Append a part with its `Content-Type`. Parts are sent in the order added.
160    pub fn part(mut self, content_type: &str, body: impl Into<Vec<u8>>) -> Self {
161        self.parts.push((sanitize(content_type), body.into()));
162        self
163    }
164    /// The `Content-Type` header value for this body.
165    pub fn content_type(&self) -> String {
166        format!("multipart/related; boundary={}", self.boundary)
167    }
168    /// Encode the body. Fails if the boundary is invalid or occurs inside a part.
169    pub fn into_body(self) -> Result<Vec<u8>, HttpError> {
170        validate_boundary(&self.boundary)?;
171        let boundary = self.boundary.as_bytes();
172        let delimiter = format!("--{}", self.boundary);
173        let mut out = Vec::new();
174        for (content_type, body) in &self.parts {
175            let head = format!("Content-Type: {content_type}\r\n");
176            if contains(head.as_bytes(), boundary) || contains(body, boundary) {
177                return Err(HttpError::InvalidRequest(
178                    "multipart boundary occurs inside a part; use another boundary".into(),
179                ));
180            }
181            out.extend_from_slice(delimiter.as_bytes());
182            out.extend_from_slice(b"\r\n");
183            out.extend_from_slice(head.as_bytes());
184            out.extend_from_slice(b"\r\n");
185            out.extend_from_slice(body);
186            out.extend_from_slice(b"\r\n");
187        }
188        out.extend_from_slice(delimiter.as_bytes());
189        out.extend_from_slice(b"--\r\n");
190        Ok(out)
191    }
192}
193
194impl Default for MultipartRelated {
195    fn default() -> Self {
196        Self::new()
197    }
198}
199
200impl Request {
201    /// Set the body and `Content-Type` from a `multipart/related` body (replacing any existing
202    /// Content-Type).
203    pub fn multipart_related(self, form: MultipartRelated) -> Result<Self, HttpError> {
204        let content_type = form.content_type();
205        let body = form.into_body()?;
206        let mut req = self.set_header("Content-Type", content_type);
207        req.body = body;
208        Ok(req)
209    }
210}
211
212fn sanitize(value: &str) -> String {
213    value
214        .chars()
215        .map(|c| match c {
216            '"' | '\r' | '\n' => '_',
217            other => other,
218        })
219        .collect()
220}
221
222fn validate_boundary(boundary: &str) -> Result<(), HttpError> {
223    let ok_len = (1..=70).contains(&boundary.len());
224    let ok_chars = boundary
225        .bytes()
226        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'));
227    if ok_len && ok_chars {
228        Ok(())
229    } else {
230        Err(HttpError::InvalidRequest(
231            "multipart boundary must be 1 to 70 ASCII letters, digits, '-', '_', '.' or '+'".into(),
232        ))
233    }
234}
235
236fn contains(haystack: &[u8], needle: &[u8]) -> bool {
237    !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
238}
239
240fn random_boundary() -> String {
241    let mut bytes = [0u8; 12];
242    if getrandom::fill(&mut bytes).is_err() {
243        let nanos = std::time::SystemTime::now()
244            .duration_since(std::time::UNIX_EPOCH)
245            .map(|d| d.as_nanos())
246            .unwrap_or(0);
247        return format!("rightkit-fallback-{nanos:x}");
248    }
249    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
250    format!("rightkit-{hex}")
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn pinned_boundary_byte_shape() {
259        let body = Multipart::with_boundary("XB")
260            .text("a", "hi")
261            .file_bytes("f", "x.txt", "text/plain", b"DATA".to_vec())
262            .into_body()
263            .unwrap();
264        let expected = "--XB\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nhi\r\n\
265--XB\r\nContent-Disposition: form-data; name=\"f\"; filename=\"x.txt\"\r\n\
266Content-Type: text/plain\r\n\r\nDATA\r\n--XB--\r\n";
267        assert_eq!(String::from_utf8(body).unwrap(), expected);
268    }
269
270    #[test]
271    fn names_and_filenames_are_sanitised() {
272        let body = Multipart::with_boundary("XB")
273            .text("we\"ird\r\nname", "v")
274            .file_bytes("f", "a\"b\r\nc.txt", "text/plain", Vec::new())
275            .into_body()
276            .unwrap();
277        let text = String::from_utf8(body).unwrap();
278        assert!(text.contains("name=\"we_ird__name\""), "{text}");
279        assert!(text.contains("filename=\"a_b__c.txt\""), "{text}");
280    }
281
282    #[test]
283    fn boundary_inside_a_part_is_rejected() {
284        let err = Multipart::with_boundary("XB")
285            .text("a", "contains XB here")
286            .into_body()
287            .unwrap_err();
288        assert!(matches!(err, HttpError::InvalidRequest(_)));
289    }
290
291    #[test]
292    fn invalid_boundary_is_rejected() {
293        assert!(Multipart::with_boundary("").into_body().is_err());
294        assert!(Multipart::with_boundary("a b\"c").into_body().is_err());
295        assert!(Multipart::with_boundary("x".repeat(71))
296            .into_body()
297            .is_err());
298    }
299
300    #[test]
301    fn content_type_carries_boundary() {
302        assert_eq!(
303            Multipart::with_boundary("XB").content_type(),
304            "multipart/form-data; boundary=XB"
305        );
306    }
307
308    #[test]
309    fn request_multipart_sets_body_and_content_type() {
310        let req = Request::post("http://x/")
311            .set_header("content-type", "text/plain")
312            .multipart(Multipart::with_boundary("XB").text("a", "1"))
313            .unwrap();
314        assert_eq!(
315            crate::client::header_of(&req.headers, "content-type"),
316            Some("multipart/form-data; boundary=XB")
317        );
318        assert_eq!(
319            req.headers
320                .iter()
321                .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
322                .count(),
323            1
324        );
325        assert!(req.body.starts_with(b"--XB\r\n"));
326    }
327
328    #[test]
329    fn related_body_frames_each_part_with_its_content_type() {
330        let body = MultipartRelated::with_boundary("b1")
331            .part("application/json; charset=UTF-8", "{\"name\":\"x\"}")
332            .part("message/rfc822", "Subject: hi\r\n\r\nbody")
333            .into_body()
334            .unwrap();
335        let text = String::from_utf8(body).unwrap();
336        assert_eq!(
337            text,
338            "--b1\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{\"name\":\"x\"}\r\n\
339             --b1\r\nContent-Type: message/rfc822\r\n\r\nSubject: hi\r\n\r\nbody\r\n--b1--\r\n"
340        );
341        assert_eq!(
342            MultipartRelated::with_boundary("b1").content_type(),
343            "multipart/related; boundary=b1"
344        );
345        assert!(MultipartRelated::with_boundary("b1")
346            .part("text/plain", "has b1 inside")
347            .into_body()
348            .is_err());
349    }
350}