use crate::field::{FieldError, FieldResult, FormField, Widget};
const DEFAULT_FILE_MAX_SIZE: u64 = 10 * 1024 * 1024;
const DEFAULT_IMAGE_MAX_SIZE: u64 = 5 * 1024 * 1024;
fn validate_filename_safety(filename: &str) -> FieldResult<()> {
if filename.contains('\0') {
return Err(FieldError::Validation(
"Filename contains null bytes".to_string(),
));
}
for component in filename.split(['/', '\\']) {
if component == ".." {
return Err(FieldError::Validation(
"Filename contains directory traversal sequence".to_string(),
));
}
}
if filename.starts_with('/') {
return Err(FieldError::Validation(
"Filename must not be an absolute path".to_string(),
));
}
if filename.starts_with("\\\\") {
return Err(FieldError::Validation(
"Filename must not be an absolute path".to_string(),
));
}
let bytes = filename.as_bytes();
if bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
{
return Err(FieldError::Validation(
"Filename must not be an absolute path".to_string(),
));
}
Ok(())
}
pub struct FileField {
pub name: String,
pub label: Option<String>,
pub required: bool,
pub help_text: Option<String>,
pub widget: Widget,
pub initial: Option<serde_json::Value>,
pub max_length: Option<usize>,
pub allow_empty_file: bool,
pub max_size: u64,
}
impl FileField {
pub fn new(name: String) -> Self {
Self {
name,
label: None,
required: true,
help_text: None,
widget: Widget::FileInput,
initial: None,
max_length: None,
allow_empty_file: false,
max_size: DEFAULT_FILE_MAX_SIZE,
}
}
pub fn with_max_size(mut self, max_size: u64) -> Self {
self.max_size = max_size;
self
}
}
impl FormField for FileField {
fn name(&self) -> &str {
&self.name
}
fn label(&self) -> Option<&str> {
self.label.as_deref()
}
fn required(&self) -> bool {
self.required
}
fn help_text(&self) -> Option<&str> {
self.help_text.as_deref()
}
fn widget(&self) -> &Widget {
&self.widget
}
fn initial(&self) -> Option<&serde_json::Value> {
self.initial.as_ref()
}
fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
match value {
None if self.required => Err(FieldError::required(None)),
None => Ok(serde_json::Value::Null),
Some(v) => {
let obj = v
.as_object()
.ok_or_else(|| FieldError::Invalid("Expected object".to_string()))?;
let filename = obj
.get("filename")
.and_then(|f| f.as_str())
.ok_or_else(|| FieldError::Invalid("Missing filename".to_string()))?;
if filename.is_empty() {
if self.required {
return Err(FieldError::required(None));
}
return Ok(serde_json::Value::Null);
}
validate_filename_safety(filename)?;
if let Some(max) = self.max_length
&& filename.len() > max
{
return Err(FieldError::Validation(format!(
"Filename is too long (max {} characters)",
max
)));
}
if let Some(size) = obj.get("size").and_then(|s| s.as_u64()) {
if size > self.max_size {
return Err(FieldError::Validation(format!(
"File size {} bytes exceeds maximum allowed size of {} bytes",
size, self.max_size
)));
}
if !self.allow_empty_file && size == 0 {
return Err(FieldError::Validation(
"The submitted file is empty".to_string(),
));
}
} else if !self.allow_empty_file {
return Err(FieldError::Validation(
"The submitted file is empty".to_string(),
));
}
Ok(v.clone())
}
}
}
}
pub struct ImageField {
pub name: String,
pub label: Option<String>,
pub required: bool,
pub help_text: Option<String>,
pub widget: Widget,
pub initial: Option<serde_json::Value>,
pub max_length: Option<usize>,
pub allow_empty_file: bool,
pub max_size: u64,
}
impl ImageField {
pub fn new(name: String) -> Self {
Self {
name,
label: None,
required: true,
help_text: None,
widget: Widget::FileInput,
initial: None,
max_length: None,
allow_empty_file: false,
max_size: DEFAULT_IMAGE_MAX_SIZE,
}
}
pub fn with_max_size(mut self, max_size: u64) -> Self {
self.max_size = max_size;
self
}
fn is_valid_image_extension(filename: &str) -> bool {
let valid_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp"];
filename
.rsplit('.')
.next()
.map(|ext| valid_extensions.contains(&ext.to_lowercase().as_str()))
.unwrap_or(false)
}
}
impl FormField for ImageField {
fn name(&self) -> &str {
&self.name
}
fn label(&self) -> Option<&str> {
self.label.as_deref()
}
fn required(&self) -> bool {
self.required
}
fn help_text(&self) -> Option<&str> {
self.help_text.as_deref()
}
fn widget(&self) -> &Widget {
&self.widget
}
fn initial(&self) -> Option<&serde_json::Value> {
self.initial.as_ref()
}
fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
match value {
None if self.required => Err(FieldError::required(None)),
None => Ok(serde_json::Value::Null),
Some(v) => {
let obj = v
.as_object()
.ok_or_else(|| FieldError::Invalid("Expected object".to_string()))?;
let filename = obj
.get("filename")
.and_then(|f| f.as_str())
.ok_or_else(|| FieldError::Invalid("Missing filename".to_string()))?;
if filename.is_empty() {
if self.required {
return Err(FieldError::required(None));
}
return Ok(serde_json::Value::Null);
}
validate_filename_safety(filename)?;
if !Self::is_valid_image_extension(filename) {
return Err(FieldError::Validation(
"Upload a valid image. The file you uploaded was either not an image or a corrupted image".to_string(),
));
}
if let Some(max) = self.max_length
&& filename.len() > max
{
return Err(FieldError::Validation(format!(
"Filename is too long (max {} characters)",
max
)));
}
if let Some(size) = obj.get("size").and_then(|s| s.as_u64()) {
if size > self.max_size {
return Err(FieldError::Validation(format!(
"File size {} bytes exceeds maximum allowed size of {} bytes",
size, self.max_size
)));
}
if !self.allow_empty_file && size == 0 {
return Err(FieldError::Validation(
"The submitted file is empty".to_string(),
));
}
} else if !self.allow_empty_file {
return Err(FieldError::Validation(
"The submitted file is empty".to_string(),
));
}
Ok(v.clone())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
fn test_filefield_valid() {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": "test.pdf",
"size": 1024
});
let result = field.clean(Some(&file));
assert!(result.is_ok());
}
#[rstest]
fn test_filefield_default_max_size() {
let field = FileField::new("document".to_string());
assert_eq!(field.max_size, 10 * 1024 * 1024);
}
#[rstest]
fn test_filefield_custom_max_size() {
let field = FileField::new("document".to_string()).with_max_size(5 * 1024 * 1024);
assert_eq!(field.max_size, 5 * 1024 * 1024);
}
#[rstest]
fn test_filefield_within_size_limit() {
let field = FileField::new("document".to_string()).with_max_size(1024);
let file = serde_json::json!({
"filename": "test.pdf",
"size": 1024
});
let result = field.clean(Some(&file));
assert!(result.is_ok());
}
#[rstest]
fn test_filefield_exceeds_size_limit() {
let field = FileField::new("document".to_string()).with_max_size(1024);
let file = serde_json::json!({
"filename": "test.pdf",
"size": 1025
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
);
}
#[rstest]
fn test_filefield_exceeds_default_size_limit() {
let field = FileField::new("document".to_string());
let over_10mb = 10 * 1024 * 1024 + 1;
let file = serde_json::json!({
"filename": "huge.bin",
"size": over_10mb
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
);
}
#[rstest]
fn test_filefield_empty() {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": "test.pdf",
"size": 0
});
assert!(matches!(
field.clean(Some(&file)),
Err(FieldError::Validation(_))
));
}
#[rstest]
fn test_filefield_no_size_field_rejects_when_empty_not_allowed() {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": "test.pdf"
});
let result = field.clean(Some(&file));
assert!(matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("empty")));
}
#[rstest]
#[case("../../etc/passwd", "directory traversal")]
#[case("../secret.txt", "directory traversal")]
#[case("foo/../../etc/shadow", "directory traversal")]
#[case("..\\windows\\system32", "directory traversal")]
#[case("..\\..\\boot.ini", "directory traversal")]
fn test_filefield_rejects_directory_traversal(
#[case] filename: &str,
#[case] expected_msg: &str,
) {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": filename,
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains(expected_msg)),
"Expected directory traversal rejection for filename: {filename}"
);
}
#[rstest]
fn test_filefield_rejects_null_bytes() {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": "file\0name.pdf",
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("null bytes")),
"Expected null bytes rejection"
);
}
#[rstest]
#[case("/etc/passwd")]
#[case("/var/log/syslog")]
fn test_filefield_rejects_unix_absolute_path(#[case] filename: &str) {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": filename,
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("absolute path")),
"Expected absolute path rejection for filename: {filename}"
);
}
#[rstest]
#[case("C:\\Windows\\system32\\config")]
#[case("D:/Documents/secret.txt")]
fn test_filefield_rejects_windows_absolute_path(#[case] filename: &str) {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": filename,
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("absolute path")),
"Expected absolute path rejection for filename: {filename}"
);
}
#[rstest]
#[case("document.pdf")]
#[case("my-file_v2.tar.gz")]
#[case("photo (1).jpg")]
fn test_filefield_accepts_safe_filenames(#[case] filename: &str) {
let field = FileField::new("document".to_string());
let file = serde_json::json!({
"filename": filename,
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
result.is_ok(),
"Expected safe filename to be accepted: {filename}"
);
}
#[rstest]
#[case(1023, true)] #[case(1024, true)] #[case(1025, false)] fn test_filefield_size_boundary(#[case] size: u64, #[case] valid: bool) {
let field = FileField::new("document".to_string()).with_max_size(1024);
let file = serde_json::json!({
"filename": "test.pdf",
"size": size
});
assert_eq!(field.clean(Some(&file)).is_ok(), valid);
}
#[rstest]
#[case(1024, 512, true)] #[case(1024, 1024, true)] #[case(1024, 2048, false)] #[case(0, 1, false)] fn test_filefield_size_decision_table(
#[case] max_size: u64,
#[case] file_size: u64,
#[case] expected_ok: bool,
) {
let field = FileField::new("document".to_string()).with_max_size(max_size);
let file = serde_json::json!({
"filename": "test.pdf",
"size": file_size
});
assert_eq!(field.clean(Some(&file)).is_ok(), expected_ok);
}
#[rstest]
fn test_imagefield_valid() {
let field = ImageField::new("photo".to_string());
let file = serde_json::json!({
"filename": "test.jpg",
"size": 1024
});
assert!(field.clean(Some(&file)).is_ok());
}
#[rstest]
fn test_imagefield_default_max_size() {
let field = ImageField::new("photo".to_string());
assert_eq!(field.max_size, 5 * 1024 * 1024);
}
#[rstest]
fn test_imagefield_custom_max_size() {
let field = ImageField::new("photo".to_string()).with_max_size(2 * 1024 * 1024);
assert_eq!(field.max_size, 2 * 1024 * 1024);
}
#[rstest]
fn test_imagefield_invalid_extension() {
let field = ImageField::new("photo".to_string());
let file = serde_json::json!({
"filename": "test.pdf",
"size": 1024
});
assert!(matches!(
field.clean(Some(&file)),
Err(FieldError::Validation(_))
));
}
#[rstest]
fn test_imagefield_rejects_svg_for_xss_prevention() {
let field = ImageField::new("photo".to_string());
let svg_file = serde_json::json!({
"filename": "malicious.svg",
"size": 1024
});
assert!(
matches!(field.clean(Some(&svg_file)), Err(FieldError::Validation(_))),
"SVG files should be rejected to prevent Stored XSS attacks"
);
}
#[rstest]
fn test_imagefield_exceeds_size_limit() {
let field = ImageField::new("photo".to_string()).with_max_size(1024);
let file = serde_json::json!({
"filename": "large.jpg",
"size": 1025
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
);
}
#[rstest]
fn test_imagefield_exceeds_default_size_limit() {
let field = ImageField::new("photo".to_string());
let over_5mb = 5 * 1024 * 1024 + 1;
let file = serde_json::json!({
"filename": "huge.png",
"size": over_5mb
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("exceeds maximum"))
);
}
#[rstest]
fn test_imagefield_rejects_directory_traversal() {
let field = ImageField::new("photo".to_string());
let file = serde_json::json!({
"filename": "../../etc/passwd.jpg",
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("directory traversal")),
"Expected directory traversal rejection for ImageField"
);
}
#[rstest]
fn test_imagefield_rejects_null_bytes() {
let field = ImageField::new("photo".to_string());
let file = serde_json::json!({
"filename": "photo\0.jpg",
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("null bytes")),
"Expected null bytes rejection for ImageField"
);
}
#[rstest]
fn test_imagefield_rejects_absolute_path() {
let field = ImageField::new("photo".to_string());
let file = serde_json::json!({
"filename": "/etc/photo.jpg",
"size": 1024
});
let result = field.clean(Some(&file));
assert!(
matches!(result, Err(FieldError::Validation(ref msg)) if msg.contains("absolute path")),
"Expected absolute path rejection for ImageField"
);
}
#[rstest]
#[case(2047, true)] #[case(2048, true)] #[case(2049, false)] fn test_imagefield_size_boundary(#[case] size: u64, #[case] valid: bool) {
let field = ImageField::new("photo".to_string()).with_max_size(2048);
let file = serde_json::json!({
"filename": "photo.jpg",
"size": size
});
assert_eq!(field.clean(Some(&file)).is_ok(), valid);
}
}