use std::fmt;
use crate::storage::error::SniffError;
use crate::storage::filename::Extension;
pub const SNIFF_BYTES: usize = 512;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SniffedType {
extension: &'static str,
mime: &'static str,
}
impl SniffedType {
#[must_use]
pub fn extension(&self) -> &'static str {
self.extension
}
#[must_use]
pub fn mime(&self) -> &'static str {
self.mime
}
}
impl fmt::Display for SniffedType {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.mime)
}
}
#[must_use]
pub fn sniff(bytes: &[u8]) -> Option<SniffedType> {
let head = &bytes[..bytes.len().min(SNIFF_BYTES)];
infer::get(head).map(|kind| SniffedType {
extension: kind.extension(),
mime: kind.mime_type(),
})
}
const SIGNATURES: &[(&str, &[&str])] = &[
("jpg", &["jpg"]),
("jpeg", &["jpg"]),
("png", &["png"]),
("gif", &["gif"]),
("webp", &["webp"]),
("bmp", &["bmp"]),
("ico", &["ico"]),
("tif", &["tif"]),
("tiff", &["tif"]),
("avif", &["avif"]),
("heic", &["heic"]),
("psd", &["psd"]),
("pdf", &["pdf"]),
("epub", &["epub"]),
("rtf", &["rtf"]),
("docx", &["docx", "zip"]),
("xlsx", &["xlsx", "zip"]),
("pptx", &["pptx", "zip"]),
("zip", &["zip"]),
("gz", &["gz"]),
("bz2", &["bz2"]),
("xz", &["xz"]),
("zst", &["zst"]),
("7z", &["7z"]),
("rar", &["rar"]),
("mp3", &["mp3"]),
("wav", &["wav"]),
("flac", &["flac"]),
("ogg", &["ogg"]),
("mp4", &["mp4"]),
("m4a", &["m4a"]),
("webm", &["webm"]),
("mov", &["mov"]),
("avi", &["avi"]),
("woff", &["woff"]),
("woff2", &["woff2"]),
("ttf", &["ttf"]),
("otf", &["otf"]),
];
#[must_use]
pub fn expected_signatures(extension: &Extension) -> Option<&'static [&'static str]> {
SIGNATURES
.iter()
.find(|(name, _)| *name == extension.as_str())
.map(|(_, signatures)| *signatures)
}
pub fn verify(bytes: &[u8], extension: &Extension) -> Result<Option<SniffedType>, SniffError> {
let sniffed = sniff(bytes);
match (expected_signatures(extension), sniffed) {
(Some(expected), Some(found)) if expected.contains(&found.extension()) => Ok(Some(found)),
(Some(_), Some(found)) => Err(SniffError::Mismatch {
declared: extension.clone(),
sniffed: found.mime(),
}),
(Some(_), None) => Err(SniffError::Unrecognized {
declared: extension.clone(),
}),
(None, None) => Ok(None),
(None, Some(found)) => Err(SniffError::Mismatch {
declared: extension.clone(),
sniffed: found.mime(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn extension(name: &str) -> Extension {
Extension::parse(name).expect("a valid extension")
}
fn png() -> Vec<u8> {
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR".to_vec()
}
fn jpeg() -> Vec<u8> {
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00".to_vec()
}
fn gif() -> Vec<u8> {
b"GIF89a\x01\x00\x01\x00".to_vec()
}
fn pdf() -> Vec<u8> {
b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n".to_vec()
}
fn webp() -> Vec<u8> {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&[0x1a, 0x00, 0x00, 0x00]);
bytes.extend_from_slice(b"WEBPVP8 ");
bytes
}
fn zip() -> Vec<u8> {
b"PK\x03\x04\x14\x00\x00\x00\x00\x00".to_vec()
}
fn php() -> Vec<u8> {
b"<?php system($_GET['c']); ?>".to_vec()
}
#[test]
fn the_signature_table_names_types_infer_actually_reports() {
for (bytes, expected) in [
(png(), "png"),
(jpeg(), "jpg"),
(gif(), "gif"),
(pdf(), "pdf"),
(webp(), "webp"),
(zip(), "zip"),
] {
assert_eq!(
sniff(&bytes).map(|found| found.extension()),
Some(expected),
"expected {expected} for its own magic bytes"
);
}
}
#[test]
fn a_real_png_under_a_png_extension_is_accepted() {
let found = verify(&png(), &extension("png")).expect("a png is a png");
assert_eq!(found.map(|kind| kind.mime()), Some("image/png"));
}
#[test]
fn jpg_and_jpeg_are_the_same_format() {
assert!(verify(&jpeg(), &extension("jpg")).is_ok());
assert!(verify(&jpeg(), &extension("jpeg")).is_ok());
}
#[test]
fn a_php_script_renamed_to_jpg_is_refused() {
let error = verify(&php(), &extension("jpg")).expect_err("a script is not an image");
assert!(matches!(error, SniffError::Unrecognized { .. }));
}
#[test]
fn a_png_renamed_to_pdf_is_refused_as_a_mismatch() {
let error = verify(&png(), &extension("pdf")).expect_err("a png is not a pdf");
match error {
SniffError::Mismatch { sniffed, .. } => assert_eq!(sniffed, "image/png"),
other => panic!("expected a mismatch, got {other:?}"),
}
}
#[test]
fn a_signature_less_extension_accepts_unrecognized_bytes() {
assert_eq!(verify(&php(), &extension("txt")), Ok(None));
assert_eq!(verify(b"a,b,c\n1,2,3\n", &extension("csv")), Ok(None));
}
#[test]
fn a_signature_less_extension_refuses_recognized_binary() {
let error = verify(&zip(), &extension("txt")).expect_err("a zip is not text");
assert!(matches!(error, SniffError::Mismatch { .. }));
}
#[test]
fn an_empty_object_matches_nothing() {
assert_eq!(sniff(b""), None);
assert!(verify(b"", &extension("png")).is_err());
assert_eq!(verify(b"", &extension("txt")), Ok(None));
}
#[test]
fn only_the_leading_bytes_are_looked_at() {
let mut buried = vec![0u8; SNIFF_BYTES];
buried.extend_from_slice(&png());
assert_eq!(sniff(&buried), None);
}
#[test]
fn a_truncated_prefix_is_still_enough() {
assert_eq!(sniff(&png()[..8]).map(|kind| kind.extension()), Some("png"));
}
#[test]
fn every_built_in_whitelist_extension_has_a_decided_rule() {
let expected: &[(&str, bool)] = &[
("jpg", true),
("jpeg", true),
("png", true),
("gif", true),
("webp", true),
("pdf", true),
("txt", false),
("csv", false),
];
for (name, has_signature) in expected {
assert_eq!(
expected_signatures(&extension(name)).is_some(),
*has_signature,
"{name}"
);
}
}
#[test]
fn the_table_has_no_duplicate_extensions() {
let mut names: Vec<&str> = SIGNATURES.iter().map(|(name, _)| *name).collect();
names.sort_unstable();
let count = names.len();
names.dedup();
assert_eq!(
names.len(),
count,
"a duplicate entry shadows the later one"
);
}
}