1#![cfg_attr(
7 not(test),
8 deny(
9 clippy::unwrap_used,
10 clippy::unreachable,
11 clippy::expect_used,
12 clippy::panic,
13 clippy::unimplemented,
14 clippy::todo
15 )
16)]
17
18use std::str::FromStr;
19
20#[cfg(feature = "sea-orm")]
21use sea_orm::entity::prelude::*;
22
23pub const FILE_CLASSIFICATION_STORAGE_LEN: u32 = 32;
29pub const MAX_EXTENSION_LEN: usize = FILE_CLASSIFICATION_STORAGE_LEN as usize;
31pub const MAX_EXTENSION_FILTERS: usize = 32;
33
34pub type Result<T> = std::result::Result<T, FileClassificationError>;
36
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39#[error("{message}")]
40pub struct FileClassificationError {
41 message: String,
42}
43
44impl FileClassificationError {
45 pub fn new(message: impl Into<String>) -> Self {
47 Self {
48 message: message.into(),
49 }
50 }
51
52 pub fn message(&self) -> &str {
54 &self.message
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
61#[cfg_attr(feature = "sea-orm", derive(EnumIter, DeriveActiveEnum))]
62#[cfg_attr(
63 feature = "sea-orm",
64 sea_orm(rs_type = "String", db_type = "String(StringLen::N(32))")
65)]
66#[serde(rename_all = "lowercase")]
67pub enum FileCategory {
68 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "image"))]
70 Image,
71 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "video"))]
73 Video,
74 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "audio"))]
76 Audio,
77 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "document"))]
79 Document,
80 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "spreadsheet"))]
82 Spreadsheet,
83 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "presentation"))]
85 Presentation,
86 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "archive"))]
88 Archive,
89 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "code"))]
91 Code,
92 #[cfg_attr(feature = "sea-orm", sea_orm(string_value = "other"))]
94 Other,
95}
96
97impl FileCategory {
98 pub const fn as_str(self) -> &'static str {
100 match self {
101 Self::Image => "image",
102 Self::Video => "video",
103 Self::Audio => "audio",
104 Self::Document => "document",
105 Self::Spreadsheet => "spreadsheet",
106 Self::Presentation => "presentation",
107 Self::Archive => "archive",
108 Self::Code => "code",
109 Self::Other => "other",
110 }
111 }
112}
113
114impl FromStr for FileCategory {
115 type Err = ();
116
117 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
118 match value {
119 "image" => Ok(Self::Image),
120 "video" => Ok(Self::Video),
121 "audio" => Ok(Self::Audio),
122 "document" => Ok(Self::Document),
123 "spreadsheet" => Ok(Self::Spreadsheet),
124 "presentation" => Ok(Self::Presentation),
125 "archive" => Ok(Self::Archive),
126 "code" => Ok(Self::Code),
127 "other" => Ok(Self::Other),
128 _ => Err(()),
129 }
130 }
131}
132
133const COMPOUND_EXTENSIONS: &[&str] = &[
134 "tar.gz", "tar.bz2", "tar.xz", "tar.zst", "tar.br", "tar.lz", "tar.lzma", "tar.lzo",
135];
136
137const IMAGE_EXTENSIONS: &[&str] = &[
138 "jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff", "svg", "ico", "avif", "heic",
139 "heif", "raw", "cr2", "nef", "orf", "rw2",
140];
141
142const VIDEO_EXTENSIONS: &[&str] = &[
146 "mp4", "m4v", "mov", "avi", "mkv", "webm", "flv", "wmv", "mpeg", "mpg", "3gp", "m2ts", "ogv",
147];
148
149const AUDIO_EXTENSIONS: &[&str] = &[
150 "mp3", "wav", "flac", "aac", "m4a", "ogg", "oga", "opus", "wma", "aiff", "alac", "mid", "midi",
151];
152
153const DOCUMENT_EXTENSIONS: &[&str] = &[
154 "pdf", "txt", "md", "markdown", "rtf", "doc", "docx", "odt", "pages", "epub", "tex",
155];
156
157const SPREADSHEET_EXTENSIONS: &[&str] = &["xls", "xlsx", "ods", "csv", "tsv", "numbers"];
158
159const PRESENTATION_EXTENSIONS: &[&str] = &["ppt", "pptx", "odp", "key"];
160
161const ARCHIVE_EXTENSIONS: &[&str] = &[
162 "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "br", "tgz", "tbz", "tbz2", "txz", "lz",
163 "lzma", "lzo", "cab", "iso", "dmg",
164];
165
166const CODE_EXTENSIONS: &[&str] = &[
167 "rs",
168 "ts",
169 "tsx",
170 "js",
171 "jsx",
172 "mjs",
173 "cjs",
174 "json",
175 "jsonc",
176 "yaml",
177 "yml",
178 "toml",
179 "xml",
180 "html",
181 "htm",
182 "css",
183 "scss",
184 "sass",
185 "less",
186 "sql",
187 "sh",
188 "bash",
189 "zsh",
190 "fish",
191 "ps1",
192 "py",
193 "rb",
194 "go",
195 "java",
196 "kt",
197 "kts",
198 "swift",
199 "c",
200 "h",
201 "cpp",
202 "cc",
203 "cxx",
204 "hpp",
205 "cs",
206 "php",
207 "lua",
208 "dart",
209 "vue",
210 "svelte",
211 "lock",
212 "ini",
213 "conf",
214 "dockerfile",
215 "makefile",
216];
217
218#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct FileClassification {
221 pub extension: String,
223 pub compound_extension: Option<String>,
225 pub category: FileCategory,
227}
228
229pub fn classify_file(name: &str, mime_type: &str) -> FileClassification {
231 let extension = extension_from_name(name).unwrap_or_default();
232 let compound_extension = compound_extension_from_name(name);
233 let category =
234 classify_extension_and_mime(&extension, compound_extension.as_deref(), mime_type);
235
236 FileClassification {
237 extension,
238 compound_extension,
239 category,
240 }
241}
242
243pub fn normalize_extension_filter(raw: &str) -> Result<String> {
245 let normalized = raw.trim().trim_start_matches('.').to_ascii_lowercase();
246 if normalized.is_empty() {
247 return Err(FileClassificationError::new(
248 "extensions must not contain empty values",
249 ));
250 }
251 if normalized.len() > MAX_EXTENSION_LEN {
252 return Err(FileClassificationError::new(format!(
253 "extensions must be at most {MAX_EXTENSION_LEN} characters"
254 )));
255 }
256 if normalized.starts_with('.')
257 || normalized.ends_with('.')
258 || normalized.contains("..")
259 || !normalized.chars().all(|ch| {
260 ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' || ch == '+'
261 })
262 {
263 return Err(FileClassificationError::new(
264 "extensions may only contain letters, numbers, dot, underscore, plus, or hyphen",
265 ));
266 }
267
268 Ok(normalized)
269}
270
271pub fn parse_extension_filters(raw: &str) -> Result<Vec<String>> {
273 let mut extensions = Vec::new();
274 for part in raw.split(',') {
275 let extension = normalize_extension_filter(part)?;
276 if !extensions.iter().any(|candidate| candidate == &extension) {
277 extensions.push(extension);
278 }
279 if extensions.len() > MAX_EXTENSION_FILTERS {
280 return Err(FileClassificationError::new(format!(
281 "extensions supports at most {MAX_EXTENSION_FILTERS} values"
282 )));
283 }
284 }
285
286 Ok(extensions)
287}
288
289pub fn parse_file_category(raw: &str) -> Result<FileCategory> {
291 FileCategory::from_str(raw.trim()).map_err(|()| {
292 FileClassificationError::new(
293 "category must be one of: image, video, audio, document, spreadsheet, presentation, archive, code, other",
294 )
295 })
296}
297
298pub fn extension_from_name(name: &str) -> Option<String> {
304 let trimmed = name.trim();
305 let dot = trimmed.rfind('.')?;
306 if dot == 0 || dot + 1 >= trimmed.len() {
307 return None;
308 }
309 let extension = &trimmed[dot + 1..];
310 if extension.is_empty()
311 || extension.len() > MAX_EXTENSION_LEN
312 || !extension.chars().all(|ch| ch.is_ascii_alphanumeric())
313 {
314 return None;
315 }
316 Some(extension.to_ascii_lowercase())
317}
318
319pub fn compound_extension_from_name(name: &str) -> Option<String> {
321 let normalized = name.trim().to_ascii_lowercase();
322 COMPOUND_EXTENSIONS
323 .iter()
324 .find(|extension| normalized.ends_with(&format!(".{extension}")))
325 .map(|extension| (*extension).to_string())
326}
327
328fn classify_extension_and_mime(
329 extension: &str,
330 compound_extension: Option<&str>,
331 mime_type: &str,
332) -> FileCategory {
333 if compound_extension.is_some() || contains(ARCHIVE_EXTENSIONS, extension) {
334 return FileCategory::Archive;
335 }
336 if contains(SPREADSHEET_EXTENSIONS, extension) {
337 return FileCategory::Spreadsheet;
338 }
339 if contains(PRESENTATION_EXTENSIONS, extension) {
340 return FileCategory::Presentation;
341 }
342 if contains(IMAGE_EXTENSIONS, extension) {
343 return FileCategory::Image;
344 }
345 if contains(VIDEO_EXTENSIONS, extension) {
346 return FileCategory::Video;
347 }
348 if contains(AUDIO_EXTENSIONS, extension) {
349 return FileCategory::Audio;
350 }
351 if contains(DOCUMENT_EXTENSIONS, extension) {
352 return FileCategory::Document;
353 }
354 if contains(CODE_EXTENSIONS, extension) {
355 return FileCategory::Code;
356 }
357
358 classify_mime(mime_type)
359}
360
361fn classify_mime(mime_type: &str) -> FileCategory {
362 let mime = mime_type.trim().to_ascii_lowercase();
363 if mime.starts_with("image/") {
364 FileCategory::Image
365 } else if mime.starts_with("video/") {
366 FileCategory::Video
367 } else if mime.starts_with("audio/") {
368 FileCategory::Audio
369 } else if mime.contains("spreadsheet") || mime.contains("excel") || mime.ends_with("/csv") {
370 FileCategory::Spreadsheet
373 } else if mime == "application/pdf" || mime.starts_with("text/") {
374 FileCategory::Document
375 } else if mime.contains("presentation") || mime.contains("powerpoint") {
376 FileCategory::Presentation
377 } else if mime.contains("zip")
378 || mime.contains("compressed")
379 || mime.contains("x-tar")
380 || mime.contains("x-7z")
381 || mime.contains("x-rar")
382 {
383 FileCategory::Archive
384 } else if mime.contains("json") || mime.contains("xml") {
385 FileCategory::Code
386 } else {
387 FileCategory::Other
388 }
389}
390
391const fn contains(values: &[&str], needle: &str) -> bool {
392 let mut index = 0;
393 while index < values.len() {
394 if values[index].len() == needle.len() {
395 let a = values[index].as_bytes();
396 let b = needle.as_bytes();
397 let mut byte_index = 0;
398 let mut equal = true;
399 while byte_index < a.len() {
400 if a[byte_index] != b[byte_index] {
401 equal = false;
402 break;
403 }
404 byte_index += 1;
405 }
406 if equal {
407 return true;
408 }
409 }
410 index += 1;
411 }
412 false
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn parses_extensions_and_compound_extensions() {
421 assert_eq!(extension_from_name("backup.tar.gz").as_deref(), Some("gz"));
422 assert_eq!(
423 compound_extension_from_name("backup.TAR.GZ").as_deref(),
424 Some("tar.gz")
425 );
426 assert_eq!(extension_from_name(".gitignore"), None);
427 assert_eq!(extension_from_name("README"), None);
428 assert_eq!(
429 extension_from_name(&format!("file.{}", "a".repeat(33))),
430 None
431 );
432 }
433
434 #[test]
435 fn classifies_with_fixed_priority() {
436 let csv = classify_file("data.csv", "text/csv");
437 assert_eq!(csv.category, FileCategory::Spreadsheet);
438
439 let markdown = classify_file("README.md", "text/markdown");
440 assert_eq!(markdown.category, FileCategory::Document);
441
442 let archive = classify_file("backup.tar.gz", "application/gzip");
443 assert_eq!(archive.category, FileCategory::Archive);
444 assert_eq!(archive.compound_extension.as_deref(), Some("tar.gz"));
445 }
446
447 #[test]
448 fn classifies_from_mime_when_extension_is_unknown() {
449 assert_eq!(
450 classify_file("asset.unknown", "image/png").category,
451 FileCategory::Image
452 );
453 assert_eq!(
454 classify_file("asset.unknown", "application/vnd.ms-excel").category,
455 FileCategory::Spreadsheet
456 );
457 assert_eq!(
458 classify_file("asset.unknown", "application/json").category,
459 FileCategory::Code
460 );
461 assert_eq!(
462 classify_file("asset.unknown", "application/octet-stream").category,
463 FileCategory::Other
464 );
465 }
466
467 #[test]
468 fn classifies_text_csv_mime_as_spreadsheet_when_extension_is_unknown() {
469 assert_eq!(
473 classify_file("data.unknown", "text/csv").category,
474 FileCategory::Spreadsheet
475 );
476 assert_eq!(
477 classify_file("notes.unknown", "text/markdown").category,
478 FileCategory::Document
479 );
480 }
481
482 #[test]
483 fn extension_from_name_rejects_path_like_and_spaced_candidates() {
484 assert_eq!(extension_from_name("dir.ext/file"), None);
488 assert_eq!(extension_from_name("report.pn g"), None);
489 assert_eq!(extension_from_name("archive.tar.gz").as_deref(), Some("gz"));
490 assert_eq!(extension_from_name("photo.JPEG").as_deref(), Some("jpeg"));
491 }
492
493 #[test]
494 fn classifies_ts_extension_as_code_not_video() {
495 assert_eq!(
498 classify_file("index.ts", "video/mp2t").category,
499 FileCategory::Code
500 );
501 assert_eq!(
502 classify_file("stream.m2ts", "video/mp2t").category,
503 FileCategory::Video
504 );
505 assert_eq!(
506 classify_file("asset.unknown", "video/mp2t").category,
507 FileCategory::Video
508 );
509 }
510
511 #[test]
512 fn parses_file_category_values() {
513 assert_eq!(parse_file_category(" image ").unwrap(), FileCategory::Image);
514 assert_eq!(FileCategory::Archive.as_str(), "archive");
515 assert!(parse_file_category("folder").is_err());
516 }
517
518 #[test]
519 fn normalizes_extension_filters() {
520 assert_eq!(
521 parse_extension_filters(" .PDF,docx,pdf ").unwrap(),
522 vec!["pdf", "docx"]
523 );
524 assert!(parse_extension_filters("pdf,,docx").is_err());
525 assert!(parse_extension_filters("../pdf").is_err());
526 }
527
528 #[test]
529 fn extension_filters_reject_length_and_count_boundaries() {
530 assert!(normalize_extension_filter(&"a".repeat(MAX_EXTENSION_LEN + 1)).is_err());
531
532 let too_many = (0..=MAX_EXTENSION_FILTERS)
533 .map(|index| format!("ext{index}"))
534 .collect::<Vec<_>>()
535 .join(",");
536 assert!(parse_extension_filters(&too_many).is_err());
537 }
538
539 #[cfg(feature = "sea-orm")]
540 #[test]
541 fn file_category_has_stable_sea_orm_values() {
542 assert_eq!(FileCategory::Image.to_value(), "image");
543 assert_eq!(FileCategory::Archive.to_value(), "archive");
544 assert_eq!(
545 FileCategory::try_from_value(&"spreadsheet".to_string()),
546 Ok(FileCategory::Spreadsheet)
547 );
548 assert!(FileCategory::try_from_value(&"folder".to_string()).is_err());
549 for category in [
550 FileCategory::Image,
551 FileCategory::Video,
552 FileCategory::Audio,
553 FileCategory::Document,
554 FileCategory::Spreadsheet,
555 FileCategory::Presentation,
556 FileCategory::Archive,
557 FileCategory::Code,
558 FileCategory::Other,
559 ] {
560 assert!(category.to_value().len() <= FILE_CLASSIFICATION_STORAGE_LEN as usize);
561 }
562 }
563}