Skip to main content

hyprforge_mime/
types.rs

1//! What the database knows about a *type*, as opposed to about a file:
2//! its other names, what it is a kind of, and what to call it in front
3//! of a person.
4//!
5//! Three more plain files next to `globs2`:
6//!
7//! - `aliases` — `alias canonical`, one per line. `application/acrobat`
8//!   is `application/pdf` under an older name, and a lookup that does
9//!   not resolve it will miss a perfectly good association.
10//! - `subclasses` — `child parent`. A `.3mf` is a zip and a
11//!   `application/yaml` is text; an application registered for the
12//!   parent can open the child.
13//! - `<media>/<subtype>.xml` — the type's `<comment>`, translated. "STL
14//!   3D model" rather than `model/stl`.
15//!
16//! # The two subclass rules that are not in the file
17//!
18//! The specification states them in prose and every implementation has
19//! to add them by hand: every `text/*` is a subclass of `text/plain`,
20//! and everything whatsoever is a subclass of
21//! `application/octet-stream`. Leave them out and "can this application
22//! open this file" answers no for every text editor and every file.
23
24use std::collections::BTreeMap;
25use std::path::Path;
26
27/// The type graph: aliases, subclasses and descriptions.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct Types {
30    /// Alias to the name the rest of the database uses.
31    aliases: BTreeMap<String, String>,
32    /// Child to its direct parents.
33    parents: BTreeMap<String, Vec<String>>,
34    /// Type to what a person should be shown.
35    descriptions: BTreeMap<String, String>,
36}
37
38impl Types {
39    /// Reads `aliases` and `subclasses` from every directory given.
40    ///
41    /// Descriptions are *not* read here: they are one XML file per type,
42    /// some thousands of them, and nothing needs them until something
43    /// asks for one. [`Types::describe_from`] reads the one file.
44    pub fn load_from(dirs: &[std::path::PathBuf]) -> Types {
45        let mut types = Types::default();
46        for dir in dirs {
47            let mime = dir.join("mime");
48            if let Ok(text) = std::fs::read_to_string(mime.join("aliases")) {
49                types.add_aliases(&text);
50            }
51            if let Ok(text) = std::fs::read_to_string(mime.join("subclasses")) {
52                types.add_subclasses(&text);
53            }
54        }
55        types
56    }
57
58    /// `alias canonical`, one per line. The first file to name an alias
59    /// keeps it, so a user's own database entry beats the system's.
60    pub fn add_aliases(&mut self, text: &str) {
61        for (from, to) in pairs(text) {
62            self.aliases.entry(from).or_insert(to);
63        }
64    }
65
66    /// `child parent`, one per line. A type can have several parents,
67    /// and each is kept.
68    pub fn add_subclasses(&mut self, text: &str) {
69        for (child, parent) in pairs(text) {
70            let parents = self.parents.entry(child).or_default();
71            if !parents.contains(&parent) {
72                parents.push(parent);
73            }
74        }
75    }
76
77    /// The name the rest of the database uses for this type.
78    ///
79    /// Aliases do not chain in practice, but a database that claimed
80    /// they did would otherwise hang here, so this follows at most a
81    /// few links and then stops.
82    pub fn canonical<'a>(&'a self, mime: &'a str) -> &'a str {
83        let mut name = mime;
84        for _ in 0..4 {
85            match self.aliases.get(name) {
86                Some(next) if next != name => name = next,
87                _ => break,
88            }
89        }
90        name
91    }
92
93    /// Whether `mime` is `parent`, or a kind of it.
94    ///
95    /// Includes the two rules the file does not carry — see the module
96    /// doc — so `text/x-shellscript` is a `text/plain` and everything is
97    /// an `application/octet-stream`.
98    pub fn is_subclass_of(&self, mime: &str, parent: &str) -> bool {
99        let mime = self.canonical(mime);
100        let parent = self.canonical(parent);
101        if mime == parent || parent == "application/octet-stream" {
102            return true;
103        }
104        if parent == "text/plain" && mime.starts_with("text/") {
105            return true;
106        }
107        // Depth-first, with a seen set: the database is generated, but
108        // a hand-written package in a user's own directory can make a
109        // cycle, and a cycle must not hang a file manager.
110        let mut seen: Vec<&str> = vec![mime];
111        let mut stack: Vec<&str> = self.parents.get(mime).into_iter().flatten().map(String::as_str).collect();
112        while let Some(next) = stack.pop() {
113            let next = self.canonical(next);
114            if next == parent {
115                return true;
116            }
117            if seen.contains(&next) {
118                continue;
119            }
120            seen.push(next);
121            stack.extend(self.parents.get(next).into_iter().flatten().map(String::as_str));
122        }
123        false
124    }
125
126    /// Every type this one is a kind of, nearest first — for finding an
127    /// application when nothing is registered for the exact type. A 3MF
128    /// with no 3MF viewer can still be opened by an archive manager.
129    pub fn ancestors(&self, mime: &str) -> Vec<String> {
130        let mut out: Vec<String> = Vec::new();
131        let mut stack: Vec<String> = vec![self.canonical(mime).to_string()];
132        while let Some(next) = stack.pop() {
133            for parent in self.parents.get(&next).into_iter().flatten() {
134                let parent = self.canonical(parent).to_string();
135                if !out.contains(&parent) {
136                    out.push(parent.clone());
137                    stack.push(parent);
138                }
139            }
140        }
141        if mime.starts_with("text/") && !out.iter().any(|p| p == "text/plain") && mime != "text/plain" {
142            out.push("text/plain".to_string());
143        }
144        if mime != "application/octet-stream" {
145            out.push("application/octet-stream".to_string());
146        }
147        out
148    }
149
150    /// Reads one type's description out of `<media>/<subtype>.xml`,
151    /// remembering it.
152    ///
153    /// `language` is an `xml:lang` to prefer — `"de"` finds
154    /// `<comment xml:lang="de">`; the untagged comment is the fallback,
155    /// which is what a request for English gets, since the database
156    /// leaves English untagged.
157    pub fn describe_from(&mut self, dirs: &[std::path::PathBuf], mime: &str, language: Option<&str>) -> Option<&str> {
158        let canonical = self.canonical(mime).to_string();
159        if !self.descriptions.contains_key(&canonical) {
160            if let Some(comment) = description_of(dirs, &canonical, language) {
161                self.descriptions.insert(canonical.clone(), comment);
162            }
163        }
164        self.descriptions.get(&canonical).map(String::as_str)
165    }
166
167    /// Whether anything was loaded.
168    pub fn is_empty(&self) -> bool {
169        self.aliases.is_empty() && self.parents.is_empty()
170    }
171}
172
173/// One type's description, read and not remembered.
174///
175/// The uncached half of [`Types::describe_from`], public because the
176/// caller that needs a screenful of descriptions at once has nowhere to
177/// put a cache: it holds the database behind an `Arc` and cannot borrow
178/// it mutably. Reading a handful of small files off the UI thread is
179/// the cheaper of the two problems.
180///
181/// `mime` must already be canonical — this reads a file named after it
182/// and resolves no aliases, which is the difference between this and
183/// the method.
184pub fn description_of(
185    dirs: &[std::path::PathBuf],
186    mime: &str,
187    language: Option<&str>,
188) -> Option<String> {
189    let (media, subtype) = mime.split_once('/')?;
190    for dir in dirs {
191        let path = dir.join("mime").join(media).join(format!("{subtype}.xml"));
192        if let Ok(text) = std::fs::read_to_string(&path) {
193            if let Some(comment) = comment_in(&text, language) {
194                return Some(comment);
195            }
196        }
197    }
198    None
199}
200
201/// `a b` per line, ignoring blanks and comments.
202fn pairs(text: &str) -> impl Iterator<Item = (String, String)> + '_ {
203    text.lines().filter_map(|line| {
204        let line = line.trim();
205        if line.is_empty() || line.starts_with('#') {
206            return None;
207        }
208        let (a, b) = line.split_once(char::is_whitespace)?;
209        Some((a.trim().to_string(), b.trim().to_string()))
210    })
211}
212
213/// The `<comment>` for a language, or the untagged one.
214///
215/// Hand-written rather than an XML parser: these files are generated by
216/// `update-mime-database` in a fixed shape, and the one element wanted
217/// is on its own line. A dependency on a full parser to read one tag out
218/// of a generated file would be the larger risk — but this is why the
219/// function is conservative, taking only what is between the tags it
220/// recognises and nothing else.
221fn comment_in(xml: &str, language: Option<&str>) -> Option<String> {
222    let mut untagged = None;
223    for line in xml.lines() {
224        let line = line.trim();
225        let Some(rest) = line.strip_prefix("<comment") else { continue };
226        let Some(end) = rest.find("</comment>") else { continue };
227        let Some(open) = rest.find('>') else { continue };
228        if open > end {
229            continue;
230        }
231        let text = unescape(&rest[open + 1..end]);
232        match (rest[..open].split_once("xml:lang=\""), language) {
233            // `<comment xml:lang="de">`, and German was asked for.
234            (Some((_, tail)), Some(wanted)) if tail.starts_with(&format!("{wanted}\"")) => {
235                return Some(text);
236            }
237            (Some(_), _) => continue,
238            // The untagged one. Kept rather than returned, so a
239            // requested language later in the file still wins.
240            (None, _) => untagged = untagged.or(Some(text)),
241        }
242    }
243    untagged
244}
245
246/// The five XML entities `update-mime-database` writes.
247fn unescape(text: &str) -> String {
248    text.replace("&lt;", "<")
249        .replace("&gt;", ">")
250        .replace("&quot;", "\"")
251        .replace("&apos;", "'")
252        .replace("&amp;", "&")
253}
254
255/// Every `mime` directory to read, for a caller that has its own list
256/// of XDG data directories.
257pub fn mime_dirs(data_dirs: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
258    data_dirs.iter().map(|dir| dir.join("mime")).collect()
259}
260
261/// Whether `path` is something other than an ordinary file, and what
262/// the database calls that.
263///
264/// The specification's own first step, and the one every content
265/// sniffer forgets: a socket is not "an empty file", and a directory is
266/// `inode/directory` rather than whatever its name suggests — a folder
267/// called `notes.txt` is still a folder.
268///
269/// `follow` is whether a symlink is reported as what it points at.
270pub fn inode_type(path: &Path, follow: bool) -> Option<&'static str> {
271    use std::os::unix::fs::FileTypeExt;
272    let meta = match follow {
273        true => std::fs::metadata(path).ok()?,
274        false => std::fs::symlink_metadata(path).ok()?,
275    };
276    let kind = meta.file_type();
277    if kind.is_file() {
278        return None;
279    }
280    Some(if kind.is_dir() {
281        // A directory on a different device from its parent is where
282        // something is mounted, and the database has a name for that:
283        // `x-content/*` handlers and "eject" actions hang off it.
284        match mounted_here(path, &meta) {
285            true => "inode/mount-point",
286            false => "inode/directory",
287        }
288    } else if kind.is_symlink() {
289        // Only reachable with `follow` false, or a broken link.
290        "inode/symlink"
291    } else if kind.is_fifo() {
292        "inode/fifo"
293    } else if kind.is_socket() {
294        "inode/socket"
295    } else if kind.is_block_device() {
296        "inode/blockdevice"
297    } else if kind.is_char_device() {
298        "inode/chardevice"
299    } else {
300        "inode/unknown"
301    })
302}
303
304/// Whether this directory is where a filesystem is mounted — its
305/// device differs from its parent's.
306///
307/// The root directory is its own parent, so it compares equal and is
308/// reported as an ordinary directory rather than a mount point. That is
309/// what `File::MimeInfo` does too, and arguing with it would mean every
310/// implementation on the machine disagreeing about `/`.
311fn mounted_here(path: &Path, meta: &std::fs::Metadata) -> bool {
312    use std::os::unix::fs::MetadataExt;
313    let Ok(absolute) = std::fs::canonicalize(path) else { return false };
314    let Some(parent) = absolute.parent() else { return false };
315    match std::fs::metadata(parent) {
316        Ok(parent) => parent.dev() != meta.dev(),
317        Err(_) => false,
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn graph() -> Types {
326        let mut types = Types::default();
327        types.add_aliases("application/acrobat application/pdf\napplication/x-pdf application/pdf\n");
328        types.add_subclasses(
329            "model/3mf application/zip\n\
330             application/zip application/octet-stream\n\
331             text/x-shellscript text/plain\n\
332             application/yaml text/plain\n",
333        );
334        types
335    }
336
337    #[test]
338    fn an_alias_resolves_to_the_name_the_database_uses() {
339        assert_eq!(graph().canonical("application/acrobat"), "application/pdf");
340        assert_eq!(graph().canonical("application/pdf"), "application/pdf");
341        assert_eq!(graph().canonical("model/stl"), "model/stl", "not an alias, unchanged");
342    }
343
344    #[test]
345    fn a_child_is_a_kind_of_its_parent_however_far_up() {
346        let types = graph();
347        assert!(types.is_subclass_of("model/3mf", "application/zip"));
348        assert!(types.is_subclass_of("model/3mf", "application/octet-stream"));
349        assert!(!types.is_subclass_of("application/zip", "model/3mf"), "not the other way round");
350    }
351
352    /// The two rules the file does not carry — see the module doc.
353    #[test]
354    fn every_text_is_plain_text_and_everything_is_bytes() {
355        let types = graph();
356        assert!(types.is_subclass_of("text/markdown", "text/plain"), "not in the file at all");
357        assert!(types.is_subclass_of("image/png", "application/octet-stream"));
358        assert!(types.is_subclass_of("model/stl", "model/stl"));
359        assert!(!types.is_subclass_of("image/png", "text/plain"));
360    }
361
362    #[test]
363    fn subclassing_sees_through_an_alias() {
364        let mut types = graph();
365        types.add_subclasses("application/pdf application/octet-stream\n");
366        assert!(types.is_subclass_of("application/acrobat", "application/octet-stream"));
367    }
368
369    /// A hand-written package in a user's own directory can make a
370    /// cycle, and a cycle must not hang the window asking the question.
371    #[test]
372    fn a_cycle_in_the_database_does_not_hang() {
373        let mut types = Types::default();
374        types.add_subclasses("a/one a/two\na/two a/one\n");
375        assert!(!types.is_subclass_of("a/one", "image/png"));
376        assert!(types.is_subclass_of("a/one", "a/two"));
377    }
378
379    #[test]
380    fn the_ancestors_are_listed_for_finding_an_application() {
381        let types = graph();
382        let ancestors = types.ancestors("model/3mf");
383        assert_eq!(ancestors.first().map(String::as_str), Some("application/zip"));
384        assert!(ancestors.contains(&"application/octet-stream".to_string()));
385
386        let text = types.ancestors("text/markdown");
387        assert!(text.contains(&"text/plain".to_string()));
388    }
389
390    #[test]
391    fn a_description_is_read_from_the_types_own_file() {
392        assert_eq!(
393            comment_in(
394                "<mime-type type=\"model/stl\">\n  <comment>STL 3D model</comment>\n  \
395                 <comment xml:lang=\"de\">STL-3D-Modell</comment>\n</mime-type>\n",
396                None
397            )
398            .as_deref(),
399            Some("STL 3D model")
400        );
401    }
402
403    #[test]
404    fn a_translation_is_preferred_when_one_is_asked_for() {
405        let xml = "<comment>STL 3D model</comment>\n<comment xml:lang=\"de\">STL-3D-Modell</comment>\n";
406        assert_eq!(comment_in(xml, Some("de")).as_deref(), Some("STL-3D-Modell"));
407        assert_eq!(comment_in(xml, Some("fr")).as_deref(), Some("STL 3D model"), "falls back");
408    }
409
410    #[test]
411    fn an_escaped_description_reads_as_the_characters_it_stands_for() {
412        assert_eq!(
413            comment_in("<comment>Bob &amp; Sons&apos; &lt;tag&gt;</comment>", None).as_deref(),
414            Some("Bob & Sons' <tag>")
415        );
416    }
417
418    #[test]
419    fn a_directory_is_a_directory_whatever_it_is_called() {
420        let dir = tempfile::tempdir().unwrap();
421        let folder = dir.path().join("notes.txt");
422        std::fs::create_dir(&folder).unwrap();
423        assert_eq!(inode_type(&folder, true), Some("inode/directory"));
424
425        let file = dir.path().join("real.txt");
426        std::fs::write(&file, b"x").unwrap();
427        assert_eq!(inode_type(&file, true), None, "an ordinary file is for the other rules");
428        assert_eq!(inode_type(&dir.path().join("nothing-here"), true), None);
429    }
430
431    /// `/` is its own parent, so the comparison says "same device" and
432    /// it reads as an ordinary directory — which is what every other
433    /// implementation on the machine says about it.
434    #[test]
435    fn the_root_directory_is_not_reported_as_a_mount_point() {
436        assert_eq!(inode_type(Path::new("/"), true), Some("inode/directory"));
437    }
438
439    #[test]
440    fn a_symlink_is_followed_or_reported_as_one() {
441        let dir = tempfile::tempdir().unwrap();
442        let target = dir.path().join("target.txt");
443        std::fs::write(&target, b"x").unwrap();
444        let link = dir.path().join("link.txt");
445        std::os::unix::fs::symlink(&target, &link).unwrap();
446        assert_eq!(inode_type(&link, true), None, "followed: an ordinary file");
447        assert_eq!(inode_type(&link, false), Some("inode/symlink"));
448    }
449}