Skip to main content

reinhardt_core/parsers/
file.rs

1use async_trait::async_trait;
2use bytes::Bytes;
3use http::HeaderMap;
4
5use super::parser::{ParseError, ParseResult, ParsedData, Parser, UploadedFile};
6
7/// Raw file upload parser
8#[derive(Debug, Clone)]
9pub struct FileUploadParser {
10	/// Maximum file size in bytes (None = unlimited)
11	pub max_file_size: Option<usize>,
12	/// Field name for the file
13	pub field_name: String,
14}
15
16impl FileUploadParser {
17	/// Parse filename from Content-Disposition header.
18	/// Supports both standard filename and RFC2231 encoded filename* parameters.
19	///
20	/// # Examples
21	///
22	/// ```
23	/// use reinhardt_core::parsers::file::FileUploadParser;
24	///
25	/// let parser = FileUploadParser::new("file");
26	/// let disposition = "inline; filename=document.txt";
27	/// let filename = parser.get_filename(Some(disposition)).unwrap();
28	/// assert_eq!(filename, "document.txt");
29	///
30	// RFC2231 encoded filename
31	/// let disposition_encoded = "inline; filename*=utf-8''%C3%A0.txt";
32	/// let filename_encoded = parser.get_filename(Some(disposition_encoded)).unwrap();
33	/// assert_eq!(filename_encoded, "à.txt");
34	/// ```
35	pub fn get_filename(&self, content_disposition: Option<&str>) -> Result<String, ParseError> {
36		let disposition = content_disposition.ok_or_else(|| {
37            ParseError::ParseError(
38                "Missing filename. Request should include a Content-Disposition header with a filename parameter.".to_string()
39            )
40        })?;
41
42		if disposition.trim().is_empty() {
43			return Err(ParseError::ParseError(
44                "Missing filename. Request should include a Content-Disposition header with a filename parameter.".to_string()
45            ));
46		}
47
48		// RFC2231 encoded filename* takes precedence
49		if let Some(encoded_filename) = Self::extract_encoded_filename(disposition) {
50			return Ok(encoded_filename);
51		}
52
53		// Standard filename parameter
54		if let Some(filename) = Self::extract_standard_filename(disposition) {
55			return Ok(filename);
56		}
57
58		Err(ParseError::ParseError(
59            "Missing filename. Request should include a Content-Disposition header with a filename parameter.".to_string()
60        ))
61	}
62
63	fn extract_encoded_filename(disposition: &str) -> Option<String> {
64		// RFC2231: filename*=utf-8''encoded_name or filename*=utf-8'lang'encoded_name
65		for part in disposition.split(';') {
66			let part = part.trim();
67			if part.starts_with("filename*=") {
68				let value = part.trim_start_matches("filename*=");
69
70				// Parse RFC2231 format: charset'language'value
71				// Find the first single quote to get charset
72				if let Some(first_quote) = value.find('\'') {
73					// Find the second single quote to get the encoded value
74					let rest = &value[first_quote + 1..];
75					if let Some(second_quote) = rest.find('\'') {
76						let encoded = &rest[second_quote + 1..];
77						// URL decode the value
78						if let Ok(decoded) = urlencoding::decode(encoded) {
79							return Some(decoded.to_string());
80						}
81					}
82				}
83			}
84		}
85		None
86	}
87
88	fn extract_standard_filename(disposition: &str) -> Option<String> {
89		for part in disposition.split(';') {
90			let part = part.trim();
91			if part.starts_with("filename=") && !part.starts_with("filename*=") {
92				let value = part.trim_start_matches("filename=");
93				// Remove quotes if present
94				let value = value.trim_matches('"').trim_matches('\'');
95				return Some(value.to_string());
96			}
97		}
98		None
99	}
100}
101
102impl FileUploadParser {
103	/// Create a new FileUploadParser with the specified field name.
104	///
105	/// # Examples
106	///
107	/// ```
108	/// use reinhardt_core::parsers::file::FileUploadParser;
109	///
110	/// let parser = FileUploadParser::new("document");
111	/// assert_eq!(parser.field_name, "document");
112	/// assert!(parser.max_file_size.is_none());
113	/// ```
114	pub fn new(field_name: impl Into<String>) -> Self {
115		Self {
116			max_file_size: None,
117			field_name: field_name.into(),
118		}
119	}
120	/// Set the maximum file size in bytes.
121	///
122	/// # Examples
123	///
124	/// ```
125	/// use reinhardt_core::parsers::file::FileUploadParser;
126	///
127	/// let parser = FileUploadParser::new("file").max_file_size(5 * 1024 * 1024); // 5MB
128	/// assert_eq!(parser.max_file_size, Some(5 * 1024 * 1024));
129	/// ```
130	pub fn max_file_size(mut self, size: usize) -> Self {
131		self.max_file_size = Some(size);
132		self
133	}
134}
135
136impl Default for FileUploadParser {
137	fn default() -> Self {
138		Self {
139			max_file_size: None,
140			field_name: "file".to_string(),
141		}
142	}
143}
144
145#[async_trait]
146impl Parser for FileUploadParser {
147	fn media_types(&self) -> Vec<String> {
148		vec!["application/octet-stream".to_string(), "*/*".to_string()]
149	}
150
151	async fn parse(
152		&self,
153		content_type: Option<&str>,
154		body: Bytes,
155		_headers: &HeaderMap,
156	) -> ParseResult<ParsedData> {
157		let size = body.len();
158
159		// Check file size limit
160		if let Some(max_size) = self.max_file_size
161			&& size > max_size
162		{
163			return Err(ParseError::ParseError(format!(
164				"File exceeds maximum size of {} bytes",
165				max_size
166			)));
167		}
168
169		let mut file = UploadedFile::new(self.field_name.clone(), body);
170
171		if let Some(ct) = content_type {
172			file = file.with_content_type(ct.to_string());
173		}
174
175		Ok(ParsedData::File(file))
176	}
177}
178
179#[cfg(test)]
180mod tests {
181	use super::*;
182
183	#[tokio::test]
184	async fn test_file_upload_parser_valid() {
185		let parser = FileUploadParser::new("upload");
186		let body = Bytes::from("binary file content here");
187		let headers = HeaderMap::new();
188
189		let result = parser
190			.parse(Some("application/octet-stream"), body.clone(), &headers)
191			.await
192			.unwrap();
193
194		match result {
195			ParsedData::File(file) => {
196				assert_eq!(file.name, "upload");
197				assert_eq!(file.data, body);
198				assert_eq!(file.size, body.len());
199				assert_eq!(
200					file.content_type,
201					Some("application/octet-stream".to_string())
202				);
203			}
204			_ => panic!("Expected file data"),
205		}
206	}
207
208	#[tokio::test]
209	async fn test_file_upload_parser_max_size() {
210		let parser = FileUploadParser::new("upload").max_file_size(10);
211		let body = Bytes::from("this is a very long file content that exceeds the limit");
212		let headers = HeaderMap::new();
213
214		let result = parser
215			.parse(Some("application/octet-stream"), body, &headers)
216			.await;
217		assert!(result.is_err());
218	}
219
220	#[tokio::test]
221	async fn test_file_upload_parser_no_content_type() {
222		let parser = FileUploadParser::new("upload");
223		let body = Bytes::from("file content");
224		let headers = HeaderMap::new();
225
226		let result = parser.parse(None, body.clone(), &headers).await.unwrap();
227
228		match result {
229			ParsedData::File(file) => {
230				assert_eq!(file.name, "upload");
231				assert_eq!(file.data, body);
232				assert_eq!(file.content_type, None);
233			}
234			_ => panic!("Expected file data"),
235		}
236	}
237
238	#[test]
239	fn test_file_upload_parser_media_types() {
240		let parser = FileUploadParser::new("upload");
241		let media_types = parser.media_types();
242
243		assert!(media_types.contains(&"application/octet-stream".to_string()));
244		assert!(media_types.contains(&"*/*".to_string()));
245	}
246
247	// Tests from Django REST Framework
248
249	#[tokio::test]
250	async fn test_file_parse_drf() {
251		// DRF test: Parse raw file upload
252		let parser = FileUploadParser::new("file");
253		let body = Bytes::from("Test text file");
254		let headers = HeaderMap::new();
255
256		let content_disposition = "Content-Disposition: inline; filename=file.txt";
257		let filename = parser.get_filename(Some(content_disposition)).unwrap();
258
259		let result = parser
260			.parse(Some("application/octet-stream"), body.clone(), &headers)
261			.await
262			.unwrap();
263
264		match result {
265			ParsedData::File(file) => {
266				assert_eq!(file.size, 14);
267				assert_eq!(filename, "file.txt");
268			}
269			_ => panic!("Expected file data"),
270		}
271	}
272
273	#[tokio::test]
274	async fn test_parse_missing_filename() {
275		// DRF test: Parse raw file upload when filename is missing
276		let parser = FileUploadParser::new("file");
277
278		let result = parser.get_filename(Some(""));
279		assert!(result.is_err());
280		assert_eq!(
281			result.unwrap_err().to_string(),
282			"Parse error: Missing filename. Request should include a Content-Disposition header with a filename parameter."
283		);
284	}
285
286	#[tokio::test]
287	async fn test_parse_missing_filename_none() {
288		// DRF test: Parse when Content-Disposition header is None
289		let parser = FileUploadParser::new("file");
290
291		let result = parser.get_filename(None);
292		assert!(result.is_err());
293		assert_eq!(
294			result.unwrap_err().to_string(),
295			"Parse error: Missing filename. Request should include a Content-Disposition header with a filename parameter."
296		);
297	}
298
299	#[test]
300	fn test_get_filename() {
301		// DRF test: Get filename from Content-Disposition header
302		let parser = FileUploadParser::new("file");
303		let content_disposition = "Content-Disposition: inline; filename=file.txt";
304
305		let filename = parser.get_filename(Some(content_disposition)).unwrap();
306		assert_eq!(filename, "file.txt");
307	}
308
309	#[test]
310	fn test_get_encoded_filename() {
311		// DRF test: Get RFC2231 encoded filename
312		let parser = FileUploadParser::new("file");
313
314		// Test 1: filename* only
315		let disposition = "inline; filename*=utf-8''%C3%80%C4%A5%C6%A6.txt";
316		let filename = parser.get_filename(Some(disposition)).unwrap();
317		assert_eq!(filename, "ÀĥƦ.txt");
318
319		// Test 2: Both filename and filename* (filename* takes precedence)
320		let disposition = "inline; filename=fallback.txt; filename*=utf-8''%C3%80%C4%A5%C6%A6.txt";
321		let filename = parser.get_filename(Some(disposition)).unwrap();
322		assert_eq!(filename, "ÀĥƦ.txt");
323
324		// Test 3: With language tag
325		let disposition =
326			"inline; filename=fallback.txt; filename*=utf-8'en-us'%C3%80%C4%A5%C6%A6.txt";
327		let filename = parser.get_filename(Some(disposition)).unwrap();
328		assert_eq!(filename, "ÀĥƦ.txt");
329	}
330}