1use std::collections::BTreeMap;
25use std::path::Path;
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub struct Types {
30 aliases: BTreeMap<String, String>,
32 parents: BTreeMap<String, Vec<String>>,
34 descriptions: BTreeMap<String, String>,
36}
37
38impl Types {
39 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 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 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 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 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 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 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 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 pub fn is_empty(&self) -> bool {
169 self.aliases.is_empty() && self.parents.is_empty()
170 }
171}
172
173pub 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
201fn 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
213fn 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 (Some((_, tail)), Some(wanted)) if tail.starts_with(&format!("{wanted}\"")) => {
235 return Some(text);
236 }
237 (Some(_), _) => continue,
238 (None, _) => untagged = untagged.or(Some(text)),
241 }
242 }
243 untagged
244}
245
246fn unescape(text: &str) -> String {
248 text.replace("<", "<")
249 .replace(">", ">")
250 .replace(""", "\"")
251 .replace("'", "'")
252 .replace("&", "&")
253}
254
255pub fn mime_dirs(data_dirs: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
258 data_dirs.iter().map(|dir| dir.join("mime")).collect()
259}
260
261pub 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 match mounted_here(path, &meta) {
285 true => "inode/mount-point",
286 false => "inode/directory",
287 }
288 } else if kind.is_symlink() {
289 "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
304fn 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 #[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 #[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 & Sons' <tag></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 #[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}