use std::io::{Read, BufReader};
use std::path::Path;
use std::fs::File as StdFile;
use std::fmt;
use bytes::Bytes;
use mime::Mime;
use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose};
#[derive(Debug, Clone)]
pub struct File {
pub name: String,
pub mime_type: Mime,
pub data: FileData,
pub size: u64,
pub hash: Option<String>,
}
#[derive(Debug, Clone)]
pub enum FileData {
Bytes(Bytes),
Base64(String),
Path(std::path::PathBuf),
TempFile(std::path::PathBuf),
}
#[derive(Debug, Clone)]
pub struct FileConstraints {
pub max_size: u64,
pub allowed_types: Option<Vec<Mime>>,
pub require_hash: bool,
}
impl Default for FileConstraints {
fn default() -> Self {
Self {
max_size: 10 * 1024 * 1024, allowed_types: None,
require_hash: false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum FileError {
#[error("File not found: {path}")]
NotFound { path: String },
#[error("File too large: {size} bytes (max: {max_size} bytes)")]
TooLarge { size: u64, max_size: u64 },
#[error("Invalid MIME type: {mime_type} (allowed: {allowed:?})")]
InvalidMimeType { mime_type: String, allowed: Vec<String> },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid base64 data: {0}")]
InvalidBase64(#[from] base64::DecodeError),
#[error("MIME detection failed")]
MimeDetectionFailed,
#[error("Hash verification failed")]
HashVerificationFailed,
#[error("Invalid file data")]
InvalidData,
}
impl File {
pub fn from_bytes(
name: impl Into<String>,
bytes: impl Into<Bytes>,
mime_type: Option<Mime>,
) -> Result<Self, FileError> {
let name = name.into();
let bytes = bytes.into();
let size = bytes.len() as u64;
let mime_type = match mime_type {
Some(mime) => mime,
None => detect_mime_type(&name, Some(&bytes))?,
};
Ok(Self {
name,
mime_type,
data: FileData::Bytes(bytes),
size,
hash: None,
})
}
pub fn from_path(path: impl AsRef<Path>) -> Result<Self, FileError> {
let path = path.as_ref();
if !path.exists() {
return Err(FileError::NotFound {
path: path.display().to_string(),
});
}
let metadata = std::fs::metadata(path)?;
let size = metadata.len();
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let mime_type = detect_mime_type(&name, None)?;
Ok(Self {
name,
mime_type,
data: FileData::Path(path.to_path_buf()),
size,
hash: None,
})
}
pub fn from_base64(
name: impl Into<String>,
base64_data: impl Into<String>,
mime_type: Option<Mime>,
) -> Result<Self, FileError> {
let name = name.into();
let base64_data = base64_data.into();
let decoded = general_purpose::STANDARD.decode(&base64_data)?;
let size = decoded.len() as u64;
let mime_type = match mime_type {
Some(mime) => mime,
None => detect_mime_type(&name, Some(&decoded))?,
};
Ok(Self {
name,
mime_type,
data: FileData::Base64(base64_data),
size,
hash: None,
})
}
pub fn from_std_file(
std_file: StdFile,
name: impl Into<String>,
mime_type: Option<Mime>,
) -> Result<Self, FileError> {
let name = name.into();
let metadata = std_file.metadata()?;
let size = metadata.len();
let mut reader = BufReader::new(std_file);
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer)?;
let mime_type = match mime_type {
Some(mime) => mime,
None => detect_mime_type(&name, Some(&buffer))?,
};
Ok(Self {
name,
mime_type,
data: FileData::Bytes(Bytes::from(buffer)),
size,
hash: None,
})
}
pub fn validate(&self, constraints: &FileConstraints) -> Result<(), FileError> {
if self.size > constraints.max_size {
return Err(FileError::TooLarge {
size: self.size,
max_size: constraints.max_size,
});
}
if let Some(allowed_types) = &constraints.allowed_types {
if !allowed_types.iter().any(|mime| mime == &self.mime_type) {
return Err(FileError::InvalidMimeType {
mime_type: self.mime_type.to_string(),
allowed: allowed_types.iter().map(|m| m.to_string()).collect(),
});
}
}
Ok(())
}
pub async fn to_bytes(&self) -> Result<Bytes, FileError> {
match &self.data {
FileData::Bytes(bytes) => Ok(bytes.clone()),
FileData::Base64(base64_data) => {
let decoded = general_purpose::STANDARD.decode(base64_data)?;
Ok(Bytes::from(decoded))
},
FileData::Path(path) => {
let bytes = tokio::fs::read(path).await?;
Ok(Bytes::from(bytes))
},
FileData::TempFile(path) => {
let bytes = tokio::fs::read(path).await?;
Ok(Bytes::from(bytes))
},
}
}
pub async fn to_base64(&self) -> Result<String, FileError> {
match &self.data {
FileData::Base64(base64_data) => Ok(base64_data.clone()),
_ => {
let bytes = self.to_bytes().await?;
Ok(general_purpose::STANDARD.encode(&bytes))
}
}
}
pub async fn calculate_hash(&mut self) -> Result<String, FileError> {
let bytes = self.to_bytes().await?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hash = format!("{:x}", hasher.finalize());
self.hash = Some(hash.clone());
Ok(hash)
}
pub async fn verify_hash(&self, expected_hash: &str) -> Result<bool, FileError> {
let bytes = self.to_bytes().await?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual_hash = format!("{:x}", hasher.finalize());
Ok(actual_hash == expected_hash)
}
pub fn is_image(&self) -> bool {
self.mime_type.type_() == mime::IMAGE
}
pub fn is_text(&self) -> bool {
self.mime_type.type_() == mime::TEXT
}
pub fn is_application(&self) -> bool {
self.mime_type.type_() == mime::APPLICATION
}
}
pub async fn to_file(
source: FileSource,
name: Option<String>,
mime_type: Option<Mime>,
) -> Result<File, FileError> {
match source {
FileSource::Bytes(bytes) => {
let name = name.unwrap_or_else(|| "file".to_string());
File::from_bytes(name, bytes, mime_type)
},
FileSource::Base64(base64_data) => {
let name = name.unwrap_or_else(|| "file".to_string());
File::from_base64(name, base64_data, mime_type)
},
FileSource::Path(path) => File::from_path(path),
FileSource::StdFile(std_file, file_name) => {
let name = name.or(file_name).unwrap_or_else(|| "file".to_string());
File::from_std_file(std_file, name, mime_type)
},
}
}
pub enum FileSource {
Bytes(Bytes),
Base64(String),
Path(std::path::PathBuf),
StdFile(StdFile, Option<String>),
}
fn detect_mime_type(filename: &str, data: Option<&[u8]>) -> Result<Mime, FileError> {
if let Some(extension) = Path::new(filename).extension() {
if let Some(ext_str) = extension.to_str() {
let mime_type = match ext_str.to_lowercase().as_str() {
"jpg" | "jpeg" => mime::IMAGE_JPEG,
"png" => mime::IMAGE_PNG,
"gif" => mime::IMAGE_GIF,
"webp" => "image/webp".parse().unwrap(),
"svg" => mime::IMAGE_SVG,
"bmp" => "image/bmp".parse().unwrap(),
"pdf" => "application/pdf".parse().unwrap(),
"doc" => "application/msword".parse().unwrap(),
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document".parse().unwrap(),
"txt" => mime::TEXT_PLAIN,
"md" => "text/markdown".parse().unwrap(),
"rtf" => "application/rtf".parse().unwrap(),
"xls" => "application/vnd.ms-excel".parse().unwrap(),
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".parse().unwrap(),
"ppt" => "application/vnd.ms-powerpoint".parse().unwrap(),
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation".parse().unwrap(),
"mp3" => "audio/mpeg".parse().unwrap(),
"wav" => "audio/wav".parse().unwrap(),
"ogg" => "audio/ogg".parse().unwrap(),
"mp4" => "video/mp4".parse().unwrap(),
"avi" => "video/x-msvideo".parse().unwrap(),
"mov" => "video/quicktime".parse().unwrap(),
"zip" => "application/zip".parse().unwrap(),
"tar" => "application/x-tar".parse().unwrap(),
"gz" => "application/gzip".parse().unwrap(),
"json" => mime::APPLICATION_JSON,
"xml" => mime::TEXT_XML,
_ => mime::APPLICATION_OCTET_STREAM,
};
return Ok(mime_type);
}
}
if let Some(bytes) = data {
if bytes.len() >= 4 {
let magic = &bytes[0..4];
if magic == [0x89, 0x50, 0x4E, 0x47] {
return Ok(mime::IMAGE_PNG);
}
if magic[0..2] == [0xFF, 0xD8] {
return Ok(mime::IMAGE_JPEG);
}
if magic == [0x25, 0x50, 0x44, 0x46] {
return Ok("application/pdf".parse().unwrap());
}
if magic[0..3] == [0x47, 0x49, 0x46] {
return Ok(mime::IMAGE_GIF);
}
}
}
Ok(mime::APPLICATION_OCTET_STREAM)
}
#[derive(Debug)]
pub struct FileBuilder {
name: Option<String>,
mime_type: Option<Mime>,
constraints: FileConstraints,
calculate_hash: bool,
}
impl FileBuilder {
pub fn new() -> Self {
Self {
name: None,
mime_type: None,
constraints: FileConstraints::default(),
calculate_hash: false,
}
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn mime_type(mut self, mime_type: Mime) -> Self {
self.mime_type = Some(mime_type);
self
}
pub fn constraints(mut self, constraints: FileConstraints) -> Self {
self.constraints = constraints;
self
}
pub fn with_hash(mut self) -> Self {
self.calculate_hash = true;
self
}
pub async fn build(self, source: FileSource) -> Result<File, FileError> {
let mut file = to_file(source, self.name, self.mime_type).await?;
file.validate(&self.constraints)?;
if self.calculate_hash {
file.calculate_hash().await?;
}
Ok(file)
}
}
impl Default for FileBuilder {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for File {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"File {{ name: {}, type: {}, size: {} bytes }}",
self.name, self.mime_type, self.size
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_from_bytes() {
let data = b"Hello, world!";
let file = File::from_bytes("test.txt", Bytes::from_static(data), None).unwrap();
assert_eq!(file.name, "test.txt");
assert_eq!(file.size, 13);
assert_eq!(file.mime_type, mime::TEXT_PLAIN);
}
#[test]
fn test_mime_detection() {
assert_eq!(detect_mime_type("test.jpg", None).unwrap(), mime::IMAGE_JPEG);
assert_eq!(detect_mime_type("test.png", None).unwrap(), mime::IMAGE_PNG);
assert_eq!(detect_mime_type("test.txt", None).unwrap(), mime::TEXT_PLAIN);
assert_eq!(detect_mime_type("test.json", None).unwrap(), mime::APPLICATION_JSON);
}
#[test]
fn test_file_validation() {
let data = b"Hello, world!";
let file = File::from_bytes("test.txt", Bytes::from_static(data), None).unwrap();
let constraints = FileConstraints {
max_size: 10,
allowed_types: None,
require_hash: false,
};
assert!(file.validate(&constraints).is_err());
}
#[test]
fn test_file_type_checks() {
let image_file = File::from_bytes("test.jpg", Bytes::new(), Some(mime::IMAGE_JPEG)).unwrap();
let text_file = File::from_bytes("test.txt", Bytes::new(), Some(mime::TEXT_PLAIN)).unwrap();
assert!(image_file.is_image());
assert!(!image_file.is_text());
assert!(text_file.is_text());
assert!(!text_file.is_image());
}
}