use crate::bytes::Reader;
use crate::error::{Result, StryptError, UnsupportedKind};
use crate::formats::{jxl, ogg};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum Format {
Jpeg,
Png,
Webp,
Pdf,
Tiff,
Gif,
Heif,
Avif,
Docx,
Xlsx,
Pptx,
Odt,
Ods,
Odp,
Svg,
Jxl,
Flac,
Wav,
Mp3,
Ogg,
Opus,
OggFlac,
Mp4,
M4a,
}
impl Format {
#[must_use]
pub const fn id(self) -> &'static str {
match self {
Self::Jpeg => "jpeg",
Self::Png => "png",
Self::Webp => "webp",
Self::Pdf => "pdf",
Self::Tiff => "tiff",
Self::Gif => "gif",
Self::Heif => "heif",
Self::Avif => "avif",
Self::Docx => "docx",
Self::Xlsx => "xlsx",
Self::Pptx => "pptx",
Self::Odt => "odt",
Self::Ods => "ods",
Self::Odp => "odp",
Self::Svg => "svg",
Self::Jxl => "jxl",
Self::Flac => "flac",
Self::Wav => "wav",
Self::Mp3 => "mp3",
Self::Ogg => "ogg",
Self::Opus => "opus",
Self::OggFlac => "ogg-flac",
Self::Mp4 => "mp4",
Self::M4a => "m4a",
}
}
#[must_use]
pub const fn conventional_extension(self) -> &'static str {
match self {
Self::Jpeg => "jpg",
Self::Png => "png",
Self::Webp => "webp",
Self::Pdf => "pdf",
Self::Tiff => "tiff",
Self::Gif => "gif",
Self::Heif => "heic",
Self::Avif => "avif",
Self::Docx => "docx",
Self::Xlsx => "xlsx",
Self::Pptx => "pptx",
Self::Odt => "odt",
Self::Ods => "ods",
Self::Odp => "odp",
Self::Svg => "svg",
Self::Jxl => "jxl",
Self::Flac => "flac",
Self::Wav => "wav",
Self::Mp3 => "mp3",
Self::Ogg => "ogg",
Self::Opus => "opus",
Self::OggFlac => "oga",
Self::Mp4 => "mp4",
Self::M4a => "m4a",
}
}
}
impl std::fmt::Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Jpeg => "JPEG",
Self::Png => "PNG",
Self::Webp => "WebP",
Self::Pdf => "PDF",
Self::Tiff => "TIFF",
Self::Gif => "GIF",
Self::Heif => "HEIF",
Self::Avif => "AVIF",
Self::Docx => "DOCX",
Self::Xlsx => "XLSX",
Self::Pptx => "PPTX",
Self::Odt => "ODT",
Self::Ods => "ODS",
Self::Odp => "ODP",
Self::Svg => "SVG",
Self::Jxl => "JPEG XL",
Self::Flac => "FLAC",
Self::Wav => "WAV",
Self::Mp3 => "MP3",
Self::Ogg => "Ogg Vorbis",
Self::Opus => "Opus",
Self::OggFlac => "Ogg FLAC",
Self::Mp4 => "MP4",
Self::M4a => "M4A",
})
}
}
const PDF_HEADER_SEARCH_WINDOW: usize = 1024;
pub fn detect(data: &[u8]) -> Result<Format> {
if let Some(format) = detect_supported(data) {
return Ok(format);
}
if let Some(kind) = detect_unsupported(data) {
return Err(StryptError::UnsupportedFormat { format: kind });
}
Err(StryptError::UnrecognisedFormat)
}
fn detect_supported(data: &[u8]) -> Option<Format> {
if starts_with(data, &[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
if starts_with(data, &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
return Some(Format::Png);
}
if is_riff_with_form(data, *b"WEBP") {
return Some(Format::Webp);
}
if is_riff_with_form(data, *b"WAVE") {
return Some(Format::Wav);
}
if starts_with(data, &[b'I', b'I', 0x2A, 0x00]) || starts_with(data, &[b'M', b'M', 0x00, 0x2A])
{
return Some(Format::Tiff);
}
if starts_with(data, b"GIF87a") || starts_with(data, b"GIF89a") {
return Some(Format::Gif);
}
if starts_with(data, &jxl::SIGNATURE_BOX) || starts_with(data, &jxl::CODESTREAM_MAGIC) {
return Some(Format::Jxl);
}
if starts_with(data, b"fLaC") || id3_precedes(data, b"fLaC") {
return Some(Format::Flac);
}
if starts_with(data, b"ID3") || crate::formats::mp3::frame_header(data).is_some() {
return Some(Format::Mp3);
}
if let Some(ogg::Sniff::Supported(format)) = ogg::sniff(data) {
return Some(format);
}
if let Some(format) = iso_base_media_still(data) {
return Some(format);
}
if let Some(IsoClass::Movie(format)) = iso_base_media_movie(data) {
return Some(format);
}
if find_pdf_header(data).is_some() {
return Some(Format::Pdf);
}
if let Some(Package::Ooxml(format) | Package::OpenDocument(format)) = zip_package(data) {
return Some(format);
}
if is_svg(data) {
return Some(Format::Svg);
}
None
}
fn detect_unsupported(data: &[u8]) -> Option<UnsupportedKind> {
if starts_with(data, b"PK\x03\x04")
|| starts_with(data, b"PK\x05\x06")
|| starts_with(data, b"PK\x07\x08")
{
return Some(match zip_package(data) {
Some(Package::MacroEnabled) => UnsupportedKind::MacroEnabledOffice,
Some(Package::OtherOpenDocument) => UnsupportedKind::OtherOpenDocument,
_ => UnsupportedKind::ZipContainer,
});
}
if starts_with(data, &[b'I', b'I', 0x2B, 0x00]) || starts_with(data, &[b'M', b'M', 0x00, 0x2B])
{
return Some(UnsupportedKind::BigTiff);
}
if data.get(4..8) == Some(b"ftyp") {
return Some(match iso_base_media_movie(data) {
Some(IsoClass::Refused(kind)) => kind,
_ => UnsupportedKind::IsoBaseMedia,
});
}
if let Some(ogg::Sniff::Refused(kind)) = ogg::sniff(data) {
return Some(kind);
}
if starts_with(data, b"OggS") {
return Some(UnsupportedKind::OtherOggCodec);
}
if starts_with(data, b"RF64") || starts_with(data, b"BW64") {
return Some(UnsupportedKind::Rf64);
}
if starts_with(data, b"RIFF") {
return Some(UnsupportedKind::OtherRiff);
}
if starts_with(data, &[0x1F, 0x8B]) {
return Some(UnsupportedKind::Gzip);
}
if looks_like_xml(data) {
return Some(UnsupportedKind::Xml);
}
None
}
const SVG_ROOT_SEARCH_WINDOW: usize = 8192;
fn is_svg(data: &[u8]) -> bool {
let window = data.get(..SVG_ROOT_SEARCH_WINDOW).unwrap_or(data);
let Ok(text) = std::str::from_utf8(window) else {
return false;
};
let mut rest = text.trim_start_matches('\u{feff}').trim_start();
for _ in 0..64 {
let terminator = if rest.starts_with("<!--") {
"-->"
} else if rest.starts_with("<?") {
"?>"
} else if rest.starts_with("<!") {
">"
} else {
return rest.starts_with("<svg")
&& rest
.get(4..5)
.is_none_or(|c| c.starts_with([' ', '\t', '\r', '\n', '>', '/']));
};
let Some(end) = rest.find(terminator) else {
return false;
};
rest = rest
.get(end.saturating_add(terminator.len())..)
.unwrap_or_default()
.trim_start();
}
false
}
enum Package {
Ooxml(Format),
MacroEnabled,
OpenDocument(Format),
OtherOpenDocument,
}
const OOXML_MAIN_TYPES: [(&str, Format); 3] = [
("wordprocessingml.document.main+xml", Format::Docx),
("spreadsheetml.sheet.main+xml", Format::Xlsx),
("presentationml.presentation.main+xml", Format::Pptx),
];
const OOXML_MACRO_TYPES: [&str; 3] = [
"wordprocessingml.document.macroEnabled.main+xml",
"spreadsheetml.sheet.macroEnabled.main+xml",
"presentationml.presentation.macroEnabled.main+xml",
];
const INDEX_PART_BUDGET: u64 = 4 * 1024 * 1024;
fn zip_package(data: &[u8]) -> Option<Package> {
let entries =
crate::container::zip::read(data, &crate::formats::ParseLimits::default()).ok()?;
opendocument_package(&entries).or_else(|| ooxml_package(&entries))
}
fn index_part(entries: &[crate::container::zip::Entry<'_>], name: &str) -> Option<String> {
let entry = entries
.iter()
.find(|entry| entry.name_str() == Some(name))?;
let bytes = entry.contents(INDEX_PART_BUDGET).ok()?;
std::str::from_utf8(bytes.as_ref()).ok().map(str::to_owned)
}
fn opendocument_package(entries: &[crate::container::zip::Entry<'_>]) -> Option<Package> {
let declared = index_part(entries, "mimetype")
.map(|text| text.trim().to_owned())
.filter(|text| text.starts_with(crate::formats::odf::MEDIA_TYPE_PREFIX))
.or_else(|| {
let manifest = index_part(entries, "META-INF/manifest.xml")?;
crate::formats::odf::root_media_type_of(&manifest)
})?;
if !declared.starts_with(crate::formats::odf::MEDIA_TYPE_PREFIX) {
return None;
}
Some(
crate::formats::odf::format_for_media_type(&declared)
.map_or(Package::OtherOpenDocument, Package::OpenDocument),
)
}
fn ooxml_package(entries: &[crate::container::zip::Entry<'_>]) -> Option<Package> {
let text = index_part(entries, "[Content_Types].xml")?;
if OOXML_MACRO_TYPES
.iter()
.any(|candidate| text.contains(candidate))
{
return Some(Package::MacroEnabled);
}
OOXML_MAIN_TYPES
.iter()
.find(|(candidate, _)| text.contains(candidate))
.map(|(_, format)| Package::Ooxml(*format))
}
const STILL_BRANDS: [(&[u8; 4], Format); 7] = [
(b"avif", Format::Avif),
(b"avio", Format::Avif),
(b"heic", Format::Heif),
(b"heix", Format::Heif),
(b"heim", Format::Heif),
(b"heis", Format::Heif),
(b"mif1", Format::Heif),
];
const SEQUENCE_BRANDS: [&[u8; 4]; 3] = [b"msf1", b"avis", b"hevc"];
const BRAND_WINDOW: usize = 256;
fn iso_base_media_still(data: &[u8]) -> Option<Format> {
if data.get(4..8) != Some(b"ftyp") {
return None;
}
let window = data.get(..BRAND_WINDOW).unwrap_or(data);
let major = window.get(8..12);
let compatible = window.get(16..).unwrap_or_default();
let brands = major
.into_iter()
.chain(compatible.chunks_exact(4))
.collect::<Vec<_>>();
if brands
.iter()
.any(|b| SEQUENCE_BRANDS.iter().any(|s| b == &&s[..]))
{
return None;
}
for (brand, format) in STILL_BRANDS {
if brands.iter().any(|b| b == &&brand[..]) {
return Some(format);
}
}
None
}
enum IsoClass {
Movie(Format),
Refused(UnsupportedKind),
}
fn iso_base_media_movie(data: &[u8]) -> Option<IsoClass> {
use crate::formats::mp4::boxes as mp4;
if data.get(4..8) != Some(b"ftyp") {
return None;
}
let window = data.get(..BRAND_WINDOW).unwrap_or(data);
let brands: Vec<&[u8]> = window
.get(8..12)
.into_iter()
.chain(window.get(16..).unwrap_or_default().chunks_exact(4))
.collect();
let has = |list: &[[u8; 4]]| {
brands
.iter()
.any(|b| list.iter().any(|candidate| *b == &candidate[..]))
};
if has(&mp4::FRAGMENT_BRANDS) {
return Some(IsoClass::Refused(UnsupportedKind::FragmentedMp4));
}
if has(&[mp4::PROTECTED_BRAND]) {
return Some(IsoClass::Refused(UnsupportedKind::ProtectedMedia));
}
if has(&[mp4::QUICKTIME_BRAND]) {
return Some(IsoClass::Refused(UnsupportedKind::QuickTimeMovie));
}
if brands
.iter()
.any(|b| b.get(..3) == Some(b"3gp") || b.get(..3) == Some(b"3g2"))
{
return Some(IsoClass::Refused(
UnsupportedKind::ThirdGenerationPartnership,
));
}
if has(&mp4::M4A_BRANDS) {
return Some(IsoClass::Movie(Format::M4a));
}
if has(&mp4::MP4_BRANDS) || has(&mp4::MP4_BRANDS_VIDEO) {
return Some(IsoClass::Movie(Format::Mp4));
}
None
}
fn starts_with(data: &[u8], prefix: &[u8]) -> bool {
data.get(0..prefix.len()) == Some(prefix)
}
fn is_riff_with_form(data: &[u8], form: [u8; 4]) -> bool {
let mut r = Reader::new(data);
if r.peek(4) != Some(b"RIFF") {
return false;
}
if r.skip(8).is_none() {
return false;
}
r.peek(4) == Some(form.as_slice())
}
fn id3_precedes(data: &[u8], marker: &[u8]) -> bool {
let mut r = Reader::new(data);
if r.skip(5).is_none() {
return false;
}
let Some(flags) = r.u8() else {
return false;
};
let Some(size) = r.take(4) else {
return false;
};
let mut total: usize = 0;
for byte in size {
total = total
.saturating_mul(128)
.saturating_add(usize::from(byte & 0x7F));
}
let mut at = total.saturating_add(10);
if flags & 0x10 != 0 {
at = at.saturating_add(10);
}
data.get(at..at.saturating_add(marker.len())) == Some(marker)
}
fn find_pdf_header(data: &[u8]) -> Option<usize> {
const HEADER: &[u8] = b"%PDF-";
let window = data.get(0..PDF_HEADER_SEARCH_WINDOW).unwrap_or(data);
window
.windows(HEADER.len())
.position(|candidate| candidate == HEADER)
}
fn looks_like_xml(data: &[u8]) -> bool {
let mut r = Reader::new(data);
if r.peek(3) == Some(&[0xEF, 0xBB, 0xBF]) && r.skip(3).is_none() {
return false;
}
for _ in 0..64 {
match r.peek(1) {
Some([b' ' | b'\t' | b'\r' | b'\n']) => {
if r.skip(1).is_none() {
return false;
}
}
_ => break,
}
}
r.peek(5) == Some(b"<?xml") || r.peek(4) == Some(b"<svg") || r.peek(9) == Some(b"<!DOCTYPE")
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn the_four_supported_formats_are_recognised() {
assert_eq!(detect(&[0xFF, 0xD8, 0xFF, 0xE0]).unwrap(), Format::Jpeg);
assert_eq!(
detect(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]).unwrap(),
Format::Png
);
assert_eq!(
detect(b"RIFF\x00\x00\x00\x00WEBPVP8 ").unwrap(),
Format::Webp
);
assert_eq!(detect(b"%PDF-1.7\n").unwrap(), Format::Pdf);
}
#[test]
fn both_gif_spellings_route_to_the_handler() {
assert_eq!(
detect(b"GIF87a\x01\x00\x01\x00\x00\x00\x00").unwrap(),
Format::Gif
);
assert_eq!(
detect(b"GIF89a\x01\x00\x01\x00\x00\x00\x00").unwrap(),
Format::Gif
);
}
#[test]
fn a_pdf_named_jpg_is_still_a_pdf() {
assert_eq!(
detect(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n").unwrap(),
Format::Pdf
);
}
#[test]
fn a_pdf_header_inside_a_jpeg_does_not_win() {
let mut data = vec![0xFF, 0xD8, 0xFF, 0xE1];
data.extend_from_slice(b"junk %PDF-1.7 junk");
assert_eq!(detect(&data).unwrap(), Format::Jpeg);
}
#[test]
fn a_pdf_with_a_leading_preamble_is_recognised() {
let mut data = b"\r\n<!-- inserted by a broken proxy -->\r\n".to_vec();
data.extend_from_slice(b"%PDF-1.5\n");
assert_eq!(detect(&data).unwrap(), Format::Pdf);
}
#[test]
fn a_pdf_header_beyond_the_search_window_is_not_scanned_for() {
let mut data = vec![b'x'; PDF_HEADER_SEARCH_WINDOW];
data.extend_from_slice(b"%PDF-1.7\n");
assert!(matches!(
detect(&data),
Err(StryptError::UnrecognisedFormat)
));
}
#[test]
fn a_riff_is_routed_by_its_form_type_rather_than_by_its_magic() {
assert_eq!(
detect(b"RIFF\x00\x00\x00\x00WAVEfmt ").unwrap(),
Format::Wav
);
assert!(matches!(
detect(b"RIFF\x00\x00\x00\x00AVI LIST").unwrap_err(),
StryptError::UnsupportedFormat {
format: UnsupportedKind::OtherRiff
}
));
for magic in [
&b"RF64\x00\x00\x00\x00WAVEds64"[..],
&b"BW64\x00\x00\x00\x00WAVEds64"[..],
] {
assert!(matches!(
detect(magic).unwrap_err(),
StryptError::UnsupportedFormat {
format: UnsupportedKind::Rf64
}
));
}
}
#[test]
fn mpeg_audio_is_claimed_by_its_tag_or_by_a_frame_header() {
for bytes in [
&b"ID3\x04\x00\x00\x00\x00\x00\x00"[..],
&b"ID3\x03\x00\x00\x00\x00\x00\x00"[..],
&b"\xFF\xFB\x90\xC0"[..],
] {
assert_eq!(detect(bytes).unwrap(), Format::Mp3, "{bytes:?}");
}
for bytes in [
&b"\xFF\xEB\x90\xC0"[..],
&b"\xFF\xF9\x90\xC0"[..],
&b"\xFF\xFB\xF0\xC0"[..],
&b"\xFF\xFB\x9C\xC0"[..],
] {
assert!(detect(bytes).is_err(), "{bytes:?}");
}
}
#[test]
fn an_id3_prefixed_flac_is_routed_to_the_flac_handler_not_to_mp3() {
let mut input = b"ID3\x04\x00\x00".to_vec();
input.extend_from_slice(&[0, 0, 0, 4]);
input.extend_from_slice(&[0u8; 4]);
input.extend_from_slice(b"fLaC");
assert_eq!(detect(&input).unwrap(), Format::Flac);
}
#[test]
fn phase_two_formats_are_named_in_the_refusal() {
for (bytes, expected) in [
(&b"PK\x03\x04"[..], UnsupportedKind::ZipContainer),
(&b"II\x2B\x00"[..], UnsupportedKind::BigTiff),
(&b"OggS"[..], UnsupportedKind::OtherOggCodec),
(
&b"\x00\x00\x00\x18ftypdash\x00\x00\x02\x00iso6dash"[..],
UnsupportedKind::FragmentedMp4,
),
(
&b"\x00\x00\x00\x18ftypM4P \x00\x00\x02\x00M4A mp42"[..],
UnsupportedKind::ProtectedMedia,
),
(
&b"\x00\x00\x00\x18ftypqt \x00\x00\x02\x00qt qt "[..],
UnsupportedKind::QuickTimeMovie,
),
(
&b"\x00\x00\x00\x18ftyp3gp4\x00\x00\x02\x003gp4isom"[..],
UnsupportedKind::ThirdGenerationPartnership,
),
(
&b"\x00\x00\x00\x18ftypzzzz\x00\x00\x02\x00zzzzyyyy"[..],
UnsupportedKind::IsoBaseMedia,
),
(&b"<?xml version=\"1.0\"?><rss/>"[..], UnsupportedKind::Xml),
(&b"\x1f\x8b\x08\x00"[..], UnsupportedKind::Gzip),
] {
let got = detect(bytes).unwrap_err();
assert!(
matches!(got, StryptError::UnsupportedFormat { format } if format == expected),
"detecting {expected:?} gave {got:?}"
);
}
}
#[test]
fn still_image_brands_route_to_the_handler_and_video_does_not() {
for (brand, expected) in [
(&b"avif"[..], Format::Avif),
(&b"heic"[..], Format::Heif),
(&b"mif1"[..], Format::Heif),
] {
let mut data = vec![0, 0, 0, 0x14];
data.extend_from_slice(b"ftyp");
data.extend_from_slice(brand);
data.extend_from_slice(&[0, 0, 0, 0]);
data.extend_from_slice(brand);
assert_eq!(detect(&data).unwrap(), expected, "brand {brand:?}");
}
}
#[test]
fn movie_brands_route_to_the_mp4_handler_and_audio_wins_over_the_generic_one() {
for (major, compatible, expected) in [
(&b"isom"[..], &b"isomiso2mp41"[..], Format::Mp4),
(&b"mp42"[..], &b"mp42isom"[..], Format::Mp4),
(&b"M4V "[..], &b"M4V mp42"[..], Format::Mp4),
(&b"M4A "[..], &b"M4A mp42isom"[..], Format::M4a),
(&b"M4B "[..], &b"M4B mp42"[..], Format::M4a),
] {
let mut data = vec![0, 0, 0, 0x18];
data.extend_from_slice(b"ftyp");
data.extend_from_slice(major);
data.extend_from_slice(&[0, 0, 2, 0]);
data.extend_from_slice(compatible);
assert_eq!(detect(&data).unwrap(), expected, "brand {major:?}");
}
}
#[test]
fn a_sequence_brand_is_not_claimed_as_a_still_image() {
let mut data = vec![0, 0, 0, 0x18];
data.extend_from_slice(b"ftypheic\x00\x00\x00\x00heicmsf1");
assert!(matches!(
detect(&data),
Err(StryptError::UnsupportedFormat {
format: UnsupportedKind::IsoBaseMedia
})
));
}
#[test]
fn an_svg_is_recognised_by_its_root_element_and_nothing_else() {
for bytes in [
&b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"[..],
b"\xef\xbb\xbf<svg/>",
b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg width=\"1\"/>",
b"<!-- Generator: Adobe Illustrator --><svg>x</svg>",
b"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"svg11.dtd\">\n<svg/>",
] {
assert_eq!(detect(bytes).unwrap(), Format::Svg, "{bytes:?}");
}
}
#[test]
fn markup_that_merely_mentions_svg_is_not_claimed_as_one() {
for bytes in [
&b"<html><body><svg><rect/></svg></body></html>"[..],
b"<?xml version=\"1.0\"?><gallery><svg/></gallery>",
b"<svgeny/>",
b"<svg:svg xmlns:svg=\"http://www.w3.org/2000/svg\"/>",
] {
assert!(
!matches!(detect(bytes), Ok(Format::Svg)),
"wrongly claimed {bytes:?}"
);
}
}
#[test]
fn a_root_element_beyond_the_search_window_is_not_scanned_for() {
let mut data = b"<!--".to_vec();
data.resize(SVG_ROOT_SEARCH_WINDOW, b'x');
data.extend_from_slice(b"--><svg/>");
assert!(!matches!(detect(&data), Ok(Format::Svg)));
}
#[test]
fn nothing_recognisable_is_an_error_never_a_silent_pass() {
assert!(matches!(detect(b""), Err(StryptError::UnrecognisedFormat)));
assert!(matches!(
detect(b"hello world"),
Err(StryptError::UnrecognisedFormat)
));
}
#[test]
fn truncated_magic_numbers_do_not_panic() {
let signatures: [&[u8]; 4] = [
&[0xFF, 0xD8, 0xFF],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
b"RIFF\x00\x00\x00\x00WEBP",
b"%PDF-1.7",
];
for sig in signatures {
for n in 0..=sig.len() {
let prefix = sig.get(0..n).unwrap_or_default();
let _ = detect(prefix);
}
}
}
}