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