1use std::io::{Read, BufReader};
2use std::path::Path;
3use std::fs::File as StdFile;
4use std::fmt;
5use bytes::Bytes;
6use mime::Mime;
7use sha2::{Sha256, Digest};
8use base64::{Engine as _, engine::general_purpose};
9
10#[derive(Debug, Clone)]
12pub struct File {
13 pub name: String,
15 pub mime_type: Mime,
17 pub data: FileData,
19 pub size: u64,
21 pub hash: Option<String>,
23}
24
25#[derive(Debug, Clone)]
27pub enum FileData {
28 Bytes(Bytes),
30 Base64(String),
32 Path(std::path::PathBuf),
34 TempFile(std::path::PathBuf),
36}
37
38#[derive(Debug, Clone)]
40pub struct FileConstraints {
41 pub max_size: u64,
43 pub allowed_types: Option<Vec<Mime>>,
45 pub require_hash: bool,
47}
48
49impl Default for FileConstraints {
50 fn default() -> Self {
51 Self {
52 max_size: 10 * 1024 * 1024, allowed_types: None,
54 require_hash: false,
55 }
56 }
57}
58
59#[derive(Debug, thiserror::Error)]
61pub enum FileError {
62 #[error("File not found: {path}")]
63 NotFound { path: String },
64
65 #[error("File too large: {size} bytes (max: {max_size} bytes)")]
66 TooLarge { size: u64, max_size: u64 },
67
68 #[error("Invalid MIME type: {mime_type} (allowed: {allowed:?})")]
69 InvalidMimeType { mime_type: String, allowed: Vec<String> },
70
71 #[error("IO error: {0}")]
72 Io(#[from] std::io::Error),
73
74 #[error("Invalid base64 data: {0}")]
75 InvalidBase64(#[from] base64::DecodeError),
76
77 #[error("MIME detection failed")]
78 MimeDetectionFailed,
79
80 #[error("Hash verification failed")]
81 HashVerificationFailed,
82
83 #[error("Invalid file data")]
84 InvalidData,
85}
86
87impl File {
88 pub fn from_bytes(
90 name: impl Into<String>,
91 bytes: impl Into<Bytes>,
92 mime_type: Option<Mime>,
93 ) -> Result<Self, FileError> {
94 let name = name.into();
95 let bytes = bytes.into();
96 let size = bytes.len() as u64;
97
98 let mime_type = match mime_type {
99 Some(mime) => mime,
100 None => detect_mime_type(&name, Some(&bytes))?,
101 };
102
103 Ok(Self {
104 name,
105 mime_type,
106 data: FileData::Bytes(bytes),
107 size,
108 hash: None,
109 })
110 }
111
112 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, FileError> {
114 let path = path.as_ref();
115
116 if !path.exists() {
117 return Err(FileError::NotFound {
118 path: path.display().to_string(),
119 });
120 }
121
122 let metadata = std::fs::metadata(path)?;
123 let size = metadata.len();
124 let name = path.file_name()
125 .and_then(|n| n.to_str())
126 .unwrap_or("file")
127 .to_string();
128
129 let mime_type = detect_mime_type(&name, None)?;
130
131 Ok(Self {
132 name,
133 mime_type,
134 data: FileData::Path(path.to_path_buf()),
135 size,
136 hash: None,
137 })
138 }
139
140 pub fn from_base64(
142 name: impl Into<String>,
143 base64_data: impl Into<String>,
144 mime_type: Option<Mime>,
145 ) -> Result<Self, FileError> {
146 let name = name.into();
147 let base64_data = base64_data.into();
148
149 let decoded = general_purpose::STANDARD.decode(&base64_data)?;
151 let size = decoded.len() as u64;
152
153 let mime_type = match mime_type {
154 Some(mime) => mime,
155 None => detect_mime_type(&name, Some(&decoded))?,
156 };
157
158 Ok(Self {
159 name,
160 mime_type,
161 data: FileData::Base64(base64_data),
162 size,
163 hash: None,
164 })
165 }
166
167 pub fn from_std_file(
169 std_file: StdFile,
170 name: impl Into<String>,
171 mime_type: Option<Mime>,
172 ) -> Result<Self, FileError> {
173 let name = name.into();
174 let metadata = std_file.metadata()?;
175 let size = metadata.len();
176
177 let mut reader = BufReader::new(std_file);
179 let mut buffer = Vec::new();
180 reader.read_to_end(&mut buffer)?;
181
182 let mime_type = match mime_type {
183 Some(mime) => mime,
184 None => detect_mime_type(&name, Some(&buffer))?,
185 };
186
187 Ok(Self {
188 name,
189 mime_type,
190 data: FileData::Bytes(Bytes::from(buffer)),
191 size,
192 hash: None,
193 })
194 }
195
196 pub fn validate(&self, constraints: &FileConstraints) -> Result<(), FileError> {
198 if self.size > constraints.max_size {
200 return Err(FileError::TooLarge {
201 size: self.size,
202 max_size: constraints.max_size,
203 });
204 }
205
206 if let Some(allowed_types) = &constraints.allowed_types {
208 if !allowed_types.iter().any(|mime| mime == &self.mime_type) {
209 return Err(FileError::InvalidMimeType {
210 mime_type: self.mime_type.to_string(),
211 allowed: allowed_types.iter().map(|m| m.to_string()).collect(),
212 });
213 }
214 }
215
216 Ok(())
217 }
218
219 pub async fn to_bytes(&self) -> Result<Bytes, FileError> {
221 match &self.data {
222 FileData::Bytes(bytes) => Ok(bytes.clone()),
223 FileData::Base64(base64_data) => {
224 let decoded = general_purpose::STANDARD.decode(base64_data)?;
225 Ok(Bytes::from(decoded))
226 },
227 FileData::Path(path) => {
228 let bytes = tokio::fs::read(path).await?;
229 Ok(Bytes::from(bytes))
230 },
231 FileData::TempFile(path) => {
232 let bytes = tokio::fs::read(path).await?;
233 Ok(Bytes::from(bytes))
234 },
235 }
236 }
237
238 pub async fn to_base64(&self) -> Result<String, FileError> {
240 match &self.data {
241 FileData::Base64(base64_data) => Ok(base64_data.clone()),
242 _ => {
243 let bytes = self.to_bytes().await?;
244 Ok(general_purpose::STANDARD.encode(&bytes))
245 }
246 }
247 }
248
249 pub async fn calculate_hash(&mut self) -> Result<String, FileError> {
251 let bytes = self.to_bytes().await?;
252 let mut hasher = Sha256::new();
253 hasher.update(&bytes);
254 let hash = format!("{:x}", hasher.finalize());
255 self.hash = Some(hash.clone());
256 Ok(hash)
257 }
258
259 pub async fn verify_hash(&self, expected_hash: &str) -> Result<bool, FileError> {
261 let bytes = self.to_bytes().await?;
262 let mut hasher = Sha256::new();
263 hasher.update(&bytes);
264 let actual_hash = format!("{:x}", hasher.finalize());
265 Ok(actual_hash == expected_hash)
266 }
267
268 pub fn is_image(&self) -> bool {
270 self.mime_type.type_() == mime::IMAGE
271 }
272
273 pub fn is_text(&self) -> bool {
275 self.mime_type.type_() == mime::TEXT
276 }
277
278 pub fn is_application(&self) -> bool {
280 self.mime_type.type_() == mime::APPLICATION
281 }
282}
283
284pub async fn to_file(
286 source: FileSource,
287 name: Option<String>,
288 mime_type: Option<Mime>,
289) -> Result<File, FileError> {
290 match source {
291 FileSource::Bytes(bytes) => {
292 let name = name.unwrap_or_else(|| "file".to_string());
293 File::from_bytes(name, bytes, mime_type)
294 },
295 FileSource::Base64(base64_data) => {
296 let name = name.unwrap_or_else(|| "file".to_string());
297 File::from_base64(name, base64_data, mime_type)
298 },
299 FileSource::Path(path) => File::from_path(path),
300 FileSource::StdFile(std_file, file_name) => {
301 let name = name.or(file_name).unwrap_or_else(|| "file".to_string());
302 File::from_std_file(std_file, name, mime_type)
303 },
304 }
305}
306
307pub enum FileSource {
309 Bytes(Bytes),
311 Base64(String),
313 Path(std::path::PathBuf),
315 StdFile(StdFile, Option<String>),
317}
318
319fn detect_mime_type(filename: &str, data: Option<&[u8]>) -> Result<Mime, FileError> {
321 if let Some(extension) = Path::new(filename).extension() {
323 if let Some(ext_str) = extension.to_str() {
324 let mime_type = match ext_str.to_lowercase().as_str() {
325 "jpg" | "jpeg" => mime::IMAGE_JPEG,
327 "png" => mime::IMAGE_PNG,
328 "gif" => mime::IMAGE_GIF,
329 "webp" => "image/webp".parse().unwrap(),
330 "svg" => mime::IMAGE_SVG,
331 "bmp" => "image/bmp".parse().unwrap(),
332
333 "pdf" => "application/pdf".parse().unwrap(),
335 "doc" => "application/msword".parse().unwrap(),
336 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document".parse().unwrap(),
337 "txt" => mime::TEXT_PLAIN,
338 "md" => "text/markdown".parse().unwrap(),
339 "rtf" => "application/rtf".parse().unwrap(),
340
341 "xls" => "application/vnd.ms-excel".parse().unwrap(),
343 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".parse().unwrap(),
344
345 "ppt" => "application/vnd.ms-powerpoint".parse().unwrap(),
347 "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation".parse().unwrap(),
348
349 "mp3" => "audio/mpeg".parse().unwrap(),
351 "wav" => "audio/wav".parse().unwrap(),
352 "ogg" => "audio/ogg".parse().unwrap(),
353
354 "mp4" => "video/mp4".parse().unwrap(),
356 "avi" => "video/x-msvideo".parse().unwrap(),
357 "mov" => "video/quicktime".parse().unwrap(),
358
359 "zip" => "application/zip".parse().unwrap(),
361 "tar" => "application/x-tar".parse().unwrap(),
362 "gz" => "application/gzip".parse().unwrap(),
363
364 "json" => mime::APPLICATION_JSON,
366 "xml" => mime::TEXT_XML,
367
368 _ => mime::APPLICATION_OCTET_STREAM,
369 };
370 return Ok(mime_type);
371 }
372 }
373
374 if let Some(bytes) = data {
376 if bytes.len() >= 4 {
377 let magic = &bytes[0..4];
378
379 if magic == [0x89, 0x50, 0x4E, 0x47] {
381 return Ok(mime::IMAGE_PNG);
382 }
383
384 if magic[0..2] == [0xFF, 0xD8] {
386 return Ok(mime::IMAGE_JPEG);
387 }
388
389 if magic == [0x25, 0x50, 0x44, 0x46] {
391 return Ok("application/pdf".parse().unwrap());
392 }
393
394 if magic[0..3] == [0x47, 0x49, 0x46] {
396 return Ok(mime::IMAGE_GIF);
397 }
398 }
399 }
400
401 Ok(mime::APPLICATION_OCTET_STREAM)
403}
404
405#[derive(Debug)]
407pub struct FileBuilder {
408 name: Option<String>,
409 mime_type: Option<Mime>,
410 constraints: FileConstraints,
411 calculate_hash: bool,
412}
413
414impl FileBuilder {
415 pub fn new() -> Self {
417 Self {
418 name: None,
419 mime_type: None,
420 constraints: FileConstraints::default(),
421 calculate_hash: false,
422 }
423 }
424
425 pub fn name(mut self, name: impl Into<String>) -> Self {
427 self.name = Some(name.into());
428 self
429 }
430
431 pub fn mime_type(mut self, mime_type: Mime) -> Self {
433 self.mime_type = Some(mime_type);
434 self
435 }
436
437 pub fn constraints(mut self, constraints: FileConstraints) -> Self {
439 self.constraints = constraints;
440 self
441 }
442
443 pub fn with_hash(mut self) -> Self {
445 self.calculate_hash = true;
446 self
447 }
448
449 pub async fn build(self, source: FileSource) -> Result<File, FileError> {
451 let mut file = to_file(source, self.name, self.mime_type).await?;
452
453 file.validate(&self.constraints)?;
455
456 if self.calculate_hash {
458 file.calculate_hash().await?;
459 }
460
461 Ok(file)
462 }
463}
464
465impl Default for FileBuilder {
466 fn default() -> Self {
467 Self::new()
468 }
469}
470
471impl fmt::Display for File {
472 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473 write!(
474 f,
475 "File {{ name: {}, type: {}, size: {} bytes }}",
476 self.name, self.mime_type, self.size
477 )
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn test_file_from_bytes() {
487 let data = b"Hello, world!";
488 let file = File::from_bytes("test.txt", Bytes::from_static(data), None).unwrap();
489
490 assert_eq!(file.name, "test.txt");
491 assert_eq!(file.size, 13);
492 assert_eq!(file.mime_type, mime::TEXT_PLAIN);
493 }
494
495 #[test]
496 fn test_mime_detection() {
497 assert_eq!(detect_mime_type("test.jpg", None).unwrap(), mime::IMAGE_JPEG);
498 assert_eq!(detect_mime_type("test.png", None).unwrap(), mime::IMAGE_PNG);
499 assert_eq!(detect_mime_type("test.txt", None).unwrap(), mime::TEXT_PLAIN);
500 assert_eq!(detect_mime_type("test.json", None).unwrap(), mime::APPLICATION_JSON);
501 }
502
503 #[test]
504 fn test_file_validation() {
505 let data = b"Hello, world!";
506 let file = File::from_bytes("test.txt", Bytes::from_static(data), None).unwrap();
507
508 let constraints = FileConstraints {
509 max_size: 10,
510 allowed_types: None,
511 require_hash: false,
512 };
513
514 assert!(file.validate(&constraints).is_err());
516 }
517
518 #[test]
519 fn test_file_type_checks() {
520 let image_file = File::from_bytes("test.jpg", Bytes::new(), Some(mime::IMAGE_JPEG)).unwrap();
521 let text_file = File::from_bytes("test.txt", Bytes::new(), Some(mime::TEXT_PLAIN)).unwrap();
522
523 assert!(image_file.is_image());
524 assert!(!image_file.is_text());
525
526 assert!(text_file.is_text());
527 assert!(!text_file.is_image());
528 }
529}
530