use bytes::Bytes;
use crate::files::{
config::{FileConfig, parse_size},
error::FileError,
traits::{FileValidator, ValidatedFile},
};
pub struct DefaultFileValidator;
impl FileValidator for DefaultFileValidator {
fn validate(
&self,
data: &Bytes,
declared_type: &str,
filename: &str,
config: &FileConfig,
) -> Result<ValidatedFile, FileError> {
validate_file(data, declared_type, filename, config)
}
}
pub fn validate_file(
data: &Bytes,
declared_type: &str,
filename: &str,
config: &FileConfig,
) -> Result<ValidatedFile, FileError> {
let max_size = parse_size(&config.max_size).unwrap_or(10 * 1024 * 1024);
if data.len() > max_size {
return Err(FileError::TooLarge {
size: data.len(),
max: max_size,
});
}
if !config.allowed_types.iter().any(|t| t == declared_type || t == "*/*") {
return Err(FileError::InvalidType {
got: declared_type.to_string(),
allowed: config.allowed_types.clone(),
});
}
let sanitized = sanitize_filename(filename)?;
let detected_type = if config.validate_magic_bytes {
let detected = detect_content_type(data);
validate_magic_bytes(&detected, declared_type)?;
Some(detected)
} else {
None
};
Ok(ValidatedFile {
content_type: declared_type.to_string(),
sanitized_filename: sanitized,
size: data.len(),
detected_type,
})
}
pub fn detect_content_type(data: &Bytes) -> String {
infer::get(data)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string())
}
fn validate_magic_bytes(detected: &str, declared: &str) -> Result<(), FileError> {
if !mime_types_compatible(detected, declared) {
return Err(FileError::MimeMismatch {
declared: declared.to_string(),
detected: detected.to_string(),
});
}
Ok(())
}
fn mime_types_compatible(detected: &str, declared: &str) -> bool {
if detected == declared {
return true;
}
let equivalents = [
("image/jpeg", "image/jpg"),
("text/plain", "application/octet-stream"),
];
for (a, b) in equivalents {
if (detected == a && declared == b) || (detected == b && declared == a) {
return true;
}
}
let detected_major = detected.split('/').next().unwrap_or("");
let declared_major = declared.split('/').next().unwrap_or("");
if detected_major == "image" && declared_major == "image" {
return true;
}
false
}
pub fn sanitize_filename(filename: &str) -> Result<String, FileError> {
let filename = filename.rsplit(['/', '\\']).next().unwrap_or(filename);
if filename.is_empty() || filename == "." || filename == ".." {
return Err(FileError::InvalidFilename {
reason: "Filename cannot be empty or path component".into(),
});
}
let filename = filename.replace('\0', "");
if filename.len() > 255 {
return Err(FileError::InvalidFilename {
reason: "Filename too long (max 255 characters)".into(),
});
}
let sanitized: String = filename
.chars()
.enumerate()
.map(|(i, c)| {
match c {
'a'..='z' | 'A'..='Z' | '0'..='9' => c,
'.' if i > 0 => c,
'-' | '_' => c,
_ => '_',
}
})
.collect();
if sanitized.is_empty() || sanitized.chars().all(|c| c == '_') {
return Err(FileError::InvalidFilename {
reason: "Filename contains no valid characters".into(),
});
}
Ok(sanitized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mime_compatibility() {
assert!(mime_types_compatible("image/jpeg", "image/jpeg"));
assert!(mime_types_compatible("image/jpeg", "image/jpg"));
assert!(mime_types_compatible("image/png", "image/webp")); assert!(!mime_types_compatible("image/jpeg", "application/pdf"));
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("photo.jpg").unwrap(), "photo.jpg");
assert_eq!(sanitize_filename("my-file_2024.pdf").unwrap(), "my-file_2024.pdf");
let result = sanitize_filename("../../../etc/passwd").unwrap();
assert!(!result.contains(".."));
assert_eq!(result, "passwd");
let result = sanitize_filename("file<>:\"|?*.jpg").unwrap();
assert!(!result.contains('<'));
assert!(!result.contains('>'));
assert!(!result.contains(':'));
}
#[test]
fn test_null_byte_removal() {
let result = sanitize_filename("image.jpg\0.exe").unwrap();
assert!(!result.contains('\0'));
}
}