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(), "7");
307        assert_eq!(table.id_for_extension("txt"), Some("text"));
308        assert_eq!(table.id_for_extension("docx"), Some("docx"));
309        assert_eq!(table.id_for_extension("json"), Some("json"));
310        assert_eq!(table.id_for_extension("yml"), Some("yaml"));
311        assert_eq!(table.id_for_extension("py"), Some("python"));
312        assert_eq!(table.id_for_extension("pkl"), Some("pickle"));
313        assert_eq!(table.id_for_extension("psb"), Some("psd"));
314    }
315
316    #[test]
317    fn extension_decides_when_magic_is_inconclusive() {
318        let dir = tempfile::tempdir().unwrap();
319        let path = dir.path().join("notes.md");
320        fs::write(&path, b"# heading\n").unwrap();
321        let table = FormatTable::builtin().unwrap();
322        let detection = detect_file(&path, &table).unwrap();
323        assert_eq!(detection.declared.as_deref(), Some("markdown"));
324        assert_eq!(detection.detected, "markdown");
325        assert!(!detection.mismatch);
326    }
327
328    #[test]
329    fn magic_wins_and_flags_a_mismatch() {
330        let dir = tempfile::tempdir().unwrap();
331        let path = dir.path().join("fake.txt");
332        fs::write(&path, PNG_MAGIC).unwrap();
333        let table = FormatTable::builtin().unwrap();
334        let detection = detect_file(&path, &table).unwrap();
335        assert_eq!(detection.declared.as_deref(), Some("text"));
336        assert_eq!(detection.detected, "png");
337        assert!(detection.mismatch);
338    }
339
340    #[test]
341    fn out_of_table_magic_is_a_warning_not_a_same_id_mismatch() {
342        let dir = tempfile::tempdir().unwrap();
343        let path = dir.path().join("pic.png");
344        std::fs::write(&path, b"GIF89a\x01\x00\x01\x00").unwrap();
345        let table = FormatTable::builtin().unwrap();
346        let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
347        assert_eq!(detection.declared.as_deref(), Some("png"));
348        assert_eq!(detection.detected, "png");
349        assert!(!detection.mismatch);
350        assert_eq!(
351            warnings,
352            vec!["magic-format-outside-table: gif".to_string()]
353        );
354    }
355
356    #[test]
357    fn cfb_magic_under_a_matching_extension_is_not_a_mismatch() {
358        let dir = tempfile::tempdir().unwrap();
359        let path = dir.path().join("locked.docx");
360        let mut bytes = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1".to_vec();
361        bytes.extend_from_slice(&[0u8; 24]);
362        std::fs::write(&path, &bytes).unwrap();
363        let table = FormatTable::builtin().unwrap();
364        let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
365        assert_eq!(detection.declared.as_deref(), Some("docx"));
366        assert_eq!(detection.detected, "docx");
367        assert!(!detection.mismatch);
368        assert_eq!(
369            warnings,
370            vec!["magic-format-outside-table: cfb".to_string()]
371        );
372    }
373
374    const XML_PROLOG_SVG: &[u8] =
375        b"<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"/>\n";
376
377    #[test]
378    fn a_declared_refinement_of_the_magic_format_is_not_a_mismatch() {
379        let dir = tempfile::tempdir().unwrap();
380        let table = FormatTable::builtin().unwrap();
381        for (file, id) in [
382            ("pic.svg", "svg"),
383            ("dash.twb", "tableau-workbook"),
384            ("src.tds", "tableau-datasource"),
385        ] {
386            let path = dir.path().join(file);
387            fs::write(&path, XML_PROLOG_SVG).unwrap();
388            let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
389            assert_eq!(detection.declared.as_deref(), Some(id), "{file}");
390            assert_eq!(detection.detected, id, "{file}");
391            assert!(!detection.mismatch, "{file}");
392            assert!(warnings.is_empty(), "{file}: {warnings:?}");
393        }
394    }
395
396    #[test]
397    fn a_plain_xml_file_stays_same_id_without_a_flag() {
398        let dir = tempfile::tempdir().unwrap();
399        let path = dir.path().join("feed.xml");
400        fs::write(&path, b"<?xml version=\"1.0\"?>\n<feed/>\n").unwrap();
401        let table = FormatTable::builtin().unwrap();
402        let detection = detect_file(&path, &table).unwrap();
403        assert_eq!(detection.declared.as_deref(), Some("xml"));
404        assert_eq!(detection.detected, "xml");
405        assert!(!detection.mismatch);
406    }
407
408    #[test]
409    fn a_sibling_disagreement_still_flags_a_mismatch() {
410        // A shell shebang under a python name is a disagreement
411        // between siblings, so refinement does not apply.
412        let dir = tempfile::tempdir().unwrap();
413        let path = dir.path().join("tool.py");
414        fs::write(&path, b"#!/bin/sh\necho hi\n").unwrap();
415        let table = FormatTable::builtin().unwrap();
416        let detection = detect_file(&path, &table).unwrap();
417        assert_eq!(detection.declared.as_deref(), Some("python"));
418        assert_eq!(detection.detected, "shell");
419        assert!(detection.mismatch);
420    }
421
422    fn table_with(entries: &str) -> Result<FormatTable> {
423        FormatTable::parse(&format!("version = \"1\"\n{entries}"), "formats.toml")
424    }
425
426    #[test]
427    fn parse_refuses_a_refine_target_missing_from_the_table() {
428        let err = table_with(
429            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"b\"\n",
430        )
431        .unwrap_err();
432        assert!(err.to_string().contains("unknown format"), "{err}");
433    }
434
435    #[test]
436    fn parse_refuses_a_self_refining_entry() {
437        let err = table_with(
438            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"a\"\n",
439        )
440        .unwrap_err();
441        assert!(err.to_string().contains("refines itself"), "{err}");
442    }
443
444    #[test]
445    fn parse_refuses_a_refinement_chain() {
446        let err = table_with(concat!(
447            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"b\"\n",
448            "[[formats]]\nid = \"b\"\nname = \"B\"\nextensions = [\"b\"]\nrefines = \"c\"\n",
449            "[[formats]]\nid = \"c\"\nname = \"C\"\nextensions = [\"c\"]\n",
450        ))
451        .unwrap_err();
452        assert!(err.to_string().contains("refines another format"), "{err}");
453    }
454
455    #[test]
456    fn a_bare_dotfile_declares_its_format_by_basename() {
457        let dir = tempfile::tempdir().unwrap();
458        let path = dir.path().join(".env");
459        fs::write(&path, b"API_URL=http://localhost\n").unwrap();
460        let table = FormatTable::builtin().unwrap();
461        let detection = detect_file(&path, &table).unwrap();
462        assert_eq!(detection.declared.as_deref(), Some("env"));
463        assert_eq!(detection.detected, "env");
464        assert!(!detection.mismatch);
465    }
466
467    #[test]
468    fn parse_refuses_a_basename_claimed_twice() {
469        let err = table_with(concat!(
470            "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nbasenames = [\".rc\"]\n",
471            "[[formats]]\nid = \"b\"\nname = \"B\"\nextensions = [\"b\"]\nbasenames = [\".rc\"]\n",
472        ))
473        .unwrap_err();
474        assert!(err.to_string().contains("basename"), "{err}");
475    }
476
477    #[test]
478    fn parse_refuses_the_reserved_format_ids() {
479        for id in ["symlink", "unknown"] {
480            let err = table_with(&format!(
481                "[[formats]]\nid = \"{id}\"\nname = \"X\"\nextensions = [\"x\"]\n"
482            ))
483            .unwrap_err();
484            assert!(err.to_string().contains("reserved"), "{id}: {err}");
485        }
486    }
487
488    #[test]
489    fn in_table_magic_against_a_different_extension_still_flags() {
490        let dir = tempfile::tempdir().unwrap();
491        let path = dir.path().join("fake.json");
492        fs::write(&path, PNG_MAGIC).unwrap();
493        let table = FormatTable::builtin().unwrap();
494        let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
495        assert_eq!(detection.declared.as_deref(), Some("json"));
496        assert_eq!(detection.detected, "png");
497        assert!(detection.mismatch);
498        assert!(warnings.is_empty());
499    }
500
501    #[test]
502    fn empty_file_is_not_a_mismatch() {
503        let dir = tempfile::tempdir().unwrap();
504        let path = dir.path().join("empty.txt");
505        std::fs::write(&path, b"").unwrap();
506        let table = FormatTable::builtin().unwrap();
507        let detection = detect_file(&path, &table).unwrap();
508        assert_eq!(detection.detected, "text");
509        assert!(!detection.mismatch);
510    }
511
512    #[test]
513    fn unplaceable_file_is_unknown() {
514        let dir = tempfile::tempdir().unwrap();
515        let path = dir.path().join("blob.xyz");
516        fs::write(&path, b"opaque bytes").unwrap();
517        let table = FormatTable::builtin().unwrap();
518        let detection = detect_file(&path, &table).unwrap();
519        assert_eq!(detection.declared, None);
520        assert_eq!(detection.detected, UNKNOWN_FORMAT);
521        assert!(!detection.mismatch);
522    }
523}