Skip to main content

armature_core/
form.rs

1//! Form processing and multipart support
2
3use crate::Error;
4use serde::de::DeserializeOwned;
5use std::collections::HashMap;
6
7/// Parse URL-encoded form data
8pub fn parse_form<T: DeserializeOwned>(body: &[u8]) -> Result<T, Error> {
9    serde_urlencoded::from_bytes(body)
10        .map_err(|e| Error::BadRequest(format!("Failed to parse form data: {}", e)))
11}
12
13/// Parse URL-encoded form data into a HashMap
14pub fn parse_form_map(body: &[u8]) -> Result<HashMap<String, String>, Error> {
15    let form_data: Vec<(String, String)> = serde_urlencoded::from_bytes(body)
16        .map_err(|e| Error::BadRequest(format!("Failed to parse form data: {}", e)))?;
17
18    Ok(form_data.into_iter().collect())
19}
20
21/// Multipart form field
22#[derive(Debug, Clone)]
23pub struct FormField {
24    /// Field name
25    pub name: String,
26
27    /// Field value (for text fields)
28    pub value: Option<String>,
29
30    /// File data (for file fields)
31    pub file: Option<FormFile>,
32}
33
34/// Uploaded file data
35#[derive(Debug, Clone)]
36pub struct FormFile {
37    /// Original filename
38    pub filename: String,
39
40    /// Content type (MIME type)
41    pub content_type: String,
42
43    /// File size in bytes
44    pub size: usize,
45
46    /// File data
47    pub data: Vec<u8>,
48}
49
50impl FormFile {
51    /// Create a new form file
52    pub fn new(filename: String, content_type: String, data: Vec<u8>) -> Self {
53        let size = data.len();
54        Self {
55            filename,
56            content_type,
57            size,
58            data,
59        }
60    }
61
62    /// Get file extension
63    pub fn extension(&self) -> Option<&str> {
64        self.filename.rsplit('.').next()
65    }
66
67    /// Check if file is an image
68    pub fn is_image(&self) -> bool {
69        self.content_type.starts_with("image/")
70    }
71
72    /// Check if file size exceeds limit
73    pub fn exceeds_size(&self, max_bytes: usize) -> bool {
74        self.size > max_bytes
75    }
76
77    /// Save file to disk
78    pub fn save_to(&self, path: &str) -> Result<(), Error> {
79        std::fs::write(path, &self.data)
80            .map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))
81    }
82
83    /// Save file to disk asynchronously
84    pub async fn save_to_async(&self, path: &str) -> Result<(), Error> {
85        tokio::fs::write(path, &self.data)
86            .await
87            .map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))
88    }
89}
90
91/// Save multiple files in parallel
92///
93/// This function saves multiple uploaded files concurrently, providing
94/// significant performance improvements over sequential saves.
95///
96/// # Arguments
97///
98/// * `files` - Vector of tuples containing (file, destination_path)
99///
100/// # Returns
101///
102/// Returns a vector of saved file paths in the same order as input.
103///
104/// # Performance
105///
106/// - **Sequential:** O(n * disk_write_time)
107/// - **Parallel:** O(max(disk_write_times))
108/// - **Speedup:** 5-10x for batch file uploads
109///
110/// # Examples
111///
112/// ```no_run
113/// # use armature_core::form::*;
114/// # async fn example(files: Vec<FormFile>) -> Result<(), armature_core::Error> {
115/// // Save 10 files in parallel (5-10x faster)
116/// let file_paths: Vec<_> = files.iter()
117///     .enumerate()
118///     .map(|(i, file)| (file, format!("uploads/file_{}.dat", i)))
119///     .collect();
120///
121/// let saved = save_files_parallel(file_paths).await?;
122/// println!("Saved {} files", saved.len());
123/// # Ok(())
124/// # }
125/// ```
126pub async fn save_files_parallel(files: Vec<(&FormFile, String)>) -> Result<Vec<String>, Error> {
127    use tokio::task::JoinSet;
128
129    let mut set = JoinSet::new();
130
131    for (file, path) in files {
132        let data = file.data.clone();
133        let path_clone = path.clone();
134
135        set.spawn(async move {
136            tokio::fs::write(&path_clone, &data)
137                .await
138                .map_err(|e| Error::Internal(format!("Failed to save file: {}", e)))?;
139            Ok::<_, Error>(path_clone)
140        });
141    }
142
143    let mut saved_paths = Vec::new();
144    while let Some(result) = set.join_next().await {
145        saved_paths.push(result.map_err(|e| Error::Internal(e.to_string()))??);
146    }
147
148    Ok(saved_paths)
149}
150
151/// Multipart form data parser
152pub struct MultipartParser {
153    boundary: String,
154}
155
156impl MultipartParser {
157    /// Create a new multipart parser from Content-Type header
158    pub fn from_content_type(content_type: &str) -> Result<Self, Error> {
159        // Extract boundary from Content-Type header
160        // Example: "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"
161        let boundary = content_type
162            .split(';')
163            .find_map(|part| {
164                let part = part.trim();
165                if part.starts_with("boundary=") {
166                    Some(
167                        part.trim_start_matches("boundary=")
168                            .trim_matches('"')
169                            .to_string(),
170                    )
171                } else {
172                    None
173                }
174            })
175            .ok_or_else(|| Error::BadRequest("Missing boundary in Content-Type".to_string()))?;
176
177        Ok(Self { boundary })
178    }
179
180    /// Parse multipart form data
181    pub fn parse(&self, body: &[u8]) -> Result<Vec<FormField>, Error> {
182        let mut fields = Vec::new();
183        let boundary_marker = format!("--{}", self.boundary);
184        let delimiter = boundary_marker.as_bytes();
185
186        // Locate every boundary delimiter with SIMD-accelerated byte search.
187        let finder = memchr::memmem::Finder::new(delimiter);
188        let positions: Vec<usize> = finder.find_iter(body).collect();
189
190        // Each part lives between two consecutive delimiters.
191        for pair in positions.windows(2) {
192            let mut part = &body[pair[0] + delimiter.len()..pair[1]];
193
194            // The closing delimiter ("--boundary--") is not a part.
195            if part.starts_with(b"--") {
196                continue;
197            }
198
199            // Strip the CRLF terminating the boundary line...
200            if let Some(rest) = part.strip_prefix(b"\r\n") {
201                part = rest;
202            } else if let Some(rest) = part.strip_prefix(b"\n") {
203                part = rest;
204            }
205
206            // ...and the CRLF preceding the next boundary.
207            if let Some(rest) = part.strip_suffix(b"\r\n") {
208                part = rest;
209            } else if let Some(rest) = part.strip_suffix(b"\n") {
210                part = rest;
211            }
212
213            // Parse each part
214            if let Some(field) = self.parse_part(part)? {
215                fields.push(field);
216            }
217        }
218
219        Ok(fields)
220    }
221
222    /// Parse a single multipart part
223    fn parse_part(&self, part: &[u8]) -> Result<Option<FormField>, Error> {
224        if part.is_empty() {
225            return Ok(None);
226        }
227
228        // Split the header block from the body at the first blank line.
229        // The body must stay a raw byte slice: file uploads are arbitrary binary.
230        let (header_block, content) = match memchr::memmem::find(part, b"\r\n\r\n") {
231            Some(pos) => (&part[..pos], &part[pos + 4..]),
232            None => match memchr::memmem::find(part, b"\n\n") {
233                Some(pos) => (&part[..pos], &part[pos + 2..]),
234                None => (part, &part[part.len()..]),
235            },
236        };
237
238        // Headers are ASCII per RFC 7578, so parsing them as UTF-8 is safe.
239        let headers = String::from_utf8_lossy(header_block);
240
241        // Parse headers
242        let mut name = None;
243        let mut filename = None;
244        let mut content_type = None;
245
246        for line in headers.lines() {
247            if line.starts_with("Content-Disposition:") {
248                // Parse name and filename
249                for attr in line.split(';') {
250                    let attr = attr.trim();
251                    if attr.starts_with("name=") {
252                        name = Some(
253                            attr.trim_start_matches("name=")
254                                .trim_matches('"')
255                                .to_string(),
256                        );
257                    } else if attr.starts_with("filename=") {
258                        filename = Some(
259                            attr.trim_start_matches("filename=")
260                                .trim_matches('"')
261                                .to_string(),
262                        );
263                    }
264                }
265            } else if line.starts_with("Content-Type:") {
266                content_type = Some(line.trim_start_matches("Content-Type:").trim().to_string());
267            }
268        }
269
270        let name = name.ok_or_else(|| Error::BadRequest("Missing field name".to_string()))?;
271
272        // Create field
273        if let Some(filename) = filename {
274            // File field: keep the body bytes exactly as uploaded
275            let file = FormFile::new(
276                filename,
277                content_type.unwrap_or_else(|| "application/octet-stream".to_string()),
278                content.to_vec(),
279            );
280            Ok(Some(FormField {
281                name,
282                value: None,
283                file: Some(file),
284            }))
285        } else {
286            // Text field
287            Ok(Some(FormField {
288                name,
289                value: Some(String::from_utf8_lossy(content).into_owned()),
290                file: None,
291            }))
292        }
293    }
294
295    /// Convert parsed fields to HashMap
296    pub fn to_map(fields: Vec<FormField>) -> HashMap<String, String> {
297        fields
298            .into_iter()
299            .filter_map(|field| field.value.map(|value| (field.name, value)))
300            .collect()
301    }
302
303    /// Get files from parsed fields
304    pub fn get_files(fields: &[FormField]) -> Vec<(String, &FormFile)> {
305        fields
306            .iter()
307            .filter_map(|field| field.file.as_ref().map(|file| (field.name.clone(), file)))
308            .collect()
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_parse_form_map() {
318        let body = b"name=John+Doe&email=john%40example.com&age=30";
319        let form = parse_form_map(body).unwrap();
320
321        assert_eq!(form.get("name"), Some(&"John Doe".to_string()));
322        assert_eq!(form.get("email"), Some(&"john@example.com".to_string()));
323        assert_eq!(form.get("age"), Some(&"30".to_string()));
324    }
325
326    #[test]
327    fn test_form_file_extension() {
328        let file = FormFile::new(
329            "document.pdf".to_string(),
330            "application/pdf".to_string(),
331            vec![1, 2, 3],
332        );
333
334        assert_eq!(file.extension(), Some("pdf"));
335    }
336
337    #[test]
338    fn test_form_file_is_image() {
339        let image = FormFile::new("photo.jpg".to_string(), "image/jpeg".to_string(), vec![]);
340        assert!(image.is_image());
341
342        let doc = FormFile::new("doc.pdf".to_string(), "application/pdf".to_string(), vec![]);
343        assert!(!doc.is_image());
344    }
345
346    #[test]
347    fn test_form_file_size_check() {
348        let file = FormFile::new(
349            "file.txt".to_string(),
350            "text/plain".to_string(),
351            vec![0; 1024], // 1KB
352        );
353
354        assert!(!file.exceeds_size(2048)); // 2KB limit
355        assert!(file.exceeds_size(512)); // 512B limit
356    }
357
358    #[test]
359    fn test_multipart_parser_from_content_type() {
360        let content_type = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW";
361        let parser = MultipartParser::from_content_type(content_type).unwrap();
362
363        assert_eq!(parser.boundary, "----WebKitFormBoundary7MA4YWxkTrZu0gW");
364    }
365
366    #[test]
367    fn test_multipart_binary_roundtrip() {
368        // Non-UTF-8 bytes, embedded CRLFs, and leading/trailing whitespace
369        // must all survive the parse exactly as uploaded.
370        let file_data: Vec<u8> = vec![
371            b' ', b'\t', 0x00, 0xFF, 0xFE, b'\r', b'\n', 0x80, 0xC3, 0x01, b'\n', b' ',
372        ];
373
374        let mut body = Vec::new();
375        body.extend_from_slice(b"--XBOUNDARY\r\n");
376        body.extend_from_slice(
377            b"Content-Disposition: form-data; name=\"file\"; filename=\"blob.bin\"\r\n",
378        );
379        body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
380        body.extend_from_slice(&file_data);
381        body.extend_from_slice(b"\r\n--XBOUNDARY\r\n");
382        body.extend_from_slice(b"Content-Disposition: form-data; name=\"note\"\r\n\r\n");
383        body.extend_from_slice(b"hello world");
384        body.extend_from_slice(b"\r\n--XBOUNDARY--\r\n");
385
386        let parser =
387            MultipartParser::from_content_type("multipart/form-data; boundary=XBOUNDARY").unwrap();
388        let fields = parser.parse(&body).unwrap();
389
390        assert_eq!(fields.len(), 2);
391
392        let file = fields[0].file.as_ref().unwrap();
393        assert_eq!(fields[0].name, "file");
394        assert_eq!(file.filename, "blob.bin");
395        assert_eq!(file.content_type, "application/octet-stream");
396        assert_eq!(file.data, file_data);
397        assert_eq!(file.size, file_data.len());
398
399        assert_eq!(fields[1].name, "note");
400        assert_eq!(fields[1].value.as_deref(), Some("hello world"));
401    }
402
403    #[test]
404    fn test_multipart_parser_missing_boundary() {
405        let content_type = "multipart/form-data";
406        let result = MultipartParser::from_content_type(content_type);
407
408        assert!(result.is_err());
409    }
410}