1use std::collections::HashMap;
10use std::path::Path;
11
12use file_format::FileFormat;
13use serde::Deserialize;
14
15use crate::{Error, Result};
16
17pub const UNKNOWN_FORMAT: &str = "unknown";
20
21#[derive(Debug, Clone, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct FormatDef {
25 pub id: String,
27 pub name: String,
29 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 #[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
52pub(crate) const RESERVED_FORMAT_IDS: [&str; 2] = [UNKNOWN_FORMAT, crate::pipeline::SYMLINK_FORMAT];
57
58#[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 pub fn builtin() -> Result<Self> {
70 Self::parse(include_str!("../rules/formats.toml"), "formats.toml")
71 }
72
73 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 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 pub fn version(&self) -> &str {
147 &self.version
148 }
149
150 pub fn formats(&self) -> &[FormatDef] {
152 &self.formats
153 }
154
155 pub fn id_for_extension(&self, extension: &str) -> Option<&str> {
157 self.by_extension.get(extension).map(String::as_str)
158 }
159
160 fn refined_parent(&self, id: &str) -> Option<&str> {
163 self.refines_by_id.get(id).map(String::as_str)
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Detection {
170 pub declared: Option<String>,
172 pub detected: String,
174 pub mismatch: bool,
176}
177
178pub 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
188pub fn detect_file(path: &Path, table: &FormatTable) -> Result<Detection> {
193 Ok(detect_file_with_warnings(path, table)?.0)
194}
195
196pub 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 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}