pub const SNIFF_PREFIX_LEN: usize = 8 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Category {
Image,
Audio,
Video,
Archive,
Document,
Font,
Text,
Binary,
}
impl Category {
pub fn as_str(self) -> &'static str {
match self {
Category::Image => "image",
Category::Audio => "audio",
Category::Video => "video",
Category::Archive => "archive",
Category::Document => "document",
Category::Font => "font",
Category::Text => "text",
Category::Binary => "binary",
}
}
fn from_matcher(matcher: infer::MatcherType) -> Self {
use infer::MatcherType as M;
match matcher {
M::Image => Category::Image,
M::Audio => Category::Audio,
M::Video => Category::Video,
M::Archive => Category::Archive,
M::Book | M::Doc => Category::Document,
M::Font => Category::Font,
M::Text => Category::Text,
M::App | M::Custom => Category::Binary,
}
}
}
impl std::fmt::Display for Category {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileType {
pub category: Category,
pub mime: &'static str,
pub extension: &'static str,
}
impl FileType {
pub fn is_image(&self) -> bool {
self.category == Category::Image
}
pub fn is_audio(&self) -> bool {
self.category == Category::Audio
}
pub fn is_video(&self) -> bool {
self.category == Category::Video
}
}
const TEXT_MIME: &str = "text/plain";
const TEXT_EXT: &str = "txt";
pub fn detect(bytes: &[u8]) -> Option<FileType> {
let matched = infer::get(bytes)?;
Some(FileType {
category: Category::from_matcher(matched.matcher_type()),
mime: matched.mime_type(),
extension: matched.extension(),
})
}
pub fn classify(bytes: &[u8]) -> Option<FileType> {
if let Some(found) = detect(bytes) {
return Some(found);
}
looks_like_text(bytes).then_some(FileType {
category: Category::Text,
mime: TEXT_MIME,
extension: TEXT_EXT,
})
}
pub fn looks_like_text(bytes: &[u8]) -> bool {
if bytes.is_empty() {
return false;
}
let valid_len = match std::str::from_utf8(bytes) {
Ok(_) => bytes.len(),
Err(e) if e.error_len().is_none() && e.valid_up_to() > 0 => e.valid_up_to(),
Err(_) => return false,
};
let text = std::str::from_utf8(&bytes[..valid_len]).unwrap_or_default();
!text.chars().any(is_binary_control)
}
fn is_binary_control(c: char) -> bool {
c.is_control() && !matches!(c, '\t' | '\n' | '\u{000b}' | '\u{000c}' | '\r')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn png_is_image() {
let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
let ft = detect(png).expect("png should be detected");
assert_eq!(ft.category, Category::Image);
assert_eq!(ft.mime, "image/png");
assert_eq!(ft.extension, "png");
assert!(ft.is_image());
}
#[test]
fn jpeg_is_image() {
let jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF";
let ft = detect(jpeg).expect("jpeg should be detected");
assert_eq!(ft.category, Category::Image);
assert_eq!(ft.mime, "image/jpeg");
}
#[test]
fn gif_is_image() {
let ft = detect(b"GIF89a").expect("gif should be detected");
assert_eq!(ft.category, Category::Image);
}
#[test]
fn webp_is_image_not_other_riff() {
let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ";
let ft = detect(webp).expect("webp should be detected");
assert_eq!(ft.category, Category::Image);
assert_eq!(ft.mime, "image/webp");
}
#[test]
fn wav_is_audio_not_other_riff() {
let wav = b"RIFF\x24\x00\x00\x00WAVEfmt ";
let ft = detect(wav).expect("wav should be detected");
assert_eq!(ft.category, Category::Audio);
assert_eq!(ft.extension, "wav");
}
#[test]
fn mp3_is_audio() {
let mp3 = b"ID3\x03\x00\x00\x00\x00\x00\x00";
let ft = detect(mp3).expect("mp3 should be detected");
assert_eq!(ft.category, Category::Audio);
}
#[test]
fn flac_is_audio() {
let ft = detect(b"fLaC\x00\x00\x00\x22").expect("flac should be detected");
assert_eq!(ft.category, Category::Audio);
}
#[test]
fn ogg_is_audio() {
let ogg = b"OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00";
let ft = detect(ogg).expect("ogg should be detected");
assert_eq!(ft.category, Category::Audio);
}
#[test]
fn raw_pcm_is_undetectable() {
let pcm = [0x00u8, 0x01, 0xff, 0x7f, 0x00, 0x80, 0x34, 0x12];
assert!(detect(&pcm).is_none());
}
#[test]
fn plain_text_has_no_magic_but_classifies_as_text() {
assert!(detect(b"the quick brown fox\n").is_none());
let ft = classify(b"the quick brown fox\n").expect("prose should classify as text");
assert_eq!(ft.category, Category::Text);
assert_eq!(ft.mime, "text/plain");
}
#[test]
fn empty_is_undetectable() {
assert!(detect(b"").is_none());
assert!(classify(b"").is_none());
assert!(!looks_like_text(b""));
}
#[test]
fn raw_pcm_classifies_to_none_not_text() {
let pcm = [0x00u8, 0x01, 0xff, 0x7f, 0x00, 0x80, 0x34, 0x12];
assert!(classify(&pcm).is_none());
assert!(!looks_like_text(&pcm));
}
#[test]
fn utf8_prose_is_text() {
assert!(looks_like_text("café — naïve façade\n".as_bytes()));
assert!(looks_like_text(b"plain ascii\twith\ttabs\r\n"));
}
#[test]
fn nul_byte_is_not_text() {
assert!(!looks_like_text(b"looks texty\x00but has a nul"));
}
#[test]
fn truncated_trailing_multibyte_is_still_text() {
let mut bytes = "long enough run of text é".as_bytes().to_vec();
bytes.pop(); assert!(looks_like_text(&bytes));
}
#[test]
fn invalid_midstream_utf8_is_not_text() {
assert!(!looks_like_text(b"good text \xff\xfe more bytes here"));
}
#[test]
fn solo_truncated_lead_byte_is_not_text() {
assert!(!looks_like_text(b"\xc3"));
}
#[test]
fn category_words_are_stable() {
assert_eq!(Category::Image.as_str(), "image");
assert_eq!(Category::Audio.to_string(), "audio");
}
}