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
135fn sanitize(value: &str) -> String {
136    value
137        .chars()
138        .map(|c| match c {
139            '"' | '\r' | '\n' => '_',
140            other => other,
141        })
142        .collect()
143}
144
145fn validate_boundary(boundary: &str) -> Result<(), HttpError> {
146    let ok_len = (1..=70).contains(&boundary.len());
147    let ok_chars = boundary
148        .bytes()
149        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+'));
150    if ok_len && ok_chars {
151        Ok(())
152    } else {
153        Err(HttpError::InvalidRequest(
154            "multipart boundary must be 1 to 70 ASCII letters, digits, '-', '_', '.' or '+'".into(),
155        ))
156    }
157}
158
159fn contains(haystack: &[u8], needle: &[u8]) -> bool {
160    !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
161}
162
163fn random_boundary() -> String {
164    let mut bytes = [0u8; 12];
165    if getrandom::fill(&mut bytes).is_err() {
166        let nanos = std::time::SystemTime::now()
167            .duration_since(std::time::UNIX_EPOCH)
168            .map(|d| d.as_nanos())
169            .unwrap_or(0);
170        return format!("rightkit-fallback-{nanos:x}");
171    }
172    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
173    format!("rightkit-{hex}")
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn pinned_boundary_byte_shape() {
182        let body = Multipart::with_boundary("XB")
183            .text("a", "hi")
184            .file_bytes("f", "x.txt", "text/plain", b"DATA".to_vec())
185            .into_body()
186            .unwrap();
187        let expected = "--XB\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nhi\r\n\
188--XB\r\nContent-Disposition: form-data; name=\"f\"; filename=\"x.txt\"\r\n\
189Content-Type: text/plain\r\n\r\nDATA\r\n--XB--\r\n";
190        assert_eq!(String::from_utf8(body).unwrap(), expected);
191    }
192
193    #[test]
194    fn names_and_filenames_are_sanitised() {
195        let body = Multipart::with_boundary("XB")
196            .text("we\"ird\r\nname", "v")
197            .file_bytes("f", "a\"b\r\nc.txt", "text/plain", Vec::new())
198            .into_body()
199            .unwrap();
200        let text = String::from_utf8(body).unwrap();
201        assert!(text.contains("name=\"we_ird__name\""), "{text}");
202        assert!(text.contains("filename=\"a_b__c.txt\""), "{text}");
203    }
204
205    #[test]
206    fn boundary_inside_a_part_is_rejected() {
207        let err = Multipart::with_boundary("XB")
208            .text("a", "contains XB here")
209            .into_body()
210            .unwrap_err();
211        assert!(matches!(err, HttpError::InvalidRequest(_)));
212    }
213
214    #[test]
215    fn invalid_boundary_is_rejected() {
216        assert!(Multipart::with_boundary("").into_body().is_err());
217        assert!(Multipart::with_boundary("a b\"c").into_body().is_err());
218        assert!(Multipart::with_boundary("x".repeat(71))
219            .into_body()
220            .is_err());
221    }
222
223    #[test]
224    fn content_type_carries_boundary() {
225        assert_eq!(
226            Multipart::with_boundary("XB").content_type(),
227            "multipart/form-data; boundary=XB"
228        );
229    }
230
231    #[test]
232    fn request_multipart_sets_body_and_content_type() {
233        let req = Request::post("http://x/")
234            .set_header("content-type", "text/plain")
235            .multipart(Multipart::with_boundary("XB").text("a", "1"))
236            .unwrap();
237        assert_eq!(
238            crate::client::header_of(&req.headers, "content-type"),
239            Some("multipart/form-data; boundary=XB")
240        );
241        assert_eq!(
242            req.headers
243                .iter()
244                .filter(|(k, _)| k.eq_ignore_ascii_case("content-type"))
245                .count(),
246            1
247        );
248        assert!(req.body.starts_with(b"--XB\r\n"));
249    }
250}