Skip to main content

provenant/utils/file/
classify.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! File classification: mime type, binary/text/archive/media/script/source
5//! flags, and a human-readable `file_type` label, mirroring ScanCode's
6//! `file_info` surface.
7
8use std::path::Path;
9
10use file_format::{FileFormat, Kind as FileFormatKind};
11use mime_guess::from_path;
12
13use crate::utils::language::detect_language;
14
15use super::encoding::{has_binary_control_chars, has_decodable_text, looks_like_textual_bytes};
16use super::format_sniff::{
17    ARCHIVE_EXTENSIONS, detect_file_format, format_based_file_type, is_known_binary_format,
18    is_textual_format, is_zip_archive, looks_like_bzip2, looks_like_deb, looks_like_gzip,
19    looks_like_pdf, looks_like_rpm, looks_like_squashfs, looks_like_xz,
20    media_file_type_from_content, media_mime_from_content,
21};
22use super::path::{
23    extension, is_c_like_source, is_java_like_source, is_makefile, is_plain_text, is_source_map,
24    lower_extension, lower_file_name,
25};
26
27const JSON_VALIDATION_MAX_BYTES: usize = 4 * 1024 * 1024;
28const BINARY_EXTENSIONS: &[&str] = &[
29    "pyc", "pyo", "pgm", "pbm", "ppm", "mp3", "mp4", "mpeg", "mpg", "emf",
30];
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct FileInfoClassification {
34    pub mime_type: String,
35    pub file_type: String,
36    pub programming_language: Option<String>,
37    pub is_binary: bool,
38    pub is_text: bool,
39    pub is_archive: bool,
40    pub is_media: bool,
41    pub is_source: bool,
42    pub is_script: bool,
43}
44
45pub fn classify_file_info(path: &Path, bytes: &[u8]) -> FileInfoClassification {
46    let detected_format = detect_file_format(bytes);
47    let detected_language = detect_language(path, bytes);
48    let is_binary = detect_is_binary(path, bytes, detected_format, detected_language.as_deref());
49    let is_text = !is_binary;
50    let mime_type = detect_mime_type(path, bytes, detected_format, detected_language.as_deref());
51    let is_archive = detect_is_archive(path, bytes, &mime_type, is_text, detected_format);
52    let is_media = detect_is_media(path, bytes, &mime_type, detected_format);
53    let is_script = detect_is_script(path, bytes, detected_language.as_deref(), is_text);
54    let is_source = detect_is_source(path, detected_language.as_deref(), is_text, is_script);
55    let programming_language = is_source.then(|| detected_language.clone()).flatten();
56    let file_type = detect_file_type(
57        path,
58        bytes,
59        detected_format,
60        &mime_type,
61        programming_language.as_deref(),
62        is_binary,
63        is_text,
64        is_archive,
65        is_media,
66        is_script,
67    );
68
69    FileInfoClassification {
70        mime_type,
71        file_type,
72        programming_language,
73        is_binary,
74        is_text,
75        is_archive,
76        is_media,
77        is_source,
78        is_script,
79    }
80}
81
82pub fn detect_mime_type(
83    path: &Path,
84    bytes: &[u8],
85    detected_format: FileFormat,
86    programming_language: Option<&str>,
87) -> String {
88    if bytes.is_empty() {
89        return "inode/x-empty".to_string();
90    }
91
92    if lower_extension(path).as_deref() == Some("json") {
93        if let Some(is_binary) = json_binary_override(bytes) {
94            if is_binary {
95                return "application/octet-stream".to_string();
96            }
97            if has_valid_json_text(bytes) {
98                return "application/json".to_string();
99            }
100            return "text/plain".to_string();
101        }
102        if has_valid_json_text(bytes) {
103            return "application/json".to_string();
104        }
105        if has_decodable_text(bytes) && looks_like_textual_bytes(bytes) {
106            return "text/plain".to_string();
107        }
108        return "application/octet-stream".to_string();
109    }
110
111    if is_zip_archive(bytes) {
112        return detect_zip_like_mime(path);
113    }
114
115    if looks_like_deb(bytes, path) {
116        return "application/vnd.debian.binary-package".to_string();
117    }
118
119    if looks_like_rpm(bytes, path) {
120        return "application/x-rpm".to_string();
121    }
122
123    let guessed_mime = from_path(path)
124        .first_or_octet_stream()
125        .essence_str()
126        .to_string();
127
128    let mime_type = match detected_format {
129        FileFormat::Empty => "inode/x-empty".to_string(),
130        FileFormat::PlainText => {
131            if guessed_mime == "application/octet-stream" || guessed_mime.starts_with("video/") {
132                "text/plain".to_string()
133            } else {
134                guessed_mime.clone()
135            }
136        }
137        _ => {
138            let detected_mime = detected_format.media_type();
139            if detected_mime == "application/octet-stream"
140                && guessed_mime != "application/octet-stream"
141            {
142                guessed_mime.clone()
143            } else {
144                detected_mime.to_string()
145            }
146        }
147    };
148
149    normalize_mime_type(path, bytes, programming_language, &mime_type)
150}
151
152pub(super) fn normalize_mime_type(
153    path: &Path,
154    bytes: &[u8],
155    programming_language: Option<&str>,
156    mime_type: &str,
157) -> String {
158    if should_prefer_text_mime(path, bytes, programming_language, mime_type) {
159        return "text/plain".to_string();
160    }
161
162    mime_type.to_string()
163}
164
165fn should_prefer_text_mime(
166    path: &Path,
167    bytes: &[u8],
168    programming_language: Option<&str>,
169    mime_type: &str,
170) -> bool {
171    has_decodable_text(bytes)
172        && looks_like_textual_bytes(bytes)
173        && is_textual_source_candidate(path, programming_language)
174        && (mime_type.starts_with("video/") || mime_type == "application/octet-stream")
175}
176
177fn has_valid_json_text(bytes: &[u8]) -> bool {
178    if bytes.len() > JSON_VALIDATION_MAX_BYTES {
179        return false;
180    }
181
182    serde_json::from_slice::<serde_json::Value>(bytes).is_ok()
183        || super::encoding::decode_utf16_json_text(bytes)
184            .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
185            .is_some()
186}
187
188fn is_wrapped_invalid_json_string_text(bytes: &[u8]) -> bool {
189    !bytes.contains(&0)
190        && !bytes.contains(&0xFF)
191        && bytes.starts_with(b"[\"")
192        && bytes.ends_with(b"\"]")
193        && bytes.len() >= 8
194}
195
196fn json_binary_override(bytes: &[u8]) -> Option<bool> {
197    if has_valid_json_text(bytes) {
198        return Some(false);
199    }
200
201    if bytes.contains(&0) {
202        return Some(true);
203    }
204
205    if bytes.contains(&0xFF) && (bytes.len() <= 5 || bytes.len() > 1024) {
206        return Some(true);
207    }
208
209    if is_wrapped_invalid_json_string_text(bytes) {
210        return Some(false);
211    }
212
213    None
214}
215
216fn detect_is_binary(
217    path: &Path,
218    bytes: &[u8],
219    detected_format: FileFormat,
220    programming_language: Option<&str>,
221) -> bool {
222    if lower_extension(path).as_deref() == Some("json")
223        && let Some(is_binary) = json_binary_override(bytes)
224    {
225        return is_binary;
226    }
227
228    if is_textual_format(detected_format) {
229        return false;
230    }
231
232    if lower_extension(path)
233        .as_deref()
234        .is_some_and(|ext| BINARY_EXTENSIONS.contains(&ext))
235    {
236        return true;
237    }
238
239    if should_treat_binary_bytes_as_text(path, bytes, programming_language) {
240        return false;
241    }
242
243    has_binary_control_chars(bytes)
244        || is_known_binary_format(detected_format)
245        || (matches!(detected_format, FileFormat::ArbitraryBinaryData)
246            && !looks_like_textual_bytes(bytes))
247}
248
249fn should_treat_binary_bytes_as_text(
250    path: &Path,
251    bytes: &[u8],
252    programming_language: Option<&str>,
253) -> bool {
254    has_decodable_text(bytes)
255        && looks_like_textual_bytes(bytes)
256        && (bytes.starts_with(b"#!") || is_textual_source_candidate(path, programming_language))
257}
258
259fn detect_is_archive(
260    path: &Path,
261    bytes: &[u8],
262    mime_type: &str,
263    is_text: bool,
264    detected_format: FileFormat,
265) -> bool {
266    if is_text {
267        return false;
268    }
269
270    lower_extension(path)
271        .as_deref()
272        .is_some_and(|ext| ARCHIVE_EXTENSIONS.contains(&ext))
273        || matches!(
274            detected_format.kind(),
275            FileFormatKind::Archive | FileFormatKind::Compressed | FileFormatKind::Package
276        )
277        || is_zip_archive(bytes)
278        || looks_like_gzip(bytes)
279        || looks_like_bzip2(bytes)
280        || looks_like_xz(bytes)
281        || looks_like_deb(bytes, path)
282        || looks_like_rpm(bytes, path)
283        || looks_like_squashfs(bytes, path)
284        || mime_type.contains("zip")
285        || mime_type.contains("compressed")
286        || mime_type.contains("tar")
287        || mime_type.contains("x-rpm")
288        || mime_type.contains("debian")
289}
290
291fn detect_is_media(
292    path: &Path,
293    bytes: &[u8],
294    mime_type: &str,
295    detected_format: FileFormat,
296) -> bool {
297    media_mime_from_content(bytes).is_some()
298        || matches!(
299            detected_format.kind(),
300            FileFormatKind::Audio | FileFormatKind::Image | FileFormatKind::Video
301        )
302        || mime_type.starts_with("image/")
303        || mime_type.starts_with("audio/")
304        || mime_type.starts_with("video/")
305        || (mime_type == "application/octet-stream"
306            && lower_extension(path).as_deref() == Some("tga")
307            && !has_binary_control_chars(bytes))
308}
309
310fn detect_is_script(
311    path: &Path,
312    bytes: &[u8],
313    programming_language: Option<&str>,
314    is_text: bool,
315) -> bool {
316    if !is_text || is_makefile(path) {
317        return false;
318    }
319
320    bytes.starts_with(b"#!")
321        || lower_extension(path).as_deref().is_some_and(|ext| {
322            matches!(
323                ext,
324                "sh" | "bash" | "zsh" | "fish" | "ksh" | "ps1" | "psm1" | "psd1" | "awk"
325            )
326        })
327        || matches!(
328            programming_language,
329            Some(
330                "Shell"
331                    | "Bash"
332                    | "Zsh"
333                    | "Fish"
334                    | "Ksh"
335                    | "Python"
336                    | "Ruby"
337                    | "Perl"
338                    | "PHP"
339                    | "PowerShell"
340                    | "Awk"
341            )
342        )
343}
344
345fn detect_is_source(
346    path: &Path,
347    programming_language: Option<&str>,
348    is_text: bool,
349    is_script: bool,
350) -> bool {
351    if !is_text || is_plain_text(path) || is_makefile(path) || is_source_map(path) {
352        return false;
353    }
354
355    if is_c_like_source(path) || is_java_like_source(path) {
356        return true;
357    }
358
359    programming_language.is_some() || is_script
360}
361
362// The arguments are individually computed classification inputs threaded down
363// from `classify_file_info`. Bundling them into a struct would only couple the
364// two functions more tightly without improving clarity; the flag pipeline is
365// intentionally kept flat here.
366#[allow(clippy::too_many_arguments)]
367fn detect_file_type(
368    path: &Path,
369    bytes: &[u8],
370    detected_format: FileFormat,
371    mime_type: &str,
372    programming_language: Option<&str>,
373    is_binary: bool,
374    is_text: bool,
375    is_archive: bool,
376    is_media: bool,
377    is_script: bool,
378) -> String {
379    if bytes.is_empty() {
380        return "empty".to_string();
381    }
382
383    if looks_like_pdf(bytes) {
384        return "PDF document".to_string();
385    }
386
387    if let Some(file_type) = media_file_type_from_content(bytes) {
388        return file_type.to_string();
389    }
390
391    if is_archive {
392        return archive_file_type(path, bytes, detected_format);
393    }
394
395    if is_script {
396        return script_file_type(programming_language, bytes);
397    }
398
399    if is_text {
400        if lower_extension(path).as_deref() == Some("json") {
401            if has_valid_json_text(bytes) {
402                return "JSON text data".to_string();
403            }
404            return text_file_type(bytes);
405        }
406        if lower_extension(path).as_deref() == Some("xml") {
407            return "XML text data".to_string();
408        }
409        if matches!(lower_extension(path).as_deref(), Some("yaml" | "yml")) {
410            return "YAML text data".to_string();
411        }
412        if lower_extension(path).as_deref() == Some("toml") {
413            return "TOML text data".to_string();
414        }
415        if matches!(
416            lower_extension(path).as_deref(),
417            Some("ini" | "cfg" | "conf")
418        ) {
419            return "INI text data".to_string();
420        }
421        if matches!(lower_file_name(path).as_str(), ".gitmodules" | ".gitconfig") {
422            return "Git configuration text".to_string();
423        }
424        if matches!(lower_extension(path).as_deref(), Some("md" | "markdown")) {
425            return text_file_type(bytes);
426        }
427        if programming_language.is_some() && !is_media {
428            return source_file_type(programming_language, bytes);
429        }
430        return text_file_type(bytes);
431    }
432
433    if let Some(file_type) = format_based_file_type(detected_format) {
434        return file_type;
435    }
436
437    if is_binary && mime_type == "application/octet-stream" {
438        return "data".to_string();
439    }
440
441    mime_type.to_string()
442}
443
444fn is_textual_source_candidate(path: &Path, programming_language: Option<&str>) -> bool {
445    if matches!(programming_language, Some(language) if is_source_like_language(language)) {
446        return true;
447    }
448
449    if matches!(
450        lower_file_name(path).as_str(),
451        "dockerfile"
452            | "containerfile"
453            | "containerfile.core"
454            | "apkbuild"
455            | "podfile"
456            | "jamfile"
457            | "jamroot"
458            | "meson.build"
459            | "build"
460            | "workspace"
461            | "buck"
462            | "default.nix"
463            | "flake.nix"
464            | "shell.nix"
465    ) {
466        return true;
467    }
468
469    path.extension()
470        .and_then(|ext| ext.to_str())
471        .is_some_and(|ext| {
472            matches!(
473                ext.to_ascii_lowercase().as_str(),
474                "rs" | "py"
475                    | "js"
476                    | "mjs"
477                    | "cjs"
478                    | "jsx"
479                    | "ts"
480                    | "mts"
481                    | "cts"
482                    | "tsx"
483                    | "c"
484                    | "cpp"
485                    | "cc"
486                    | "cxx"
487                    | "h"
488                    | "hpp"
489                    | "m"
490                    | "mm"
491                    | "s"
492                    | "asm"
493                    | "java"
494                    | "go"
495                    | "rb"
496                    | "php"
497                    | "pl"
498                    | "swift"
499                    | "sh"
500                    | "bash"
501                    | "zsh"
502                    | "fish"
503                    | "ksh"
504                    | "ps1"
505                    | "psm1"
506                    | "psd1"
507                    | "awk"
508                    | "kt"
509                    | "kts"
510                    | "dart"
511                    | "scala"
512                    | "groovy"
513                    | "gradle"
514                    | "gvy"
515                    | "gy"
516                    | "gsh"
517                    | "cs"
518                    | "fs"
519                    | "fsx"
520                    | "r"
521                    | "lua"
522                    | "jl"
523                    | "ex"
524                    | "exs"
525                    | "clj"
526                    | "cljs"
527                    | "cljc"
528                    | "hs"
529                    | "erl"
530                    | "nix"
531                    | "zig"
532                    | "bzl"
533                    | "bazel"
534                    | "star"
535                    | "sky"
536                    | "ml"
537                    | "mli"
538                    | "tex"
539            )
540        })
541}
542
543fn is_source_like_language(language: &str) -> bool {
544    matches!(
545        language,
546        "Rust"
547            | "Python"
548            | "JavaScript"
549            | "TypeScript"
550            | "JavaScript/TypeScript"
551            | "C"
552            | "C++"
553            | "Objective-C"
554            | "Objective-C++"
555            | "GAS"
556            | "Java"
557            | "Go"
558            | "Ruby"
559            | "PHP"
560            | "Perl"
561            | "Swift"
562            | "Shell"
563            | "PowerShell"
564            | "Awk"
565            | "Kotlin"
566            | "Dart"
567            | "Scala"
568            | "C#"
569            | "F#"
570            | "R"
571            | "Lua"
572            | "Julia"
573            | "Elixir"
574            | "Clojure"
575            | "Haskell"
576            | "Erlang"
577            | "Groovy"
578            | "Nix"
579            | "Zig"
580            | "Starlark"
581            | "OCaml"
582            | "Meson"
583            | "TeX"
584            | "Dockerfile"
585            | "Makefile"
586            | "Jamfile"
587    )
588}
589
590fn detect_zip_like_mime(path: &Path) -> String {
591    match extension(path).map(|ext| ext.to_ascii_lowercase()) {
592        Some(ext) if ext == "apk" => "application/vnd.android.package-archive".to_string(),
593        Some(ext) if matches!(ext.as_str(), "jar" | "war" | "ear") => {
594            "application/java-archive".to_string()
595        }
596        _ => "application/zip".to_string(),
597    }
598}
599
600fn archive_file_type(path: &Path, bytes: &[u8], detected_format: FileFormat) -> String {
601    if looks_like_deb(bytes, path) {
602        "debian binary package (format 2.0)".to_string()
603    } else if looks_like_rpm(bytes, path) {
604        "RPM package".to_string()
605    } else if looks_like_squashfs(bytes, path) {
606        "Squashfs filesystem".to_string()
607    } else if looks_like_gzip(bytes) {
608        "gzip compressed data".to_string()
609    } else if looks_like_bzip2(bytes) {
610        "bzip2 compressed data".to_string()
611    } else if looks_like_xz(bytes) {
612        "XZ compressed data".to_string()
613    } else if is_zip_archive(bytes) {
614        "Zip archive data".to_string()
615    } else if lower_extension(path).as_deref() == Some("gem") {
616        "POSIX tar archive".to_string()
617    } else if let Some(file_type) = format_based_file_type(detected_format) {
618        file_type
619    } else {
620        "archive data".to_string()
621    }
622}
623
624fn script_file_type(programming_language: Option<&str>, bytes: &[u8]) -> String {
625    let suffix = text_executable_label(bytes);
626
627    match programming_language {
628        Some("Python") => format!("python script, {suffix}"),
629        Some("Ruby") => format!("ruby script, {suffix}"),
630        Some("Perl") => format!("perl script, {suffix}"),
631        Some("PHP") => format!("php script, {suffix}"),
632        Some("Shell") => format!("shell script, {suffix}"),
633        Some("Bash") => format!("bash script, {suffix}"),
634        Some("Zsh") => format!("zsh script, {suffix}"),
635        Some("Fish") => format!("fish script, {suffix}"),
636        Some("Ksh") => format!("ksh script, {suffix}"),
637        Some("JavaScript") => format!("javascript script, {suffix}"),
638        Some("TypeScript") => format!("typescript script, {suffix}"),
639        Some("PowerShell") => format!("powershell script, {suffix}"),
640        Some("Awk") => format!("awk script, {suffix}"),
641        _ => format!("script, {suffix}"),
642    }
643}
644
645fn source_file_type(programming_language: Option<&str>, bytes: &[u8]) -> String {
646    let suffix = text_label(bytes);
647    match programming_language {
648        Some("C") => format!("C source, {suffix}"),
649        Some("C++") => format!("C++ source, {suffix}"),
650        Some("Java") => format!("Java source, {suffix}"),
651        Some("C#") => format!("C# source, {suffix}"),
652        Some("F#") => format!("F# source, {suffix}"),
653        Some("Go") => format!("Go source, {suffix}"),
654        Some("Rust") => format!("Rust source, {suffix}"),
655        Some("Starlark") => format!("Starlark source, {suffix}"),
656        Some("CMake") => format!("CMake source, {suffix}"),
657        Some("Meson") => format!("Meson source, {suffix}"),
658        Some("Nix") => format!("Nix source, {suffix}"),
659        Some("Groovy") => format!("Groovy source, {suffix}"),
660        Some("Makefile") => format!("Makefile source, {suffix}"),
661        Some("Dockerfile") => format!("Dockerfile source, {suffix}"),
662        Some("Jamfile") => format!("Jamfile source, {suffix}"),
663        Some("Batchfile") => format!("Batchfile source, {suffix}"),
664        Some(language) => format!("{language} source, {suffix}"),
665        None => text_file_type(bytes),
666    }
667}
668
669fn text_file_type(bytes: &[u8]) -> String {
670    text_label(bytes).to_string()
671}
672
673fn text_label(bytes: &[u8]) -> &'static str {
674    if std::str::from_utf8(bytes).is_ok() {
675        if bytes.contains(&b'\n') {
676            "UTF-8 Unicode text"
677        } else {
678            "UTF-8 Unicode text, with no line terminators"
679        }
680    } else if bytes.contains(&b'\n') {
681        "text"
682    } else {
683        "text, with no line terminators"
684    }
685}
686
687fn text_executable_label(bytes: &[u8]) -> &'static str {
688    if std::str::from_utf8(bytes).is_ok() {
689        if bytes.contains(&b'\n') {
690            "UTF-8 Unicode text executable"
691        } else {
692            "UTF-8 Unicode text executable, with no line terminators"
693        }
694    } else if bytes.contains(&b'\n') {
695        "text executable"
696    } else {
697        "text executable, with no line terminators"
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use std::path::Path;
704
705    use super::normalize_mime_type;
706
707    #[test]
708    fn test_normalize_mime_type_prefers_text_for_textual_video_guess() {
709        assert_eq!(
710            normalize_mime_type(
711                Path::new("main.ts"),
712                b"export const answer = 42;\n",
713                Some("TypeScript"),
714                "video/mp2t",
715            ),
716            "text/plain"
717        );
718    }
719
720    #[test]
721    fn test_normalize_mime_type_prefers_text_for_octet_stream_source_guess() {
722        assert_eq!(
723            normalize_mime_type(
724                Path::new("main.js"),
725                b"console.log('hello');\n",
726                Some("JavaScript"),
727                "application/octet-stream",
728            ),
729            "text/plain"
730        );
731    }
732
733    #[test]
734    fn test_normalize_mime_type_preserves_binary_video_guess() {
735        assert_eq!(
736            normalize_mime_type(
737                Path::new("main.ts"),
738                &[0, 159, 146, 150, 0, 1, 2, 3],
739                Some("TypeScript"),
740                "video/mp2t",
741            ),
742            "video/mp2t"
743        );
744    }
745
746    #[test]
747    fn test_normalize_mime_type_preserves_short_binary_octet_stream_guess() {
748        assert_eq!(
749            normalize_mime_type(
750                Path::new("main.ts"),
751                &[0, 159, 146, 150],
752                Some("TypeScript"),
753                "application/octet-stream",
754            ),
755            "application/octet-stream"
756        );
757    }
758}