Skip to main content

rainier_http/
upload.rs

1//! `multipart/form-data` parsing — [`UploadedFile`] and the file bag.
2//!
3//! Deliberately a **buffered** parser: it takes the complete body and splits it
4//! on the boundary, rather than streaming parts as they arrive. That matches
5//! the rest of the request model (see [`crate::body`]) and keeps
6//! `$request->file('avatar')` a synchronous call. The protection against a
7//! malicious 10 GB upload is therefore the server's body-size limit, applied
8//! *before* this ever runs — not backpressure inside it.
9
10use std::collections::HashMap;
11
12use bytes::Bytes;
13use rainier_support::{Error, Result};
14use serde_json::Value;
15
16/// A file received in a `multipart/form-data` request.
17#[derive(Debug, Clone)]
18pub struct UploadedFile {
19    field: String,
20    file_name: Option<String>,
21    content_type: Option<String>,
22    bytes: Bytes,
23}
24
25impl UploadedFile {
26    /// The form field this file arrived under.
27    pub fn field(&self) -> &str {
28        &self.field
29    }
30
31    /// The client-supplied file name.
32    ///
33    /// **Untrusted.** A browser sends whatever the client says, which may
34    /// contain `../`, a null byte, or a name designed to collide with
35    /// something on disk. Use [`extension`](Self::extension) and generate your
36    /// own name when storing; never join this onto a path.
37    pub fn client_file_name(&self) -> Option<&str> {
38        self.file_name.as_deref()
39    }
40
41    /// The client-supplied MIME type. Also untrusted — sniff the contents if
42    /// the distinction matters for security.
43    pub fn content_type(&self) -> Option<&str> {
44        self.content_type.as_deref()
45    }
46
47    /// The file's contents.
48    pub fn bytes(&self) -> &Bytes {
49        &self.bytes
50    }
51
52    /// The file's size in bytes.
53    pub fn size(&self) -> usize {
54        self.bytes.len()
55    }
56
57    /// Whether the part carried no content — a file input left empty submits
58    /// an empty part rather than nothing at all.
59    pub fn is_empty(&self) -> bool {
60        self.bytes.is_empty()
61    }
62
63    /// The lowercased extension of the client file name, with no dot, and only
64    /// when it is alphanumeric — so `image.php\0.jpg` or `x.../y` yields
65    /// `None` rather than something dangerous to concatenate.
66    pub fn extension(&self) -> Option<String> {
67        let name = self.file_name.as_deref()?;
68        let ext = name.rsplit_once('.')?.1;
69        if ext.is_empty() || !ext.chars().all(|c| c.is_ascii_alphanumeric()) {
70            return None;
71        }
72        Some(ext.to_ascii_lowercase())
73    }
74
75    /// Write the file to `path`.
76    pub fn store(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
77        std::fs::write(path.as_ref(), &self.bytes).map_err(|e| {
78            Error::internal(format!("could not store upload at {}: {e}", path.as_ref().display()))
79        })
80    }
81}
82
83/// The outcome of parsing a `multipart/form-data` body: ordinary fields lifted
84/// into a JSON tree, plus the files.
85#[derive(Debug, Default)]
86pub struct Multipart {
87    /// Non-file fields, in the same shape as a urlencoded form.
88    pub fields: Value,
89    /// Files, keyed by form field name. A repeated field (`photos[]`) keeps
90    /// every file under that key.
91    pub files: HashMap<String, Vec<UploadedFile>>,
92}
93
94/// Pull the `boundary=` parameter out of a `Content-Type` header.
95pub fn boundary_of(content_type: &str) -> Option<String> {
96    let (_, params) = content_type.split_once(';')?;
97    for param in params.split(';') {
98        let (name, value) = param.split_once('=')?;
99        if name.trim().eq_ignore_ascii_case("boundary") {
100            let value = value.trim();
101            let value = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')).unwrap_or(value);
102            return Some(value.to_string());
103        }
104    }
105    None
106}
107
108/// Parse a `multipart/form-data` body given its boundary.
109pub fn parse(body: &Bytes, boundary: &str) -> Result<Multipart> {
110    let delimiter = format!("--{boundary}");
111    let mut result =
112        Multipart { fields: Value::Object(serde_json::Map::new()), ..Default::default() };
113
114    for part in split_on(body, delimiter.as_bytes()) {
115        // The closing delimiter is `--boundary--`; anything before the first
116        // delimiter is preamble. Both arrive here as parts we skip.
117        let part = match part.strip_prefix(b"\r\n".as_slice()) {
118            Some(rest) => rest,
119            None => continue,
120        };
121
122        let Some((head, body)) = split_once(part, b"\r\n\r\n") else {
123            continue;
124        };
125
126        let headers = parse_headers(head);
127        let Some(disposition) = headers.get("content-disposition") else {
128            continue;
129        };
130        let Some(field) = header_param(disposition, "name") else {
131            continue;
132        };
133
134        // Each part's body is terminated by the CRLF preceding its delimiter.
135        let body = body.strip_suffix(b"\r\n".as_slice()).unwrap_or(body);
136
137        match header_param(disposition, "filename") {
138            Some(file_name) => {
139                let file = UploadedFile {
140                    field: field.clone(),
141                    file_name: Some(file_name),
142                    content_type: headers.get("content-type").cloned(),
143                    bytes: Bytes::copy_from_slice(body),
144                };
145                // `photos[]` and `photos` both collect under `photos`.
146                let key = field.strip_suffix("[]").unwrap_or(&field).to_string();
147                result.files.entry(key).or_default().push(file);
148            }
149            None => {
150                let text = String::from_utf8_lossy(body).into_owned();
151                crate::input::insert(&mut result.fields, &field, Value::String(text));
152            }
153        }
154    }
155
156    Ok(result)
157}
158
159/// Split `haystack` on every occurrence of `needle`.
160fn split_on<'a>(haystack: &'a [u8], needle: &[u8]) -> Vec<&'a [u8]> {
161    let mut parts = Vec::new();
162    let mut rest = haystack;
163
164    while let Some(at) = find(rest, needle) {
165        parts.push(&rest[..at]);
166        rest = &rest[at + needle.len()..];
167    }
168    parts.push(rest);
169    parts
170}
171
172fn split_once<'a>(haystack: &'a [u8], needle: &[u8]) -> Option<(&'a [u8], &'a [u8])> {
173    let at = find(haystack, needle)?;
174    Some((&haystack[..at], &haystack[at + needle.len()..]))
175}
176
177fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
178    if needle.is_empty() || haystack.len() < needle.len() {
179        return None;
180    }
181    haystack.windows(needle.len()).position(|window| window == needle)
182}
183
184/// Parse a part's headers into a lowercase-keyed map.
185fn parse_headers(head: &[u8]) -> HashMap<String, String> {
186    let mut headers = HashMap::new();
187    for line in String::from_utf8_lossy(head).lines() {
188        if let Some((name, value)) = line.split_once(':') {
189            headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
190        }
191    }
192    headers
193}
194
195/// Read a `name="value"` parameter out of a header value.
196fn header_param(header: &str, param: &str) -> Option<String> {
197    for piece in header.split(';').skip(1) {
198        let (name, value) = piece.split_once('=')?;
199        if !name.trim().eq_ignore_ascii_case(param) {
200            continue;
201        }
202        let value = value.trim();
203        let value = value.strip_prefix('"').and_then(|v| v.strip_suffix('"')).unwrap_or(value);
204        return Some(value.to_string());
205    }
206    None
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use serde_json::json;
213
214    const BOUNDARY: &str = "----RainierBoundary";
215
216    fn body(parts: &[&str]) -> Bytes {
217        let mut out = String::new();
218        for part in parts {
219            out.push_str(&format!("--{BOUNDARY}\r\n{part}\r\n"));
220        }
221        out.push_str(&format!("--{BOUNDARY}--\r\n"));
222        Bytes::from(out)
223    }
224
225    #[test]
226    fn extracts_the_boundary_from_a_content_type() {
227        assert_eq!(boundary_of("multipart/form-data; boundary=abc123").as_deref(), Some("abc123"));
228        assert_eq!(
229            boundary_of("multipart/form-data; charset=utf-8; boundary=\"a b\"").as_deref(),
230            Some("a b")
231        );
232        assert_eq!(boundary_of("application/json"), None);
233    }
234
235    #[test]
236    fn parses_plain_fields() {
237        let raw = body(&[
238            "Content-Disposition: form-data; name=\"title\"\r\n\r\nHello",
239            "Content-Disposition: form-data; name=\"body\"\r\n\r\nWorld",
240        ]);
241        let parsed = parse(&raw, BOUNDARY).unwrap();
242        assert_eq!(parsed.fields, json!({ "title": "Hello", "body": "World" }));
243        assert!(parsed.files.is_empty());
244    }
245
246    #[test]
247    fn fields_honour_bracket_notation() {
248        let raw = body(&[
249            "Content-Disposition: form-data; name=\"tags[]\"\r\n\r\na",
250            "Content-Disposition: form-data; name=\"tags[]\"\r\n\r\nb",
251            "Content-Disposition: form-data; name=\"user[name]\"\r\n\r\nada",
252        ]);
253        let parsed = parse(&raw, BOUNDARY).unwrap();
254        assert_eq!(parsed.fields, json!({ "tags": ["a", "b"], "user": { "name": "ada" } }));
255    }
256
257    #[test]
258    fn parses_a_file_part() {
259        let raw =
260            body(&["Content-Disposition: form-data; name=\"avatar\"; filename=\"me.PNG\"\r\n\
261             Content-Type: image/png\r\n\r\nPNGDATA"]);
262        let parsed = parse(&raw, BOUNDARY).unwrap();
263
264        let file = &parsed.files["avatar"][0];
265        assert_eq!(file.field(), "avatar");
266        assert_eq!(file.client_file_name(), Some("me.PNG"));
267        assert_eq!(file.content_type(), Some("image/png"));
268        assert_eq!(file.bytes(), &Bytes::from("PNGDATA"));
269        assert_eq!(file.size(), 7);
270        assert_eq!(file.extension().as_deref(), Some("png"));
271    }
272
273    #[test]
274    fn several_files_collect_under_one_key() {
275        let raw = body(&[
276            "Content-Disposition: form-data; name=\"photos[]\"; filename=\"a.jpg\"\r\n\r\nAAA",
277            "Content-Disposition: form-data; name=\"photos[]\"; filename=\"b.jpg\"\r\n\r\nBBB",
278        ]);
279        let parsed = parse(&raw, BOUNDARY).unwrap();
280        assert_eq!(parsed.files["photos"].len(), 2);
281        assert_eq!(parsed.files["photos"][1].bytes(), &Bytes::from("BBB"));
282    }
283
284    #[test]
285    fn files_and_fields_coexist() {
286        let raw = body(&[
287            "Content-Disposition: form-data; name=\"title\"\r\n\r\nMy post",
288            "Content-Disposition: form-data; name=\"cover\"; filename=\"c.gif\"\r\n\r\nGIF",
289        ]);
290        let parsed = parse(&raw, BOUNDARY).unwrap();
291        assert_eq!(parsed.fields, json!({ "title": "My post" }));
292        assert_eq!(parsed.files["cover"][0].bytes(), &Bytes::from("GIF"));
293    }
294
295    #[test]
296    fn binary_content_survives_intact() {
297        // Includes bytes that are not valid UTF-8 and a CRLF in the middle.
298        let payload: &[u8] = &[0x00, 0xFF, 0x0D, 0x0A, 0x42, 0x80];
299        let mut raw = Vec::new();
300        raw.extend_from_slice(
301            format!(
302                "--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"f\"; filename=\"b.bin\"\r\n\r\n"
303            )
304            .as_bytes(),
305        );
306        raw.extend_from_slice(payload);
307        raw.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes());
308
309        let parsed = parse(&Bytes::from(raw), BOUNDARY).unwrap();
310        assert_eq!(parsed.files["f"][0].bytes().as_ref(), payload);
311    }
312
313    #[test]
314    fn an_empty_file_part_is_reported_as_empty() {
315        let raw = body(&["Content-Disposition: form-data; name=\"avatar\"; filename=\"\"\r\n\r\n"]);
316        let parsed = parse(&raw, BOUNDARY).unwrap();
317        assert!(parsed.files["avatar"][0].is_empty());
318    }
319
320    #[test]
321    fn a_hostile_file_name_yields_no_extension() {
322        let raw = body(&[
323            "Content-Disposition: form-data; name=\"f\"; filename=\"../../etc/passwd\"\r\n\r\nx",
324        ]);
325        let parsed = parse(&raw, BOUNDARY).unwrap();
326        let file = &parsed.files["f"][0];
327        // The raw name is preserved (it may be worth logging) but yields
328        // nothing that could be concatenated into a path.
329        assert_eq!(file.client_file_name(), Some("../../etc/passwd"));
330        assert_eq!(file.extension(), None);
331    }
332
333    #[test]
334    fn a_double_extension_with_a_null_byte_yields_none() {
335        let raw = body(&[
336            "Content-Disposition: form-data; name=\"f\"; filename=\"shell.php\u{0}.jpg\"\r\n\r\nx",
337        ]);
338        let parsed = parse(&raw, BOUNDARY).unwrap();
339        // The last extension is `jpg` here, which is fine — the point is that
340        // it is alphanumeric-only and the caller never sees `php\0`.
341        assert_eq!(parsed.files["f"][0].extension().as_deref(), Some("jpg"));
342    }
343
344    #[test]
345    fn an_empty_body_parses_to_nothing() {
346        let parsed = parse(&Bytes::new(), BOUNDARY).unwrap();
347        assert_eq!(parsed.fields, json!({}));
348        assert!(parsed.files.is_empty());
349    }
350
351    #[test]
352    fn parts_without_a_name_are_skipped() {
353        let raw = body(&["Content-Disposition: form-data\r\n\r\norphan"]);
354        let parsed = parse(&raw, BOUNDARY).unwrap();
355        assert_eq!(parsed.fields, json!({}));
356    }
357}