use super::{File, UploadedFile};
#[derive(Debug, thiserror::Error)]
pub enum FileValidateError {
#[error("{msg}")]
SizeExceeded {
actual: u64,
max: u64,
msg: String,
},
#[error("{msg}")]
ExtNotAllowed {
ext: String,
allowed: Vec<String>,
msg: String,
},
#[error("{msg}")]
MimeNotAllowed {
mime: String,
allowed: Vec<String>,
msg: String,
},
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("获取 MIME 失败: {0}")]
MimeDetect(String),
}
#[derive(Debug, Clone, Default)]
pub struct FileValidateRule {
pub file_size: Option<u64>,
pub file_ext: Option<Vec<String>>,
pub file_mime: Option<Vec<String>>,
}
impl FileValidateRule {
pub fn new() -> Self {
Self::default()
}
pub fn with_size(mut self, size: u64) -> Self {
self.file_size = Some(size);
self
}
pub fn with_ext(mut self, ext: &str) -> Self {
self.file_ext = Some(parse_ext_list(ext));
self
}
pub fn with_ext_vec(mut self, ext: Vec<String>) -> Self {
self.file_ext = Some(ext.into_iter().map(|e| e.to_lowercase()).collect());
self
}
pub fn with_mime(mut self, mime: &str) -> Self {
self.file_mime = Some(parse_mime_list(mime));
self
}
pub fn with_mime_vec(mut self, mime: Vec<String>) -> Self {
self.file_mime = Some(mime.into_iter().map(|m| m.to_lowercase()).collect());
self
}
pub fn default_image() -> Self {
Self::new()
.with_size(20 * 1024 * 1024)
.with_ext("jpg,jpeg,png,gif,bmp")
.with_mime("image/jpeg,image/png,image/gif,image/bmp")
}
}
#[derive(Debug, Clone)]
pub struct FileValidateMessages {
pub file_size: String,
pub file_ext: String,
pub file_mime: String,
}
impl Default for FileValidateMessages {
fn default() -> Self {
Self {
file_size: "上传文件大小不符!".to_string(),
file_ext: "上传文件后缀不允许".to_string(),
file_mime: "上传文件MIME类型不允许!".to_string(),
}
}
}
impl FileValidateMessages {
pub fn default_image() -> Self {
Self {
file_size: "最大可上传2M图片".to_string(),
file_ext: "只能上传jpg,jpeg,png,gif,bmp格式图片".to_string(),
file_mime: "只能上传jpg,jpeg,png,gif,bmp格式图片".to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct FileValidator {
rule: FileValidateRule,
messages: FileValidateMessages,
}
impl Default for FileValidator {
fn default() -> Self {
Self::new()
}
}
impl FileValidator {
pub fn new() -> Self {
Self {
rule: FileValidateRule::default_image(),
messages: FileValidateMessages::default_image(),
}
}
pub fn with(rule: FileValidateRule, messages: FileValidateMessages) -> Self {
Self { rule, messages }
}
pub fn rule(&self) -> &FileValidateRule {
&self.rule
}
pub fn messages(&self) -> &FileValidateMessages {
&self.messages
}
pub fn check_ext(file: &UploadedFile, allowed: &[String]) -> bool {
let ext = file.extension().to_lowercase();
allowed.contains(&ext)
}
pub fn check_mime(file: &File, allowed: &[String]) -> Result<bool, FileValidateError> {
let mime = file
.get_mime()
.map_err(|e| FileValidateError::MimeDetect(e.to_string()))?;
Ok(allowed.contains(&mime.to_lowercase()))
}
pub fn check_size(file: &File, max: u64) -> Result<bool, FileValidateError> {
let actual = file.path().metadata()?.len();
Ok(actual <= max)
}
pub fn validate_image(&self, file: &UploadedFile) -> Result<(), FileValidateError> {
if let Some(ref allowed_ext) = self.rule.file_ext {
if !Self::check_ext(file, allowed_ext) {
return Err(FileValidateError::ExtNotAllowed {
ext: file.extension().to_lowercase(),
allowed: allowed_ext.clone(),
msg: self.messages.file_ext.clone(),
});
}
}
if let Some(ref allowed_mime) = self.rule.file_mime {
if !Self::check_mime(file.as_file(), allowed_mime)? {
return Err(FileValidateError::MimeNotAllowed {
mime: file.as_file().get_mime().unwrap_or_default().to_lowercase(),
allowed: allowed_mime.clone(),
msg: self.messages.file_mime.clone(),
});
}
}
if let Some(max_size) = self.rule.file_size {
let actual = file.as_file().path().metadata()?.len();
if actual > max_size {
return Err(FileValidateError::SizeExceeded {
actual,
max: max_size,
msg: self.messages.file_size.clone(),
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
Image,
Video,
File,
}
impl FileType {
pub fn as_str(self) -> &'static str {
match self {
FileType::Image => "image",
FileType::Video => "video",
FileType::File => "file",
}
}
}
const IMAGE_EXTS: &[&str] = &[
"jpg", "png", "jpeg", "bmp", "gif", "icon", "svg", "tif", "webp", "tiff", "avif", "pjp",
];
const VIDEO_EXTS: &[&str] = &[
"mp4", "m3u8", "mp3", "wmv", "mpg", "webm", "mov", "avi", "m4v", "mpeg", "ogv", "asx", "ogm",
];
pub fn detect_file_type(ext: &str) -> FileType {
let ext_lower = ext.to_lowercase();
if IMAGE_EXTS.contains(&ext_lower.as_str()) {
FileType::Image
} else if VIDEO_EXTS.contains(&ext_lower.as_str()) {
FileType::Video
} else {
FileType::File
}
}
pub fn parse_ext_list(s: &str) -> Vec<String> {
s.split(',')
.map(|p| p.trim().to_lowercase())
.filter(|p| !p.is_empty())
.collect()
}
pub fn parse_mime_list(s: &str) -> Vec<String> {
s.split(',')
.map(|p| p.trim().to_lowercase())
.filter(|p| !p.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::upload::UploadedFile;
use std::io::Write;
fn create_temp_file(content: &[u8], suffix: &str) -> tempfile::NamedTempFile {
let mut temp = tempfile::Builder::new()
.suffix(suffix)
.tempfile()
.expect("创建临时文件失败");
temp.write_all(content).expect("写入临时文件失败");
temp.flush().expect("flush 失败");
temp
}
#[test]
fn test_rule_new() {
let rule = FileValidateRule::new();
assert!(rule.file_size.is_none());
assert!(rule.file_ext.is_none());
assert!(rule.file_mime.is_none());
}
#[test]
fn test_rule_with_size() {
let rule = FileValidateRule::new().with_size(1024);
assert_eq!(rule.file_size, Some(1024));
}
#[test]
fn test_rule_with_ext() {
let rule = FileValidateRule::new().with_ext("jpg,png,GIF");
assert_eq!(
rule.file_ext,
Some(vec![
"jpg".to_string(),
"png".to_string(),
"gif".to_string(),
])
);
}
#[test]
fn test_rule_with_mime() {
let rule = FileValidateRule::new().with_mime("image/jpeg,image/png");
assert_eq!(
rule.file_mime,
Some(vec!["image/jpeg".to_string(), "image/png".to_string(),])
);
}
#[test]
fn test_rule_default_image() {
let rule = FileValidateRule::default_image();
assert_eq!(rule.file_size, Some(20 * 1024 * 1024));
assert_eq!(
rule.file_ext,
Some(vec![
"jpg".to_string(),
"jpeg".to_string(),
"png".to_string(),
"gif".to_string(),
"bmp".to_string(),
])
);
assert_eq!(
rule.file_mime,
Some(vec![
"image/jpeg".to_string(),
"image/png".to_string(),
"image/gif".to_string(),
"image/bmp".to_string(),
])
);
}
#[test]
fn test_rule_with_ext_vec() {
let rule = FileValidateRule::new().with_ext_vec(vec!["JPG".to_string(), "PNG".to_string()]);
assert_eq!(
rule.file_ext,
Some(vec!["jpg".to_string(), "png".to_string(),])
);
}
#[test]
fn test_rule_with_mime_vec() {
let rule = FileValidateRule::new()
.with_mime_vec(vec!["IMAGE/JPEG".to_string(), "IMAGE/PNG".to_string()]);
assert_eq!(
rule.file_mime,
Some(vec!["image/jpeg".to_string(), "image/png".to_string(),])
);
}
#[test]
fn test_messages_default() {
let msgs = FileValidateMessages::default();
assert_eq!(msgs.file_size, "上传文件大小不符!");
assert_eq!(msgs.file_ext, "上传文件后缀不允许");
assert_eq!(msgs.file_mime, "上传文件MIME类型不允许!");
}
#[test]
fn test_messages_default_image() {
let msgs = FileValidateMessages::default_image();
assert_eq!(msgs.file_size, "最大可上传2M图片");
assert_eq!(msgs.file_ext, "只能上传jpg,jpeg,png,gif,bmp格式图片");
assert_eq!(msgs.file_mime, "只能上传jpg,jpeg,png,gif,bmp格式图片");
}
#[test]
fn test_messages_custom() {
let msgs = FileValidateMessages {
file_size: "文件太大".to_string(),
file_ext: "格式不对".to_string(),
file_mime: "MIME不对".to_string(),
};
assert_eq!(msgs.file_size, "文件太大");
assert_eq!(msgs.file_ext, "格式不对");
assert_eq!(msgs.file_mime, "MIME不对");
}
#[test]
fn test_validator_new() {
let v = FileValidator::new();
assert_eq!(v.rule().file_size, Some(20 * 1024 * 1024));
assert_eq!(v.messages().file_size, "最大可上传2M图片");
}
#[test]
fn test_validator_with_custom() {
let rule = FileValidateRule::new().with_ext("pdf,doc");
let msgs = FileValidateMessages::default();
let v = FileValidator::with(rule, msgs);
assert_eq!(
v.rule().file_ext,
Some(vec!["pdf".to_string(), "doc".to_string()])
);
assert_eq!(v.messages().file_ext, "上传文件后缀不允许");
}
#[test]
fn test_check_ext_pass() {
let temp = create_temp_file(b"hello", ".jpg");
let file = UploadedFile::new(temp.path(), "photo.JPG", None, Some(0), true).unwrap();
let allowed = parse_ext_list("jpg,jpeg,png,gif,bmp");
assert!(FileValidator::check_ext(&file, &allowed));
}
#[test]
fn test_check_ext_fail() {
let temp = create_temp_file(b"hello", ".txt");
let file = UploadedFile::new(temp.path(), "doc.txt", None, Some(0), true).unwrap();
let allowed = parse_ext_list("jpg,jpeg,png,gif,bmp");
assert!(!FileValidator::check_ext(&file, &allowed));
}
#[test]
fn test_check_ext_case_insensitive() {
let temp = create_temp_file(b"hello", ".jpg");
let file = UploadedFile::new(temp.path(), "photo.JPEG", None, Some(0), true).unwrap();
let allowed = parse_ext_list("jpg,jpeg");
assert!(FileValidator::check_ext(&file, &allowed));
}
#[test]
fn test_check_size_pass() {
let temp = create_temp_file(b"hello", ".txt");
let file = File::new(temp.path(), false).unwrap();
assert!(FileValidator::check_size(&file, 100).unwrap());
}
#[test]
fn test_check_size_equal() {
let temp = create_temp_file(b"hello", ".txt");
let file = File::new(temp.path(), false).unwrap();
assert!(FileValidator::check_size(&file, 5).unwrap());
}
#[test]
fn test_check_size_fail() {
let temp = create_temp_file(b"hello world", ".txt");
let file = File::new(temp.path(), false).unwrap();
assert!(!FileValidator::check_size(&file, 5).unwrap());
}
#[test]
fn test_validate_image_ext_pass() {
let temp = create_temp_file(b"\x89PNG\r\n\x1a\n", ".png");
let file = UploadedFile::new(temp.path(), "photo.png", None, Some(0), true).unwrap();
let v = FileValidator::new();
let result = v.validate_image(&file);
match result {
Ok(()) => {}
Err(FileValidateError::ExtNotAllowed { .. }) => panic!("扩展名应通过"),
Err(_) => {}
}
}
#[test]
fn test_validate_image_ext_fail() {
let temp = create_temp_file(b"hello", ".txt");
let file = UploadedFile::new(temp.path(), "doc.txt", None, Some(0), true).unwrap();
let v = FileValidator::new();
let result = v.validate_image(&file);
assert!(matches!(
result,
Err(FileValidateError::ExtNotAllowed { .. })
));
}
#[test]
fn test_validate_image_size_fail() {
let temp = create_temp_file(b"hello world, this is a long file", ".jpg");
let file = UploadedFile::new(temp.path(), "photo.jpg", None, Some(0), true).unwrap();
let rule = FileValidateRule::new().with_size(5); let v = FileValidator::with(rule, FileValidateMessages::default());
let result = v.validate_image(&file);
assert!(matches!(
result,
Err(FileValidateError::SizeExceeded { .. })
));
}
#[test]
fn test_validate_image_all_pass() {
let png_header = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01";
let temp = create_temp_file(png_header, ".png");
let file = UploadedFile::new(temp.path(), "photo.png", None, Some(0), true).unwrap();
let rule = FileValidateRule::new()
.with_ext("png")
.with_mime("image/png")
.with_size(1024);
let v = FileValidator::with(rule, FileValidateMessages::default());
let result = v.validate_image(&file);
assert!(result.is_ok(), "校验应通过: {:?}", result);
}
#[test]
fn test_validate_image_no_rule_passes() {
let temp = create_temp_file(b"hello", ".txt");
let file = UploadedFile::new(temp.path(), "doc.txt", None, Some(0), true).unwrap();
let v = FileValidator::with(FileValidateRule::new(), FileValidateMessages::default());
let result = v.validate_image(&file);
assert!(result.is_ok());
}
#[test]
fn test_validate_image_error_messages() {
let temp = create_temp_file(b"hello", ".txt");
let file = UploadedFile::new(temp.path(), "doc.txt", None, Some(0), true).unwrap();
let v = FileValidator::new(); let result = v.validate_image(&file);
match result {
Err(FileValidateError::ExtNotAllowed { msg, .. }) => {
assert_eq!(msg, "只能上传jpg,jpeg,png,gif,bmp格式图片");
}
_ => panic!("应返回 ExtNotAllowed"),
}
}
#[test]
fn test_detect_file_type_image() {
assert_eq!(detect_file_type("jpg"), FileType::Image);
assert_eq!(detect_file_type("png"), FileType::Image);
assert_eq!(detect_file_type("jpeg"), FileType::Image);
assert_eq!(detect_file_type("bmp"), FileType::Image);
assert_eq!(detect_file_type("gif"), FileType::Image);
assert_eq!(detect_file_type("icon"), FileType::Image);
assert_eq!(detect_file_type("svg"), FileType::Image);
assert_eq!(detect_file_type("tif"), FileType::Image);
assert_eq!(detect_file_type("webp"), FileType::Image);
assert_eq!(detect_file_type("tiff"), FileType::Image);
assert_eq!(detect_file_type("avif"), FileType::Image);
assert_eq!(detect_file_type("pjp"), FileType::Image);
}
#[test]
fn test_detect_file_type_video() {
assert_eq!(detect_file_type("mp4"), FileType::Video);
assert_eq!(detect_file_type("m3u8"), FileType::Video);
assert_eq!(detect_file_type("mp3"), FileType::Video);
assert_eq!(detect_file_type("wmv"), FileType::Video);
assert_eq!(detect_file_type("mpg"), FileType::Video);
assert_eq!(detect_file_type("webm"), FileType::Video);
assert_eq!(detect_file_type("mov"), FileType::Video);
assert_eq!(detect_file_type("avi"), FileType::Video);
assert_eq!(detect_file_type("m4v"), FileType::Video);
assert_eq!(detect_file_type("mpeg"), FileType::Video);
assert_eq!(detect_file_type("ogv"), FileType::Video);
assert_eq!(detect_file_type("asx"), FileType::Video);
assert_eq!(detect_file_type("ogm"), FileType::Video);
}
#[test]
fn test_detect_file_type_file() {
assert_eq!(detect_file_type("pdf"), FileType::File);
assert_eq!(detect_file_type("doc"), FileType::File);
assert_eq!(detect_file_type("xls"), FileType::File);
assert_eq!(detect_file_type("zip"), FileType::File);
assert_eq!(detect_file_type("exe"), FileType::File);
assert_eq!(detect_file_type("php"), FileType::File);
}
#[test]
fn test_detect_file_type_case_insensitive() {
assert_eq!(detect_file_type("JPG"), FileType::Image);
assert_eq!(detect_file_type("MP4"), FileType::Video);
assert_eq!(detect_file_type("PDF"), FileType::File);
}
#[test]
fn test_file_type_as_str() {
assert_eq!(FileType::Image.as_str(), "image");
assert_eq!(FileType::Video.as_str(), "video");
assert_eq!(FileType::File.as_str(), "file");
}
#[test]
fn test_parse_ext_list_basic() {
let list = parse_ext_list("jpg,jpeg,png,gif,bmp");
assert_eq!(list, vec!["jpg", "jpeg", "png", "gif", "bmp"]);
}
#[test]
fn test_parse_ext_list_lowercase() {
let list = parse_ext_list("JPG,JPEG,PNG");
assert_eq!(list, vec!["jpg", "jpeg", "png"]);
}
#[test]
fn test_parse_ext_list_trim() {
let list = parse_ext_list("jpg, jpeg , png");
assert_eq!(list, vec!["jpg", "jpeg", "png"]);
}
#[test]
fn test_parse_ext_list_empty() {
let list = parse_ext_list("");
assert!(list.is_empty());
}
#[test]
fn test_parse_mime_list_basic() {
let list = parse_mime_list("image/jpeg,image/png,image/gif,image/bmp");
assert_eq!(
list,
vec!["image/jpeg", "image/png", "image/gif", "image/bmp"]
);
}
#[test]
fn test_parse_mime_list_lowercase() {
let list = parse_mime_list("IMAGE/JPEG,IMAGE/PNG");
assert_eq!(list, vec!["image/jpeg", "image/png"]);
}
#[test]
fn test_php_behavior_default_image_rule() {
let rule = FileValidateRule::default_image();
assert_eq!(rule.file_size, Some(20971520));
assert_eq!(
rule.file_ext,
Some(
["jpg", "jpeg", "png", "gif", "bmp"]
.iter()
.map(|&s| s.to_string())
.collect::<Vec<_>>()
)
);
assert_eq!(
rule.file_mime,
Some(
["image/jpeg", "image/png", "image/gif", "image/bmp"]
.iter()
.map(|&s| s.to_string())
.collect::<Vec<_>>()
)
);
}
#[test]
fn test_php_behavior_default_image_messages() {
let msgs = FileValidateMessages::default_image();
assert_eq!(msgs.file_size, "最大可上传2M图片");
assert_eq!(msgs.file_ext, "只能上传jpg,jpeg,png,gif,bmp格式图片");
assert_eq!(msgs.file_mime, "只能上传jpg,jpeg,png,gif,bmp格式图片");
}
#[test]
fn test_php_behavior_check_ext_lowercase() {
let temp = create_temp_file(b"hello", ".jpg");
let file = UploadedFile::new(temp.path(), "PHOTO.JPG", None, Some(0), true).unwrap();
let allowed = parse_ext_list("jpg,jpeg,png,gif,bmp");
assert!(FileValidator::check_ext(&file, &allowed));
}
#[test]
fn test_php_behavior_check_mime_lowercase() {
let png_header = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01";
let temp = create_temp_file(png_header, ".png");
let file = File::new(temp.path(), false).unwrap();
let allowed = parse_mime_list("image/png,image/jpeg");
assert!(FileValidator::check_mime(&file, &allowed).unwrap());
}
#[test]
fn test_php_behavior_check_size_leq() {
let temp = create_temp_file(b"hello", ".txt"); let file = File::new(temp.path(), false).unwrap();
assert!(FileValidator::check_size(&file, 5).unwrap());
assert!(FileValidator::check_size(&file, 10).unwrap());
assert!(!FileValidator::check_size(&file, 4).unwrap());
}
#[test]
fn test_php_behavior_file_type_classification() {
let image_count = [
"jpg", "png", "jpeg", "bmp", "gif", "icon", "svg", "tif", "webp", "tiff", "avif", "pjp",
]
.iter()
.filter(|&&e| detect_file_type(e) == FileType::Image)
.count();
assert_eq!(image_count, 12);
let video_count = [
"mp4", "m3u8", "mp3", "wmv", "mpg", "webm", "mov", "avi", "m4v", "mpeg", "ogv", "asx",
"ogm",
]
.iter()
.filter(|&&e| detect_file_type(e) == FileType::Video)
.count();
assert_eq!(video_count, 13);
assert_eq!(detect_file_type("pdf"), FileType::File);
assert_eq!(detect_file_type("xyz"), FileType::File);
assert_eq!(detect_file_type(""), FileType::File);
}
#[test]
fn test_php_behavior_validate_image_only() {
let temp = create_temp_file(b"hello", ".txt");
let file = UploadedFile::new(temp.path(), "doc.txt", None, Some(0), true).unwrap();
let v = FileValidator::new();
let result = v.validate_image(&file);
assert!(matches!(
result,
Err(FileValidateError::ExtNotAllowed { .. })
));
}
#[test]
fn test_validate_order_ext_first() {
let temp = create_temp_file(b"hello", ".txt");
let file = UploadedFile::new(temp.path(), "doc.txt", None, Some(0), true).unwrap();
let rule = FileValidateRule::new()
.with_ext("jpg")
.with_mime("image/jpeg")
.with_size(1); let v = FileValidator::with(rule, FileValidateMessages::default());
let result = v.validate_image(&file);
assert!(matches!(
result,
Err(FileValidateError::ExtNotAllowed { .. })
));
}
#[test]
fn test_validate_order_mime_before_size() {
let png_header = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01";
let temp = create_temp_file(png_header, ".jpg");
let file = UploadedFile::new(temp.path(), "photo.jpg", None, Some(0), true).unwrap();
let rule = FileValidateRule::new()
.with_ext("jpg")
.with_mime("image/jpeg") .with_size(1); let v = FileValidator::with(rule, FileValidateMessages::default());
let result = v.validate_image(&file);
assert!(matches!(
result,
Err(FileValidateError::MimeNotAllowed { .. })
));
}
}