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