Skip to main content

reinhardt_forms/fields/
file_field.rs

1use crate::field::{FieldError, FieldResult, FormField, Widget};
2
3/// Default maximum file size: 10 MB
4const DEFAULT_FILE_MAX_SIZE: u64 = 10 * 1024 * 1024;
5
6/// Default maximum image file size: 5 MB
7const DEFAULT_IMAGE_MAX_SIZE: u64 = 5 * 1024 * 1024;
8
9/// FileField for file upload
10pub struct FileField {
11	pub name: String,
12	pub label: Option<String>,
13	pub required: bool,
14	pub help_text: Option<String>,
15	pub widget: Widget,
16	pub initial: Option<serde_json::Value>,
17	pub max_length: Option<usize>,
18	pub allow_empty_file: bool,
19	/// Maximum file size in bytes. Defaults to 10 MB.
20	pub max_size: u64,
21}
22
23impl FileField {
24	/// Create a new FileField
25	///
26	/// # Examples
27	///
28	/// ```
29	/// use reinhardt_forms::fields::FileField;
30	///
31	/// let field = FileField::new("upload".to_string());
32	/// assert_eq!(field.name, "upload");
33	/// assert_eq!(field.max_size, 10 * 1024 * 1024);
34	/// ```
35	pub fn new(name: String) -> Self {
36		Self {
37			name,
38			label: None,
39			required: true,
40			help_text: None,
41			widget: Widget::FileInput,
42			initial: None,
43			max_length: None,
44			allow_empty_file: false,
45			max_size: DEFAULT_FILE_MAX_SIZE,
46		}
47	}
48
49	/// Set the maximum file size in bytes.
50	///
51	/// # Examples
52	///
53	/// ```
54	/// use reinhardt_forms::fields::FileField;
55	///
56	/// let field = FileField::new("upload".to_string()).with_max_size(5 * 1024 * 1024);
57	/// assert_eq!(field.max_size, 5 * 1024 * 1024);
58	/// ```
59	pub fn with_max_size(mut self, max_size: u64) -> Self {
60		self.max_size = max_size;
61		self
62	}
63}
64
65impl FormField for FileField {
66	fn name(&self) -> &str {
67		&self.name
68	}
69
70	fn label(&self) -> Option<&str> {
71		self.label.as_deref()
72	}
73
74	fn required(&self) -> bool {
75		self.required
76	}
77
78	fn help_text(&self) -> Option<&str> {
79		self.help_text.as_deref()
80	}
81
82	fn widget(&self) -> &Widget {
83		&self.widget
84	}
85
86	fn initial(&self) -> Option<&serde_json::Value> {
87		self.initial.as_ref()
88	}
89
90	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
91		match value {
92			None if self.required => Err(FieldError::required(None)),
93			None => Ok(serde_json::Value::Null),
94			Some(v) => {
95				// Expect an object with filename and optional size
96				let obj = v
97					.as_object()
98					.ok_or_else(|| FieldError::Invalid("Expected object".to_string()))?;
99
100				let filename = obj
101					.get("filename")
102					.and_then(|f| f.as_str())
103					.ok_or_else(|| FieldError::Invalid("Missing filename".to_string()))?;
104
105				if filename.is_empty() {
106					if self.required {
107						return Err(FieldError::required(None));
108					}
109					return Ok(serde_json::Value::Null);
110				}
111
112				// Check filename length
113				if let Some(max) = self.max_length
114					&& filename.len() > max
115				{
116					return Err(FieldError::Validation(format!(
117						"Filename is too long (max {} characters)",
118						max
119					)));
120				}
121
122				// Check file size limit before further processing
123				if let Some(size) = obj.get("size").and_then(|s| s.as_u64()) {
124					if size > self.max_size {
125						return Err(FieldError::Validation(format!(
126							"File size {} bytes exceeds maximum allowed size of {} bytes",
127							size, self.max_size
128						)));
129					}
130
131					// Check for empty file
132					if !self.allow_empty_file && size == 0 {
133						return Err(FieldError::Validation(
134							"The submitted file is empty".to_string(),
135						));
136					}
137				} else if !self.allow_empty_file {
138					// No size field present and empty files not allowed
139					return Err(FieldError::Validation(
140						"The submitted file is empty".to_string(),
141					));
142				}
143
144				Ok(v.clone())
145			}
146		}
147	}
148}
149
150/// ImageField for image upload with additional validation
151pub struct ImageField {
152	pub name: String,
153	pub label: Option<String>,
154	pub required: bool,
155	pub help_text: Option<String>,
156	pub widget: Widget,
157	pub initial: Option<serde_json::Value>,
158	pub max_length: Option<usize>,
159	pub allow_empty_file: bool,
160	/// Maximum file size in bytes. Defaults to 5 MB.
161	pub max_size: u64,
162}
163
164impl ImageField {
165	/// Create a new ImageField
166	///
167	/// # Examples
168	///
169	/// ```
170	/// use reinhardt_forms::fields::ImageField;
171	///
172	/// let field = ImageField::new("photo".to_string());
173	/// assert_eq!(field.name, "photo");
174	/// assert_eq!(field.max_size, 5 * 1024 * 1024);
175	/// ```
176	pub fn new(name: String) -> Self {
177		Self {
178			name,
179			label: None,
180			required: true,
181			help_text: None,
182			widget: Widget::FileInput,
183			initial: None,
184			max_length: None,
185			allow_empty_file: false,
186			max_size: DEFAULT_IMAGE_MAX_SIZE,
187		}
188	}
189
190	/// Set the maximum file size in bytes.
191	///
192	/// # Examples
193	///
194	/// ```
195	/// use reinhardt_forms::fields::ImageField;
196	///
197	/// let field = ImageField::new("photo".to_string()).with_max_size(2 * 1024 * 1024);
198	/// assert_eq!(field.max_size, 2 * 1024 * 1024);
199	/// ```
200	pub fn with_max_size(mut self, max_size: u64) -> Self {
201		self.max_size = max_size;
202		self
203	}
204
205	fn is_valid_image_extension(filename: &str) -> bool {
206		// NOTE: SVG is intentionally excluded due to Stored XSS risk.
207		// SVG files can contain arbitrary JavaScript that executes when served
208		// with Content-Type: image/svg+xml. Use opt-in validation if SVG support
209		// is required, with appropriate sanitization or Content-Disposition headers.
210		let valid_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
211		filename
212			.rsplit('.')
213			.next()
214			.map(|ext| valid_extensions.contains(&ext.to_lowercase().as_str()))
215			.unwrap_or(false)
216	}
217}
218
219impl FormField for ImageField {
220	fn name(&self) -> &str {
221		&self.name
222	}
223
224	fn label(&self) -> Option<&str> {
225		self.label.as_deref()
226	}
227
228	fn required(&self) -> bool {
229		self.required
230	}
231
232	fn help_text(&self) -> Option<&str> {
233		self.help_text.as_deref()
234	}
235
236	fn widget(&self) -> &Widget {
237		&self.widget
238	}
239
240	fn initial(&self) -> Option<&serde_json::Value> {
241		self.initial.as_ref()
242	}
243
244	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
245		match value {
246			None if self.required => Err(FieldError::required(None)),
247			None => Ok(serde_json::Value::Null),
248			Some(v) => {
249				let obj = v
250					.as_object()
251					.ok_or_else(|| FieldError::Invalid("Expected object".to_string()))?;
252
253				let filename = obj
254					.get("filename")
255					.and_then(|f| f.as_str())
256					.ok_or_else(|| FieldError::Invalid("Missing filename".to_string()))?;
257
258				if filename.is_empty() {
259					if self.required {
260						return Err(FieldError::required(None));
261					}
262					return Ok(serde_json::Value::Null);
263				}
264
265				// Validate image extension
266				if !Self::is_valid_image_extension(filename) {
267					return Err(FieldError::Validation(
268						"Upload a valid image. The file you uploaded was either not an image or a corrupted image".to_string(),
269					));
270				}
271
272				// Check filename length
273				if let Some(max) = self.max_length
274					&& filename.len() > max
275				{
276					return Err(FieldError::Validation(format!(
277						"Filename is too long (max {} characters)",
278						max
279					)));
280				}
281
282				// Check file size limit before further processing
283				if let Some(size) = obj.get("size").and_then(|s| s.as_u64()) {
284					if size > self.max_size {
285						return Err(FieldError::Validation(format!(
286							"File size {} bytes exceeds maximum allowed size of {} bytes",
287							size, self.max_size
288						)));
289					}
290
291					// Check for empty file
292					if !self.allow_empty_file && size == 0 {
293						return Err(FieldError::Validation(
294							"The submitted file is empty".to_string(),
295						));
296					}
297				} else if !self.allow_empty_file {
298					// No size field present and empty files not allowed
299					return Err(FieldError::Validation(
300						"The submitted file is empty".to_string(),
301					));
302				}
303
304				Ok(v.clone())
305			}
306		}
307	}
308}
309
310#[cfg(test)]
311mod tests {
312	use super::*;
313	use rstest::rstest;
314
315	// =========================================================================
316	// FileField Tests
317	// =========================================================================
318
319	// ---- Happy Path ----
320
321	#[rstest]
322	fn test_filefield_valid() {
323		// Arrange
324		let field = FileField::new("document".to_string());
325		let file = serde_json::json!({
326			"filename": "test.pdf",
327			"size": 1024
328		});
329
330		// Act
331		let result = field.clean(Some(&file));
332
333		// Assert
334		assert!(result.is_ok());
335	}
336
337	#[rstest]
338	fn test_filefield_default_max_size() {
339		// Arrange & Act
340		let field = FileField::new("document".to_string());
341
342		// Assert
343		assert_eq!(field.max_size, 10 * 1024 * 1024);
344	}
345
346	#[rstest]
347	fn test_filefield_custom_max_size() {
348		// Arrange & Act
349		let field = FileField::new("document".to_string()).with_max_size(5 * 1024 * 1024);
350
351		// Assert
352		assert_eq!(field.max_size, 5 * 1024 * 1024);
353	}
354
355	#[rstest]
356	fn test_filefield_within_size_limit() {
357		// Arrange
358		let field = FileField::new("document".to_string()).with_max_size(1024);
359		let file = serde_json::json!({
360			"filename": "test.pdf",
361			"size": 1024
362		});
363
364		// Act
365		let result = field.clean(Some(&file));
366
367		// Assert
368		assert!(result.is_ok());
369	}
370
371	// ---- Error Cases ----
372
373	#[rstest]
374	fn test_filefield_exceeds_size_limit() {
375		// Arrange
376		let field = FileField::new("document".to_string()).with_max_size(1024);
377		let file = serde_json::json!({
378			"filename": "test.pdf",
379			"size": 1025
380		});
381
382		// Act
383		let result = field.clean(Some(&file));
384
385		// Assert
386		assert!(
387			matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
388		);
389	}
390
391	#[rstest]
392	fn test_filefield_exceeds_default_size_limit() {
393		// Arrange
394		let field = FileField::new("document".to_string());
395		let over_10mb = 10 * 1024 * 1024 + 1;
396		let file = serde_json::json!({
397			"filename": "huge.bin",
398			"size": over_10mb
399		});
400
401		// Act
402		let result = field.clean(Some(&file));
403
404		// Assert
405		assert!(
406			matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
407		);
408	}
409
410	#[rstest]
411	fn test_filefield_empty() {
412		// Arrange
413		let field = FileField::new("document".to_string());
414		let file = serde_json::json!({
415			"filename": "test.pdf",
416			"size": 0
417		});
418
419		// Act & Assert
420		assert!(matches!(
421			field.clean(Some(&file)),
422			Err(FieldError::Validation(_))
423		));
424	}
425
426	#[rstest]
427	fn test_filefield_no_size_field_rejects_when_empty_not_allowed() {
428		// Arrange
429		let field = FileField::new("document".to_string());
430		let file = serde_json::json!({
431			"filename": "test.pdf"
432		});
433
434		// Act
435		let result = field.clean(Some(&file));
436
437		// Assert
438		assert!(matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("empty")));
439	}
440
441	// ---- Boundary Value Analysis ----
442
443	#[rstest]
444	#[case(1023, true)] // max_size - 1
445	#[case(1024, true)] // max_size (boundary)
446	#[case(1025, false)] // max_size + 1
447	fn test_filefield_size_boundary(#[case] size: u64, #[case] valid: bool) {
448		// Arrange
449		let field = FileField::new("document".to_string()).with_max_size(1024);
450		let file = serde_json::json!({
451			"filename": "test.pdf",
452			"size": size
453		});
454
455		// Act & Assert
456		assert_eq!(field.clean(Some(&file)).is_ok(), valid);
457	}
458
459	// ---- Decision Table ----
460
461	#[rstest]
462	#[case(1024, 512, true)] // max_size=1024, size=512 -> OK
463	#[case(1024, 1024, true)] // max_size=1024, size=1024 -> OK (at limit)
464	#[case(1024, 2048, false)] // max_size=1024, size=2048 -> Error
465	#[case(0, 1, false)] // max_size=0, size=1 -> Error (zero tolerance)
466	fn test_filefield_size_decision_table(
467		#[case] max_size: u64,
468		#[case] file_size: u64,
469		#[case] expected_ok: bool,
470	) {
471		// Arrange
472		let field = FileField::new("document".to_string()).with_max_size(max_size);
473		let file = serde_json::json!({
474			"filename": "test.pdf",
475			"size": file_size
476		});
477
478		// Act & Assert
479		assert_eq!(field.clean(Some(&file)).is_ok(), expected_ok);
480	}
481
482	// =========================================================================
483	// ImageField Tests
484	// =========================================================================
485
486	// ---- Happy Path ----
487
488	#[rstest]
489	fn test_imagefield_valid() {
490		// Arrange
491		let field = ImageField::new("photo".to_string());
492		let file = serde_json::json!({
493			"filename": "test.jpg",
494			"size": 1024
495		});
496
497		// Act & Assert
498		assert!(field.clean(Some(&file)).is_ok());
499	}
500
501	#[rstest]
502	fn test_imagefield_default_max_size() {
503		// Arrange & Act
504		let field = ImageField::new("photo".to_string());
505
506		// Assert
507		assert_eq!(field.max_size, 5 * 1024 * 1024);
508	}
509
510	#[rstest]
511	fn test_imagefield_custom_max_size() {
512		// Arrange & Act
513		let field = ImageField::new("photo".to_string()).with_max_size(2 * 1024 * 1024);
514
515		// Assert
516		assert_eq!(field.max_size, 2 * 1024 * 1024);
517	}
518
519	// ---- Error Cases ----
520
521	#[rstest]
522	fn test_imagefield_invalid_extension() {
523		// Arrange
524		let field = ImageField::new("photo".to_string());
525		let file = serde_json::json!({
526			"filename": "test.pdf",
527			"size": 1024
528		});
529
530		// Act & Assert
531		assert!(matches!(
532			field.clean(Some(&file)),
533			Err(FieldError::Validation(_))
534		));
535	}
536
537	#[rstest]
538	fn test_imagefield_rejects_svg_for_xss_prevention() {
539		// Arrange
540		let field = ImageField::new("photo".to_string());
541		// SVG files are rejected due to Stored XSS vulnerability risk
542		let svg_file = serde_json::json!({
543			"filename": "malicious.svg",
544			"size": 1024
545		});
546
547		// Act & Assert
548		assert!(
549			matches!(field.clean(Some(&svg_file)), Err(FieldError::Validation(_))),
550			"SVG files should be rejected to prevent Stored XSS attacks"
551		);
552	}
553
554	#[rstest]
555	fn test_imagefield_exceeds_size_limit() {
556		// Arrange
557		let field = ImageField::new("photo".to_string()).with_max_size(1024);
558		let file = serde_json::json!({
559			"filename": "large.jpg",
560			"size": 1025
561		});
562
563		// Act
564		let result = field.clean(Some(&file));
565
566		// Assert
567		assert!(
568			matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
569		);
570	}
571
572	#[rstest]
573	fn test_imagefield_exceeds_default_size_limit() {
574		// Arrange
575		let field = ImageField::new("photo".to_string());
576		let over_5mb = 5 * 1024 * 1024 + 1;
577		let file = serde_json::json!({
578			"filename": "huge.png",
579			"size": over_5mb
580		});
581
582		// Act
583		let result = field.clean(Some(&file));
584
585		// Assert
586		assert!(
587			matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
588		);
589	}
590
591	// ---- Boundary Value Analysis ----
592
593	#[rstest]
594	#[case(2047, true)] // max_size - 1
595	#[case(2048, true)] // max_size (boundary)
596	#[case(2049, false)] // max_size + 1
597	fn test_imagefield_size_boundary(#[case] size: u64, #[case] valid: bool) {
598		// Arrange
599		let field = ImageField::new("photo".to_string()).with_max_size(2048);
600		let file = serde_json::json!({
601			"filename": "photo.jpg",
602			"size": size
603		});
604
605		// Act & Assert
606		assert_eq!(field.clean(Some(&file)).is_ok(), valid);
607	}
608}