mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
refactor(media-proxy): split into modules and harden streaming (#2437)
This commit is contained in:
1 parent
16ae98e189
commit
d7d4e8da03
214 files changed
+47213
-18092
No files matched your search
@@ -0,0 +1,367 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::SniffInfo;
|
||||
use crate::media_type::MediaType;
|
||||
|
||||
const WEBP_CHUNK_HEADER_BYTES: usize = 20;
|
||||
const WEBP_EXTENDED_HEADER_BYTES: usize = 30;
|
||||
const WEBP_LOSSLESS_HEADER_BYTES: usize = 25;
|
||||
const GIF_LOGICAL_SCREEN_BYTES: usize = 13;
|
||||
const GIF_IMAGE_DESCRIPTOR_BYTES: usize = 9;
|
||||
const GIF_STRUCTURE_BLOCK_LIMIT: usize = 262_144;
|
||||
const PNG_SNIFF_CHUNK_LIMIT: usize = 262_144;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum GIFAnimation {
|
||||
Incomplete,
|
||||
Static,
|
||||
Animated,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum PNGAnimation {
|
||||
Incomplete,
|
||||
Static,
|
||||
Animated(u32),
|
||||
Invalid,
|
||||
}
|
||||
|
||||
fn gif_color_table_end(data: &[u8], offset: usize, packed: u8) -> Option<usize> {
|
||||
assert!(offset <= data.len());
|
||||
if packed & 0x80 == 0 {
|
||||
return Some(offset);
|
||||
}
|
||||
let entries = 1usize << ((packed & 0x07) + 1);
|
||||
let table_bytes = entries
|
||||
.checked_mul(3)
|
||||
.expect("GIF color table byte count must fit usize");
|
||||
let end = offset.checked_add(table_bytes)?;
|
||||
(end <= data.len()).then_some(end)
|
||||
}
|
||||
|
||||
fn gif_u16(data: &[u8], offset: usize) -> u16 {
|
||||
assert!(offset <= data.len());
|
||||
assert!(data.len() - offset >= 2);
|
||||
u16::from_le_bytes(
|
||||
data[offset..offset + 2]
|
||||
.try_into()
|
||||
.expect("validated GIF u16 slice"),
|
||||
)
|
||||
}
|
||||
|
||||
fn gif_validate_image_geometry(
|
||||
data: &[u8],
|
||||
offset: usize,
|
||||
canvas_width: u16,
|
||||
canvas_height: u16,
|
||||
) -> Result<(), GIFAnimation> {
|
||||
assert!(offset <= data.len());
|
||||
assert!(data.len() - offset >= GIF_IMAGE_DESCRIPTOR_BYTES);
|
||||
let left = gif_u16(data, offset);
|
||||
let top = gif_u16(data, offset + 2);
|
||||
let width = gif_u16(data, offset + 4);
|
||||
let height = gif_u16(data, offset + 6);
|
||||
if width == 0 {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
}
|
||||
if height == 0 {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
}
|
||||
if u32::from(left) + u32::from(width) > u32::from(canvas_width) {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
}
|
||||
if u32::from(top) + u32::from(height) > u32::from(canvas_height) {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn gif_skip_sub_blocks(
|
||||
data: &[u8],
|
||||
mut offset: usize,
|
||||
block_count: &mut usize,
|
||||
) -> Result<usize, GIFAnimation> {
|
||||
assert!(offset <= data.len());
|
||||
loop {
|
||||
if *block_count >= GIF_STRUCTURE_BLOCK_LIMIT {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
}
|
||||
let Some(&length) = data.get(offset) else {
|
||||
return Err(GIFAnimation::Incomplete);
|
||||
};
|
||||
*block_count += 1;
|
||||
offset += 1;
|
||||
if length == 0 {
|
||||
return Ok(offset);
|
||||
}
|
||||
let Some(end) = offset.checked_add(usize::from(length)) else {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
};
|
||||
if end > data.len() {
|
||||
return Err(GIFAnimation::Incomplete);
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
}
|
||||
|
||||
fn gif_skip_image(
|
||||
data: &[u8],
|
||||
offset: usize,
|
||||
canvas_width: u16,
|
||||
canvas_height: u16,
|
||||
block_count: &mut usize,
|
||||
) -> Result<usize, GIFAnimation> {
|
||||
assert!(offset <= data.len());
|
||||
let Some(descriptor_end) = offset.checked_add(GIF_IMAGE_DESCRIPTOR_BYTES) else {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
};
|
||||
if descriptor_end > data.len() {
|
||||
return Err(GIFAnimation::Incomplete);
|
||||
}
|
||||
gif_validate_image_geometry(data, offset, canvas_width, canvas_height)?;
|
||||
let packed = data[offset + 8];
|
||||
let Some(table_end) = gif_color_table_end(data, descriptor_end, packed) else {
|
||||
return Err(GIFAnimation::Incomplete);
|
||||
};
|
||||
let Some(&minimum_code_size) = data.get(table_end) else {
|
||||
return Err(GIFAnimation::Incomplete);
|
||||
};
|
||||
if !(2..=8).contains(&minimum_code_size) {
|
||||
return Err(GIFAnimation::Invalid);
|
||||
}
|
||||
gif_skip_sub_blocks(data, table_end + 1, block_count)
|
||||
}
|
||||
|
||||
pub(super) fn gif_animation(data: &[u8]) -> GIFAnimation {
|
||||
if data.len() < 6 {
|
||||
return GIFAnimation::Incomplete;
|
||||
}
|
||||
match &data[..6] {
|
||||
b"GIF87a" | b"GIF89a" => {}
|
||||
_ => return GIFAnimation::Invalid,
|
||||
}
|
||||
if data.len() < GIF_LOGICAL_SCREEN_BYTES {
|
||||
return GIFAnimation::Incomplete;
|
||||
}
|
||||
let canvas_width = gif_u16(data, 6);
|
||||
let canvas_height = gif_u16(data, 8);
|
||||
if canvas_width == 0 {
|
||||
return GIFAnimation::Invalid;
|
||||
}
|
||||
if canvas_height == 0 {
|
||||
return GIFAnimation::Invalid;
|
||||
}
|
||||
let Some(mut offset) = gif_color_table_end(data, GIF_LOGICAL_SCREEN_BYTES, data[10]) else {
|
||||
return GIFAnimation::Incomplete;
|
||||
};
|
||||
let mut image_count = 0usize;
|
||||
let mut block_count = 0usize;
|
||||
loop {
|
||||
if block_count >= GIF_STRUCTURE_BLOCK_LIMIT {
|
||||
return GIFAnimation::Invalid;
|
||||
}
|
||||
let Some(&introducer) = data.get(offset) else {
|
||||
return GIFAnimation::Incomplete;
|
||||
};
|
||||
block_count += 1;
|
||||
offset += 1;
|
||||
match introducer {
|
||||
0x3b if image_count == 1 => return GIFAnimation::Static,
|
||||
0x3b => return GIFAnimation::Invalid,
|
||||
0x21 => {
|
||||
if data.get(offset).is_none() {
|
||||
return GIFAnimation::Incomplete;
|
||||
}
|
||||
offset = match gif_skip_sub_blocks(data, offset + 1, &mut block_count) {
|
||||
Ok(next) => next,
|
||||
Err(outcome) => return outcome,
|
||||
};
|
||||
}
|
||||
0x2c => {
|
||||
offset = match gif_skip_image(
|
||||
data,
|
||||
offset,
|
||||
canvas_width,
|
||||
canvas_height,
|
||||
&mut block_count,
|
||||
) {
|
||||
Ok(next) => next,
|
||||
Err(outcome) => return outcome,
|
||||
};
|
||||
image_count += 1;
|
||||
if image_count == 2 {
|
||||
return GIFAnimation::Animated;
|
||||
}
|
||||
}
|
||||
_ => return GIFAnimation::Invalid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn png_animation(data: &[u8]) -> PNGAnimation {
|
||||
if data.len() < 8 {
|
||||
return PNGAnimation::Incomplete;
|
||||
}
|
||||
if &data[..8] != b"\x89PNG\r\n\x1a\n" {
|
||||
return PNGAnimation::Invalid;
|
||||
}
|
||||
let mut offset = 8usize;
|
||||
let mut first_chunk = true;
|
||||
let mut chunk_count = 0usize;
|
||||
loop {
|
||||
if chunk_count >= PNG_SNIFF_CHUNK_LIMIT {
|
||||
return PNGAnimation::Invalid;
|
||||
}
|
||||
let Some(length_end) = offset.checked_add(4) else {
|
||||
return PNGAnimation::Invalid;
|
||||
};
|
||||
let Some(header_end) = offset.checked_add(8) else {
|
||||
return PNGAnimation::Invalid;
|
||||
};
|
||||
if header_end > data.len() {
|
||||
break;
|
||||
}
|
||||
chunk_count += 1;
|
||||
let length_bytes = data[offset..length_end]
|
||||
.try_into()
|
||||
.expect("validated PNG chunk length slice");
|
||||
let length = usize::try_from(u32::from_be_bytes(length_bytes))
|
||||
.expect("PNG chunk length must fit the configured platform");
|
||||
let kind = &data[length_end..header_end];
|
||||
if first_chunk {
|
||||
if kind != b"IHDR" {
|
||||
return PNGAnimation::Invalid;
|
||||
}
|
||||
if length != 13 {
|
||||
return PNGAnimation::Invalid;
|
||||
}
|
||||
}
|
||||
first_chunk = false;
|
||||
if kind == b"acTL" {
|
||||
if length != 8 {
|
||||
return PNGAnimation::Invalid;
|
||||
}
|
||||
let Some(chunk_end) = offset.checked_add(16) else {
|
||||
return PNGAnimation::Invalid;
|
||||
};
|
||||
if chunk_end > data.len() {
|
||||
return PNGAnimation::Incomplete;
|
||||
}
|
||||
let frame_end = header_end
|
||||
.checked_add(4)
|
||||
.expect("validated PNG animation frame count end");
|
||||
let frame_bytes = data[header_end..frame_end]
|
||||
.try_into()
|
||||
.expect("validated PNG animation frame count slice");
|
||||
let frames = u32::from_be_bytes(frame_bytes);
|
||||
return if frames > 0 {
|
||||
PNGAnimation::Animated(frames)
|
||||
} else {
|
||||
PNGAnimation::Invalid
|
||||
};
|
||||
}
|
||||
match kind {
|
||||
b"IDAT" | b"IEND" => return PNGAnimation::Static,
|
||||
_ => {}
|
||||
}
|
||||
let Some(next) = offset
|
||||
.checked_add(12)
|
||||
.and_then(|next| next.checked_add(length))
|
||||
else {
|
||||
return PNGAnimation::Invalid;
|
||||
};
|
||||
if next > data.len() {
|
||||
return PNGAnimation::Incomplete;
|
||||
}
|
||||
offset = next;
|
||||
}
|
||||
PNGAnimation::Incomplete
|
||||
}
|
||||
|
||||
fn webp_u24(data: &[u8]) -> u32 {
|
||||
u32::from(data[0]) | (u32::from(data[1]) << 8) | (u32::from(data[2]) << 16)
|
||||
}
|
||||
|
||||
pub(super) fn webp_sniff(data: &[u8]) -> SniffInfo {
|
||||
let mut out = SniffInfo {
|
||||
mime: MediaType::WebP.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
if data.len() < WEBP_CHUNK_HEADER_BYTES {
|
||||
return out;
|
||||
}
|
||||
match &data[12..16] {
|
||||
b"VP8X" => {
|
||||
let chunk_size = u32::from_le_bytes(
|
||||
data[16..20]
|
||||
.try_into()
|
||||
.expect("validated WebP chunk size slice"),
|
||||
);
|
||||
if chunk_size != 10 || data.len() < WEBP_EXTENDED_HEADER_BYTES {
|
||||
return out;
|
||||
}
|
||||
let flags = data[20];
|
||||
out.animated = flags & 0x02 != 0;
|
||||
out.frames = if out.animated { 2 } else { 1 };
|
||||
out.has_alpha = flags & 0x10 != 0;
|
||||
out.width = webp_u24(&data[24..27])
|
||||
.checked_add(1)
|
||||
.expect("24-bit WebP width must fit u32");
|
||||
out.height = webp_u24(&data[27..30])
|
||||
.checked_add(1)
|
||||
.expect("24-bit WebP height must fit u32");
|
||||
}
|
||||
b"VP8 " if data.len() >= WEBP_EXTENDED_HEADER_BYTES => {
|
||||
if &data[23..26] == b"\x9d\x01\x2a" {
|
||||
out.width = u32::from(
|
||||
u16::from_le_bytes(
|
||||
data[26..28].try_into().expect("validated WebP width slice"),
|
||||
) & 0x3fff,
|
||||
);
|
||||
out.height = u32::from(
|
||||
u16::from_le_bytes(
|
||||
data[28..30]
|
||||
.try_into()
|
||||
.expect("validated WebP height slice"),
|
||||
) & 0x3fff,
|
||||
);
|
||||
}
|
||||
}
|
||||
b"VP8L" if data.len() >= WEBP_LOSSLESS_HEADER_BYTES && data[20] == 0x2f => {
|
||||
let bits = u32::from_le_bytes(
|
||||
data[21..25]
|
||||
.try_into()
|
||||
.expect("validated lossless WebP dimensions slice"),
|
||||
);
|
||||
out.width = (bits & 0x3fff)
|
||||
.checked_add(1)
|
||||
.expect("14-bit WebP width must fit u32");
|
||||
out.height = ((bits >> 14) & 0x3fff)
|
||||
.checked_add(1)
|
||||
.expect("14-bit WebP height must fit u32");
|
||||
out.has_alpha = bits & (1 << 28) != 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn webp_sniff_complete(data: &[u8]) -> bool {
|
||||
if data.len() < WEBP_CHUNK_HEADER_BYTES {
|
||||
return false;
|
||||
}
|
||||
match &data[12..16] {
|
||||
b"VP8X" => {
|
||||
let chunk_size = u32::from_le_bytes(
|
||||
data[16..20]
|
||||
.try_into()
|
||||
.expect("validated WebP animation chunk size slice"),
|
||||
);
|
||||
chunk_size != 10 || data.len() >= WEBP_EXTENDED_HEADER_BYTES
|
||||
}
|
||||
b"VP8 " => data.len() >= WEBP_EXTENDED_HEADER_BYTES,
|
||||
b"VP8L" => data.len() >= WEBP_LOSSLESS_HEADER_BYTES,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::SniffInfo;
|
||||
use crate::media_type::MediaType;
|
||||
|
||||
const ISO_BMFF_BRAND_SCAN_BYTES: usize = 128;
|
||||
const ISO_BMFF_BASE_HEADER_BYTES: usize = 8;
|
||||
const ISO_BMFF_EXTENDED_HEADER_BYTES: usize = 16;
|
||||
const ISO_BMFF_FTYP_FIELDS_BYTES: usize = 8;
|
||||
|
||||
struct FTYPLayout {
|
||||
major_brand_offset: usize,
|
||||
compatible_brands_offset: usize,
|
||||
scan_end: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ISOBMFFBrandSet {
|
||||
avif: bool,
|
||||
animated_avif: bool,
|
||||
heic: bool,
|
||||
heif: bool,
|
||||
audio_mp4: bool,
|
||||
mp4: bool,
|
||||
quicktime: bool,
|
||||
three_gpp: bool,
|
||||
}
|
||||
|
||||
impl ISOBMFFBrandSet {
|
||||
fn record(&mut self, brand: &[u8]) {
|
||||
if matches!(brand, b"avif" | b"avis" | b"avio") {
|
||||
self.avif = true;
|
||||
if brand == b"avis" {
|
||||
self.animated_avif = true;
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
brand,
|
||||
b"heic" | b"heix" | b"heif" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"hevm" | b"hevs"
|
||||
) {
|
||||
self.heic = true;
|
||||
}
|
||||
if matches!(brand, b"mif1" | b"msf1") {
|
||||
self.heif = true;
|
||||
}
|
||||
if matches!(
|
||||
brand,
|
||||
b"mp41"
|
||||
| b"mp42"
|
||||
| b"isom"
|
||||
| b"iso2"
|
||||
| b"iso3"
|
||||
| b"iso4"
|
||||
| b"iso5"
|
||||
| b"iso6"
|
||||
| b"M4V "
|
||||
| b"M4P "
|
||||
| b"dash"
|
||||
| b"msdh"
|
||||
| b"msix"
|
||||
| b"mj2s"
|
||||
) {
|
||||
self.mp4 = true;
|
||||
}
|
||||
if matches!(brand, b"M4A " | b"M4B " | b"M4P ") {
|
||||
self.audio_mp4 = true;
|
||||
}
|
||||
if brand == b"qt " {
|
||||
self.quicktime = true;
|
||||
}
|
||||
if brand.starts_with(b"3gp") {
|
||||
self.three_gpp = true;
|
||||
}
|
||||
if brand.starts_with(b"3g2") {
|
||||
self.three_gpp = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(self) -> Option<SniffInfo> {
|
||||
if self.avif {
|
||||
return Some(SniffInfo {
|
||||
mime: MediaType::AVIF.mime(),
|
||||
animated: self.animated_avif,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if self.heic {
|
||||
return Some(SniffInfo {
|
||||
mime: MediaType::HEIC.mime(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if self.heif {
|
||||
return Some(SniffInfo {
|
||||
mime: MediaType::HEIF.mime(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if self.quicktime {
|
||||
return Some(SniffInfo {
|
||||
mime: MediaType::QuickTimeVideo.mime(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if self.three_gpp {
|
||||
return Some(SniffInfo {
|
||||
mime: MediaType::ThreeGPPVideo.mime(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if self.audio_mp4 {
|
||||
return Some(SniffInfo {
|
||||
mime: MediaType::MP4Audio.mime(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if self.mp4 {
|
||||
return Some(SniffInfo {
|
||||
mime: MediaType::MP4Video.mime(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn ftyp_layout(data: &[u8]) -> Option<FTYPLayout> {
|
||||
if data.len() < ISO_BMFF_BASE_HEADER_BYTES {
|
||||
return None;
|
||||
}
|
||||
if &data[4..8] != b"ftyp" {
|
||||
return None;
|
||||
}
|
||||
let size32 = u32::from_be_bytes(
|
||||
data[0..4]
|
||||
.try_into()
|
||||
.expect("validated ISO BMFF box size slice"),
|
||||
);
|
||||
let (header_bytes, declared_box_size) = if size32 == 1 {
|
||||
if data.len() < ISO_BMFF_EXTENDED_HEADER_BYTES {
|
||||
return None;
|
||||
}
|
||||
let size64 = u64::from_be_bytes(
|
||||
data[8..16]
|
||||
.try_into()
|
||||
.expect("validated extended ISO BMFF box size slice"),
|
||||
);
|
||||
let box_size = usize::try_from(size64).ok()?;
|
||||
(ISO_BMFF_EXTENDED_HEADER_BYTES, Some(box_size))
|
||||
} else if size32 == 0 {
|
||||
(ISO_BMFF_BASE_HEADER_BYTES, None)
|
||||
} else {
|
||||
let box_size = usize::try_from(size32).expect("u32 ISO BMFF box size must fit usize");
|
||||
(ISO_BMFF_BASE_HEADER_BYTES, Some(box_size))
|
||||
};
|
||||
let box_size = declared_box_size.unwrap_or(data.len());
|
||||
let minimum_size = header_bytes.checked_add(ISO_BMFF_FTYP_FIELDS_BYTES)?;
|
||||
if box_size < minimum_size {
|
||||
return None;
|
||||
}
|
||||
let major_brand_end = header_bytes.checked_add(4)?;
|
||||
let scan_end = match declared_box_size {
|
||||
Some(declared) if declared <= data.len() => declared,
|
||||
_ => data.len().min(ISO_BMFF_BRAND_SCAN_BYTES),
|
||||
};
|
||||
if major_brand_end > scan_end {
|
||||
return None;
|
||||
}
|
||||
Some(FTYPLayout {
|
||||
major_brand_offset: header_bytes,
|
||||
compatible_brands_offset: minimum_size,
|
||||
scan_end,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn iso_bmff_sniff(data: &[u8]) -> Option<SniffInfo> {
|
||||
let layout = ftyp_layout(data)?;
|
||||
let mut brands = ISOBMFFBrandSet::default();
|
||||
brands.record(&data[layout.major_brand_offset..layout.major_brand_offset + 4]);
|
||||
let mut offset = layout.compatible_brands_offset;
|
||||
loop {
|
||||
let end = offset
|
||||
.checked_add(4)
|
||||
.expect("bounded ISO BMFF brand offset must fit usize");
|
||||
if end > layout.scan_end {
|
||||
break;
|
||||
}
|
||||
brands.record(&data[offset..end]);
|
||||
offset = end;
|
||||
}
|
||||
brands.resolve()
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
mod image_containers;
|
||||
mod iso_bmff;
|
||||
mod registry;
|
||||
mod stream_containers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use self::image_containers::{
|
||||
GIFAnimation, PNGAnimation, gif_animation, png_animation, webp_sniff, webp_sniff_complete,
|
||||
};
|
||||
use self::iso_bmff::iso_bmff_sniff;
|
||||
use self::stream_containers::{is_adts, looks_like_svg, matroska_sniff, mpeg_ts_sniff, ogg_sniff};
|
||||
use crate::media_type::MediaType;
|
||||
|
||||
pub use self::registry::{category, extension_mime, normalize, passthrough_mime};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Category {
|
||||
Image,
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SniffInfo {
|
||||
pub mime: &'static str,
|
||||
pub animated: bool,
|
||||
pub frames: u32,
|
||||
pub duration_ms: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub has_alpha: bool,
|
||||
pub color_space: &'static str,
|
||||
}
|
||||
|
||||
impl Default for SniffInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mime: "application/octet-stream",
|
||||
animated: false,
|
||||
frames: 1,
|
||||
duration_ms: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
has_alpha: false,
|
||||
color_space: "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MediaPrefixSniff {
|
||||
pub media: SniffInfo,
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
pub fn is_supported_media_mime(mime_type_raw: &str) -> bool {
|
||||
let Some(mime_type) = normalize(Some(mime_type_raw)) else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
mime_type,
|
||||
"image/jpeg"
|
||||
| "image/png"
|
||||
| "image/apng"
|
||||
| "image/gif"
|
||||
| "image/webp"
|
||||
| "image/avif"
|
||||
| "image/heic"
|
||||
| "image/heif"
|
||||
| "image/jxl"
|
||||
| "image/svg+xml"
|
||||
| "image/tiff"
|
||||
| "image/bmp"
|
||||
| "video/mp4"
|
||||
| "video/webm"
|
||||
| "video/quicktime"
|
||||
| "video/3gpp"
|
||||
| "video/x-matroska"
|
||||
| "video/x-msvideo"
|
||||
| "video/x-flv"
|
||||
| "video/ogg"
|
||||
| "video/mp2t"
|
||||
| "video/mpeg"
|
||||
| "video/x-ms-wmv"
|
||||
| "audio/mpeg"
|
||||
| "audio/wav"
|
||||
| "audio/flac"
|
||||
| "audio/ogg"
|
||||
| "audio/aac"
|
||||
| "audio/mp4"
|
||||
| "audio/webm"
|
||||
| "audio/aiff"
|
||||
)
|
||||
}
|
||||
|
||||
fn starts(data: &[u8], prefix: &[u8]) -> bool {
|
||||
data.len() >= prefix.len() && &data[..prefix.len()] == prefix
|
||||
}
|
||||
|
||||
pub fn sniff(data: &[u8]) -> SniffInfo {
|
||||
if starts(data, b"\x89PNG\r\n\x1a\n") {
|
||||
let mut out = SniffInfo {
|
||||
mime: MediaType::PNG.mime(),
|
||||
has_alpha: true,
|
||||
..Default::default()
|
||||
};
|
||||
if data.len() >= 26 && &data[12..16] == b"IHDR" {
|
||||
out.width =
|
||||
u32::from_be_bytes(data[16..20].try_into().expect("validated PNG width slice"));
|
||||
out.height =
|
||||
u32::from_be_bytes(data[20..24].try_into().expect("validated PNG height slice"));
|
||||
out.has_alpha = data[25] == 4 || data[25] == 6;
|
||||
}
|
||||
if let PNGAnimation::Animated(frames) = png_animation(data) {
|
||||
out.mime = MediaType::APNG.mime();
|
||||
out.animated = frames > 1;
|
||||
out.frames = frames;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if starts(data, b"\xff\xd8\xff") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::JPEG.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"GIF87a") || starts(data, b"GIF89a") {
|
||||
let animated = gif_animation(data) == GIFAnimation::Animated;
|
||||
return SniffInfo {
|
||||
mime: MediaType::GIF.mime(),
|
||||
animated,
|
||||
frames: if animated { 2 } else { 1 },
|
||||
width: if data.len() >= 10 {
|
||||
u32::from(u16::from_le_bytes(
|
||||
data[6..8].try_into().expect("validated GIF width slice"),
|
||||
))
|
||||
} else {
|
||||
0
|
||||
},
|
||||
height: if data.len() >= 10 {
|
||||
u32::from(u16::from_le_bytes(
|
||||
data[8..10].try_into().expect("validated GIF height slice"),
|
||||
))
|
||||
} else {
|
||||
0
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if data.len() >= 12 && starts(data, b"RIFF") && &data[8..12] == b"WEBP" {
|
||||
return webp_sniff(data);
|
||||
}
|
||||
if let Some(info) = iso_bmff_sniff(data) {
|
||||
return info;
|
||||
}
|
||||
if starts(data, b"\xff\x0a") || starts(data, b"\x00\x00\x00\x0cJXL \r\n\x87\n") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::JXL.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"II*\0") || starts(data, b"MM\0*") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::TIFF.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"BM") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::BMP.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"\x1a\x45\xdf\xa3") {
|
||||
return matroska_sniff(data);
|
||||
}
|
||||
if starts(data, b"FLV") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::FLVVideo.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"RIFF") && data.len() >= 12 && &data[8..12] == b"AVI " {
|
||||
return SniffInfo {
|
||||
mime: MediaType::AVIVideo.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(
|
||||
data,
|
||||
b"\x30\x26\xb2\x75\x8e\x66\xcf\x11\xa6\xd9\x00\xaa\x00\x62\xce\x6c",
|
||||
) {
|
||||
return SniffInfo {
|
||||
mime: MediaType::WMVVideo.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"\x00\x00\x01\xba") || starts(data, b"\x00\x00\x01\xb3") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::MPEGVideo.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if mpeg_ts_sniff(data) {
|
||||
return SniffInfo {
|
||||
mime: MediaType::MPEGTSVideo.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"ID3")
|
||||
|| starts(data, b"\xff\xfb")
|
||||
|| starts(data, b"\xff\xf3")
|
||||
|| starts(data, b"\xff\xf2")
|
||||
{
|
||||
return SniffInfo {
|
||||
mime: MediaType::MPEGAudio.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"OggS") {
|
||||
return ogg_sniff(data);
|
||||
}
|
||||
if starts(data, b"fLaC") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::FLACAudio.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if is_adts(data) {
|
||||
return SniffInfo {
|
||||
mime: MediaType::AACAudio.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"RIFF") && data.len() >= 12 && &data[8..12] == b"WAVE" {
|
||||
return SniffInfo {
|
||||
mime: MediaType::WAVAudio.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"FORM") && data.len() >= 12 && matches!(&data[8..12], b"AIFF" | b"AIFC") {
|
||||
return SniffInfo {
|
||||
mime: MediaType::AIFFAudio.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if starts(data, b"%PDF-") {
|
||||
return SniffInfo {
|
||||
mime: "application/pdf",
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if looks_like_svg(data) {
|
||||
return SniffInfo {
|
||||
mime: MediaType::SVG.mime(),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
SniffInfo::default()
|
||||
}
|
||||
|
||||
pub fn sniff_prefix(data: &[u8], total_len: usize) -> MediaPrefixSniff {
|
||||
assert!(data.len() <= total_len);
|
||||
let sniffed = sniff(data);
|
||||
if data.len() == total_len {
|
||||
return MediaPrefixSniff {
|
||||
media: sniffed,
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
let complete =
|
||||
if sniffed.mime == MediaType::PNG.mime() || sniffed.mime == MediaType::APNG.mime() {
|
||||
png_animation(data) != PNGAnimation::Incomplete
|
||||
} else if sniffed.mime == MediaType::WebP.mime() {
|
||||
webp_sniff_complete(data)
|
||||
} else if sniffed.mime == MediaType::GIF.mime() {
|
||||
gif_animation(data) != GIFAnimation::Incomplete
|
||||
} else {
|
||||
matches!(
|
||||
MediaType::from_mime(sniffed.mime),
|
||||
Some(
|
||||
MediaType::JPEG
|
||||
| MediaType::JXL
|
||||
| MediaType::TIFF
|
||||
| MediaType::BMP
|
||||
| MediaType::SVG
|
||||
)
|
||||
)
|
||||
};
|
||||
MediaPrefixSniff {
|
||||
media: sniffed,
|
||||
complete,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(data: &[u8], filename: &str, header_mime: Option<&str>) -> String {
|
||||
let sniffed = sniff(data);
|
||||
if sniffed.mime != "application/octet-stream" {
|
||||
if sniffed.mime == "video/mp4" && extension_mime(filename) == Some("audio/mp4") {
|
||||
return "audio/mp4".to_owned();
|
||||
}
|
||||
return sniffed.mime.to_owned();
|
||||
}
|
||||
if let Some(m) = extension_mime(filename) {
|
||||
return m.to_owned();
|
||||
}
|
||||
if let Some(m) = normalize(header_mime) {
|
||||
return m.to_owned();
|
||||
}
|
||||
"application/octet-stream".to_owned()
|
||||
}
|
||||
|
||||
pub fn filename_for_mime(mime_type: &str, fallback: &str) -> String {
|
||||
if fallback.contains('.') {
|
||||
return fallback.to_owned();
|
||||
}
|
||||
let ext = match mime_type {
|
||||
"image/jpeg" => "jpg",
|
||||
"image/png" => "png",
|
||||
"image/webp" => "webp",
|
||||
"image/gif" => "gif",
|
||||
"video/mp4" => "mp4",
|
||||
_ => "bin",
|
||||
};
|
||||
format!("{fallback}.{ext}")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::Category;
|
||||
use crate::media_type::MediaType;
|
||||
use http::HeaderValue;
|
||||
|
||||
pub fn normalize(raw: Option<&str>) -> Option<&str> {
|
||||
let value = raw?;
|
||||
let semi = value.find(';').unwrap_or(value.len());
|
||||
let trimmed = value[..semi].trim_matches([' ', '\t']);
|
||||
(!trimmed.is_empty() && HeaderValue::from_bytes(trimmed.as_bytes()).is_ok()).then_some(trimmed)
|
||||
}
|
||||
|
||||
pub fn category(mime_type: &str) -> Option<Category> {
|
||||
let prefix = mime_type.as_bytes().get(..6);
|
||||
if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"image/")) {
|
||||
Some(Category::Image)
|
||||
} else if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"video/")) {
|
||||
Some(Category::Video)
|
||||
} else if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"audio/")) {
|
||||
Some(Category::Audio)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn passthrough_mime(raw: Option<&str>) -> Option<&'static str> {
|
||||
let normalized = normalize(raw)?;
|
||||
if normalized.eq_ignore_ascii_case("application/pdf") {
|
||||
return Some("application/pdf");
|
||||
}
|
||||
if normalized.eq_ignore_ascii_case("text/css") {
|
||||
return Some("text/css; charset=utf-8");
|
||||
}
|
||||
MediaType::from_mime(normalized).map(MediaType::mime)
|
||||
}
|
||||
|
||||
pub fn extension_mime(filename: &str) -> Option<&'static str> {
|
||||
let ext = filename.rsplit_once('.')?.1;
|
||||
if ext.eq_ignore_ascii_case("css") {
|
||||
return Some("text/css; charset=utf-8");
|
||||
}
|
||||
MediaType::from_extension(ext).map(MediaType::mime)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::SniffInfo;
|
||||
use crate::media_type::MediaType;
|
||||
|
||||
pub(super) fn mpeg_ts_sniff(data: &[u8]) -> bool {
|
||||
data.len() >= 188
|
||||
&& data[0] == 0x47
|
||||
&& (data.len() < 376 || data[188] == 0x47)
|
||||
&& (data.len() < 564 || data[376] == 0x47)
|
||||
}
|
||||
|
||||
pub(super) fn is_adts(data: &[u8]) -> bool {
|
||||
if data.len() < 7 || data[0] != 0xff || data[1] & 0xf6 != 0xf0 {
|
||||
return false;
|
||||
}
|
||||
let sample_rate_index = (data[2] >> 2) & 0x0f;
|
||||
if sample_rate_index == 0x0f {
|
||||
return false;
|
||||
}
|
||||
let frame_length = (usize::from(data[3] & 0x03) << 11)
|
||||
| (usize::from(data[4]) << 3)
|
||||
| (usize::from(data[5]) >> 5);
|
||||
frame_length >= 7 && frame_length <= data.len()
|
||||
}
|
||||
|
||||
pub(super) fn looks_like_svg(data: &[u8]) -> bool {
|
||||
let mut window = &data[..data.len().min(4096)];
|
||||
if window.starts_with(b"\xef\xbb\xbf") {
|
||||
window = &window[3..];
|
||||
}
|
||||
window.windows(4).any(|w| w == b"<svg")
|
||||
&& (window.windows(5).any(|w| w == b"xmlns")
|
||||
|| window.starts_with(b"<svg")
|
||||
|| window.starts_with(b"<?xml"))
|
||||
}
|
||||
|
||||
pub(super) fn ogg_sniff(data: &[u8]) -> SniffInfo {
|
||||
let window = &data[..data.len().min(8192)];
|
||||
if window.windows(6).any(|w| w == b"theora" || w == b"Theora") {
|
||||
SniffInfo {
|
||||
mime: MediaType::OGGVideo.mime(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
SniffInfo {
|
||||
mime: MediaType::OGGAudio.mime(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn matroska_sniff(data: &[u8]) -> SniffInfo {
|
||||
let window = &data[..data.len().min(4096)];
|
||||
if window.windows(4).any(|w| w == b"webm") {
|
||||
SniffInfo {
|
||||
mime: MediaType::WebMVideo.mime(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
SniffInfo {
|
||||
mime: MediaType::MatroskaVideo.mime(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use super::*;
|
||||
use crate::test_fixtures::{
|
||||
adversarial_media_bytes, animated_gif_fixture, apng_header, minimal_gif,
|
||||
};
|
||||
|
||||
fn vp8x_webp(flags: u8, width_minus_one: u32, height_minus_one: u32) -> Vec<u8> {
|
||||
let mut webp = vec![0_u8; 30];
|
||||
webp[0..4].copy_from_slice(b"RIFF");
|
||||
webp[8..12].copy_from_slice(b"WEBP");
|
||||
webp[12..16].copy_from_slice(b"VP8X");
|
||||
webp[16..20].copy_from_slice(&10_u32.to_le_bytes());
|
||||
webp[20] = flags;
|
||||
webp[24..27].copy_from_slice(&width_minus_one.to_le_bytes()[..3]);
|
||||
webp[27..30].copy_from_slice(&height_minus_one.to_le_bytes()[..3]);
|
||||
webp
|
||||
}
|
||||
|
||||
fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
|
||||
let mut chunk = Vec::new();
|
||||
chunk.extend_from_slice(
|
||||
&u32::try_from(payload.len())
|
||||
.expect("test payload fits a PNG chunk")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
chunk.extend_from_slice(kind);
|
||||
chunk.extend_from_slice(payload);
|
||||
chunk.extend_from_slice(&[0; 4]);
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_common_image_formats() {
|
||||
assert_eq!("image/png", sniff(b"\x89PNG\r\n\x1a\nxxxx").mime);
|
||||
assert_eq!("image/jpeg", sniff(b"\xff\xd8\xff").mime);
|
||||
assert_eq!("image/gif", sniff(b"GIF89a\x01\x00\x01\x00").mime);
|
||||
assert_eq!("image/tiff", sniff(b"II*\0xxxx").mime);
|
||||
assert_eq!("image/tiff", sniff(b"MM\0*xxxx").mime);
|
||||
assert_eq!("image/bmp", sniff(b"BMxxxx").mime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_apng_via_actl_chunk() {
|
||||
let apng = apng_header(2);
|
||||
let info = sniff(&apng);
|
||||
assert_eq!("image/apng", info.mime);
|
||||
assert!(info.animated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_animated_webp_via_anim_chunk() {
|
||||
let info = sniff(&vp8x_webp(0x02, 0, 0));
|
||||
assert_eq!("image/webp", info.mime);
|
||||
assert!(info.animated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_ftyp_boxes_for_heic_avif_mp4_variants() {
|
||||
assert_eq!(
|
||||
"image/avif",
|
||||
sniff(b"\x00\x00\x00\x20ftypavifsome bytes").mime
|
||||
);
|
||||
assert_eq!(
|
||||
"image/avif",
|
||||
sniff(b"\x00\x00\x00\x20ftypavissome bytes").mime
|
||||
);
|
||||
assert!(sniff(b"\x00\x00\x00\x20ftypavissome bytes").animated);
|
||||
assert_eq!(
|
||||
"image/heif",
|
||||
sniff(b"\x00\x00\x00\x20ftypmif1some bytes").mime
|
||||
);
|
||||
assert_eq!(
|
||||
"image/heic",
|
||||
sniff(b"\x00\x00\x00\x20ftypheicsome bytes").mime
|
||||
);
|
||||
assert_eq!(
|
||||
"video/mp4",
|
||||
sniff(b"\x00\x00\x00\x20ftypiso5some bytes").mime
|
||||
);
|
||||
assert_eq!(
|
||||
"video/mp4",
|
||||
sniff(b"\x00\x00\x00\x20ftypM4V some bytes").mime
|
||||
);
|
||||
assert_eq!(
|
||||
"audio/mp4",
|
||||
sniff(b"\x00\x00\x00\x20ftypM4A some bytes").mime
|
||||
);
|
||||
assert_eq!(
|
||||
"video/quicktime",
|
||||
sniff(b"\x00\x00\x00\x20ftypqt some bytes").mime
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_the_literal_heif_ftyp_brand() {
|
||||
let heif = b"\x00\x00\x00\x20ftypheifsome bytes";
|
||||
assert_eq!("image/heic", sniff(heif).mime);
|
||||
assert!(!sniff(heif).animated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scans_every_compatible_brand_of_a_fully_present_ftyp_box() {
|
||||
let compatible_brands = 40;
|
||||
let box_size = 16 + compatible_brands * 4;
|
||||
let mut ftyp = Vec::new();
|
||||
ftyp.extend_from_slice(
|
||||
&u32::try_from(box_size)
|
||||
.expect("test ftyp box size fits u32")
|
||||
.to_be_bytes(),
|
||||
);
|
||||
ftyp.extend_from_slice(b"ftyp");
|
||||
ftyp.extend_from_slice(b"isom");
|
||||
ftyp.extend_from_slice(&0x0000_0200_u32.to_be_bytes());
|
||||
for _ in 0..compatible_brands - 1 {
|
||||
ftyp.extend_from_slice(b"isom");
|
||||
}
|
||||
ftyp.extend_from_slice(b"avif");
|
||||
assert_eq!(box_size, ftyp.len());
|
||||
assert!(box_size - 4 > 128);
|
||||
assert_eq!("image/avif", sniff(&ftyp).mime);
|
||||
|
||||
let mut truncated_box = ftyp.clone();
|
||||
truncated_box[0..4].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!("video/mp4", sniff(&truncated_box).mime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_prefers_m4a_extension_over_generic_mp4_brand() {
|
||||
assert_eq!(
|
||||
"audio/mp4",
|
||||
detect(
|
||||
b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00isomiso2mp41",
|
||||
"track.m4a",
|
||||
None
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_matroska_vs_webm() {
|
||||
let mkv = b"\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\xf7\x81\x01\x42\xf2\x81\x04\x42\xf3\x81\x08\x42\x82\x88matroska";
|
||||
assert_eq!("video/x-matroska", sniff(mkv).mime);
|
||||
assert_eq!(
|
||||
"video/webm",
|
||||
sniff(b"\x1a\x45\xdf\xa3 here is the webm doctype").mime
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_audio_variants() {
|
||||
assert_eq!("audio/mpeg", sniff(b"ID3\x04\x00\x00").mime);
|
||||
assert_eq!("audio/mpeg", sniff(b"\xff\xfb\x90\x00").mime);
|
||||
assert_eq!("audio/ogg", sniff(b"OggS\x00\x02").mime);
|
||||
assert_eq!("video/ogg", sniff(b"OggS\x00\x02xxxx\x80theora").mime);
|
||||
assert_eq!("audio/flac", sniff(b"fLaC\x00\x00").mime);
|
||||
assert_eq!("audio/wav", sniff(b"RIFF\x00\x00\x00\x00WAVEdata").mime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_mime_covers_common_audio_and_video_containers() {
|
||||
assert_eq!(Some("video/ogg"), extension_mime("movie.ogv"));
|
||||
assert_eq!(Some("audio/ogg"), extension_mime("voice.opus"));
|
||||
assert_eq!(Some("audio/flac"), extension_mime("track.flac"));
|
||||
assert_eq!(Some("video/x-matroska"), extension_mime("clip.mkv"));
|
||||
assert_eq!(Some("image/tiff"), extension_mime("scan.tiff"));
|
||||
assert_eq!(Some("image/bmp"), extension_mime("bitmap.bmp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_pdf() {
|
||||
assert_eq!("application/pdf", sniff(b"%PDF-1.7\n").mime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_sniff_recognizes_bounded_container_headers() {
|
||||
let png = apng_header(2);
|
||||
let sniffed = sniff(&png);
|
||||
assert_eq!("image/apng", sniffed.mime);
|
||||
assert!(sniffed.animated);
|
||||
assert_eq!(2, sniffed.frames);
|
||||
assert_eq!((2, 3), (sniffed.width, sniffed.height));
|
||||
assert!(sniffed.has_alpha);
|
||||
|
||||
let gif = minimal_gif();
|
||||
let sniffed = sniff(&gif);
|
||||
assert_eq!("image/gif", sniffed.mime);
|
||||
assert!(!sniffed.animated);
|
||||
assert_eq!((1, 1), (sniffed.width, sniffed.height));
|
||||
|
||||
let animated = animated_gif_fixture();
|
||||
let sniffed = sniff(&animated);
|
||||
assert_eq!("image/gif", sniffed.mime);
|
||||
assert!(sniffed.animated);
|
||||
assert_eq!(2, sniffed.frames);
|
||||
assert_eq!((32, 32), (sniffed.width, sniffed.height));
|
||||
|
||||
let sniffed = sniff(&vp8x_webp(0x12, 1, 2));
|
||||
assert_eq!("image/webp", sniffed.mime);
|
||||
assert!(sniffed.animated);
|
||||
assert!(sniffed.has_alpha);
|
||||
assert_eq!((2, 3), (sniffed.width, sniffed.height));
|
||||
|
||||
let mut bmff = Vec::new();
|
||||
bmff.extend_from_slice(&24_u32.to_be_bytes());
|
||||
bmff.extend_from_slice(b"ftyp");
|
||||
bmff.extend_from_slice(b"M4A ");
|
||||
bmff.extend_from_slice(&0_u32.to_be_bytes());
|
||||
bmff.extend_from_slice(b"isom");
|
||||
bmff.extend_from_slice(b"M4A ");
|
||||
assert_eq!("audio/mp4", sniff(&bmff).mime);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_sniffing_stays_bounded_on_adversarial_containers() {
|
||||
for bytes in adversarial_media_bytes() {
|
||||
let sniffed = sniff(&bytes);
|
||||
assert!(sniffed.frames >= 1, "zero frames for {} bytes", bytes.len());
|
||||
|
||||
let complete = sniff_prefix(&bytes, bytes.len());
|
||||
assert!(complete.complete, "incomplete for {} bytes", bytes.len());
|
||||
assert!(complete.media.frames >= 1);
|
||||
|
||||
let truncated = sniff_prefix(&bytes, bytes.len().saturating_add(1024));
|
||||
assert!(truncated.media.frames >= 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_sniffing_reports_truncated_containers_as_incomplete() {
|
||||
let gif = minimal_gif();
|
||||
for prefix_length in 0..gif.len() {
|
||||
let result = sniff_prefix(&gif[..prefix_length], gif.len());
|
||||
if prefix_length < 6 {
|
||||
assert_eq!("application/octet-stream", result.media.mime);
|
||||
}
|
||||
}
|
||||
let complete = sniff_prefix(&gif, gif.len());
|
||||
assert!(complete.complete);
|
||||
assert_eq!("image/gif", complete.media.mime);
|
||||
assert!(!sniff_prefix(&gif[..gif.len() - 1], gif.len()).complete);
|
||||
|
||||
let apng = apng_header(2);
|
||||
assert!(sniff_prefix(&apng, apng.len() + 1024).complete);
|
||||
assert!(!sniff_prefix(&apng[..20], apng.len()).complete);
|
||||
|
||||
let webp = vp8x_webp(0x02, 0, 0);
|
||||
assert!(sniff_prefix(&webp, webp.len() + 1024).complete);
|
||||
assert!(!sniff_prefix(&webp[..24], webp.len()).complete);
|
||||
|
||||
assert!(sniff_prefix(b"\xff\xd8\xff\xe0", 4096).complete);
|
||||
assert!(!sniff_prefix(b"OggS\x00\x02", 4096).complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_parsers_reject_the_byte_scan_false_positives() {
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
png.extend_from_slice(&png_chunk(b"IHDR", &[0; 13]));
|
||||
png.extend_from_slice(&png_chunk(b"IDAT", b"acTL rides inside the pixel data"));
|
||||
let info = sniff(&png);
|
||||
assert_eq!("image/png", info.mime);
|
||||
assert!(!info.animated);
|
||||
|
||||
let mut gif = minimal_gif();
|
||||
gif.extend_from_slice(b"\x21\xffNETSCAPE2.0");
|
||||
let info = sniff(&gif);
|
||||
assert_eq!("image/gif", info.mime);
|
||||
assert!(!info.animated);
|
||||
|
||||
let info = sniff(b"RIFF\x00\x00\x00\x00WEBPVP8XANIMxxxx");
|
||||
assert_eq!("image/webp", info.mime);
|
||||
assert!(!info.animated);
|
||||
assert!(!info.has_alpha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_falls_back_to_the_extension_then_the_declared_header() {
|
||||
assert_eq!("audio/mp4", detect(b"", "clip.m4a", None));
|
||||
assert_eq!(
|
||||
"IMAGE/PNG",
|
||||
detect(b"", "unknown.bin", Some("IMAGE/PNG; charset=binary"))
|
||||
);
|
||||
assert_eq!("application/octet-stream", detect(b"", "unknown.bin", None));
|
||||
assert_eq!(
|
||||
"image/png",
|
||||
detect(b"\x89PNG\r\n\x1a\nxxxx", "audio.m4a", Some("image/gif"))
|
||||
);
|
||||
assert_eq!("photo.png", filename_for_mime("image/png", "photo"));
|
||||
assert_eq!("photo.gif", filename_for_mime("image/png", "photo.gif"));
|
||||
assert_eq!("blob.bin", filename_for_mime("application/pdf", "blob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniffs_adts_aac_and_aiff_containers() {
|
||||
let mut adts = vec![0xff, 0xf1, 0x50, 0x80, 0x01, 0xa0, 0xfc];
|
||||
adts.resize(13, 0);
|
||||
assert_eq!("audio/aac", sniff(&adts).mime);
|
||||
assert_eq!("audio/aiff", sniff(b"FORM\x00\x00\x00\x12AIFF").mime);
|
||||
assert_eq!("audio/aiff", sniff(b"FORM\x00\x00\x00\x12AIFC").mime);
|
||||
assert_eq!(
|
||||
"application/octet-stream",
|
||||
sniff(b"FORM\x00\x00\x00\x12WAVE").mime
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_type_normalization_and_categories_ignore_case_and_reject_control_bytes() {
|
||||
assert_eq!(Some("image/png"), normalize(Some("image/png; charset=x")));
|
||||
assert_eq!(Some("image/png"), normalize(Some(" \timage/png\t ")));
|
||||
assert_eq!(None, normalize(Some("image/p\rng")));
|
||||
assert_eq!(None, normalize(Some(" ")));
|
||||
assert_eq!(None, normalize(None));
|
||||
|
||||
assert_eq!(Some(Category::Image), category("IMAGE/PNG"));
|
||||
assert_eq!(Some(Category::Video), category("Video/MP4"));
|
||||
assert_eq!(Some(Category::Audio), category("AUDIO/wav"));
|
||||
assert_eq!(None, category("text/css"));
|
||||
assert_eq!(None, category("image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lookups_canonicalize_extensions_and_passthrough_types() {
|
||||
assert_eq!(Some("image/jpeg"), extension_mime("photo.JPG"));
|
||||
assert_eq!(Some("text/css; charset=utf-8"), extension_mime("app.css"));
|
||||
assert_eq!(None, extension_mime("manual.PDF"));
|
||||
assert_eq!(None, extension_mime("archive.zip"));
|
||||
assert_eq!(None, extension_mime("noextension"));
|
||||
|
||||
assert_eq!(
|
||||
Some("image/png"),
|
||||
passthrough_mime(Some("IMAGE/PNG; charset=binary"))
|
||||
);
|
||||
assert_eq!(
|
||||
Some("image/avif"),
|
||||
passthrough_mime(Some("image/avif-sequence"))
|
||||
);
|
||||
assert_eq!(
|
||||
Some("application/pdf"),
|
||||
passthrough_mime(Some("Application/PDF"))
|
||||
);
|
||||
assert_eq!(
|
||||
Some("text/css; charset=utf-8"),
|
||||
passthrough_mime(Some("text/css"))
|
||||
);
|
||||
assert_eq!(None, passthrough_mime(Some("application/octet-stream")));
|
||||
assert_eq!(None, passthrough_mime(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_media_mimes_gate_metadata_on_the_frozen_allow_list() {
|
||||
assert!(is_supported_media_mime("image/heif"));
|
||||
assert!(is_supported_media_mime("image/heic"));
|
||||
assert!(is_supported_media_mime("video/mp4; codecs=avc1"));
|
||||
assert!(!is_supported_media_mime("image/avif-sequence"));
|
||||
assert!(!is_supported_media_mime("IMAGE/PNG"));
|
||||
assert!(!is_supported_media_mime("application/pdf"));
|
||||
assert!(!is_supported_media_mime(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_iso_bmff_brand_matches_the_old_era_table() {
|
||||
for (brand, expected) in [
|
||||
(&b"heic"[..], "image/heic"),
|
||||
(b"heix", "image/heic"),
|
||||
(b"heif", "image/heic"),
|
||||
(b"heim", "image/heic"),
|
||||
(b"heis", "image/heic"),
|
||||
(b"hevc", "image/heic"),
|
||||
(b"hevx", "image/heic"),
|
||||
(b"hevm", "image/heic"),
|
||||
(b"hevs", "image/heic"),
|
||||
(b"mif1", "image/heif"),
|
||||
(b"msf1", "image/heif"),
|
||||
] {
|
||||
let mut bytes = b"\x00\x00\x00\x20ftyp".to_vec();
|
||||
bytes.extend_from_slice(brand);
|
||||
bytes.extend_from_slice(b"some bytes");
|
||||
let info = sniff(&bytes);
|
||||
assert_eq!(expected, info.mime, "brand {brand:?}");
|
||||
assert!(!info.animated, "brand {brand:?} must not be animated");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user