1use std::io::Read;
9use std::path::Path;
10
11use image::ImageFormat;
12
13use super::process::{self, Sniffed};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum FileKind {
17 Image(ImageFormat),
19 Svg,
20 Text,
21 Pdf,
22 Word,
23 Excel,
24 PowerPoint,
25 Archive,
26 Audio,
27 Video,
28 ImageFile,
30 Other,
31}
32
33impl FileKind {
34 pub fn as_str(self) -> &'static str {
36 match self {
37 FileKind::Image(_) => "image",
38 FileKind::Svg => "svg",
39 FileKind::Text => "text",
40 FileKind::Pdf => "pdf",
41 FileKind::Word => "word",
42 FileKind::Excel => "excel",
43 FileKind::PowerPoint => "powerpoint",
44 FileKind::Archive => "archive",
45 FileKind::Audio => "audio",
46 FileKind::Video => "video",
47 FileKind::ImageFile => "image_file",
48 FileKind::Other => "other",
49 }
50 }
51}
52
53const TEXT_EXTS: &[&str] = &[
55 "txt",
56 "text",
57 "md",
58 "markdown",
59 "mdx",
60 "rst",
61 "adoc",
62 "csv",
63 "tsv",
64 "json",
65 "jsonl",
66 "ndjson",
67 "yaml",
68 "yml",
69 "toml",
70 "ini",
71 "cfg",
72 "conf",
73 "env",
74 "properties",
75 "xml",
76 "html",
77 "htm",
78 "css",
79 "scss",
80 "sass",
81 "less",
82 "log",
83 "rs",
84 "ts",
85 "tsx",
86 "js",
87 "jsx",
88 "mjs",
89 "cjs",
90 "py",
91 "pyi",
92 "go",
93 "java",
94 "kt",
95 "kts",
96 "scala",
97 "c",
98 "h",
99 "cc",
100 "cpp",
101 "cxx",
102 "hpp",
103 "hh",
104 "cs",
105 "fs",
106 "rb",
107 "php",
108 "swift",
109 "m",
110 "mm",
111 "dart",
112 "lua",
113 "pl",
114 "r",
115 "jl",
116 "ex",
117 "exs",
118 "erl",
119 "hs",
120 "clj",
121 "sh",
122 "bash",
123 "zsh",
124 "fish",
125 "ps1",
126 "psm1",
127 "bat",
128 "cmd",
129 "sql",
130 "graphql",
131 "gql",
132 "proto",
133 "vue",
134 "svelte",
135 "astro",
136 "tf",
137 "hcl",
138 "gradle",
139 "cmake",
140 "make",
141 "mk",
142 "dockerfile",
143 "gitignore",
144 "diff",
145 "patch",
146 "tex",
147 "bib",
148 "srt",
149 "vtt",
150 "ipynb",
151];
152const AUDIO_EXTS: &[&str] = &[
153 "mp3", "wav", "flac", "m4a", "aac", "ogg", "oga", "opus", "wma", "aiff", "aif",
154];
155const VIDEO_EXTS: &[&str] = &[
156 "mp4", "m4v", "mov", "webm", "mkv", "avi", "wmv", "flv", "mpg", "mpeg",
157];
158const ARCHIVE_EXTS: &[&str] = &["zip", "gz", "tgz", "tar", "7z", "rar", "bz2", "xz", "zst"];
159
160pub fn extension_of(name: &str) -> Option<String> {
162 let ext = Path::new(name).extension()?.to_str()?.to_ascii_lowercase();
163 (!ext.is_empty() && ext.len() <= 10 && ext.bytes().all(|b| b.is_ascii_alphanumeric()))
164 .then_some(ext)
165}
166
167fn read_head(path: &Path, n: usize) -> std::io::Result<Vec<u8>> {
168 let mut f = std::fs::File::open(path)?;
169 let mut head = Vec::with_capacity(n);
170 (&mut f).take(n as u64).read_to_end(&mut head)?;
171 Ok(head)
172}
173
174fn looks_like_text(head: &[u8]) -> bool {
177 if head.contains(&0) {
178 return false;
179 }
180 match std::str::from_utf8(head) {
181 Ok(_) => true,
182 Err(e) => e.error_len().is_none() && e.valid_up_to() + 4 > head.len(),
184 }
185}
186
187fn office_kind_of_zip(path: &Path) -> Option<FileKind> {
190 let f = std::fs::File::open(path).ok()?;
191 let mut zip = zip::ZipArchive::new(f).ok()?;
192 if zip.len() > super::extract::MAX_ZIP_ENTRIES {
193 return None;
194 }
195 let names: Vec<String> = zip.file_names().map(str::to_string).collect();
196 let has = |n: &str| names.iter().any(|x| x == n);
197 if has("word/document.xml") {
198 return Some(FileKind::Word);
199 }
200 if has("xl/workbook.xml") || has("xl/workbook.bin") {
201 return Some(FileKind::Excel);
202 }
203 if has("ppt/presentation.xml") {
204 return Some(FileKind::PowerPoint);
205 }
206 if has("mimetype") {
207 let mut mt = String::new();
208 if let Ok(e) = zip.by_name("mimetype") {
209 let _ = e.take(200).read_to_string(&mut mt);
210 }
211 return match mt.trim() {
212 "application/vnd.oasis.opendocument.text" => Some(FileKind::Word),
213 "application/vnd.oasis.opendocument.spreadsheet" => Some(FileKind::Excel),
214 "application/vnd.oasis.opendocument.presentation" => Some(FileKind::PowerPoint),
215 _ => None,
216 };
217 }
218 None
219}
220
221pub fn classify(path: &Path, name: &str) -> std::io::Result<FileKind> {
223 let head = read_head(path, 8192)?;
224 let ext = extension_of(name);
225 let ext = ext.as_deref();
226 Ok(classify_bytes(&head, ext, || office_kind_of_zip(path)))
227}
228
229pub fn classify_bytes(
231 head: &[u8],
232 ext: Option<&str>,
233 zip_kind: impl FnOnce() -> Option<FileKind>,
234) -> FileKind {
235 match process::sniff(head) {
236 Sniffed::Image(f) => return FileKind::Image(f),
237 Sniffed::Heic | Sniffed::Avif => return FileKind::ImageFile,
238 Sniffed::Svg => return FileKind::Svg,
239 Sniffed::NotImage => {}
240 }
241 if head.starts_with(b"%PDF-") {
242 return FileKind::Pdf;
243 }
244 if head.starts_with(b"PK\x03\x04") || head.starts_with(b"PK\x05\x06") {
245 return zip_kind().unwrap_or(FileKind::Archive);
246 }
247 if head.starts_with(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]) {
249 return match ext {
250 Some("doc") | Some("dot") => FileKind::Word,
251 Some("xls") | Some("xlt") => FileKind::Excel,
252 Some("ppt") | Some("pps") | Some("pot") => FileKind::PowerPoint,
253 _ => FileKind::Other,
254 };
255 }
256 if head.starts_with(b"{\\rtf") {
257 return FileKind::Word;
258 }
259 if head.starts_with(&[0x1F, 0x8B])
260 || head.starts_with(b"7z\xBC\xAF\x27\x1C")
261 || head.starts_with(b"Rar!")
262 || head.starts_with(b"BZh")
263 || head.starts_with(&[0xFD, b'7', b'z', b'X', b'Z', 0x00])
264 || head.starts_with(&[0x28, 0xB5, 0x2F, 0xFD])
265 || (head.len() > 262 && &head[257..262] == b"ustar")
266 {
267 return FileKind::Archive;
268 }
269 if head.starts_with(b"ID3")
270 || head.starts_with(b"fLaC")
271 || head.starts_with(b"OggS")
272 || (head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"WAVE")
273 {
274 return FileKind::Audio;
275 }
276 if head.starts_with(&[0x1A, 0x45, 0xDF, 0xA3])
277 || (head.len() >= 12 && &head[0..4] == b"RIFF" && &head[8..12] == b"AVI ")
278 {
279 return FileKind::Video;
280 }
281 if head.len() >= 12 && &head[4..8] == b"ftyp" {
282 return if ext.is_some_and(|e| AUDIO_EXTS.contains(&e)) {
284 FileKind::Audio
285 } else {
286 FileKind::Video
287 };
288 }
289 let text = looks_like_text(head);
290 match ext {
291 Some(e) if text && (TEXT_EXTS.contains(&e)) => FileKind::Text,
292 Some(e) if AUDIO_EXTS.contains(&e) => FileKind::Audio,
293 Some(e) if VIDEO_EXTS.contains(&e) => FileKind::Video,
294 Some(e) if ARCHIVE_EXTS.contains(&e) => FileKind::Archive,
295 _ if text && !head.is_empty() => FileKind::Text,
298 _ => FileKind::Other,
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn kind(head: &[u8], ext: Option<&str>) -> FileKind {
307 classify_bytes(head, ext, || None)
308 }
309
310 #[test]
311 fn content_decides_before_the_name() {
312 assert_eq!(kind(b"%PDF-1.7\n...", Some("txt")), FileKind::Pdf);
313 assert_eq!(kind(b"PK\x03\x04rest", Some("txt")), FileKind::Archive);
314 assert_eq!(
315 classify_bytes(b"PK\x03\x04rest", Some("zip"), || Some(FileKind::Word)),
316 FileKind::Word
317 );
318 assert_eq!(
319 kind(&[0x89, b'P', b'N', b'G', 0, 0], Some("docx")),
320 FileKind::Image(ImageFormat::Png)
321 );
322 assert_eq!(
323 kind(b"\0\0\0\x18ftypheic\0\0\0\0", Some("jpg")),
324 FileKind::ImageFile
325 );
326 assert_eq!(kind(b"<svg xmlns='x'/>", Some("svg")), FileKind::Svg);
327 }
328
329 #[test]
330 fn text_needs_text_content() {
331 assert_eq!(kind(b"fn main() {}\n", Some("rs")), FileKind::Text);
332 assert_eq!(kind(b"a,b\n1,2\n", Some("csv")), FileKind::Text);
333 assert_eq!(kind(b"just words", None), FileKind::Text);
334 assert_eq!(
335 kind(b"MZ\x90\0\x03\0\0\0", Some("txt")),
336 FileKind::Other,
337 "an exe renamed .txt"
338 );
339 assert_eq!(kind(b"", Some("txt")), FileKind::Text);
341 assert_eq!(kind(b"", None), FileKind::Other);
342 }
343
344 #[test]
345 fn an_office_extension_alone_never_makes_an_office_file() {
346 assert_eq!(
348 kind(b"MZ\x90\0\x03\0\0\0\x04\0", Some("doc")),
349 FileKind::Other
350 );
351 assert_eq!(kind(b"\x7fELF\x02\x01\x01\0", Some("xls")), FileKind::Other);
352 assert_eq!(kind(b"MZ\x90\0\x03\0\0\0", Some("rtf")), FileKind::Other);
353 }
354
355 #[test]
356 fn legacy_office_goes_by_extension() {
357 let ole = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0];
358 assert_eq!(kind(&ole, Some("doc")), FileKind::Word);
359 assert_eq!(kind(&ole, Some("xls")), FileKind::Excel);
360 assert_eq!(kind(&ole, Some("ppt")), FileKind::PowerPoint);
361 assert_eq!(kind(&ole, Some("msi")), FileKind::Other);
362 assert_eq!(kind(b"{\\rtf1\\ansi", Some("rtf")), FileKind::Word);
363 }
364
365 #[test]
366 fn media_and_archives() {
367 assert_eq!(kind(b"ID3\x04\0\0", Some("mp3")), FileKind::Audio);
368 assert_eq!(
369 kind(b"\0\0\0\x20ftypisom\0\0", Some("mp4")),
370 FileKind::Video
371 );
372 assert_eq!(
373 kind(b"\0\0\0\x20ftypM4A \0\0", Some("m4a")),
374 FileKind::Audio
375 );
376 assert_eq!(kind(&[0x1F, 0x8B, 8, 0], Some("gz")), FileKind::Archive);
377 assert_eq!(kind(b"7z\xBC\xAF\x27\x1C\0", None), FileKind::Archive);
378 }
379
380 #[test]
381 fn extension_is_sanitized() {
382 assert_eq!(extension_of("Report.DOCX").as_deref(), Some("docx"));
383 assert_eq!(extension_of("noext"), None);
384 assert_eq!(extension_of("weird.e x e"), None);
385 }
386}