Skip to main content

hyprforge_mime/
lookup.rs

1//! Putting the name and the contents together: what *is* this file.
2//!
3//! # The order, and why it is this one
4//!
5//! ```text
6//! 1. Is it a file at all?      inode/directory, inode/socket, ...
7//! 2. Strong magic (>= 80)      \x89PNG is a PNG whatever it is called
8//! 3. The name                  part.3mf is a model, though it is a zip
9//! 4. Weaker magic (< 80)       a last look at the contents
10//! 5. Fall back                 empty / text / bytes
11//! ```
12//!
13//! Every step of that is load bearing, and the two in the middle are the
14//! ones implementations get wrong in opposite directions:
15//!
16//! - **Contents only** — what `file --mime-type` does, and what
17//!   `xdg-open` falls back to on a desktop it does not recognise — calls
18//!   a `.stl` `application/octet-stream`, a `.3mf` `application/zip` and
19//!   a `.blend` `application/zstd`. All true about the bytes; none has a
20//!   default application, so the file opens in a web browser.
21//! - **Name only** believes a `.txt` that is really a JPEG, and has
22//!   nothing at all to say about a file called `download`.
23//!
24//! Strong magic first is what settles the disagreement: a rule the
25//! database marks 80 or above is one nobody should doubt (a PNG header,
26//! a PDF header), so it beats a filename. Everything below that yields
27//! to the name, because a name is a statement of intent and a weak magic
28//! rule is a guess.
29//!
30//! This is the same order `File::MimeInfo::Magic` uses, which is the
31//! order `xdg-open` would follow if the perl package happened to be
32//! installed — so a machine with this crate on it answers the same
33//! question the same way, rather than a third way.
34
35use crate::globs::Globs;
36use crate::magic::Magic;
37use crate::types::{self, Types};
38use std::path::Path;
39
40/// How the answer was reached. Kept because the difference matters to a
41/// caller: a type from a filename is a statement about intent, one from
42/// contents is a statement about bytes, and the fallbacks are neither.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum How {
45    /// It is not an ordinary file.
46    Inode,
47    /// A magic rule the database marks 80 or above.
48    StrongMagic,
49    /// A filename rule.
50    Name,
51    /// A weaker magic rule.
52    Magic,
53    /// Nothing recognised it: empty, text, or bytes.
54    Fallback,
55}
56
57/// What a file turned out to be.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Found {
60    pub mime: String,
61    pub how: How,
62}
63
64/// The whole database: names, contents, and the type graph.
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct Lookup {
67    pub globs: Globs,
68    pub magic: Magic,
69    pub types: Types,
70}
71
72/// Nothing recognised it and it reads as text.
73pub const TEXT: &str = "text/plain";
74/// Nothing recognised it and it does not.
75pub const BYTES: &str = "application/octet-stream";
76
77/// A magic rule at or above this priority beats the filename. The
78/// database's own scale, and the threshold every implementation uses.
79const STRONG: u32 = 80;
80
81impl Lookup {
82    /// Reads every part of the database from the given data directories.
83    pub fn load_from(data_dirs: &[std::path::PathBuf]) -> Lookup {
84        Lookup {
85            globs: Globs::load_from(data_dirs),
86            magic: Magic::load_from(data_dirs),
87            types: Types::load_from(data_dirs),
88        }
89    }
90
91    /// What this file is, reading it if the name does not settle it.
92    ///
93    /// `follow` decides whether a symlink is reported as itself or as
94    /// what it points at.
95    pub fn of_file(&self, path: &Path, follow: bool) -> Found {
96        if let Some(inode) = types::inode_type(path, follow) {
97            return Found { mime: inode.to_string(), how: How::Inode };
98        }
99        match crate::magic::head(path).map(|data| self.of_data_and_name(&data, Some(path))) {
100            Some(found) => found,
101            // Unreadable — no permission, or it went away between the
102            // listing and the question. The name is all there is, and
103            // saying "bytes" about a file nobody could read would be a
104            // claim rather than an answer.
105            None => match self.globs.type_of(path) {
106                Some(mime) => Found { mime: mime.to_string(), how: How::Name },
107                None => Found { mime: BYTES.to_string(), how: How::Fallback },
108            },
109        }
110    }
111
112    /// The same question for bytes already in hand, with the name if
113    /// there is one — a paste, a download, or standard input.
114    pub fn of_data_and_name(&self, data: &[u8], path: Option<&Path>) -> Found {
115        if let Some(found) = self.magic.of_data(data).filter(|m| m.priority >= STRONG) {
116            return Found { mime: found.mime, how: How::StrongMagic };
117        }
118        if let Some(mime) = path.and_then(|path| self.globs.type_of(path)) {
119            return Found { mime: mime.to_string(), how: How::Name };
120        }
121        if let Some(found) = self.magic.of_data(data) {
122            return Found { mime: found.mime, how: How::Magic };
123        }
124        Found { mime: fallback(data).to_string(), how: How::Fallback }
125    }
126
127    /// Whether an application registered for `parent` can be expected to
128    /// open a `mime` — the subclass question, asked the way a caller
129    /// means it.
130    pub fn is_subclass_of(&self, mime: &str, parent: &str) -> bool {
131        self.types.is_subclass_of(mime, parent)
132    }
133}
134
135/// What to call something nothing recognised.
136///
137/// An empty file is `text/plain`, which surprises people who expect
138/// `application/x-zerosize`: that name exists, and `gio` uses it, but
139/// nothing is registered to open it, so a desktop that answers with it
140/// has turned an empty `.svg` into a file with no application. This
141/// follows `File::MimeInfo` instead, which calls an empty file text —
142/// and an empty file with a known extension never reaches here at all,
143/// because the name is consulted first.
144///
145/// Otherwise the text test, because a shell script, a config file and a
146/// README with no extension are all openable by anything that takes
147/// `text/plain`, and calling them `application/octet-stream` closes
148/// that door for no reason.
149pub fn fallback(data: &[u8]) -> &'static str {
150    if data.is_empty() || looks_like_text(data) {
151        return TEXT;
152    }
153    BYTES
154}
155
156/// Whether these bytes read as text.
157///
158/// A control character that is not whitespace means binary. Valid UTF-8
159/// is not required — a Latin-1 file is still text, and demanding UTF-8
160/// would call it bytes.
161///
162/// Only the first 32 bytes, which is what `File::MimeInfo` looks at.
163/// It matters more than it sounds: a long text file with one stray
164/// control byte in the middle stays text, and a binary file whose first
165/// 32 bytes happen to be printable is called text by both
166/// implementations. Agreeing with the tool a desktop would otherwise
167/// have used is worth more here than being cleverer than it.
168fn looks_like_text(data: &[u8]) -> bool {
169    data.iter().take(32).all(|byte| match byte {
170        // Perl's `\s`: space, tab, newline, carriage return, form feed,
171        // vertical tab. Escape is *not* whitespace and does mean binary.
172        b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c => true,
173        // C0 controls and DEL. Everything at 0x80 and above is left
174        // alone: that is where non-ASCII text lives.
175        0x00..=0x1f | 0x7f => false,
176        _ => true,
177    })
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn lookup() -> Lookup {
185        let mut magic = Vec::new();
186        magic.extend(b"MIME-Magic\0\n");
187        // A strong rule: a PNG header is a PNG whatever the file is
188        // called.
189        magic.extend(b"[90:image/png]\n>0=");
190        magic.extend(4u16.to_be_bytes());
191        magic.extend(b"\x89PNG\n");
192        // A weak rule: "solid " opens an ASCII STL, and also plenty of
193        // ordinary sentences.
194        magic.extend(b"[50:model/stl]\n>0=");
195        magic.extend(6u16.to_be_bytes());
196        magic.extend(b"solid \n");
197        let mut types = Types::default();
198        types.add_subclasses("model/3mf application/zip\n");
199        Lookup {
200            globs: Globs::parse("50:model/3mf:*.3mf\n50:text/plain:*.txt\n50:image/jpeg:*.jpg\n"),
201            magic: Magic::parse(&magic),
202            types,
203        }
204    }
205
206    fn write(dir: &tempfile::TempDir, name: &str, contents: &[u8]) -> std::path::PathBuf {
207        let path = dir.path().join(name);
208        std::fs::write(&path, contents).unwrap();
209        path
210    }
211
212    /// The case the whole crate exists for: the name knows, and the
213    /// contents only say "it is a zip".
214    #[test]
215    fn a_name_beats_a_weak_guess_about_the_bytes() {
216        let dir = tempfile::tempdir().unwrap();
217        let found = lookup().of_file(&write(&dir, "part.3mf", b"PK\x03\x04zip data"), true);
218        assert_eq!(found, Found { mime: "model/3mf".to_string(), how: How::Name });
219    }
220
221    /// And the other way: a strong magic rule beats a name, because a
222    /// file called `notes.txt` that begins `\x89PNG` is a PNG.
223    #[test]
224    fn strong_magic_beats_a_name_that_lies() {
225        let dir = tempfile::tempdir().unwrap();
226        let found = lookup().of_file(&write(&dir, "notes.txt", b"\x89PNG\r\n\x1a\n"), true);
227        assert_eq!(found, Found { mime: "image/png".to_string(), how: How::StrongMagic });
228    }
229
230    /// A weak rule does not: "solid " starts an ASCII STL and also
231    /// plenty of English sentences, so the name is the better evidence.
232    #[test]
233    fn a_weak_rule_yields_to_the_name() {
234        let dir = tempfile::tempdir().unwrap();
235        let found = lookup().of_file(&write(&dir, "notes.txt", b"solid ground underfoot"), true);
236        assert_eq!(found.mime, "text/plain");
237        assert_eq!(found.how, How::Name);
238    }
239
240    /// With no name to go on, the weak rule is still better than
241    /// nothing — this is the file called `download`.
242    #[test]
243    fn a_file_with_no_useful_name_falls_back_to_its_contents() {
244        let dir = tempfile::tempdir().unwrap();
245        let found = lookup().of_file(&write(&dir, "download", b"solid model\n"), true);
246        assert_eq!(found, Found { mime: "model/stl".to_string(), how: How::Magic });
247    }
248
249    #[test]
250    fn something_that_is_not_a_file_is_answered_first() {
251        let dir = tempfile::tempdir().unwrap();
252        let folder = dir.path().join("photos.jpg");
253        std::fs::create_dir(&folder).unwrap();
254        assert_eq!(
255            lookup().of_file(&folder, true),
256            Found { mime: "inode/directory".to_string(), how: How::Inode },
257            "a folder called photos.jpg is still a folder"
258        );
259    }
260
261    #[test]
262    fn nothing_recognised_is_text_or_bytes() {
263        let dir = tempfile::tempdir().unwrap();
264        assert_eq!(
265            lookup().of_file(&write(&dir, "nothing", b""), true).mime,
266            TEXT,
267            "an empty file is text, not a type nothing can open"
268        );
269        assert_eq!(lookup().of_file(&write(&dir, "readme", b"hello there\n"), true).mime, TEXT);
270        assert_eq!(lookup().of_file(&write(&dir, "blob", b"\x00\x01\x02binary"), true).mime, BYTES);
271    }
272
273    /// An empty file with a name the database knows keeps that name's
274    /// answer — the step order puts globs before the fallback. `gio`
275    /// says `application/x-zerosize` for these and so has nothing to
276    /// open them with.
277    #[test]
278    fn an_empty_file_still_has_the_type_its_name_gives() {
279        let dir = tempfile::tempdir().unwrap();
280        let found = lookup().of_file(&write(&dir, "empty.jpg", b""), true);
281        assert_eq!(found, Found { mime: "image/jpeg".to_string(), how: How::Name });
282    }
283
284    #[test]
285    fn text_with_accents_is_still_text() {
286        assert!(looks_like_text("café — naïve\n".as_bytes()));
287        assert!(looks_like_text(&[0xe9, 0xe8, b'\n']), "latin-1, not utf-8, still text");
288        assert!(!looks_like_text(b"\x00\x01"));
289        assert!(!looks_like_text(b"\x1b[0;31mred\x1b[0m"), "escape means binary, not whitespace");
290    }
291
292    /// Only the first 32 bytes are judged, which is what the tool this
293    /// agrees with does.
294    #[test]
295    fn a_stray_control_byte_later_on_does_not_make_a_file_binary() {
296        let mut data = b"a text file, for the first thirty-two bytes at least".to_vec();
297        data.push(0x00);
298        assert!(looks_like_text(&data));
299    }
300
301    /// A file that cannot be read is not a file full of bytes: the name
302    /// is all there is, and it is used.
303    #[test]
304    fn an_unreadable_file_is_typed_by_its_name() {
305        let dir = tempfile::tempdir().unwrap();
306        let path = write(&dir, "secret.3mf", b"PK\x03\x04");
307        let mut perms = std::fs::metadata(&path).unwrap().permissions();
308        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o000);
309        std::fs::set_permissions(&path, perms).unwrap();
310        // Running as root defeats the point of the test, and CI may.
311        if std::fs::read(&path).is_ok() {
312            eprintln!("HYPRFORGE-SKIP: this user can read a mode 000 file (root?)");
313            return;
314        }
315        assert_eq!(lookup().of_file(&path, true).mime, "model/3mf");
316    }
317
318    #[test]
319    fn data_without_a_name_is_answered_too() {
320        let found = lookup().of_data_and_name(b"\x89PNG\r\n", None);
321        assert_eq!(found.mime, "image/png");
322        assert_eq!(lookup().of_data_and_name(b"plain words", None).mime, TEXT);
323    }
324}