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}