Skip to main content

libmagic_rs/output/
ascmagic.rs

1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Minimal text/data fallback classification, modeled on GNU `file`'s
5//! `file_ascmagic` (`src/ascmagic.c`).
6//!
7//! When no magic rule produces a usable description -- either because no
8//! rule matched at all, or because every rule that matched carries no
9//! description text (GOTCHAS S13.2) -- GNU `file` never prints a blank
10//! line. It falls back to a basic content classification: `"empty"` for a
11//! zero-byte file, `"ASCII text"` for plain textual content, a Unicode
12//! variant for valid non-ASCII UTF-8, and `"data"` for anything else
13//! (binary content).
14//!
15//! # Scope
16//!
17//! This is a deliberately narrow subset of GNU `file`'s real charset
18//! detection, which additionally distinguishes ISO-8859 variants, UTF-16,
19//! line-ending styles, and several "text with X" qualifiers (escape
20//! sequences, overstriking, CRLF terminators, byte-order marks, etc. --
21//! see `src/ascmagic.c` and `src/encoding.c` upstream). Replicating that
22//! fully is out of scope for this fallback: the goal here is solely to
23//! ensure the CLI never emits a blank description for a readable file
24//! (the assembler-source-text and plain-ASCII-text bugs this module
25//! fixes), not full charset fidelity. Every classification below is a
26//! true subset of what GNU `file` would print for the same input -- e.g.
27//! `file` prints `"ASCII text, with CRLF line terminators"` for a
28//! CRLF-terminated buffer where we print plain `"ASCII text"` -- so
29//! differential tests that check for a specific classification (rather
30//! than exact byte-for-byte output) still hold.
31
32/// Bytes GNU `file`'s `ascmagic`/`encoding` text test treats as part of
33/// ordinary "text" content: printable ASCII (0x20..=0x7E) plus the common
34/// control characters that appear in real-world text files (tab, LF, CR,
35/// vertical tab, form feed) and a few legacy terminal-control bytes GNU
36/// `file` still classifies as text -- bell, backspace, and escape --
37/// which it reports as `"ASCII text, with ..."` qualifiers rather than
38/// reclassifying as binary `"data"`. This fallback does not reproduce
39/// those qualifiers (see the module doc), but a buffer containing only
40/// these bytes is still `"ASCII text"`, not `"data"`.
41fn is_text_safe_byte(b: u8) -> bool {
42    matches!(
43        b,
44        0x20..=0x7E | b'\t' | b'\n' | b'\r' | 0x0B | 0x0C | 0x07 | 0x08 | 0x1B
45    )
46}
47
48/// Classify a buffer using the minimal text/data fallback described in
49/// the module doc.
50///
51/// Returns one of `"empty"`, `"ASCII text"`, `"UTF-8 Unicode text"`, or
52/// `"data"`. This is intentionally infallible -- there is no input for
53/// which classification can fail, so the caller never needs to handle
54/// an error path here (matching the evaluator's graceful-degradation
55/// discipline: a fallback that can itself fail would defeat its purpose).
56///
57/// # Examples
58///
59/// ```
60/// use libmagic_rs::output::ascmagic::classify_fallback;
61///
62/// assert_eq!(classify_fallback(b""), "empty");
63/// assert_eq!(classify_fallback(b"hello world\n"), "ASCII text");
64/// assert_eq!(classify_fallback(&[0x00, 0x01, 0x02, 0xff]), "data");
65/// ```
66#[must_use]
67pub fn classify_fallback(buffer: &[u8]) -> &'static str {
68    if buffer.is_empty() {
69        return "empty";
70    }
71
72    if buffer.iter().all(|&b| is_text_safe_byte(b)) {
73        return "ASCII text";
74    }
75
76    // Valid non-ASCII UTF-8 only counts as text when every low byte (< 0x80) is
77    // itself text-safe. A NUL or other binary control byte is a strong binary
78    // signal even inside an otherwise-valid UTF-8 buffer -- GNU `file`
79    // classifies such input as data, and the ASCII branch above already treats
80    // these bytes as non-text. High bytes (>= 0x80) are exempt from the check
81    // because they are the UTF-8 continuation/lead bytes the `from_utf8` gate
82    // validates. Without this, a valid-UTF-8 buffer carrying an embedded NUL
83    // would be mislabelled "UTF-8 Unicode text".
84    let has_non_ascii_byte = buffer.iter().any(|&b| b >= 0x80);
85    let no_binary_control_byte = buffer.iter().all(|&b| b >= 0x80 || is_text_safe_byte(b));
86    if has_non_ascii_byte && no_binary_control_byte && std::str::from_utf8(buffer).is_ok() {
87        return "UTF-8 Unicode text";
88    }
89
90    "data"
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn classifies_empty_buffer_as_empty() {
99        assert_eq!(classify_fallback(b""), "empty");
100    }
101
102    #[test]
103    fn classifies_plain_ascii_as_ascii_text() {
104        let cases: &[(&str, &[u8])] = &[
105            ("simple sentence", b"hello world this is plain text\n"),
106            ("tabs and newlines", b"a\tb\nc\r\nd\n"),
107            ("vertical tab and form feed", b"a\x0bb\x0cc\n"),
108            ("bell, backspace, escape", b"a\x07b\x08c\x1bd\n"),
109        ];
110        for (label, input) in cases {
111            assert_eq!(
112                classify_fallback(input),
113                "ASCII text",
114                "case {label:?} should classify as ASCII text"
115            );
116        }
117    }
118
119    #[test]
120    fn classifies_valid_non_ascii_utf8_as_utf8_unicode_text() {
121        let cases: &[(&str, &[u8])] = &[
122            ("accented latin", "caf\u{e9} r\u{e9}sum\u{e9}\n".as_bytes()),
123            ("bom prefix", &[0xEF, 0xBB, 0xBF, b'h', b'i']),
124            ("multi-byte cjk", "\u{4f60}\u{597d}\n".as_bytes()),
125        ];
126        for (label, input) in cases {
127            assert_eq!(
128                classify_fallback(input),
129                "UTF-8 Unicode text",
130                "case {label:?} should classify as UTF-8 Unicode text"
131            );
132        }
133    }
134
135    #[test]
136    fn classifies_binary_content_as_data() {
137        let cases: &[(&str, &[u8])] = &[
138            ("null byte in otherwise-ascii text", b"hello\x00world\n"),
139            ("random high bytes", &[0x80, 0x81, 0x82, 0x83]),
140            ("invalid utf8 continuation-only", &[0xC0, 0x80]),
141            ("elf magic", &[0x7f, b'E', b'L', b'F']),
142        ];
143        for (label, input) in cases {
144            assert_eq!(
145                classify_fallback(input),
146                "data",
147                "case {label:?} should classify as data"
148            );
149        }
150    }
151}