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 RawTable {
36    version: String,
37    formats: Vec<FormatDef>,
38}
39
40/// The parsed format table from `rules/formats.toml`.
41#[derive(Debug)]
42pub struct FormatTable {
43    version: String,
44    formats: Vec<FormatDef>,
45    by_extension: HashMap<String, String>,
46}
47
48impl FormatTable {
49    /// The table compiled into the binary from `rules/formats.toml`.
50    pub fn builtin() -> Result<Self> {
51        Self::parse(include_str!("../rules/formats.toml"), "formats.toml")
52    }
53
54    /// Parses a format table and validates it.
55    pub fn parse(text: &str, name: &str) -> Result<Self> {
56        let raw: RawTable = toml::from_str(text).map_err(|e| Error::Rules {
57            name: name.to_string(),
58            message: e.to_string(),
59        })?;
60        let mut by_extension = HashMap::new();
61        let mut ids = HashMap::new();
62        for format in &raw.formats {
63            if ids.insert(format.id.clone(), ()).is_some() {
64                return Err(Error::Rules {
65                    name: name.to_string(),
66                    message: format!("duplicate format id {:?}", format.id),
67                });
68            }
69            for extension in &format.extensions {
70                if by_extension
71                    .insert(extension.clone(), format.id.clone())
72                    .is_some()
73                {
74                    return Err(Error::Rules {
75                        name: name.to_string(),
76                        message: format!("extension {extension:?} claimed twice"),
77                    });
78                }
79            }
80        }
81        Ok(FormatTable {
82            version: raw.version,
83            formats: raw.formats,
84            by_extension,
85        })
86    }
87
88    /// The table version, part of the run's rules version.
89    pub fn version(&self) -> &str {
90        &self.version
91    }
92
93    /// All table entries.
94    pub fn formats(&self) -> &[FormatDef] {
95        &self.formats
96    }
97
98    /// Maps a lowercase extension to a format id.
99    pub fn id_for_extension(&self, extension: &str) -> Option<&str> {
100        self.by_extension.get(extension).map(String::as_str)
101    }
102}
103
104/// The detection outcome for one file.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct Detection {
107    /// Format id the file extension declares, when the table knows it.
108    pub declared: Option<String>,
109    /// Resolved format id, [`UNKNOWN_FORMAT`] when nothing matches.
110    pub detected: String,
111    /// True when magic bytes and the extension resolve to different ids.
112    pub mismatch: bool,
113}
114
115/// The format id a file's extension declares, when the table knows it.
116///
117/// Needs no file access, so it works for a file the run cannot read.
118pub fn declared_format(path: &Path, table: &FormatTable) -> Option<String> {
119    path.extension()
120        .and_then(|e| e.to_str())
121        .and_then(|e| table.id_for_extension(&e.to_ascii_lowercase()))
122        .map(String::from)
123}
124
125/// Detects the format of one file against the table.
126///
127/// Magic detection of a real format outside the table cannot resolve
128/// to an id, so the extension decides. When that happens against a
129/// declared format, the mismatch flag is still set, because the bytes
130/// contradict the extension.
131pub fn detect_file(path: &Path, table: &FormatTable) -> Result<Detection> {
132    let declared = declared_format(path, table);
133    let magic = FileFormat::from_file(path).map_err(|e| Error::io("detect", path, e))?;
134    let magic_known = magic != FileFormat::default() && magic != FileFormat::Empty;
135    let magic_id = if magic_known {
136        table.id_for_extension(magic.extension()).map(String::from)
137    } else {
138        None
139    };
140    let (detected, mismatch) = match (&magic_id, &declared) {
141        (Some(magic), Some(declared)) => (magic.clone(), magic != declared),
142        (Some(magic), None) => (magic.clone(), false),
143        (None, Some(declared)) => (declared.clone(), magic_known),
144        (None, None) => (UNKNOWN_FORMAT.to_string(), false),
145    };
146    Ok(Detection {
147        declared,
148        detected,
149        mismatch,
150    })
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use std::fs;
157
158    const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
159
160    #[test]
161    fn builtin_table_parses() {
162        let table = FormatTable::builtin().unwrap();
163        assert_eq!(table.version(), "3");
164        assert_eq!(table.id_for_extension("txt"), Some("text"));
165        assert_eq!(table.id_for_extension("docx"), Some("docx"));
166    }
167
168    #[test]
169    fn extension_decides_when_magic_is_inconclusive() {
170        let dir = tempfile::tempdir().unwrap();
171        let path = dir.path().join("notes.md");
172        fs::write(&path, b"# heading\n").unwrap();
173        let table = FormatTable::builtin().unwrap();
174        let detection = detect_file(&path, &table).unwrap();
175        assert_eq!(detection.declared.as_deref(), Some("markdown"));
176        assert_eq!(detection.detected, "markdown");
177        assert!(!detection.mismatch);
178    }
179
180    #[test]
181    fn magic_wins_and_flags_a_mismatch() {
182        let dir = tempfile::tempdir().unwrap();
183        let path = dir.path().join("fake.txt");
184        fs::write(&path, PNG_MAGIC).unwrap();
185        let table = FormatTable::builtin().unwrap();
186        let detection = detect_file(&path, &table).unwrap();
187        assert_eq!(detection.declared.as_deref(), Some("text"));
188        assert_eq!(detection.detected, "png");
189        assert!(detection.mismatch);
190    }
191
192    #[test]
193    fn out_of_table_magic_still_flags_a_mismatch() {
194        let dir = tempfile::tempdir().unwrap();
195        let path = dir.path().join("pic.png");
196        std::fs::write(&path, b"GIF89a\x01\x00\x01\x00").unwrap();
197        let table = FormatTable::builtin().unwrap();
198        let detection = detect_file(&path, &table).unwrap();
199        assert_eq!(detection.declared.as_deref(), Some("png"));
200        assert_eq!(detection.detected, "png");
201        assert!(detection.mismatch);
202    }
203
204    #[test]
205    fn empty_file_is_not_a_mismatch() {
206        let dir = tempfile::tempdir().unwrap();
207        let path = dir.path().join("empty.txt");
208        std::fs::write(&path, b"").unwrap();
209        let table = FormatTable::builtin().unwrap();
210        let detection = detect_file(&path, &table).unwrap();
211        assert_eq!(detection.detected, "text");
212        assert!(!detection.mismatch);
213    }
214
215    #[test]
216    fn unplaceable_file_is_unknown() {
217        let dir = tempfile::tempdir().unwrap();
218        let path = dir.path().join("blob.xyz");
219        fs::write(&path, b"opaque bytes").unwrap();
220        let table = FormatTable::builtin().unwrap();
221        let detection = detect_file(&path, &table).unwrap();
222        assert_eq!(detection.declared, None);
223        assert_eq!(detection.detected, UNKNOWN_FORMAT);
224        assert!(!detection.mismatch);
225    }
226}