Skip to main content

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;