libmagic_rs/output/ascmagic/mod.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 ports the byte-level subset of GNU `file`'s `ascmagic.c` and
18//! `encoding.c`: ASCII, UTF-8, ISO-8859 and non-ISO extended-ASCII
19//! classification, plus the line-terminator, long-line, escape and
20//! overstrike qualifiers. Still out of scope (#524): UTF-16/UTF-32 and
21//! EBCDIC, which classify as `"data"`; a UTF-8 BOM, which is not stripped
22//! and classifies as UTF-8 text; and UTF-7, which reads as plain ASCII.
23
24/// Classification of a single byte in GNU `file`'s `encoding.c::text_chars`
25/// table.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub(crate) enum TextClass {
28 /// `F`: never text (NUL, most C0 controls, DEL).
29 Binary,
30 /// `T`: plain text (BEL BS HT LF VT FF CR ESC, 0x20..=0x7E, NEL).
31 Text,
32 /// `I`: ISO-8859 high half (0xA0..=0xFF).
33 Latin1,
34 /// `X`: C1 controls (0x80..=0x9F except NEL); "non-ISO extended ASCII".
35 Extended,
36}
37
38/// Port of upstream `text_chars[256]` (`encoding.c`).
39pub(crate) const fn text_char_class(b: u8) -> TextClass {
40 match b {
41 0x07..=0x0D | 0x1B | 0x20..=0x7E | 0x85 => TextClass::Text,
42 0x80..=0x9F => TextClass::Extended,
43 0xA0..=0xFF => TextClass::Latin1,
44 _ => TextClass::Binary,
45 }
46}
47
48/// Result of [`looks_utf8`], mirroring upstream `file_looks_utf8`'s
49/// `-1 / 0 / 1 / 2` return codes.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub(crate) enum Utf8Look {
52 /// `-1`: not valid UTF-8.
53 Invalid,
54 /// `0`: valid, but uses control bytes the text table rejects.
55 Control,
56 /// `1`: 7-bit text. A lead byte truncated at EOF is ignored (the scan
57 /// just stops), so the result reflects only what came before it.
58 Ascii,
59 /// `2`: valid UTF-8 with at least one multi-byte character.
60 Multibyte,
61}
62
63/// Lead-byte shape from upstream's `first[]` / `accept_ranges[]` tables:
64/// (continuation count, lowest and highest value accepted for the first
65/// continuation byte). `None` is an invalid lead byte.
66const fn utf8_lead_shape(b: u8) -> Option<(usize, u8, u8)> {
67 match b {
68 0xC2..=0xDF => Some((1, 0x80, 0xBF)),
69 0xE0 => Some((2, 0xA0, 0xBF)),
70 0xE1..=0xEC | 0xEE..=0xEF => Some((2, 0x80, 0xBF)),
71 0xED => Some((2, 0x80, 0x9F)),
72 0xF0 => Some((3, 0x90, 0xBF)),
73 0xF1..=0xF3 => Some((3, 0x80, 0xBF)),
74 0xF4 => Some((3, 0x80, 0x8F)),
75 _ => None,
76 }
77}
78
79/// Direct port of `encoding.c::file_looks_utf8`, quirks included: a lead
80/// byte truncated by end-of-buffer is not an error (the scan just stops),
81/// and a rejected control byte anywhere yields [`Utf8Look::Control`] even
82/// when multi-byte characters were seen.
83pub(crate) fn looks_utf8(buf: &[u8]) -> Utf8Look {
84 let finish = |ctrl: bool, gotone: bool| match (ctrl, gotone) {
85 (true, _) => Utf8Look::Control,
86 (false, true) => Utf8Look::Multibyte,
87 (false, false) => Utf8Look::Ascii,
88 };
89 let mut gotone = false;
90 let mut ctrl = false;
91 let mut i = 0;
92 while let Some(&b) = buf.get(i) {
93 i += 1;
94 if b & 0x80 == 0 {
95 if text_char_class(b) != TextClass::Text {
96 ctrl = true;
97 }
98 continue;
99 }
100 if b & 0x40 == 0 {
101 return Utf8Look::Invalid;
102 }
103 let Some((following, lo, hi)) = utf8_lead_shape(b) else {
104 return Utf8Look::Invalid;
105 };
106 for n in 0..following {
107 let Some(&c) = buf.get(i) else {
108 return finish(ctrl, gotone);
109 };
110 i += 1;
111 if (n == 0 && !(lo..=hi).contains(&c)) || c & 0xC0 != 0x80 {
112 return Utf8Look::Invalid;
113 }
114 }
115 gotone = true;
116 }
117 finish(ctrl, gotone)
118}
119
120/// The text/data class of a buffer, in upstream `file_encoding` order.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub(crate) enum TextEncoding {
123 /// A zero-byte buffer.
124 Empty,
125 /// Only bytes of [`TextClass::Text`].
126 Ascii,
127 /// Valid UTF-8 with a multi-byte character and no rejected control byte.
128 Utf8,
129 /// Text bytes plus the ISO-8859 high half (`looks_latin1`).
130 Latin1,
131 /// Text and ISO-8859 bytes plus C1 controls (`looks_extended`).
132 Extended,
133 /// Anything else, including multi-byte encodings not ported (#524).
134 Data,
135}
136
137impl TextEncoding {
138 /// The label GNU `file` prints for the class.
139 pub(crate) const fn label(self) -> &'static str {
140 match self {
141 Self::Empty => "empty",
142 Self::Ascii => "ASCII text",
143 Self::Utf8 => "Unicode text, UTF-8 text",
144 Self::Latin1 => "ISO-8859 text",
145 Self::Extended => "Non-ISO extended-ASCII text",
146 Self::Data => "data",
147 }
148 }
149
150 /// Whether the buffer gets the text pass and the `, <class>` tail.
151 pub(crate) const fn is_text(self) -> bool {
152 !matches!(self, Self::Empty | Self::Data)
153 }
154}
155
156/// Classify a buffer in upstream `file_encoding` order: empty, ASCII,
157/// multi-byte UTF-8, ISO-8859, non-ISO extended ASCII, else data.
158/// Infallible: no input fails to classify.
159#[must_use]
160pub(crate) fn classify(buffer: &[u8]) -> TextEncoding {
161 if buffer.is_empty() {
162 return TextEncoding::Empty;
163 }
164 if buffer
165 .iter()
166 .all(|&b| text_char_class(b) == TextClass::Text)
167 {
168 return TextEncoding::Ascii;
169 }
170 // Upstream `file_encoding` order: valid multi-byte UTF-8 with no rejected
171 // control byte is Unicode text (`encoding.c` code "Unicode text, UTF-8" +
172 // type "text"). A NUL inside otherwise-valid UTF-8 is `Control`, not text.
173 if looks_utf8(buffer) == Utf8Look::Multibyte {
174 return TextEncoding::Utf8;
175 }
176 // Upstream `looks_latin1` then `looks_extended`.
177 if buffer
178 .iter()
179 .all(|&b| matches!(text_char_class(b), TextClass::Text | TextClass::Latin1))
180 {
181 return TextEncoding::Latin1;
182 }
183 if buffer
184 .iter()
185 .all(|&b| text_char_class(b) != TextClass::Binary)
186 {
187 return TextEncoding::Extended;
188 }
189 TextEncoding::Data
190}
191
192/// The label of the crate-private `classify` result: the text/data
193/// fallback described in the module doc.
194///
195/// Returns one of `"empty"`, `"ASCII text"`, `"Unicode text, UTF-8 text"`,
196/// `"ISO-8859 text"`, `"Non-ISO extended-ASCII text"`, or `"data"`.
197///
198/// # Examples
199///
200/// ```
201/// use libmagic_rs::output::ascmagic::classify_fallback;
202///
203/// assert_eq!(classify_fallback(b""), "empty");
204/// assert_eq!(classify_fallback(b"hello world\n"), "ASCII text");
205/// assert_eq!(classify_fallback(&[0x00, 0x01, 0x02, 0xff]), "data");
206/// ```
207#[must_use]
208pub fn classify_fallback(buffer: &[u8]) -> &'static str {
209 classify(buffer).label()
210}
211
212/// Upstream `MAXLINELEN` (`ascmagic.c`): longest line not reported as long.
213const MAXLINELEN: usize = 300;
214/// Upstream `FILE_BYTES_MAX` (`file.h`): how much of a file `file` reads.
215/// 7 MiB since file 5.44 (1 MiB through 5.43).
216const BYTES_MAX: usize = 7 * 1_048_576;
217/// Upstream `FILE_ENCODING_MAX` (`file.h`): how much of that read
218/// `file_encoding` and the text pass inspect.
219const ENCODING_MAX: usize = 65_536;
220
221/// The views of a buffer that GNU `file`'s text handling works on.
222#[derive(Debug, Clone, Copy)]
223pub(crate) struct TextWindow<'a> {
224 /// First `ENCODING_MAX` bytes of the read after trailing NULs are
225 /// trimmed (`file_ascmagic`'s `trim_nuls` plus its UTF-16 parity
226 /// restore): what is classified and
227 /// scanned for qualifiers; the text pass evaluates its UTF-8 widening
228 /// ([`text_pass_buffer`]).
229 pub(crate) scan: &'a [u8],
230 /// First `ENCODING_MAX` bytes of the untrimmed read: what
231 /// `file_buffer`'s `looks_text` hint for the `/b` and `/t` skips sees.
232 pub(crate) hint: &'a [u8],
233}
234
235/// Upstream `trim_nuls`: drop trailing NULs, keeping at least one byte.
236/// `file_ascmagic` then restores one byte when trimming an even-length
237/// read left an odd length, so a UTF-16LE text file keeps its last unit.
238fn trim_nuls(buf: &[u8]) -> &[u8] {
239 let mut len = buf.len();
240 while len > 1 && buf.get(len - 1) == Some(&0) {
241 len -= 1;
242 }
243 if !len.is_multiple_of(2) && buf.len().is_multiple_of(2) {
244 len += 1;
245 }
246 buf.get(..len).unwrap_or(buf)
247}
248
249/// The views of `buffer` the text classification is computed over.
250///
251/// `file` reads at most `BYTES_MAX` bytes and inspects at most
252/// `ENCODING_MAX` of them for text, so looking further would both diverge
253/// from it and cost a full scan on every evaluation.
254#[must_use]
255pub(crate) fn text_window(buffer: &[u8]) -> TextWindow<'_> {
256 let read = buffer.get(..BYTES_MAX).unwrap_or(buffer);
257 let trimmed = trim_nuls(read);
258 TextWindow {
259 scan: trimmed.get(..ENCODING_MAX).unwrap_or(trimmed),
260 hint: read.get(..ENCODING_MAX).unwrap_or(read),
261 }
262}
263
264/// The longest prefix of `buf` that is complete UTF-8: `file_looks_utf8`
265/// decodes into `ubuf` only finished code points, so a sequence cut off by
266/// the window end is dropped.
267fn complete_utf8_prefix(buf: &[u8]) -> &[u8] {
268 match std::str::from_utf8(buf) {
269 Ok(_) => buf,
270 Err(e) => buf.get(..e.valid_up_to()).unwrap_or(buf),
271 }
272}
273
274/// The bytes the text pass evaluates rules against: `encode_utf8(ubuf)`.
275///
276/// `file_encoding` decodes the window into code points and `file_ascmagic`
277/// runs its softmagic pass over their UTF-8 encoding. For the single-byte
278/// classes (ASCII, whose only high byte is NEL, `ISO-8859 text`,
279/// `Non-ISO extended-ASCII text`) each byte is one code point, so every
280/// byte at or above 0x80 widens to two bytes; a UTF-8 window is already
281/// encoded but loses a sequence the window end cut through. Anything else
282/// (`data`, multi-byte encodings, #524) is passed through.
283pub(crate) fn text_pass_buffer(scan: &[u8], class: TextEncoding) -> std::borrow::Cow<'_, [u8]> {
284 use std::borrow::Cow;
285 match class {
286 TextEncoding::Utf8 => Cow::Borrowed(complete_utf8_prefix(scan)),
287 TextEncoding::Ascii | TextEncoding::Latin1 | TextEncoding::Extended
288 if !scan.iter().all(u8::is_ascii) =>
289 {
290 let mut out = Vec::with_capacity(scan.len() * 2);
291 for &b in scan {
292 if b < 0x80 {
293 out.push(b);
294 } else {
295 out.push(0xC0 | (b >> 6));
296 out.push(0x80 | (b & 0x3F));
297 }
298 }
299 Cow::Owned(out)
300 }
301 TextEncoding::Ascii
302 | TextEncoding::Latin1
303 | TextEncoding::Extended
304 | TextEncoding::Empty
305 | TextEncoding::Data => Cow::Borrowed(scan),
306 }
307}
308
309/// Code point of the X3.64 "next line" character.
310const NEL: u32 = 0x85;
311
312/// Port of the qualifier scan in `ascmagic.c::file_ascmagic_with_encoding`.
313///
314/// Returns the text-class suffix (each piece begins with `, with`), or an
315/// empty string when nothing applies. Scans Unicode scalar values when
316/// [`looks_utf8`] reports multi-byte UTF-8 (over the complete prefix, as
317/// `ubuf` holds only finished code points), otherwise bytes. A lone
318/// trailing CR counts for nothing: file 5.45 dropped 5.41's post-loop
319/// `seen_cr` flush.
320pub(crate) fn text_qualifiers(text: &[u8]) -> String {
321 if looks_utf8(text) == Utf8Look::Multibyte
322 && let Ok(s) = std::str::from_utf8(complete_utf8_prefix(text))
323 {
324 return scan_qualifiers(s.chars().map(u32::from));
325 }
326 scan_qualifiers(text.iter().map(|&b| u32::from(b)))
327}
328
329fn scan_qualifiers(code_points: impl Iterator<Item = u32>) -> String {
330 let (mut n_crlf, mut n_cr, mut n_lf, mut n_nel) = (0_usize, 0_usize, 0_usize, 0_usize);
331 let (mut has_escapes, mut has_backspace, mut seen_cr) = (false, false, false);
332 let mut longest = 0_usize;
333 // Index of the first char of the current line (upstream last_line_end + 1).
334 let mut line_start = 0_usize;
335 for (i, c) in code_points.enumerate() {
336 if c == u32::from(b'\n') {
337 if seen_cr {
338 n_crlf += 1;
339 } else {
340 n_lf += 1;
341 }
342 line_start = i + 1;
343 } else if seen_cr {
344 n_cr += 1;
345 }
346 seen_cr = c == u32::from(b'\r');
347 if seen_cr {
348 line_start = i + 1;
349 }
350 if c == NEL {
351 n_nel += 1;
352 line_start = i + 1;
353 }
354 let line_len = i + 1 - line_start;
355 if line_len > MAXLINELEN {
356 longest = longest.max(line_len);
357 }
358 has_escapes |= c == 0x1B;
359 has_backspace |= c == 0x08;
360 }
361 let mut out = String::new();
362 if longest > 0 {
363 out.push_str(", with very long lines (");
364 out.push_str(&longest.to_string());
365 out.push(')');
366 }
367 let none = n_crlf == 0 && n_cr == 0 && n_nel == 0 && n_lf == 0;
368 if none || n_crlf != 0 || n_cr != 0 || n_nel != 0 {
369 let kinds: Vec<&str> = [(n_crlf, "CRLF"), (n_cr, "CR"), (n_lf, "LF"), (n_nel, "NEL")]
370 .iter()
371 .filter(|(n, _)| *n != 0)
372 .map(|&(_, name)| name)
373 .collect();
374 out.push_str(", with ");
375 out.push_str(&if none {
376 "no".to_string()
377 } else {
378 kinds.join(", ")
379 });
380 out.push_str(" line terminators");
381 }
382 if has_escapes {
383 out.push_str(", with escape sequences");
384 }
385 if has_backspace {
386 out.push_str(", with overstriking");
387 }
388 out
389}
390
391/// Port of file 5.45's description rewrite: swap a trailing ` text` (or
392/// ` text executable`) for the text class, then append `qualifiers`.
393pub(crate) fn append_text_class(desc: &str, class: &str, qualifiers: &str) -> String {
394 let mut head = if desc.is_empty() {
395 class.to_string()
396 } else if let Some(base) = desc.strip_suffix(" text") {
397 format!("{base}, {class}")
398 } else if let Some(base) = desc.strip_suffix(" text executable") {
399 format!("{base}, {class} executable")
400 } else {
401 format!("{desc}, {class}")
402 };
403 head.push_str(qualifiers);
404 head
405}
406
407#[cfg(test)]
408mod tests;