1use std::collections::HashMap;
11
12use bytes::Bytes;
13use rainier_support::{Error, Result};
14use serde_json::Value;
15
16#[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 pub fn field(&self) -> &str {
28 &self.field
29 }
30
31 pub fn client_file_name(&self) -> Option<&str> {
38 self.file_name.as_deref()
39 }
40
41 pub fn content_type(&self) -> Option<&str> {
44 self.content_type.as_deref()
45 }
46
47 pub fn bytes(&self) -> &Bytes {
49 &self.bytes
50 }
51
52 pub fn size(&self) -> usize {
54 self.bytes.len()
55 }
56
57 pub fn is_empty(&self) -> bool {
60 self.bytes.is_empty()
61 }
62
63 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 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#[derive(Debug, Default)]
86pub struct Multipart {
87 pub fields: Value,
89 pub files: HashMap<String, Vec<UploadedFile>>,
92}
93
94pub 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
108pub 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 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 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 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
159fn 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
184fn 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
195fn 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 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 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 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}