Skip to main content

text_mirror/
detect.rs

1//! Format detection. Magic bytes first, extension as tiebreak.
2//!
3//! Detection resolves every file against the versioned format table in
4//! `rules/formats.toml`. Magic bytes win when they map to a table
5//! entry. When magic bytes are inconclusive, or point at a format the
6//! table does not name, the file extension decides. Both views are
7//! recorded, with a mismatch flag when they disagree.
8
9use std::collections::HashMap;
10use std::path::Path;
11
12use file_format::FileFormat;
13use serde::Deserialize;
14
15use crate::{Error, Result};
16
17/// The detected id for a file neither magic bytes nor the extension
18/// can place.
19pub const UNKNOWN_FORMAT: &str = "unknown";
20
21/// One entry in the format table.
22#[derive(Debug, Clone, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct FormatDef {
25    /// Stable format id recorded in the manifest.
26    pub id: String,
27    /// Human-readable format name.
28    pub name: String,
29    /// Lowercase extensions that declare this format.
30    pub extensions: Vec<String>,
31}
32
33#[derive(Debug, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct RawFormatDef {
36    id: String,
37    name: String,
38    extensions: Vec<String>,
39    /// Id of the generic parent format this entry is a specific form
40    /// of, such as svg refining xml. Carried privately by the table.
41    #[serde(default)]
42    refines: Option<String>,
43    /// Exact file names that declare this format for paths with no
44    /// extension, such as a file named exactly `.env`. Carried
45    /// privately by the table.
46    #[serde(default)]
47    basenames: Vec<String>,
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct RawTable {
53    version: String,
54    formats: Vec<RawFormatDef>,
55}
56
57/// Format ids the pipeline synthesizes, so no rules file may claim
58/// them: a table entry or registry claim on `unknown` would route
59/// every unplaceable file, and one on `symlink` would contradict the
60/// symlink records the pipeline writes itself.
61pub(crate) const RESERVED_FORMAT_IDS: [&str; 2] = [UNKNOWN_FORMAT, crate::pipeline::SYMLINK_FORMAT];
62
63/// The parsed format table from `rules/formats.toml`.
64#[derive(Debug)]
65pub struct FormatTable {
66    version: String,
67    formats: Vec<FormatDef>,
68    by_extension: HashMap<String, String>,
69    by_basename: HashMap<String, String>,
70    refines_by_id: HashMap<String, String>,
71}
72
73impl FormatTable {
74    /// The table compiled into the binary from `rules/formats.toml`.
75    pub fn builtin() -> Result<Self> {
76        Self::parse(include_str!("../rules/formats.toml"), "formats.toml")
77    }
78
79    /// Parses a format table and validates it.
80    pub fn parse(text: &str, name: &str) -> Result<Self> {
81        let raw: RawTable = toml::from_str(text).map_err(|e| Error::Rules {
82            name: name.to_string(),
83            message: e.to_string(),
84        })?;
85        let rules_error = |message: String| Error::Rules {
86            name: name.to_string(),
87            message,
88        };
89        let mut by_extension = HashMap::new();
90        let mut by_basename = HashMap::new();
91        let mut ids = HashMap::new();
92        let mut refines_by_id = HashMap::new();
93        for format in &raw.formats {
94            if RESERVED_FORMAT_IDS.contains(&format.id.as_str()) {
95                return Err(rules_error(format!(
96                    "format id {:?} is reserved",
97                    format.id
98                )));
99            }
100            if ids.insert(format.id.clone(), ()).is_some() {
101                return Err(rules_error(format!("duplicate format id {:?}", format.id)));
102            }
103            for extension in &format.extensions {
104                if by_extension
105                    .insert(extension.clone(), format.id.clone())
106                    .is_some()
107                {
108                    return Err(rules_error(format!(
109                        "extension {extension:?} claimed twice"
110                    )));
111                }
112            }
113            for basename in &format.basenames {
114                if basename.is_empty()
115                    || by_basename
116                        .insert(basename.clone(), format.id.clone())
117                        .is_some()
118                {
119                    return Err(rules_error(format!("basename {basename:?} claimed twice")));
120                }
121            }
122            if let Some(target) = &format.refines {
123                refines_by_id.insert(format.id.clone(), target.clone());
124            }
125        }
126        // Refinement is one level deep: every target must be a real
127        // entry that refines nothing itself, and no entry refines
128        // itself.
129        for (id, target) in &refines_by_id {
130            if target == id {
131                return Err(rules_error(format!("format {id:?} refines itself")));
132            }
133            if !ids.contains_key(target) {
134                return Err(rules_error(format!(
135                    "format {id:?} refines unknown format {target:?}"
136                )));
137            }
138            if refines_by_id.contains_key(target) {
139                return Err(rules_error(format!(
140                    "format {id:?} refines {target:?}, which refines another format itself"
141                )));
142            }
143        }
144        let formats = raw
145            .formats
146            .into_iter()
147            .map(|format| FormatDef {
148                id: format.id,
149                name: format.name,
150                extensions: format.extensions,
151            })
152            .collect();
153        Ok(FormatTable {
154            version: raw.version,
155            formats,
156            by_extension,
157            by_basename,
158            refines_by_id,
159        })
160    }
161
162    /// The table version, part of the run's rules version.
163    pub fn version(&self) -> &str {
164        &self.version
165    }
166
167    /// All table entries.
168    pub fn formats(&self) -> &[FormatDef] {
169        &self.formats
170    }
171
172    /// Maps a lowercase extension to a format id.
173    pub fn id_for_extension(&self, extension: &str) -> Option<&str> {
174        self.by_extension.get(extension).map(String::as_str)
175    }
176
177    /// The generic parent id a format refines, when the table names
178    /// one.
179    fn refined_parent(&self, id: &str) -> Option<&str> {
180        self.refines_by_id.get(id).map(String::as_str)
181    }
182}
183
184/// The detection outcome for one file.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Detection {
187    /// Format id the file extension declares, when the table knows it.
188    pub declared: Option<String>,
189    /// Resolved format id, [`UNKNOWN_FORMAT`] when nothing matches.
190    pub detected: String,
191    /// True when magic bytes and the extension resolve to different ids.
192    pub mismatch: bool,
193}
194
195/// The format id a file's extension declares, when the table knows it.
196///
197/// A path with no extension falls back to the table's exact basename
198/// entries, so a file named exactly `.env` still declares its format.
199/// Needs no file access, so it works for a file the run cannot read.
200pub fn declared_format(path: &Path, table: &FormatTable) -> Option<String> {
201    if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
202        return table
203            .id_for_extension(&extension.to_ascii_lowercase())
204            .map(String::from);
205    }
206    path.file_name()
207        .and_then(|n| n.to_str())
208        .and_then(|n| table.by_basename.get(n))
209        .cloned()
210}
211
212/// Detects the format of one file against the table.
213///
214/// Same outcome as [`detect_file_with_warnings`] with the warnings
215/// dropped, for callers that only need the resolved ids.
216pub fn detect_file(path: &Path, table: &FormatTable) -> Result<Detection> {
217    Ok(detect_file_with_warnings(path, table)?.0)
218}
219
220/// Detects the format of one file and collects detection warnings.
221///
222/// The mismatch flag is true only when the declared and detected ids
223/// genuinely disagree. Two resolutions keep it down. When magic
224/// resolves to the generic parent the declared id refines, svg bytes
225/// reading as bare xml, the observations agree and the declared id
226/// wins as the more specific truth. When magic resolves to a real
227/// format outside the table, the extension decides, the ids are
228/// identical, and a warning names the out-of-table format instead, as
229/// `magic-format-outside-table: cfb` for an encrypted docx whose
230/// bytes are a bare compound file. The warnings belong on the
231/// manifest record beside the flag.
232pub fn detect_file_with_warnings(
233    path: &Path,
234    table: &FormatTable,
235) -> Result<(Detection, Vec<String>)> {
236    let magic = FileFormat::from_file(path).map_err(|e| Error::io("detect", path, e))?;
237    Ok(resolve_detection(path, magic, table))
238}
239
240/// Detects the format of in-memory bytes against the table, using a
241/// path hint only for the declared extension or basename.
242///
243/// A container member has no file on disk, so magic runs over the
244/// extracted bytes and the member's own path names the declared
245/// format. The mismatch and warning semantics match
246/// [`detect_file_with_warnings`].
247pub fn detect_bytes_with_warnings(
248    path_hint: &Path,
249    bytes: &[u8],
250    table: &FormatTable,
251) -> (Detection, Vec<String>) {
252    let magic = FileFormat::from_bytes(bytes);
253    resolve_detection(path_hint, magic, table)
254}
255
256fn resolve_detection(
257    path: &Path,
258    magic: FileFormat,
259    table: &FormatTable,
260) -> (Detection, Vec<String>) {
261    let declared = declared_format(path, table);
262    let magic_known = magic != FileFormat::default() && magic != FileFormat::Empty;
263    let magic_id = if magic_known {
264        table.id_for_extension(magic.extension()).map(String::from)
265    } else {
266        None
267    };
268    let mut warnings = Vec::new();
269    let (detected, mismatch) = match (&magic_id, &declared) {
270        (Some(magic), Some(declared)) => {
271            if magic != declared && table.refined_parent(declared) == Some(magic.as_str()) {
272                (declared.clone(), false)
273            } else {
274                (magic.clone(), magic != declared)
275            }
276        }
277        (Some(magic), None) => (magic.clone(), false),
278        (None, Some(declared)) => {
279            if magic_known {
280                warnings.push(format!("magic-format-outside-table: {}", magic.extension()));
281            }
282            (declared.clone(), false)
283        }
284        (None, None) => (UNKNOWN_FORMAT.to_string(), false),
285    };
286    (
287        Detection {
288            declared,
289            detected,
290            mismatch,
291        },
292        warnings,
293    )
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::fs;
300
301    const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
302
303    #[test]
304    fn builtin_table_parses() {
305        let table = FormatTable::builtin().unwrap();
306        assert_eq!(table.version(), "10");
307        assert_eq!(table.id_for_extension("flac"), Some("flac"));
308        assert_eq!(table.id_for_extension("txt"), Some("text"));
309        assert_eq!(table.id_for_extension("docx"), Some("docx"));
310        assert_eq!(table.id_for_extension("json"), Some("json"));
311        assert_eq!(table.id_for_extension("yml"), Some("yaml"));
312        assert_eq!(table.id_for_extension("py"), Some("python"));
313        assert_eq!(table.id_for_extension("pkl"), Some("pickle"));
314        assert_eq!(table.id_for_extension("psb"), Some("psd"));
315    }
316
317    #[test]
318    fn extension_decides_when_magic_is_inconclusive() {
319        let dir = tempfile::tempdir().unwrap();
320        let path = dir.path().join("notes.md");
321        fs::write(&path, b"# heading\n").unwrap();
322        let table = FormatTable::builtin().unwrap();
323        let detection = detect_file(&path, &table).unwrap();
324        assert_eq!(detection.declared.as_deref(), Some("markdown"));
325        assert_eq!(detection.detected, "markdown");
326        assert!(!detection.mismatch);
327    }
328
329    #[test]
330    fn magic_wins_and_flags_a_mismatch() {
331        let dir = tempfile::tempdir().unwrap();
332        let path = dir.path().join("fake.txt");
333        fs::write(&path, PNG_MAGIC).unwrap();
334        let table = FormatTable::builtin().unwrap();
335        let detection = detect_file(&path, &table).unwrap();
336        assert_eq!(detection.declared.as_deref(), Some("text"));
337        assert_eq!(detection.detected, "png");
338        assert!(detection.mismatch);
339    }
340
341    #[test]
342    fn out_of_table_magic_is_a_warning_not_a_same_id_mismatch() {
343        let dir = tempfile::tempdir().unwrap();
344        let path = dir.path().join("pic.png");
345        std::fs::write(&path, b"GIF89a\x01\x00\x01\x00").unwrap();
346        let table = FormatTable::builtin().unwrap();
347        let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
348        assert_eq!(detection.declared.as_deref(), Some("png"));
349        assert_eq!(detection.detected, "png");
350        assert!(!detection.mismatch);
351        assert_eq!(
352            warnings,
353            vec!["magic-format-outside-table: gif".to_string()]
354        );
355    }
356
357    #[test]
358    fn cfb_magic_under_a_matching_extension_is_not_a_mismatch() {
359        let dir = tempfile::tempdir().unwrap();
360        let path = dir.path().join("locked.docx");
361        let mut bytes = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1".to_vec();
362        bytes.extend_from_slice(&[0u8; 24]);
363        std::fs::write(&path, &bytes).unwrap();
364        let table = FormatTable::builtin().unwrap();
365        let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
366        assert_eq!(detection.declared.as_deref(), Some("docx"));
367        assert_eq!(detection.detected, "docx");
368        assert!(!detection.mismatch);
369        assert_eq!(
370            warnings,
371            vec!["magic-format-outside-table: cfb".to_string()]
372        );
373    }
374
375    const XML_PROLOG_SVG: &[u8] =
376        b"<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"/>\n";
377
378    #[test]
379    fn a_declared_refinement_of_the_magic_format_is_not_a_mismatch() {
380        let dir = tempfile::tempdir().unwrap();
381        let table = FormatTable::builtin().unwrap();
382        for (file, id) in [
383            ("pic.svg", "svg"),
384            ("dash.twb", "tableau-workbook"),
385            ("src.tds", "tableau-datasource"),
386        ] {
387            let path = dir.path().join(file);
388            fs::write(&path, XML_PROLOG_SVG).unwrap();
389            let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
390            assert_eq!(detection.declared.as_deref(), Some(id), "{file}");
391            assert_eq!(detection.detected, id, "{file}");
392            assert!(!detection.mismatch, "{file}");
393            assert!(warnings.is_empty(), "{file}: {warnings:?}");
394        }
395    }
396
397    #[test]
398    fn a_plain_xml_file_stays_same_id_without_a_flag() {
399        let dir = tempfile::tempdir().unwrap();
400        let path = dir.path().join("feed.xml");
401        fs::write(&path, b"<?xml version=\"1.0\"?>\n<feed/>\n").unwrap();
402        let table = FormatTable::builtin().unwrap();
403        let detection = detect_file(&path, &table).unwrap();
404        assert_eq!(detection.declared.as_deref(), Some("xml"));
405        assert_eq!(detection.detected, "xml");
406        assert!(!detection.mismatch);
407    }
408
409    #[test]
410    fn a_sibling_disagreement_still_flags_a_mismatch() {
411        // A shell shebang under a python name is a disagreement
412        // between siblings, so refinement does not apply.
413        let dir = tempfile::tempdir().unwrap();
414        let path = dir.path().join("tool.py");
415        fs::write(&path, b"#!/bin/sh\necho hi\n").unwrap();
416        let table = FormatTable::builtin().unwrap();
417        let detection = detect_file(&path, &table).unwrap();
418        assert_eq!(detection.declared.as_deref(), Some("python"));
419        assert_eq!(detection.detected, "shell");
420        assert!(detection.mismatch);
421    }
422
423    fn table_with(entries: &str) -> Result<FormatTable> {
424        FormatTable::parse(&format!("version = \"1\"\n{entries}"), "formats.toml")
425    }
426
427    #[test]
428    fn parse_refuses_a_refine_target_missing_from_the_table() {
429        let err = table_with(
430            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"b\"\n",
431        )
432        .unwrap_err();
433        assert!(err.to_string().contains("unknown format"), "{err}");
434    }
435
436    #[test]
437    fn parse_refuses_a_self_refining_entry() {
438        let err = table_with(
439            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"a\"\n",
440        )
441        .unwrap_err();
442        assert!(err.to_string().contains("refines itself"), "{err}");
443    }
444
445    #[test]
446    fn parse_refuses_a_refinement_chain() {
447        let err = table_with(concat!(
448            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"b\"\n",
449            "[[formats]]\nid = \"b\"\nname = \"B\"\nextensions = [\"b\"]\nrefines = \"c\"\n",
450            "[[formats]]\nid = \"c\"\nname = \"C\"\nextensions = [\"c\"]\n",
451        ))
452        .unwrap_err();
453        assert!(err.to_string().contains("refines another format"), "{err}");
454    }
455
456    #[test]
457    fn a_bare_dotfile_declares_its_format_by_basename() {
458        let dir = tempfile::tempdir().unwrap();
459        let path = dir.path().join(".env");
460        fs::write(&path, b"API_URL=http://localhost\n").unwrap();
461        let table = FormatTable::builtin().unwrap();
462        let detection = detect_file(&path, &table).unwrap();
463        assert_eq!(detection.declared.as_deref(), Some("env"));
464        assert_eq!(detection.detected, "env");
465        assert!(!detection.mismatch);
466    }
467
468    #[test]
469    fn parse_refuses_a_basename_claimed_twice() {
470        let err = table_with(concat!(
471            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nbasenames = [\".rc\"]\n",
472            "[[formats]]\nid = \"b\"\nname = \"B\"\nextensions = [\"b\"]\nbasenames = [\".rc\"]\n",
473        ))
474        .unwrap_err();
475        assert!(err.to_string().contains("basename"), "{err}");
476    }
477
478    #[test]
479    fn parse_refuses_the_reserved_format_ids() {
480        for id in ["symlink", "unknown"] {
481            let err = table_with(&format!(
482                "[[formats]]\nid = \"{id}\"\nname = \"X\"\nextensions = [\"x\"]\n"
483            ))
484            .unwrap_err();
485            assert!(err.to_string().contains("reserved"), "{id}: {err}");
486        }
487    }
488
489    #[test]
490    fn in_table_magic_against_a_different_extension_still_flags() {
491        let dir = tempfile::tempdir().unwrap();
492        let path = dir.path().join("fake.json");
493        fs::write(&path, PNG_MAGIC).unwrap();
494        let table = FormatTable::builtin().unwrap();
495        let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
496        assert_eq!(detection.declared.as_deref(), Some("json"));
497        assert_eq!(detection.detected, "png");
498        assert!(detection.mismatch);
499        assert!(warnings.is_empty());
500    }
501
502    #[test]
503    fn empty_file_is_not_a_mismatch() {
504        let dir = tempfile::tempdir().unwrap();
505        let path = dir.path().join("empty.txt");
506        std::fs::write(&path, b"").unwrap();
507        let table = FormatTable::builtin().unwrap();
508        let detection = detect_file(&path, &table).unwrap();
509        assert_eq!(detection.detected, "text");
510        assert!(!detection.mismatch);
511    }
512
513    #[test]
514    fn unplaceable_file_is_unknown() {
515        let dir = tempfile::tempdir().unwrap();
516        let path = dir.path().join("blob.xyz");
517        fs::write(&path, b"opaque bytes").unwrap();
518        let table = FormatTable::builtin().unwrap();
519        let detection = detect_file(&path, &table).unwrap();
520        assert_eq!(detection.declared, None);
521        assert_eq!(detection.detected, UNKNOWN_FORMAT);
522        assert!(!detection.mismatch);
523    }
524}