Files
buzz/crates/buzz-media/src/validation.rs

2595 lines
101 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Content validation — magic bytes, allowlist, size, image bomb protection, video metadata.
use std::io::{BufReader, Seek, SeekFrom};
use std::path::Path;
use crate::config::MediaConfig;
use crate::error::MediaError;
/// Accepted MIME types for the image upload path.
///
/// `video/mp4` is intentionally excluded — video uploads use a separate pipeline
/// (`process_video_upload`) with its own magic-byte check. If an MP4 is uploaded
/// through the image path (Content-Type spoofing), `infer::get()` detects
/// `video/mp4` and `validate_content()` rejects it here.
const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
const MP4_BRANDS: &[[u8; 4]] = &[
*b"isom", *b"iso2", *b"iso3", *b"iso4", *b"iso5", *b"iso6", *b"iso7", *b"iso8", *b"iso9",
*b"mp41", *b"mp42", *b"avc1", *b"dash", *b"M4V ",
];
fn iso_bmff_ftyp_payload(bytes: &[u8]) -> Option<&[u8]> {
if bytes.len() < 16 || &bytes[4..8] != b"ftyp" {
return None;
}
let compact = u32::from_be_bytes(bytes[..4].try_into().ok()?) as u64;
let (declared_size, header_size) = if compact == 1 {
if bytes.len() < 24 {
return None;
}
(u64::from_be_bytes(bytes[8..16].try_into().ok()?), 16usize)
} else if compact == 0 {
(bytes.len() as u64, 8usize)
} else {
(compact, 8usize)
};
if declared_size < (header_size + 8) as u64 {
return None;
}
let available_end = usize::try_from(declared_size)
.unwrap_or(usize::MAX)
.min(bytes.len());
(available_end >= header_size + 8).then_some(&bytes[header_size..available_end])
}
/// Return whether the leading bytes contain a structurally valid ISO-BMFF
/// `ftyp` box, independent of the request MIME type or `infer`'s brand list.
pub fn looks_like_iso_bmff(bytes: &[u8]) -> bool {
iso_bmff_ftyp_payload(bytes).is_some()
}
pub(crate) fn looks_like_mp4_iso_bmff(bytes: &[u8]) -> bool {
let Some(payload) = iso_bmff_ftyp_payload(bytes) else {
return false;
};
let major = payload[..4].try_into().ok();
major.is_some_and(|brand| MP4_BRANDS.contains(&brand))
|| payload[8..]
.chunks_exact(4)
.any(|brand| MP4_BRANDS.iter().any(|candidate| brand == candidate))
}
/// MIME types blocked from the generic file-upload path.
///
/// These are the formats a browser (or the desktop webview) will *execute* or
/// *render as active content* if it ever reaches them with the wrong response
/// headers. We serve generic files with `Content-Disposition: attachment` +
/// `X-Content-Type-Options: nosniff` + `CSP: default-src 'none'`, which already
/// neutralises them — this allowlist-of-denials is defence in depth, so a future
/// header regression can't turn an uploaded blob into a stored-XSS vector.
///
/// HTML, JS, and SVG are the classic stored-XSS carriers. Native executables are
/// blocked because there's no legitimate reason to host them inline in chat and
/// they're a malware-distribution risk.
const BLOCKED_FILE_MIME_TYPES: &[&str] = &[
// Active web content — stored-XSS vectors.
"text/html",
"application/xhtml+xml",
"image/svg+xml",
"application/javascript",
"text/javascript",
// Native executables / installers.
"application/x-msdownload", // .exe / .dll
"application/x-executable", // ELF
"application/vnd.microsoft.portable-executable",
"application/x-mach-binary", // Mach-O
"application/x-sharedlib",
"application/x-elf",
"application/x-msi",
"application/vnd.android.package-archive", // .apk
"application/x-apple-diskimage", // .dmg
];
/// Map a sniffed MIME type to a file extension for the generic file path.
///
/// Covers the common document, archive, audio, and data formats `infer`
/// recognises. Returns `None` for MIME types we don't have a canonical
/// extension for — the caller falls back to `bin`.
fn file_mime_to_ext(mime: &str) -> Option<&'static str> {
let ext = match mime {
// Documents
"application/pdf" => "pdf",
"application/msword" => "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
"application/vnd.ms-excel" => "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
"application/vnd.ms-powerpoint" => "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx",
"application/vnd.oasis.opendocument.text" => "odt",
"application/vnd.oasis.opendocument.spreadsheet" => "ods",
"application/vnd.oasis.opendocument.presentation" => "odp",
"application/rtf" => "rtf",
"application/epub+zip" => "epub",
// Archives
"application/zip" => "zip",
"application/gzip" => "gz",
"application/x-tar" => "tar",
"application/x-7z-compressed" => "7z",
"application/x-rar-compressed" | "application/vnd.rar" => "rar",
"application/x-bzip2" => "bz2",
"application/x-xz" => "xz",
"application/zstd" => "zst",
// Audio
"audio/mpeg" => "mp3",
"audio/mp4" | "audio/m4a" | "audio/x-m4a" => "m4a",
"audio/flac" | "audio/x-flac" => "flac",
"audio/wav" | "audio/x-wav" => "wav",
"audio/ogg" => "ogg",
"audio/aac" => "aac",
"audio/opus" => "opus",
// Other media containers (served as downloads, not transcoded)
"video/quicktime" => "mov",
"video/webm" => "webm",
"video/x-matroska" => "mkv",
// Data / text
"application/json" => "json",
"text/csv" => "csv",
"text/plain" => "txt",
_ => return None,
};
Some(ext)
}
/// Validate uploaded bytes for the **generic file** upload path.
///
/// This is the catch-all path for non-media attachments (documents, archives,
/// text, data). It enforces three things:
/// 1. A size cap (`config.max_file_bytes`).
/// 2. A *deny* list — known active-content and executable MIME types are
/// rejected even though safe headers already neutralise them.
/// 3. Magic-byte sniffing where possible.
///
/// Files with no detectable signature (plain text, CSV, source code, JSON —
/// none of which have magic bytes) are accepted as `application/octet-stream`.
/// They are always served as downloads, so an un-sniffable file can never
/// execute in the app.
///
/// Returns `(mime, ext)`.
pub fn validate_file_content(
bytes: &[u8],
config: &MediaConfig,
) -> Result<(String, String), MediaError> {
// 1. Size cap.
if bytes.len() as u64 > config.max_file_bytes {
return Err(MediaError::FileTooLarge {
size: bytes.len() as u64,
max: config.max_file_bytes,
});
}
// ISO-BMFF permits arbitrary major brands, so `infer` cannot enumerate all
// valid MP4 signatures. Never let an `ftyp` container fall through as an
// opaque attachment merely because its brand is unfamiliar.
if looks_like_iso_bmff(bytes) {
let mime = infer::get(bytes)
.map(|kind| kind.mime_type().to_string())
.unwrap_or_else(|| "application/iso-bmff".to_string());
return Err(MediaError::DisallowedContentType(mime));
}
// 2. Sniff. `None` means no magic signature (text/csv/json/source) — that's
// fine for the generic path; treat as opaque binary served as a download.
match infer::get(bytes) {
Some(kind) => {
let mime = kind.mime_type().to_string();
// Recognized media must never fall through exact-byte attachment
// storage. Images and video use their canonical media validators;
// audio is rejected until Buzz has an explicit sanitizer and
// location-metadata validator for its container.
if mime.starts_with("image/")
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
{
return Err(MediaError::DisallowedContentType(mime));
}
// 3. Deny dangerous active-content / executable types.
if BLOCKED_FILE_MIME_TYPES.contains(&mime.as_str()) {
return Err(MediaError::DisallowedContentType(mime));
}
let ext = file_mime_to_ext(&mime)
.map(str::to_string)
.unwrap_or_else(|| kind.extension().to_string());
Ok((mime, ext))
}
None => Ok(("application/octet-stream".to_string(), "bin".to_string())),
}
}
/// Whether a stored blob should be served inline (rendered in the client) or as
/// an attachment (forced download).
///
/// Images and video are previewed inline by the renderer; everything else is a
/// generic file card with a download action, so it serves as an attachment.
/// PDF is intentionally *not* inline yet — inline PDF preview is a planned
/// fast-follow; until the renderer handles it, force download like any other file.
pub fn serve_inline(mime: &str) -> bool {
mime.starts_with("image/") || mime.starts_with("video/")
}
/// Metadata extracted from a validated MP4 file.
#[derive(Debug, Clone)]
pub struct VideoMeta {
/// Duration in seconds (from mvhd timescale — not edit lists).
pub duration_secs: f64,
/// Width of the first video track in pixels.
pub width: u32,
/// Height of the first video track in pixels.
pub height: u32,
/// Whether the file contains at least one audio track.
pub has_audio: bool,
}
/// Validate uploaded bytes for the **image** upload path.
///
/// Checks magic bytes, MIME allowlist (images only), size, and pixel dimensions.
/// Rejects `video/mp4` — video uploads must use [`process_video_upload`] which
/// has its own magic-byte check and full MP4 validation pipeline.
pub fn validate_content(bytes: &[u8], config: &MediaConfig) -> Result<String, MediaError> {
// 1. Magic bytes — never trust Content-Type header
let mime = infer::get(bytes)
.map(|t| t.mime_type().to_string())
.ok_or(MediaError::UnknownContentType)?;
// 2. Allowlist (SVG, PDF, executables all rejected)
if !ALLOWED_MIME_TYPES.contains(&mime.as_str()) {
return Err(MediaError::DisallowedContentType(mime));
}
// 3. Size cap (images only — video uses its own size enforcement in the streaming pipeline)
let max = if mime == "image/gif" {
config.max_gif_bytes
} else {
config.max_image_bytes
};
if bytes.len() as u64 > max {
return Err(MediaError::FileTooLarge {
size: bytes.len() as u64,
max,
});
}
// 4. Reject metadata-bearing or non-canonical container structures.
validate_image_metadata_free(bytes, &mime)?;
// 5. Image bomb — check pixel dimensions before full decode.
// Fail closed: imagesize supports JPEG, PNG, GIF, WebP. If dimensions
// can't be parsed, reject — don't let unknown-geometry images reach the
// full decoder in thumbnail generation.
const MAX_PIXELS: u64 = 25_000_000; // 25 megapixels — 100MB max RGBA decode
let size = imagesize::blob_size(bytes).map_err(|_| MediaError::InvalidImage)?;
if (size.width as u64) * (size.height as u64) > MAX_PIXELS {
return Err(MediaError::ImageTooLarge);
}
Ok(mime)
}
/// Validate an MP4 file on disk.
///
/// Checks:
/// - Container is MP4 (ftyp brand is not QuickTime `qt `)
/// - Exactly one video track using `avc1` (H.264 only — rejects HEVC, VP9, AV1)
/// - At most one audio track, using `mp4a` (AAC)
/// - Duration ≤ 600 seconds (from mvhd timescale, not edit lists)
/// - Resolution ≤ 3840×2160
/// - moov atom precedes mdat (fast-start / web-optimised)
///
/// Returns [`VideoMeta`] on success.
pub fn validate_video_file(path: &Path, config: &MediaConfig) -> Result<VideoMeta, MediaError> {
// --- moov-before-mdat check (raw byte scan) ---
// We scan the top-level atom sequence before handing off to the mp4 crate,
// because the mp4 crate parses the whole file regardless of atom order.
check_moov_before_mdat(path)?;
let file = std::fs::File::open(path).map_err(|e| MediaError::Io(e.to_string()))?;
let size = file
.metadata()
.map_err(|e| MediaError::Io(e.to_string()))?
.len();
// Size guard (belt-and-suspenders — the streaming layer also enforces this).
if size > config.max_video_bytes {
return Err(MediaError::FileTooLarge {
size,
max: config.max_video_bytes,
});
}
validate_mp4_metadata_free(path)?;
let reader = BufReader::new(file);
let mp4 = mp4::Mp4Reader::read_header(reader, size).map_err(|_| MediaError::InvalidVideo)?;
// --- Container check ---
// QuickTime (MOV) uses brand "qt ". We reject it — only ISO-base MP4.
// The mp4 crate exposes the ftyp major brand via mp4.major_brand().
let brand = mp4.major_brand();
let qt_brand = mp4::FourCC::from(*b"qt ");
if *brand == qt_brand {
return Err(MediaError::UnsupportedContainer);
}
// --- Track inspection ---
let mut video_meta: Option<VideoMeta> = None;
let mut has_audio = false;
for track in mp4.tracks().values() {
match track.track_type().map_err(|_| MediaError::InvalidVideo)? {
mp4::TrackType::Video => {
if video_meta.is_some() {
// Alternate video tracks can carry telemetry or other content
// that the client did not intend to publish. Canonical uploads
// contain exactly one video track.
return Err(MediaError::MetadataForbidden);
}
// Codec check: only H.264 (avc1).
// media_type() reads the handler type and sample entry box type.
let media_type = track.media_type().map_err(|_| MediaError::WrongCodec)?;
if media_type != mp4::MediaType::H264 {
return Err(MediaError::WrongCodec);
}
// Duration from mvhd timescale (track duration / timescale).
// Reject zero/negative (malformed) and >600s (too long).
// Must match imeta validation which requires duration > 0.0.
// Guard: timescale=0 causes division-by-zero in the mp4 crate's
// duration() method. Fail fast before it panics.
if track.timescale() == 0 {
return Err(MediaError::InvalidVideo);
}
let duration_ms = track.duration().as_millis();
let duration_secs = duration_ms as f64 / 1000.0;
if duration_secs <= 0.0 {
return Err(MediaError::InvalidVideo);
}
if duration_secs > 600.0 {
return Err(MediaError::DurationTooLong);
}
// Resolution check.
let width = track.width() as u32;
let height = track.height() as u32;
if width > 3840 || height > 2160 {
return Err(MediaError::ResolutionTooHigh);
}
video_meta = Some(VideoMeta {
duration_secs,
width,
height,
has_audio: false, // filled in after audio scan
});
}
mp4::TrackType::Audio => {
if has_audio {
// Reject alternate audio tracks for the same reason as
// alternate video tracks: only the canonical primary stream
// produced by the client sanitizer is permitted.
return Err(MediaError::MetadataForbidden);
}
let media_type = track.media_type().map_err(|_| MediaError::WrongCodec)?;
if media_type != mp4::MediaType::AAC {
return Err(MediaError::WrongCodec);
}
has_audio = true;
}
_ => return Err(MediaError::MetadataForbidden),
}
}
let mut meta = video_meta.ok_or_else(|| {
if has_audio {
MediaError::DisallowedContentType("audio/mp4".to_string())
} else {
MediaError::InvalidVideo
}
})?;
meta.has_audio = has_audio;
Ok(meta)
}
/// Scan the top-level atom sequence to verify moov appears before mdat.
///
/// Reads only the 8-byte atom headers (size + fourcc) — never loads atom bodies.
/// Extended-size atoms (size==1) are handled by reading the 64-bit size field.
/// Iteration is capped to prevent DoS from crafted files with millions of tiny atoms.
fn check_moov_before_mdat(path: &Path) -> Result<(), MediaError> {
use std::io::Read;
/// Maximum top-level atoms to scan before giving up.
/// A normal MP4 has < 20 top-level atoms. 1024 is generous but bounded.
const MAX_ATOMS: u32 = 1024;
let mut file = std::fs::File::open(path).map_err(|e| MediaError::Io(e.to_string()))?;
let file_size = file
.metadata()
.map_err(|e| MediaError::Io(e.to_string()))?
.len();
let mut offset: u64 = 0;
let mut moov_seen = false;
let mut atoms_scanned: u32 = 0;
while offset < file_size {
atoms_scanned += 1;
if atoms_scanned > MAX_ATOMS {
// Fail closed: too many top-level atoms is abnormal. A crafted file
// could hide mdat after 1025 junk atoms to bypass the moov check.
return Err(MediaError::MoovNotAtFront);
}
file.seek(SeekFrom::Start(offset))
.map_err(|e| MediaError::Io(e.to_string()))?;
let mut header = [0u8; 8];
match file.read_exact(&mut header) {
Ok(_) => {}
Err(_) => break, // truncated file — mp4 parser will catch it
}
let compact_size = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as u64;
let fourcc = &header[4..8];
// Resolve actual atom size.
let atom_size = if compact_size == 1 {
// Extended size: next 8 bytes are the real 64-bit size (includes the 16-byte header).
let mut ext = [0u8; 8];
match file.read_exact(&mut ext) {
Ok(_) => {}
Err(_) => break, // truncated — mp4 parser will catch it
}
let extended = u64::from_be_bytes(ext);
if extended < 16 {
break; // malformed extended size — mp4 parser will reject
}
extended
} else if compact_size == 0 {
// atom_size == 0 means "extends to EOF" — this is the last atom.
// Check fourcc before stopping: mdat-at-EOF without prior moov is an error.
if fourcc == b"mdat" && !moov_seen {
return Err(MediaError::MoovNotAtFront);
}
break;
} else if compact_size < 8 {
break; // malformed — mp4 parser will reject
} else {
compact_size
};
match fourcc {
b"moov" => {
moov_seen = true;
}
b"mdat" if !moov_seen => {
return Err(MediaError::MoovNotAtFront);
}
_ => {}
}
offset += atom_size;
}
Ok(())
}
/// Reject metadata-bearing image structures without decoding pixel data.
///
/// This is deliberately a structural allowlist rather than an EXIF-tag denylist:
/// location can also live in XMP, comments, PNG text, ICC descriptions, or
/// private chunks. Client encoders remove these before upload.
fn validate_image_metadata_free(bytes: &[u8], mime: &str) -> Result<(), MediaError> {
match mime {
"image/jpeg" => validate_jpeg_metadata_free(bytes),
"image/png" => validate_png_metadata_free(bytes),
"image/webp" => validate_webp_metadata_free(bytes),
"image/gif" => validate_gif_metadata_free(bytes),
_ => Ok(()),
}
}
fn validate_jpeg_metadata_free(bytes: &[u8]) -> Result<(), MediaError> {
if !bytes.starts_with(&[0xff, 0xd8]) {
return Err(MediaError::InvalidImage);
}
let mut i = 2usize;
let mut in_scan = false;
while i < bytes.len() {
if bytes[i] != 0xff {
if in_scan {
i += 1;
continue;
}
return Err(MediaError::InvalidImage);
}
while i < bytes.len() && bytes[i] == 0xff {
i += 1;
}
if i >= bytes.len() {
return Err(MediaError::InvalidImage);
}
let marker = bytes[i];
i += 1;
if in_scan && marker == 0x00 {
continue;
}
if (0xd0..=0xd7).contains(&marker) || marker == 0x01 {
continue;
}
if marker == 0xd9 {
return (i == bytes.len())
.then_some(())
.ok_or(MediaError::MetadataForbidden);
}
if marker == 0xd8 {
return Err(MediaError::InvalidImage);
}
if i + 2 > bytes.len() {
return Err(MediaError::InvalidImage);
}
let len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize;
if len < 2 {
return Err(MediaError::InvalidImage);
}
let end = i
.checked_add(len)
.filter(|&end| end <= bytes.len())
.ok_or(MediaError::InvalidImage)?;
// Only canonical JFIF/Adobe colour headers are allowed. Their lengths and
// identifiers are fixed; accepting arbitrary APP0/APP14 payloads would
// leave a metadata side channel.
if marker == 0xe0 {
let payload = &bytes[i + 2..end];
let canonical_jfif = payload.len() >= 14
&& &payload[..5] == b"JFIF\0"
&& payload.len() == 14 + 3 * payload[12] as usize * payload[13] as usize;
if !canonical_jfif {
return Err(MediaError::MetadataForbidden);
}
} else if marker == 0xee {
let payload = &bytes[i + 2..end];
if payload.len() != 12 || &payload[..5] != b"Adobe" {
return Err(MediaError::MetadataForbidden);
}
} else if (0xe1..=0xed).contains(&marker) || marker == 0xef || marker == 0xfe {
return Err(MediaError::MetadataForbidden);
}
i = end;
in_scan = marker == 0xda;
}
Err(MediaError::InvalidImage)
}
/// tEXt keywords that carry Buzz snapshot manifests (`.agent.png` /
/// `.team.png`). These are deliberate product payloads — agent/team sharing
/// embeds a manifest in a single tEXt chunk — so they are exempt from the
/// metadata ban. Exactly one snapshot chunk is permitted per file; every
/// other textual/metadata chunk remains forbidden.
const PNG_SNAPSHOT_KEYWORDS: [&[u8]; 2] = [b"buzz_agent_snapshot", b"buzz_team_snapshot"];
/// Returns true when a raw tEXt chunk payload is a Buzz snapshot manifest:
/// the payload must start with an allowlisted keyword followed by the
/// keyword/text NUL separator.
fn is_snapshot_text_chunk(payload: &[u8]) -> bool {
PNG_SNAPSHOT_KEYWORDS.iter().any(|keyword| {
payload.len() > keyword.len()
&& &payload[..keyword.len()] == *keyword
&& payload[keyword.len()] == 0
})
}
fn validate_png_metadata_free(bytes: &[u8]) -> Result<(), MediaError> {
const SIG: &[u8] = b"\x89PNG\r\n\x1a\n";
if !bytes.starts_with(SIG) {
return Err(MediaError::InvalidImage);
}
let mut i = SIG.len();
let mut saw_iend = false;
let mut saw_snapshot_chunk = false;
while i < bytes.len() {
if i + 12 > bytes.len() {
return Err(MediaError::InvalidImage);
}
let len = u32::from_be_bytes(bytes[i..i + 4].try_into().unwrap()) as usize;
let kind: [u8; 4] = bytes[i + 4..i + 8].try_into().unwrap();
let end = i
.checked_add(12)
.and_then(|v| v.checked_add(len))
.filter(|&v| v <= bytes.len())
.ok_or(MediaError::InvalidImage)?;
if &kind == b"tEXt" {
// Buzz agent/team snapshot manifests ride in a single tEXt chunk
// with an allowlisted keyword. Anything else — other keywords, or
// a second snapshot chunk — is a forbidden metadata channel.
let payload = &bytes[i + 8..end - 4];
if saw_snapshot_chunk || !is_snapshot_text_chunk(payload) {
return Err(MediaError::MetadataForbidden);
}
saw_snapshot_chunk = true;
i = end;
continue;
}
if matches!(&kind, b"eXIf" | b"zTXt" | b"iTXt" | b"iCCP") {
return Err(MediaError::MetadataForbidden);
}
// Unknown ancillary chunks are private metadata channels. Keep only
// rendering chunks that client encoders may legitimately emit; pHYs is
// deliberately excluded because arbitrary values are an identity channel.
let ancillary = kind[0] & 0x20 != 0;
let known_rendering = matches!(
&kind,
b"cHRM"
| b"gAMA"
| b"sBIT"
| b"sRGB"
| b"bKGD"
| b"hIST"
| b"tRNS"
| b"sPLT"
| b"acTL"
| b"fcTL"
| b"fdAT"
);
if ancillary && !known_rendering {
return Err(MediaError::MetadataForbidden);
}
i = end;
if &kind == b"IEND" {
saw_iend = true;
break;
}
}
if !saw_iend || i != bytes.len() {
return Err(MediaError::MetadataForbidden);
}
Ok(())
}
fn validate_webp_metadata_free(bytes: &[u8]) -> Result<(), MediaError> {
fn validate_frame_payload(payload: &[u8]) -> Result<(), MediaError> {
const FRAME_HEADER_LEN: usize = 16;
if payload.len() < FRAME_HEADER_LEN {
return Err(MediaError::InvalidImage);
}
let mut i = FRAME_HEADER_LEN;
let mut saw_alpha = false;
let mut saw_image = false;
while i < payload.len() {
if i + 8 > payload.len() {
return Err(MediaError::InvalidImage);
}
let kind: [u8; 4] = payload[i..i + 4].try_into().unwrap();
let len = u32::from_le_bytes(payload[i + 4..i + 8].try_into().unwrap()) as usize;
let padded = len.checked_add(len & 1).ok_or(MediaError::InvalidImage)?;
i = i
.checked_add(8)
.and_then(|start| start.checked_add(padded))
.filter(|&end| end <= payload.len())
.ok_or(MediaError::InvalidImage)?;
match &kind {
b"ALPH" if !saw_alpha && !saw_image => saw_alpha = true,
b"VP8 " if !saw_image => saw_image = true,
b"VP8L" if !saw_alpha && !saw_image => saw_image = true,
b"ALPH" | b"VP8 " | b"VP8L" => return Err(MediaError::InvalidImage),
_ => return Err(MediaError::MetadataForbidden),
}
}
saw_image.then_some(()).ok_or(MediaError::InvalidImage)
}
if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
return Err(MediaError::InvalidImage);
}
let declared = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
if declared.checked_add(8) != Some(bytes.len()) {
return Err(MediaError::MetadataForbidden);
}
let mut i = 12usize;
while i < bytes.len() {
if i + 8 > bytes.len() {
return Err(MediaError::InvalidImage);
}
let kind: [u8; 4] = bytes[i..i + 4].try_into().unwrap();
let len = u32::from_le_bytes(bytes[i + 4..i + 8].try_into().unwrap()) as usize;
let payload_start = i + 8;
let padded = len.checked_add(len & 1).ok_or(MediaError::InvalidImage)?;
i = payload_start
.checked_add(padded)
.filter(|&v| v <= bytes.len())
.ok_or(MediaError::InvalidImage)?;
if !matches!(
&kind,
b"VP8 " | b"VP8L" | b"VP8X" | b"ALPH" | b"ANIM" | b"ANMF"
) {
return Err(MediaError::MetadataForbidden);
}
if &kind == b"VP8X" {
let flags = *bytes.get(payload_start).ok_or(MediaError::InvalidImage)?;
// ICC, EXIF, and XMP presence flags are metadata even if a malformed
// file omits their corresponding chunks.
if flags & (0x20 | 0x08 | 0x04) != 0 {
return Err(MediaError::MetadataForbidden);
}
} else if &kind == b"ANMF" {
validate_frame_payload(&bytes[payload_start..payload_start + len])?;
}
}
Ok(())
}
fn validate_gif_metadata_free(bytes: &[u8]) -> Result<(), MediaError> {
if !(bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")) || bytes.len() < 13 {
return Err(MediaError::InvalidImage);
}
fn skip_sub_blocks(bytes: &[u8], i: &mut usize) -> Result<(), MediaError> {
loop {
let len = *bytes.get(*i).ok_or(MediaError::InvalidImage)? as usize;
*i += 1;
if len == 0 {
return Ok(());
}
*i = i
.checked_add(len)
.filter(|&end| end <= bytes.len())
.ok_or(MediaError::InvalidImage)?;
}
}
let packed = bytes[10];
let mut i = 13usize;
if packed & 0x80 != 0 {
let table_len = 3usize << ((packed & 0x07) as usize + 1);
i = i
.checked_add(table_len)
.filter(|&end| end <= bytes.len())
.ok_or(MediaError::InvalidImage)?;
}
loop {
match *bytes.get(i).ok_or(MediaError::InvalidImage)? {
0x2c => {
// Image descriptor, optional local colour table, LZW code size,
// then length-prefixed image-data sub-blocks.
if i + 10 > bytes.len() {
return Err(MediaError::InvalidImage);
}
let image_packed = bytes[i + 9];
i += 10;
if image_packed & 0x80 != 0 {
let table_len = 3usize << ((image_packed & 0x07) as usize + 1);
i = i
.checked_add(table_len)
.filter(|&end| end <= bytes.len())
.ok_or(MediaError::InvalidImage)?;
}
i = i
.checked_add(1)
.filter(|&v| v <= bytes.len())
.ok_or(MediaError::InvalidImage)?;
skip_sub_blocks(bytes, &mut i)?;
}
0x21 => {
let label = *bytes.get(i + 1).ok_or(MediaError::InvalidImage)?;
i += 2;
match label {
// Graphic Control Extension carries rendering/animation state,
// not descriptive metadata. Its shape is fixed by the spec.
0xf9 => {
if bytes.get(i) != Some(&4) || i + 6 > bytes.len() || bytes[i + 5] != 0 {
return Err(MediaError::InvalidImage);
}
i += 6;
}
// Preserve only the standard looping application extensions.
// Other application, comment, and plain-text extensions are
// unrestricted metadata channels.
0xff => {
if bytes.get(i) != Some(&11) || i + 12 > bytes.len() {
return Err(MediaError::InvalidImage);
}
let app = &bytes[i + 1..i + 12];
if app != b"NETSCAPE2.0" && app != b"ANIMEXTS1.0" {
return Err(MediaError::MetadataForbidden);
}
i += 12;
if bytes.get(i) != Some(&3)
|| bytes.get(i + 1) != Some(&1)
|| bytes.get(i + 4) != Some(&0)
{
return Err(MediaError::MetadataForbidden);
}
i += 5;
}
_ => return Err(MediaError::MetadataForbidden),
}
}
0x3b => {
return (i + 1 == bytes.len())
.then_some(())
.ok_or(MediaError::MetadataForbidden);
}
_ => return Err(MediaError::InvalidImage),
}
}
}
fn validate_mp4_metadata_free(path: &Path) -> Result<(), MediaError> {
const MAX_BOXES: usize = 100_000;
const MAX_BOX_DEPTH: usize = 32;
const EMPTY_FFMPEG_UDTA: &[u8] = &[
0, 0, 0, 0x35, b'm', b'e', b't', b'a', 0, 0, 0, 0, 0, 0, 0, 0x21, b'h', b'd', b'l', b'r',
0, 0, 0, 0, 0, 0, 0, 0, b'm', b'd', b'i', b'r', b'a', b'p', b'p', b'l', 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 8, b'i', b'l', b's', b't',
];
const FORBIDDEN: &[[u8; 4]] = &[
*b"meta",
*b"ilst",
*b"keys",
*b"data",
*b"uuid",
*b"xml ",
*b"bxml",
*b"loci",
*b"\xa9xyz",
*b"name",
*b"chap",
];
const CONTAINERS: &[[u8; 4]] = &[
*b"moov", *b"trak", *b"mdia", *b"minf", *b"stbl", *b"edts", *b"dinf", *b"sinf", *b"schi",
];
// Constrained H.264/AAC MP4 produced by our client encoders. Unknown boxes
// are rejected rather than guessed safe because private boxes can carry GPS.
const ALLOWED: &[[u8; 4]] = &[
*b"ftyp", *b"moov", *b"mdat", *b"free", *b"skip", *b"wide", *b"trak", *b"mdia", *b"minf",
*b"stbl", *b"edts", *b"dinf", *b"sinf", *b"schi", *b"udta", *b"mvhd", *b"tkhd", *b"mdhd",
*b"hdlr", *b"vmhd", *b"smhd", *b"dref", *b"url ", *b"urn ", *b"stsd", *b"stts", *b"stss",
*b"ctts", *b"stsc", *b"stsz", *b"stco", *b"co64", *b"sgpd", *b"sbgp", *b"elst",
];
fn walk(
file: &mut std::fs::File,
start: u64,
end: u64,
count: &mut usize,
depth: usize,
) -> Result<(), MediaError> {
use std::io::{Read, Seek, SeekFrom};
if depth > MAX_BOX_DEPTH {
return Err(MediaError::InvalidVideo);
}
let mut off = start;
while off < end {
*count += 1;
if *count > MAX_BOXES || end - off < 8 {
return Err(MediaError::InvalidVideo);
}
file.seek(SeekFrom::Start(off))
.map_err(|e| MediaError::Io(e.to_string()))?;
let mut h = [0u8; 8];
file.read_exact(&mut h)
.map_err(|_| MediaError::InvalidVideo)?;
let compact = u32::from_be_bytes(h[..4].try_into().unwrap()) as u64;
let kind: [u8; 4] = h[4..8].try_into().unwrap();
let (size, header) = if compact == 1 {
let mut ext = [0u8; 8];
file.read_exact(&mut ext)
.map_err(|_| MediaError::InvalidVideo)?;
(u64::from_be_bytes(ext), 16)
} else if compact == 0 {
(end - off, 8)
} else {
(compact, 8)
};
if size < header || off.checked_add(size).filter(|&v| v <= end).is_none() {
return Err(MediaError::InvalidVideo);
}
if FORBIDDEN.contains(&kind) || !ALLOWED.contains(&kind) {
return Err(MediaError::MetadataForbidden);
}
if kind == *b"udta" {
if size != header + EMPTY_FFMPEG_UDTA.len() as u64 {
return Err(MediaError::MetadataForbidden);
}
let mut body = vec![0; EMPTY_FFMPEG_UDTA.len()];
file.read_exact(&mut body)
.map_err(|_| MediaError::InvalidVideo)?;
if body != EMPTY_FFMPEG_UDTA {
return Err(MediaError::MetadataForbidden);
}
} else if CONTAINERS.contains(&kind) {
walk(file, off + header, off + size, count, depth + 1)?;
}
off += size;
}
Ok(())
}
let mut file = std::fs::File::open(path).map_err(|e| MediaError::Io(e.to_string()))?;
let end = file
.metadata()
.map_err(|e| MediaError::Io(e.to_string()))?
.len();
let mut count = 0;
walk(&mut file, 0, end, &mut count, 0)
}
/// Map MIME type to file extension.
pub fn mime_to_ext(mime: &str) -> &'static str {
match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"video/mp4" => "mp4",
_ => "bin",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> MediaConfig {
MediaConfig {
s3_endpoint: String::new(),
s3_access_key: String::new(),
s3_secret_key: String::new(),
s3_bucket: String::new(),
s3_region: "us-east-1".to_string(),
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
max_video_bytes: 524_288_000,
max_file_bytes: 104_857_600,
public_base_url: String::new(),
upload_records_enabled: false,
upload_ip_header: None,
upload_port_header: None,
}
}
// Minimal valid JPEG: SOI + APP0 + SOF0 (1x1px).
// SOF0 is required for imagesize to parse dimensions (fail-closed check).
const TINY_JPEG: &[u8] = &[
// SOI
0xFF, 0xD8, // APP0 (JFIF marker)
0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00,
0x01, 0x00, 0x00, // SOF0: precision=8, height=1, width=1, components=1
0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, // EOI
0xFF, 0xD9,
];
// Minimal PNG header
const TINY_PNG: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, // IEND chunk
0x00, 0x00, 0x00, 0x00, b'I', b'E', b'N', b'D', 0xAE, 0x42, 0x60, 0x82,
];
#[test]
fn test_validate_jpeg() {
let config = test_config();
let result = validate_content(TINY_JPEG, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/jpeg");
}
#[test]
fn test_validate_png() {
let config = test_config();
let result = validate_content(TINY_PNG, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
}
fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(payload);
// The policy parser is structural; the image decoder validates CRC later.
out.extend_from_slice(&0u32.to_be_bytes());
out
}
/// Deterministic little-endian TIFF payload with a real EXIF GPS IFD.
/// Coordinates are synthetic: 37°46'30" N, 122°25'10" W.
fn gps_exif_tiff() -> Vec<u8> {
let mut tiff = b"II\x2a\0\x08\0\0\0".to_vec();
// IFD0: one GPSInfo pointer (tag 0x8825) to offset 26.
tiff.extend_from_slice(&1u16.to_le_bytes());
tiff.extend_from_slice(&0x8825u16.to_le_bytes());
tiff.extend_from_slice(&4u16.to_le_bytes()); // LONG
tiff.extend_from_slice(&1u32.to_le_bytes());
tiff.extend_from_slice(&26u32.to_le_bytes());
tiff.extend_from_slice(&0u32.to_le_bytes()); // next IFD
// GPS IFD: latitude ref/value and longitude ref/value.
tiff.extend_from_slice(&4u16.to_le_bytes());
for (tag, field_type, count, value) in [
(1u16, 2u16, 2u32, u32::from_le_bytes(*b"N\0\0\0")),
(2, 5, 3, 80),
(3, 2, 2, u32::from_le_bytes(*b"W\0\0\0")),
(4, 5, 3, 104),
] {
tiff.extend_from_slice(&tag.to_le_bytes());
tiff.extend_from_slice(&field_type.to_le_bytes());
tiff.extend_from_slice(&count.to_le_bytes());
tiff.extend_from_slice(&value.to_le_bytes());
}
tiff.extend_from_slice(&0u32.to_le_bytes()); // next GPS IFD
for value in [37u32, 46, 30, 122, 25, 10] {
tiff.extend_from_slice(&value.to_le_bytes());
tiff.extend_from_slice(&1u32.to_le_bytes());
}
tiff
}
/// Independent minimal EXIF parser used to prove the fixture really has a
/// GPSInfo pointer and latitude/longitude entries before policy validation.
fn assert_exif_fixture_has_gps(tiff: &[u8]) {
assert_eq!(&tiff[..4], b"II\x2a\0");
let read_u16 = |offset: usize| u16::from_le_bytes([tiff[offset], tiff[offset + 1]]);
let read_u32 = |offset: usize| {
u32::from_le_bytes([
tiff[offset],
tiff[offset + 1],
tiff[offset + 2],
tiff[offset + 3],
])
};
let ifd0 = read_u32(4) as usize;
let ifd0_entries = read_u16(ifd0) as usize;
let gps_offset = (0..ifd0_entries)
.map(|index| ifd0 + 2 + index * 12)
.find(|&entry| read_u16(entry) == 0x8825)
.map(|entry| read_u32(entry + 8) as usize)
.expect("fixture must contain an EXIF GPSInfo pointer");
let gps_entries = read_u16(gps_offset) as usize;
let tags: Vec<u16> = (0..gps_entries)
.map(|index| read_u16(gps_offset + 2 + index * 12))
.collect();
assert!(tags.contains(&1) && tags.contains(&2));
assert!(tags.contains(&3) && tags.contains(&4));
}
#[test]
fn test_real_exif_gps_is_rejected_in_image_containers() {
let tiff = gps_exif_tiff();
assert_exif_fixture_has_gps(&tiff);
let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe1];
let jpeg_payload_len = 6 + tiff.len();
jpeg.extend_from_slice(&((jpeg_payload_len + 2) as u16).to_be_bytes());
jpeg.extend_from_slice(b"Exif\0\0");
jpeg.extend_from_slice(&tiff);
jpeg.extend_from_slice(&TINY_JPEG[2..]);
assert!(matches!(
validate_jpeg_metadata_free(&jpeg),
Err(MediaError::MetadataForbidden)
));
let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec();
png.extend_from_slice(&png_chunk(b"eXIf", &tiff));
png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]);
assert!(matches!(
validate_png_metadata_free(&png),
Err(MediaError::MetadataForbidden)
));
let mut webp_body = b"WEBP".to_vec();
let mut webp_exif = b"Exif\0\0".to_vec();
webp_exif.extend_from_slice(&tiff);
webp_body.extend_from_slice(b"EXIF");
webp_body.extend_from_slice(&(webp_exif.len() as u32).to_le_bytes());
webp_body.extend_from_slice(&webp_exif);
if !webp_exif.len().is_multiple_of(2) {
webp_body.push(0);
}
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&(webp_body.len() as u32).to_le_bytes());
webp.extend_from_slice(&webp_body);
assert!(matches!(
validate_webp_metadata_free(&webp),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_real_xmp_location_is_rejected_in_image_containers() {
let xmp = br#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/"
exif:GPSLatitude="37,46.500N" exif:GPSLongitude="122,25.167W"/>
</x:xmpmeta>"#;
assert!(xmp
.windows(b"GPSLatitude".len())
.any(|window| window == b"GPSLatitude"));
assert!(xmp
.windows(b"GPSLongitude".len())
.any(|window| window == b"GPSLongitude"));
let namespace = b"http://ns.adobe.com/xap/1.0/\0";
let mut jpeg_payload = namespace.to_vec();
jpeg_payload.extend_from_slice(xmp);
let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe1];
jpeg.extend_from_slice(&((jpeg_payload.len() + 2) as u16).to_be_bytes());
jpeg.extend_from_slice(&jpeg_payload);
jpeg.extend_from_slice(&TINY_JPEG[2..]);
assert!(matches!(
validate_jpeg_metadata_free(&jpeg),
Err(MediaError::MetadataForbidden)
));
let mut itxt = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec();
itxt.extend_from_slice(xmp);
let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec();
png.extend_from_slice(&png_chunk(b"iTXt", &itxt));
png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]);
assert!(matches!(
validate_png_metadata_free(&png),
Err(MediaError::MetadataForbidden)
));
let mut webp_body = b"WEBP".to_vec();
webp_body.extend_from_slice(b"XMP ");
webp_body.extend_from_slice(&(xmp.len() as u32).to_le_bytes());
webp_body.extend_from_slice(xmp);
if !xmp.len().is_multiple_of(2) {
webp_body.push(0);
}
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&(webp_body.len() as u32).to_le_bytes());
webp.extend_from_slice(&webp_body);
assert!(matches!(
validate_webp_metadata_free(&webp),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_android_image_processor_outputs_match_relay_contract() {
let config = test_config();
for (name, bytes, expected_mime) in [
(
"sRGB PNG",
include_bytes!("../tests/fixtures/android/sanitized/bitmap-srgb-sanitized.png")
.as_slice(),
"image/png",
),
(
"sRGB JPEG",
include_bytes!("../tests/fixtures/android/sanitized/bitmap-srgb-sanitized.jpg")
.as_slice(),
"image/jpeg",
),
(
"Display-P3 PNG",
include_bytes!(
"../tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.png"
)
.as_slice(),
"image/png",
),
(
"Display-P3 JPEG",
include_bytes!(
"../tests/fixtures/android/sanitized/bitmap-display-p3-sanitized.jpg"
)
.as_slice(),
"image/jpeg",
),
] {
let actual = validate_content(bytes, &config).unwrap_or_else(|error| {
panic!("rejected Android-sanitized {name} fixture: {error}")
});
assert_eq!(actual, expected_mime);
}
}
#[test]
fn test_android_bitmap_encoder_outputs_require_sanitization() {
let config = test_config();
let srgb_png = include_bytes!("../tests/fixtures/android/bitmap-srgb.png").as_slice();
assert_eq!(
validate_content(srgb_png, &config).expect("rejected Android sRGB PNG fixture"),
"image/png"
);
for (name, bytes) in [
(
"sRGB JPEG",
include_bytes!("../tests/fixtures/android/bitmap-srgb.jpg").as_slice(),
),
(
"Display-P3 PNG",
include_bytes!("../tests/fixtures/android/bitmap-display-p3.png").as_slice(),
),
(
"Display-P3 JPEG",
include_bytes!("../tests/fixtures/android/bitmap-display-p3.jpg").as_slice(),
),
] {
assert!(
matches!(
validate_content(bytes, &config),
Err(MediaError::MetadataForbidden)
),
"accepted unsanitized Android Bitmap.compress {name} fixture"
);
}
}
#[test]
fn test_ios_uikit_sanitizer_outputs_match_relay_contract() {
let config = test_config();
for (name, bytes, expected_mime) in [
(
"PNG",
include_bytes!("../tests/fixtures/ios/uikit-sanitized.png").as_slice(),
"image/png",
),
(
"JPEG",
include_bytes!("../tests/fixtures/ios/uikit-sanitized.jpg").as_slice(),
"image/jpeg",
),
] {
let actual = validate_content(bytes, &config).unwrap_or_else(|error| {
panic!("rejected iOS UIKit-sanitized {name} fixture: {error}")
});
assert_eq!(actual, expected_mime);
}
}
#[test]
fn test_ios_uikit_encoder_outputs_require_sanitization() {
let config = test_config();
for (name, bytes) in [
(
"PNG",
include_bytes!("../tests/fixtures/ios/uikit-encoded.png").as_slice(),
),
(
"JPEG",
include_bytes!("../tests/fixtures/ios/uikit-encoded.jpg").as_slice(),
),
] {
assert!(
matches!(
validate_content(bytes, &config),
Err(MediaError::MetadataForbidden)
),
"accepted unsanitized iOS UIKit {name} fixture"
);
}
}
#[test]
fn test_rejects_png_metadata_and_trailing_payload() {
let config = test_config();
for kind in [
b"eXIf", b"tEXt", b"zTXt", b"iTXt", b"iCCP", b"pHYs", b"vpAg",
] {
let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec();
png.extend_from_slice(&png_chunk(kind, b"GPS=37.7,-122.4"));
png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]);
assert!(
matches!(
validate_content(&png, &config),
Err(MediaError::MetadataForbidden)
),
"accepted {kind:?}"
);
}
let mut trailing = TINY_PNG.to_vec();
trailing.extend_from_slice(b"hidden location");
assert!(matches!(
validate_content(&trailing, &config),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_png_snapshot_text_chunks_are_allowed() {
// Agent/team snapshot manifests ride in an allowlisted tEXt chunk;
// the relay must accept exactly one such chunk per file.
let config = test_config();
for keyword in [b"buzz_agent_snapshot".as_slice(), b"buzz_team_snapshot"] {
let mut payload = keyword.to_vec();
payload.push(0);
payload.extend_from_slice(b"eyJmb3JtYXQiOiJidXp6In0=");
let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec();
png.extend_from_slice(&png_chunk(b"tEXt", &payload));
png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]);
assert_eq!(
validate_content(&png, &config).unwrap_or_else(|error| panic!(
"rejected snapshot tEXt keyword {}: {error}",
String::from_utf8_lossy(keyword)
)),
"image/png"
);
}
}
#[test]
fn test_png_snapshot_text_chunk_rejected_when_duplicated_or_spoofed() {
let config = test_config();
// Two snapshot chunks: the second is a covert channel.
let mut payload = b"buzz_agent_snapshot".to_vec();
payload.push(0);
payload.extend_from_slice(b"data");
let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec();
png.extend_from_slice(&png_chunk(b"tEXt", &payload));
png.extend_from_slice(&png_chunk(b"tEXt", &payload));
png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]);
assert!(matches!(
validate_content(&png, &config),
Err(MediaError::MetadataForbidden)
));
// Keyword prefix without the NUL separator, and near-miss keywords,
// stay forbidden.
for payload in [
b"buzz_agent_snapshotX\0data".as_slice(),
b"buzz_agent_snapshot_extra\0data",
b"buzz_agent_snapshot", // no separator at all
b"Comment\0GPS=37.7,-122.4",
] {
let mut png = TINY_PNG[..TINY_PNG.len() - 12].to_vec();
png.extend_from_slice(&png_chunk(b"tEXt", payload));
png.extend_from_slice(&TINY_PNG[TINY_PNG.len() - 12..]);
assert!(
matches!(
validate_content(&png, &config),
Err(MediaError::MetadataForbidden)
),
"accepted non-snapshot tEXt payload {:?}",
String::from_utf8_lossy(payload)
);
}
}
#[test]
fn test_rejects_jpeg_app_metadata_comments_and_trailing_payload() {
let config = test_config();
for marker in [0xe1, 0xec, 0xed, 0xef, 0xfe] {
let mut jpeg = vec![0xff, 0xd8, 0xff, marker, 0x00, 0x08];
jpeg.extend_from_slice(b"secret");
jpeg.extend_from_slice(&TINY_JPEG[2..]);
assert!(
matches!(
validate_content(&jpeg, &config),
Err(MediaError::MetadataForbidden)
),
"accepted marker {marker:#x}"
);
}
for marker in [0xe0, 0xee] {
let mut jpeg = vec![0xff, 0xd8, 0xff, marker, 0x00, 0x08];
jpeg.extend_from_slice(b"secret");
jpeg.extend_from_slice(&TINY_JPEG[2..]);
assert!(matches!(
validate_content(&jpeg, &config),
Err(MediaError::MetadataForbidden)
));
}
let mut trailing = TINY_JPEG.to_vec();
trailing.extend_from_slice(b"motion photo payload");
assert!(matches!(
validate_content(&trailing, &config),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_rejects_webp_metadata_unknown_chunks_and_trailing_payload() {
fn webp(chunks: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
let mut body = b"WEBP".to_vec();
for (kind, payload) in chunks {
body.extend_from_slice(*kind);
body.extend_from_slice(&(payload.len() as u32).to_le_bytes());
body.extend_from_slice(payload);
if payload.len() % 2 != 0 {
body.push(0);
}
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
for kind in [b"EXIF", b"XMP ", b"ICCP", b"priv"] {
let bytes = webp(&[(kind, b"GPS=37.7,-122.4")]);
assert!(matches!(
validate_webp_metadata_free(&bytes),
Err(MediaError::MetadataForbidden)
));
}
for flag in [0x20, 0x08, 0x04] {
let mut payload = vec![0; 10];
payload[0] = flag;
let bytes = webp(&[(b"VP8X", &payload)]);
assert!(matches!(
validate_webp_metadata_free(&bytes),
Err(MediaError::MetadataForbidden)
));
}
let mut frame = vec![0; 16];
frame.extend_from_slice(b"VP8 ");
frame.extend_from_slice(&3u32.to_le_bytes());
frame.extend_from_slice(&[1, 2, 3, 0]);
let clean_frame = frame.clone();
frame.extend_from_slice(b"JUNK");
frame.extend_from_slice(&8u32.to_le_bytes());
frame.extend_from_slice(b"location");
let nested_metadata = webp(&[
(b"VP8X", &[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
(b"ANIM", &[0; 6]),
(b"ANMF", &frame),
]);
assert!(matches!(
validate_webp_metadata_free(&nested_metadata),
Err(MediaError::MetadataForbidden)
));
let canonical_animation = webp(&[
(b"VP8X", &[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
(b"ANIM", &[0; 6]),
(b"ANMF", &clean_frame),
]);
assert!(validate_webp_metadata_free(&canonical_animation).is_ok());
let mut trailing = webp(&[(b"VP8 ", b"pixels")]);
trailing.extend_from_slice(b"hidden");
assert!(matches!(
validate_webp_metadata_free(&trailing),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_rejects_gif_metadata_extensions_and_trailing_payload() {
for extension in [
&[0x21, 0xfe, 1, b'x', 0][..],
&[0x21, 0x01, 0][..],
&[
0x21, 0xff, 11, b'P', b'R', b'I', b'V', b'A', b'T', b'E', b'A', b'P', b'P', b'0', 0,
][..],
] {
let mut gif = TINY_GIF[..TINY_GIF.len() - 1].to_vec();
gif.extend_from_slice(extension);
gif.push(0x3b);
assert!(matches!(
validate_gif_metadata_free(&gif),
Err(MediaError::MetadataForbidden)
));
}
let mut trailing = TINY_GIF.to_vec();
trailing.extend_from_slice(b"hidden");
assert!(matches!(
validate_gif_metadata_free(&trailing),
Err(MediaError::MetadataForbidden)
));
let mut hidden_in_loop = TINY_GIF[..TINY_GIF.len() - 1].to_vec();
hidden_in_loop.extend_from_slice(&[0x21, 0xff, 11]);
hidden_in_loop.extend_from_slice(b"NETSCAPE2.0");
hidden_in_loop.extend_from_slice(&[3, 1, 0, 0, 8]);
hidden_in_loop.extend_from_slice(b"location");
hidden_in_loop.extend_from_slice(&[0, 0x3b]);
assert!(matches!(
validate_gif_metadata_free(&hidden_in_loop),
Err(MediaError::MetadataForbidden)
));
let mut canonical_loop = TINY_GIF[..TINY_GIF.len() - 1].to_vec();
canonical_loop.extend_from_slice(&[0x21, 0xff, 11]);
canonical_loop.extend_from_slice(b"NETSCAPE2.0");
canonical_loop.extend_from_slice(&[3, 1, 0, 0, 0, 0x3b]);
assert!(validate_gif_metadata_free(&canonical_loop).is_ok());
}
#[test]
fn test_generic_file_path_cannot_bypass_media_validation() {
let config = test_config();
assert!(
matches!(validate_file_content(TINY_JPEG, &config), Err(MediaError::DisallowedContentType(m)) if m == "image/jpeg")
);
assert!(
matches!(validate_file_content(MP4_FTYP_MAGIC, &config), Err(MediaError::DisallowedContentType(m)) if m == "video/mp4")
);
let proprietary_major = b"\x00\x00\x00\x18ftypPRIV\x00\x00\x00\x00isommp42";
assert!(infer::get(proprietary_major).is_none());
assert!(looks_like_iso_bmff(proprietary_major));
assert!(looks_like_mp4_iso_bmff(proprietary_major));
assert!(
matches!(validate_file_content(proprietary_major, &config), Err(MediaError::DisallowedContentType(m)) if m == "application/iso-bmff")
);
}
#[test]
fn test_generic_file_path_rejects_recognized_audio() {
let config = test_config();
let fixtures: &[(&str, &[u8])] = &[
("mp3", b"ID3\x04\x00\x00\x00\x00\x00\x00"),
("flac", b"fLaC\x00\x00\x00\x22"),
("wav", b"RIFF\x24\x00\x00\x00WAVEfmt "),
("ogg", b"OggS\x00\x02\x00\x00\x00\x00\x00\x00"),
("m4a", b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A "),
("aac", b"\xff\xf1\x50\x80\x00\x1f\xfc"),
];
for (name, bytes) in fixtures {
let detected = infer::get(bytes)
.unwrap_or_else(|| panic!("{name} fixture must be recognized as audio"));
assert!(
detected.mime_type().starts_with("audio/"),
"{name} fixture detected as {}",
detected.mime_type()
);
assert!(
matches!(
validate_file_content(bytes, &config),
Err(MediaError::DisallowedContentType(mime)) if mime.starts_with("audio/")
),
"generic path accepted {name}"
);
}
}
#[test]
fn test_validate_svg_rejected() {
let config = test_config();
// SVG starts with XML declaration — infer won't detect it as image
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
let result = validate_content(svg, &config);
assert!(result.is_err());
}
#[test]
fn test_validate_oversized() {
let mut config = test_config();
config.max_image_bytes = 10; // 10 bytes max
let result = validate_content(TINY_JPEG, &config);
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
}
// Minimal valid GIF89a (1x1 pixel) — full logical screen descriptor so imagesize can parse.
const TINY_GIF: &[u8] = &[
// Header
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
// Logical Screen Descriptor: width=1, height=1, flags, bgcolor, aspect
0x01, 0x00, 0x01, 0x00, 0x80, 0x00,
0x00, // Global Color Table (2 colors: white, black)
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // Image Descriptor
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Data
0x02, 0x02, 0x4C, 0x01, 0x00, // Trailer
0x3B,
];
#[test]
fn test_validate_gif_cap() {
let mut config = test_config();
config.max_gif_bytes = 5; // tiny cap
config.max_image_bytes = 50 * 1024 * 1024;
let result = validate_content(TINY_GIF, &config);
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
}
#[test]
fn test_validate_gif_ok() {
let config = test_config();
let result = validate_content(TINY_GIF, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/gif");
}
#[test]
fn test_mime_to_ext() {
assert_eq!(mime_to_ext("image/jpeg"), "jpg");
assert_eq!(mime_to_ext("image/png"), "png");
assert_eq!(mime_to_ext("image/gif"), "gif");
assert_eq!(mime_to_ext("image/webp"), "webp");
assert_eq!(mime_to_ext("video/mp4"), "mp4");
assert_eq!(mime_to_ext("application/pdf"), "bin");
}
// --- MP4 magic bytes test ---
// A minimal ftyp box that infer recognises as video/mp4.
// ftyp: size=20, 'ftyp', major_brand='isom', minor_version=0, compatible=['isom']
const MP4_FTYP_MAGIC: &[u8] = &[
0x00, 0x00, 0x00, 0x14, // size = 20
0x66, 0x74, 0x79, 0x70, // 'ftyp'
0x69, 0x73, 0x6F, 0x6D, // major brand: 'isom'
0x00, 0x00, 0x00, 0x00, // minor version
0x69, 0x73, 0x6F, 0x6D, // compatible brand: 'isom'
// padding to ensure infer has enough bytes
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[test]
fn test_validate_mp4_magic_bytes_rejected() {
// MP4 uploaded through the image path must be rejected — video/mp4 is
// not in ALLOWED_MIME_TYPES. This prevents Content-Type spoofing attacks
// where an MP4 is uploaded as image/jpeg to bypass video validation.
let config = test_config();
let result = validate_content(MP4_FTYP_MAGIC, &config);
match result {
Err(MediaError::DisallowedContentType(mime)) => {
assert_eq!(mime, "video/mp4");
}
Err(MediaError::UnknownContentType) => {
// infer needs more bytes — acceptable, still rejected
}
Ok(mime) => panic!("MP4 should be rejected by image path, got Ok({mime})"),
Err(e) => panic!("unexpected error: {e:?}"),
}
}
// --- validate_video_file tests ---
// These tests use real MP4 files written to a NamedTempFile.
// We build minimal but structurally valid MP4 boxes by hand.
/// Build a minimal fast-start MP4 with moov before mdat.
/// Contains one H.264 video track (avc1), 1 second, 320x240.
fn build_minimal_mp4_moov_first() -> Vec<u8> {
build_mp4_bytes(true, b"avc1", 1_000, 320, 240, false)
}
/// Build an MP4 with mdat before moov (not fast-start).
fn build_minimal_mp4_mdat_first() -> Vec<u8> {
build_mp4_bytes(false, b"avc1", 1_000, 320, 240, false)
}
/// Build an MP4 with HEVC codec (hev1 — the box type the mp4 crate recognises).
fn build_mp4_hevc() -> Vec<u8> {
build_mp4_bytes(true, b"hev1", 1_000, 320, 240, false)
}
/// Build an MP4 with duration > 600s.
fn build_mp4_too_long() -> Vec<u8> {
build_mp4_bytes(true, b"avc1", 601_000, 320, 240, false)
}
/// Build an MP4 with resolution > 3840x2160.
fn build_mp4_too_large() -> Vec<u8> {
build_mp4_bytes(true, b"avc1", 1_000, 3841, 2161, false)
}
/// Build an MP4 with audio track.
fn build_mp4_with_audio() -> Vec<u8> {
build_mp4_bytes(true, b"avc1", 1_000, 320, 240, true)
}
/// Insert a child box at the end of the top-level `moov` box.
fn append_box_to_moov(mut bytes: Vec<u8>, child: &[u8]) -> Vec<u8> {
const FTYP_SIZE: usize = 20;
let moov_size = u32::from_be_bytes(
bytes[FTYP_SIZE..FTYP_SIZE + 4]
.try_into()
.expect("moov size bytes"),
) as usize;
let moov_end = FTYP_SIZE + moov_size;
bytes.splice(moov_end..moov_end, child.iter().copied());
let new_moov_size = (moov_size + child.len()) as u32;
bytes[FTYP_SIZE..FTYP_SIZE + 4].copy_from_slice(&new_moov_size.to_be_bytes());
bytes
}
/// Construct a minimal but parseable MP4 byte stream.
///
/// Layout (fast-start): ftyp | moov | mdat
/// Layout (non-fast-start): ftyp | mdat | moov
///
/// The moov contains:
/// mvhd (duration_ms, timescale=1000)
/// trak (video: tkhd + mdia[mdhd+hdlr+minf[stbl[stsd[codec_box]]]])
/// optionally a second trak (audio: tkhd + mdia[mdhd+hdlr+minf[stbl[stsd[mp4a]]]])
fn build_mp4_bytes(
fast_start: bool,
codec: &[u8; 4],
duration_ms: u32,
width: u16,
height: u16,
with_audio: bool,
) -> Vec<u8> {
// ftyp box: size(4) + 'ftyp'(4) + major_brand(4) + minor_ver(4) + compat(4)
let ftyp: Vec<u8> = {
let mut b = Vec::new();
b.extend_from_slice(&20u32.to_be_bytes()); // size
b.extend_from_slice(b"ftyp");
b.extend_from_slice(b"isom"); // major brand
b.extend_from_slice(&0u32.to_be_bytes()); // minor version
b.extend_from_slice(b"isom"); // compatible brand
b
};
// mdat box: just an empty payload (no actual media samples needed for header parse)
let mdat: Vec<u8> = {
let mut b = Vec::new();
b.extend_from_slice(&8u32.to_be_bytes()); // size = 8 (header only)
b.extend_from_slice(b"mdat");
b
};
// Build moov
let moov = build_moov(duration_ms, codec, width, height, with_audio);
let mut out = Vec::new();
out.extend_from_slice(&ftyp);
if fast_start {
out.extend_from_slice(&moov);
out.extend_from_slice(&mdat);
} else {
out.extend_from_slice(&mdat);
out.extend_from_slice(&moov);
}
out
}
fn box_wrap(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let size = (8 + payload.len()) as u32;
let mut b = Vec::new();
b.extend_from_slice(&size.to_be_bytes());
b.extend_from_slice(fourcc);
b.extend_from_slice(payload);
b
}
fn build_moov(
duration_ms: u32,
codec: &[u8; 4],
width: u16,
height: u16,
with_audio: bool,
) -> Vec<u8> {
let timescale: u32 = 1000;
let duration: u32 = duration_ms;
// mvhd (version 0): flags(3) + creation(4) + modification(4) + timescale(4) +
// duration(4) + rate(4) + volume(2) + reserved(10) +
// matrix(36) + pre_defined(24) + next_track_id(4) = 100 bytes payload
let mvhd_payload: Vec<u8> = {
let mut b = vec![0u8; 4]; // version=0 + flags=0
b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
b.extend_from_slice(&timescale.to_be_bytes());
b.extend_from_slice(&duration.to_be_bytes());
b.extend_from_slice(&0x00010000u32.to_be_bytes()); // rate = 1.0
b.extend_from_slice(&0x0100u16.to_be_bytes()); // volume = 1.0
b.extend_from_slice(&[0u8; 10]); // reserved
// identity matrix
b.extend_from_slice(&0x00010000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0x00010000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0x40000000u32.to_be_bytes());
b.extend_from_slice(&[0u8; 24]); // pre_defined
b.extend_from_slice(&2u32.to_be_bytes()); // next_track_id
b
};
let mvhd = box_wrap(b"mvhd", &mvhd_payload);
let video_trak = build_video_trak(1, duration, timescale, codec, width, height);
let mut moov_payload = Vec::new();
moov_payload.extend_from_slice(&mvhd);
moov_payload.extend_from_slice(&video_trak);
if with_audio {
let audio_trak = build_audio_trak(2, duration, timescale);
moov_payload.extend_from_slice(&audio_trak);
}
box_wrap(b"moov", &moov_payload)
}
fn build_video_trak(
track_id: u32,
duration: u32,
timescale: u32,
codec: &[u8; 4],
width: u16,
height: u16,
) -> Vec<u8> {
// tkhd (version 0, flags=3 = enabled+in-movie)
let tkhd_payload: Vec<u8> = {
let mut b = vec![0u8, 0u8, 0u8, 3u8]; // version=0, flags=3
b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
b.extend_from_slice(&track_id.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // reserved
b.extend_from_slice(&duration.to_be_bytes());
b.extend_from_slice(&[0u8; 8]); // reserved
b.extend_from_slice(&0i16.to_be_bytes()); // layer
b.extend_from_slice(&0i16.to_be_bytes()); // alternate_group
b.extend_from_slice(&0u16.to_be_bytes()); // volume
b.extend_from_slice(&0u16.to_be_bytes()); // reserved
// identity matrix
b.extend_from_slice(&0x00010000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0x00010000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0x40000000u32.to_be_bytes());
// width and height as 16.16 fixed point
b.extend_from_slice(&((width as u32) << 16).to_be_bytes());
b.extend_from_slice(&((height as u32) << 16).to_be_bytes());
b
};
let tkhd = box_wrap(b"tkhd", &tkhd_payload);
let mdia = build_video_mdia(duration, timescale, codec, width, height);
let trak_payload = {
let mut b = Vec::new();
b.extend_from_slice(&tkhd);
b.extend_from_slice(&mdia);
b
};
box_wrap(b"trak", &trak_payload)
}
fn build_video_mdia(
duration: u32,
timescale: u32,
codec: &[u8; 4],
width: u16,
height: u16,
) -> Vec<u8> {
// mdhd
let mdhd_payload: Vec<u8> = {
let mut b = vec![0u8; 4]; // version=0, flags=0
b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
b.extend_from_slice(&timescale.to_be_bytes());
b.extend_from_slice(&duration.to_be_bytes());
b.extend_from_slice(&0u16.to_be_bytes()); // language
b.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
b
};
let mdhd = box_wrap(b"mdhd", &mdhd_payload);
// hdlr for video
let hdlr = build_hdlr(b"vide", b"VideoHandler");
// minf -> stbl -> stsd -> codec_box
let minf = build_video_minf(codec, width, height);
let mdia_payload = {
let mut b = Vec::new();
b.extend_from_slice(&mdhd);
b.extend_from_slice(&hdlr);
b.extend_from_slice(&minf);
b
};
box_wrap(b"mdia", &mdia_payload)
}
fn build_hdlr(handler_type: &[u8; 4], name: &[u8]) -> Vec<u8> {
let mut payload = vec![0u8; 4]; // version=0, flags=0
payload.extend_from_slice(&0u32.to_be_bytes()); // pre_defined
payload.extend_from_slice(handler_type);
payload.extend_from_slice(&[0u8; 12]); // reserved
payload.extend_from_slice(name);
payload.push(0); // null terminator
box_wrap(b"hdlr", &payload)
}
fn build_video_minf(codec: &[u8; 4], width: u16, height: u16) -> Vec<u8> {
// vmhd
let vmhd_payload = {
let mut b = vec![0u8, 0u8, 0u8, 1u8]; // version=0, flags=1
b.extend_from_slice(&0u16.to_be_bytes()); // graphicsMode
b.extend_from_slice(&[0u8; 6]); // opcolor
b
};
let vmhd = box_wrap(b"vmhd", &vmhd_payload);
// dinf -> dref
let url_payload = vec![0u8, 0u8, 0u8, 1u8]; // version=0, flags=1 (self-contained)
let url_box = box_wrap(b"url ", &url_payload);
let dref_payload = {
let mut b = vec![0u8; 4]; // version=0, flags=0
b.extend_from_slice(&1u32.to_be_bytes()); // entry_count=1
b.extend_from_slice(&url_box);
b
};
let dref = box_wrap(b"dref", &dref_payload);
let dinf = box_wrap(b"dinf", &dref);
// stbl -> stsd -> codec sample entry
let stsd = build_video_stsd(codec, width, height);
// Minimal stts (time-to-sample): 1 entry, 1 sample, duration=1000
let stts_payload = {
let mut b = vec![0u8; 4]; // version=0, flags=0
b.extend_from_slice(&1u32.to_be_bytes()); // entry_count
b.extend_from_slice(&1u32.to_be_bytes()); // sample_count
b.extend_from_slice(&1000u32.to_be_bytes()); // sample_delta
b
};
let stts = box_wrap(b"stts", &stts_payload);
// stsc: 1 chunk, 1 sample per chunk
let stsc_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes()); // first_chunk
b.extend_from_slice(&1u32.to_be_bytes()); // samples_per_chunk
b.extend_from_slice(&1u32.to_be_bytes()); // sample_description_index
b
};
let stsc = box_wrap(b"stsc", &stsc_payload);
// stsz: 1 sample, size=0
let stsz_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes()); // sample_size=0 (variable)
b.extend_from_slice(&1u32.to_be_bytes()); // sample_count
b.extend_from_slice(&0u32.to_be_bytes()); // entry_size[0]
b
};
let stsz = box_wrap(b"stsz", &stsz_payload);
// stco: 1 chunk offset
let stco_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&28u32.to_be_bytes()); // offset (after ftyp)
b
};
let stco = box_wrap(b"stco", &stco_payload);
let stbl_payload = {
let mut b = Vec::new();
b.extend_from_slice(&stsd);
b.extend_from_slice(&stts);
b.extend_from_slice(&stsc);
b.extend_from_slice(&stsz);
b.extend_from_slice(&stco);
b
};
let stbl = box_wrap(b"stbl", &stbl_payload);
let minf_payload = {
let mut b = Vec::new();
b.extend_from_slice(&vmhd);
b.extend_from_slice(&dinf);
b.extend_from_slice(&stbl);
b
};
box_wrap(b"minf", &minf_payload)
}
fn build_video_stsd(codec: &[u8; 4], width: u16, height: u16) -> Vec<u8> {
// Visual sample entry (avc1/hvc1/etc.)
// VisualSampleEntry: reserved(6) + data_ref_idx(2) + pre_defined(2) + reserved(2) +
// pre_defined(12) + width(2) + height(2) + horiz_res(4) + vert_res(4) +
// reserved(4) + frame_count(2) + compressorname(32) + depth(2) + pre_defined(2)
let mut entry_payload = Vec::new();
entry_payload.extend_from_slice(&[0u8; 6]); // reserved
entry_payload.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index
entry_payload.extend_from_slice(&[0u8; 2]); // pre_defined
entry_payload.extend_from_slice(&[0u8; 2]); // reserved
entry_payload.extend_from_slice(&[0u8; 12]); // pre_defined
entry_payload.extend_from_slice(&width.to_be_bytes());
entry_payload.extend_from_slice(&height.to_be_bytes());
entry_payload.extend_from_slice(&0x00480000u32.to_be_bytes()); // horiz_res 72dpi
entry_payload.extend_from_slice(&0x00480000u32.to_be_bytes()); // vert_res 72dpi
entry_payload.extend_from_slice(&0u32.to_be_bytes()); // reserved
entry_payload.extend_from_slice(&1u16.to_be_bytes()); // frame_count
entry_payload.extend_from_slice(&[0u8; 32]); // compressorname
entry_payload.extend_from_slice(&0x0018u16.to_be_bytes()); // depth
entry_payload.extend_from_slice(&(-1i16).to_be_bytes()); // pre_defined
// Append the codec-specific config box.
if codec == b"avc1" {
// avcC: minimal (version=1, profile=66/Baseline, compat=0, level=30)
let avcc_payload = vec![
0x01, 0x42, 0x00, 0x1E, // configurationVersion, AVCProfileIndication,
// profile_compatibility, AVCLevelIndication
0xFF, // lengthSizeMinusOne=3
0xE1, // numSequenceParameterSets=1
0x00, 0x00, // sequenceParameterSetLength=0 (empty SPS)
0x01, // numPictureParameterSets=1
0x00, 0x00, // pictureParameterSetLength=0 (empty PPS)
];
entry_payload.extend_from_slice(&box_wrap(b"avcC", &avcc_payload));
} else {
// hvcC: minimal — configuration_version=1 (1 byte payload).
// The mp4 crate's HvcCBox::read_box reads exactly 1 byte.
entry_payload.extend_from_slice(&box_wrap(b"hvcC", &[0x01]));
}
let codec_box = box_wrap(codec, &entry_payload);
let mut stsd_payload = vec![0u8; 4]; // version=0, flags=0
stsd_payload.extend_from_slice(&1u32.to_be_bytes()); // entry_count
stsd_payload.extend_from_slice(&codec_box);
box_wrap(b"stsd", &stsd_payload)
}
fn build_audio_trak(track_id: u32, duration: u32, timescale: u32) -> Vec<u8> {
let tkhd_payload: Vec<u8> = {
let mut b = vec![0u8, 0u8, 0u8, 3u8];
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&track_id.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&duration.to_be_bytes());
b.extend_from_slice(&[0u8; 8]);
b.extend_from_slice(&0i16.to_be_bytes());
b.extend_from_slice(&0i16.to_be_bytes());
b.extend_from_slice(&0x0100u16.to_be_bytes()); // volume=1.0 for audio
b.extend_from_slice(&0u16.to_be_bytes());
b.extend_from_slice(&0x00010000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0x00010000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0x40000000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // width=0
b.extend_from_slice(&0u32.to_be_bytes()); // height=0
b
};
let tkhd = box_wrap(b"tkhd", &tkhd_payload);
let mdia = build_audio_mdia(duration, timescale);
let trak_payload = {
let mut b = Vec::new();
b.extend_from_slice(&tkhd);
b.extend_from_slice(&mdia);
b
};
box_wrap(b"trak", &trak_payload)
}
fn build_audio_mdia(duration: u32, timescale: u32) -> Vec<u8> {
let mdhd_payload: Vec<u8> = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&timescale.to_be_bytes());
b.extend_from_slice(&duration.to_be_bytes());
b.extend_from_slice(&0u16.to_be_bytes());
b.extend_from_slice(&0u16.to_be_bytes());
b
};
let mdhd = box_wrap(b"mdhd", &mdhd_payload);
let hdlr = build_hdlr(b"soun", b"SoundHandler");
let minf = build_audio_minf();
let mdia_payload = {
let mut b = Vec::new();
b.extend_from_slice(&mdhd);
b.extend_from_slice(&hdlr);
b.extend_from_slice(&minf);
b
};
box_wrap(b"mdia", &mdia_payload)
}
fn build_audio_minf() -> Vec<u8> {
// smhd
let smhd_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u16.to_be_bytes()); // balance
b.extend_from_slice(&0u16.to_be_bytes()); // reserved
b
};
let smhd = box_wrap(b"smhd", &smhd_payload);
// dinf -> dref -> url
let url_payload = vec![0u8, 0u8, 0u8, 1u8];
let url_box = box_wrap(b"url ", &url_payload);
let dref_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&url_box);
b
};
let dref = box_wrap(b"dref", &dref_payload);
let dinf = box_wrap(b"dinf", &dref);
// stbl: minimal stsd with mp4a
// mp4a layout: 4 reserved + 2 reserved + 2 data_ref_idx + 8 reserved +
// 2 channelcount + 2 samplesize + 4 pre_defined/reserved + 4 samplerate
// esds is optional — omit it to avoid needing a valid ESDescriptor.
let mp4a_payload = {
let mut b = vec![0u8; 6]; // reserved
b.extend_from_slice(&1u16.to_be_bytes()); // data_reference_index
b.extend_from_slice(&[0u8; 8]); // reserved
b.extend_from_slice(&2u16.to_be_bytes()); // channelcount
b.extend_from_slice(&16u16.to_be_bytes()); // samplesize
b.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
b.extend_from_slice(&0u16.to_be_bytes()); // reserved
b.extend_from_slice(&(44100u32 << 16).to_be_bytes()); // samplerate 44100.0
// No esds box — mp4a.esds is Option<EsdsBox>, None is valid.
b
};
let mp4a = box_wrap(b"mp4a", &mp4a_payload);
let mut stsd_payload = vec![0u8; 4];
stsd_payload.extend_from_slice(&1u32.to_be_bytes());
stsd_payload.extend_from_slice(&mp4a);
let stsd = box_wrap(b"stsd", &stsd_payload);
let stts_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1024u32.to_be_bytes());
b
};
let stts = box_wrap(b"stts", &stts_payload);
let stsc_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b
};
let stsc = box_wrap(b"stsc", &stsc_payload);
let stsz_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b
};
let stsz = box_wrap(b"stsz", &stsz_payload);
let stco_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&28u32.to_be_bytes());
b
};
let stco = box_wrap(b"stco", &stco_payload);
let stbl_payload = {
let mut b = Vec::new();
b.extend_from_slice(&stsd);
b.extend_from_slice(&stts);
b.extend_from_slice(&stsc);
b.extend_from_slice(&stsz);
b.extend_from_slice(&stco);
b
};
let stbl = box_wrap(b"stbl", &stbl_payload);
let minf_payload = {
let mut b = Vec::new();
b.extend_from_slice(&smhd);
b.extend_from_slice(&dinf);
b.extend_from_slice(&stbl);
b
};
box_wrap(b"minf", &minf_payload)
}
// --- check_moov_before_mdat edge case tests ---
/// Helper: write bytes to a temp file and run check_moov_before_mdat.
fn check_moov_bytes(bytes: &[u8]) -> Result<(), MediaError> {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), bytes).unwrap();
check_moov_before_mdat(tmp.path())
}
#[test]
fn test_moov_scanner_iteration_limit() {
// Craft a file with 2000 minimal 8-byte "free" atoms followed by moov + mdat.
// The scanner hits MAX_ATOMS (1024) and fails closed — it can't verify
// moov-before-mdat, so it rejects the file rather than silently passing.
let mut bytes = Vec::new();
for _ in 0..2000 {
bytes.extend_from_slice(&8u32.to_be_bytes()); // size = 8
bytes.extend_from_slice(b"free");
}
bytes.extend_from_slice(&8u32.to_be_bytes());
bytes.extend_from_slice(b"moov");
bytes.extend_from_slice(&8u32.to_be_bytes());
bytes.extend_from_slice(b"mdat");
// Fail closed: too many atoms → reject
let err = check_moov_bytes(&bytes);
assert!(
matches!(err, Err(MediaError::MoovNotAtFront)),
"expected MoovNotAtFront, got {err:?}"
);
}
#[test]
fn test_moov_scanner_extended_atom_size() {
// Build: ftyp(20) + moov(extended size, 24 bytes total) + mdat(8)
// Extended size: compact_size=1, then 8-byte real size.
let mut bytes = Vec::new();
// ftyp
bytes.extend_from_slice(&20u32.to_be_bytes());
bytes.extend_from_slice(b"ftyp");
bytes.extend_from_slice(b"isom");
bytes.extend_from_slice(&0u32.to_be_bytes());
bytes.extend_from_slice(b"isom");
// moov with extended size (compact_size=1, extended_size=24)
// 24 = 16 byte header + 8 bytes payload
bytes.extend_from_slice(&1u32.to_be_bytes()); // compact size = 1 (extended)
bytes.extend_from_slice(b"moov");
bytes.extend_from_slice(&24u64.to_be_bytes()); // extended size = 24
bytes.extend_from_slice(&[0u8; 8]); // 8 bytes of moov payload
// mdat
bytes.extend_from_slice(&8u32.to_be_bytes());
bytes.extend_from_slice(b"mdat");
// moov is before mdat — should pass
assert!(check_moov_bytes(&bytes).is_ok());
}
#[test]
fn test_moov_scanner_extended_mdat_before_moov() {
// Extended-size mdat before moov — must be rejected.
let mut bytes = Vec::new();
// ftyp
bytes.extend_from_slice(&20u32.to_be_bytes());
bytes.extend_from_slice(b"ftyp");
bytes.extend_from_slice(b"isom");
bytes.extend_from_slice(&0u32.to_be_bytes());
bytes.extend_from_slice(b"isom");
// mdat with extended size (before moov)
bytes.extend_from_slice(&1u32.to_be_bytes()); // compact size = 1 (extended)
bytes.extend_from_slice(b"mdat");
bytes.extend_from_slice(&24u64.to_be_bytes()); // extended size = 24
bytes.extend_from_slice(&[0u8; 8]); // payload
// moov after mdat
bytes.extend_from_slice(&8u32.to_be_bytes());
bytes.extend_from_slice(b"moov");
let err = check_moov_bytes(&bytes);
assert!(
matches!(err, Err(MediaError::MoovNotAtFront)),
"expected MoovNotAtFront, got {err:?}"
);
}
#[test]
fn test_moov_scanner_eof_atom_mdat_before_moov() {
// atom_size==0 (extends to EOF) on mdat, with no moov seen — must be rejected.
let mut bytes = Vec::new();
// ftyp
bytes.extend_from_slice(&20u32.to_be_bytes());
bytes.extend_from_slice(b"ftyp");
bytes.extend_from_slice(b"isom");
bytes.extend_from_slice(&0u32.to_be_bytes());
bytes.extend_from_slice(b"isom");
// mdat with size=0 (extends to EOF), no moov before it
bytes.extend_from_slice(&0u32.to_be_bytes()); // size = 0 (EOF)
bytes.extend_from_slice(b"mdat");
let err = check_moov_bytes(&bytes);
assert!(
matches!(err, Err(MediaError::MoovNotAtFront)),
"expected MoovNotAtFront, got {err:?}"
);
}
// --- actual test cases ---
#[test]
fn test_validate_video_ok() {
let config = test_config();
let mp4_bytes = build_minimal_mp4_moov_first();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &config);
match result {
Ok(meta) => {
assert_eq!(meta.width, 320);
assert_eq!(meta.height, 240);
assert!(!meta.has_audio);
assert!(meta.duration_secs > 0.0 && meta.duration_secs <= 600.0);
}
Err(e) => panic!("expected Ok, got {e:?}"),
}
}
#[test]
fn test_validate_video_accepts_proprietary_major_with_isom_compatibility() {
let mut mp4_bytes = build_minimal_mp4_moov_first();
mp4_bytes[8..12].copy_from_slice(b"PRIV");
assert!(infer::get(&mp4_bytes).is_none());
assert!(looks_like_mp4_iso_bmff(&mp4_bytes));
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
assert!(validate_video_file(tmp.path(), &test_config()).is_ok());
}
#[test]
fn test_accepts_exact_empty_ffmpeg_udta() {
let empty_ffmpeg_udta = hex::decode(
"000000356d657461000000000000002168646c7200000000000000006d6469726170706c00000000000000000000000008696c7374",
)
.unwrap();
let bytes = [
box_wrap(b"ftyp", b"isom\0\0\0\0isom"),
box_wrap(b"moov", &box_wrap(b"udta", &empty_ffmpeg_udta)),
box_wrap(b"mdat", b""),
]
.concat();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), bytes).unwrap();
assert!(validate_mp4_metadata_free(tmp.path()).is_ok());
}
#[test]
fn test_rejects_excessive_mp4_box_nesting() {
let mut nested = box_wrap(b"free", b"");
// One level beyond the validator's intentionally generous bound.
for _ in 0..=32 {
nested = box_wrap(b"moov", &nested);
}
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), nested).unwrap();
assert!(matches!(
validate_mp4_metadata_free(tmp.path()),
Err(MediaError::InvalidVideo)
));
}
#[test]
fn test_rejects_mp4_metadata_boxes_and_trailing_payload() {
let config = test_config();
for kind in [
b"udta", b"meta", b"ilst", b"keys", b"data", b"uuid", b"xml ", b"\xa9xyz",
] {
let mut bytes = build_minimal_mp4_moov_first();
bytes.extend_from_slice(&box_wrap(kind, b"GPS=37.7,-122.4"));
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &bytes).unwrap();
assert!(
matches!(
validate_video_file(tmp.path(), &config),
Err(MediaError::MetadataForbidden)
),
"accepted {kind:?}"
);
}
let mut bytes = build_minimal_mp4_moov_first();
bytes.extend_from_slice(b"trailing");
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &bytes).unwrap();
assert!(validate_video_file(tmp.path(), &config).is_err());
}
#[test]
fn test_validate_video_with_audio() {
let config = test_config();
let mp4_bytes = build_mp4_with_audio();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &config);
match result {
Ok(meta) => assert!(meta.has_audio),
Err(e) => panic!("expected Ok, got {e:?}"),
}
}
#[test]
fn test_validate_video_rejects_alternate_video_track() {
let config = test_config();
let extra_video = build_video_trak(2, 1_000, 1_000, b"avc1", 320, 240);
let mp4_bytes = append_box_to_moov(build_minimal_mp4_moov_first(), &extra_video);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
assert!(matches!(
validate_video_file(tmp.path(), &config),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_validate_video_rejects_alternate_audio_track() {
let config = test_config();
let extra_audio = build_audio_trak(3, 1_000, 1_000);
let mp4_bytes = append_box_to_moov(build_mp4_with_audio(), &extra_audio);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
assert!(matches!(
validate_video_file(tmp.path(), &config),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_validate_video_rejects_real_quicktime_iso6709_location() {
// QuickTime stores ISO-6709 coordinates in a ©xyz child of moov/udta.
let coordinates = b"+37.7750-122.4183+015.000/";
let location = box_wrap(b"\xa9xyz", coordinates);
let udta = box_wrap(b"udta", &location);
let mp4_bytes = append_box_to_moov(build_minimal_mp4_moov_first(), &udta);
// Independently prove this source contains the expected location box
// and coordinates before exercising the validator.
assert!(mp4_bytes.windows(4).any(|window| window == b"\xa9xyz"));
assert!(mp4_bytes
.windows(coordinates.len())
.any(|window| window == coordinates));
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
assert!(matches!(
validate_video_file(tmp.path(), &test_config()),
Err(MediaError::MetadataForbidden)
));
}
#[test]
fn test_validate_video_rejects_timed_metadata_track() {
// Start from a parseable audio track and change its handler from
// `soun` to `meta`, representing a timed telemetry/GPS track.
let mut telemetry_track = build_audio_trak(2, 1_000, 1_000);
let handler = telemetry_track
.windows(4)
.position(|window| window == b"soun")
.expect("audio fixture must contain a soun handler");
telemetry_track[handler..handler + 4].copy_from_slice(b"meta");
assert!(telemetry_track.windows(4).any(|window| window == b"meta"));
let mp4_bytes = append_box_to_moov(build_minimal_mp4_moov_first(), &telemetry_track);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
assert!(validate_video_file(tmp.path(), &test_config()).is_err());
}
#[test]
fn test_validate_video_mdat_first_rejected() {
let config = test_config();
let mp4_bytes = build_minimal_mp4_mdat_first();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &config);
assert!(
matches!(result, Err(MediaError::MoovNotAtFront)),
"expected MoovNotAtFront, got {result:?}"
);
}
#[test]
fn test_validate_video_hevc_rejected() {
let config = test_config();
let mp4_bytes = build_mp4_hevc();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &config);
assert!(
matches!(result, Err(MediaError::WrongCodec)),
"expected WrongCodec, got {result:?}"
);
}
#[test]
fn test_validate_video_too_long_rejected() {
let config = test_config();
let mp4_bytes = build_mp4_too_long();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &config);
assert!(
matches!(result, Err(MediaError::DurationTooLong)),
"expected DurationTooLong, got {result:?}"
);
}
#[test]
fn test_validate_video_zero_duration_rejected() {
let config = test_config();
// duration_ms=0 → duration_secs=0.0 → rejected as InvalidVideo
let mp4_bytes = build_mp4_bytes(true, b"avc1", 0, 320, 240, false);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &config);
assert!(
matches!(result, Err(MediaError::InvalidVideo)),
"expected InvalidVideo for zero-duration, got {result:?}"
);
}
#[test]
fn test_validate_video_resolution_too_high() {
let config = test_config();
let mp4_bytes = build_mp4_too_large();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &config);
assert!(
matches!(result, Err(MediaError::ResolutionTooHigh)),
"expected ResolutionTooHigh, got {result:?}"
);
}
// --- Generic file path tests ---
/// Minimal PDF header — infer detects `application/pdf` from `%PDF`.
const TINY_PDF: &[u8] = b"%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n%%EOF";
/// Minimal ZIP header — infer detects `application/zip` from `PK\x03\x04`.
const TINY_ZIP: &[u8] = &[
0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
];
#[test]
fn test_validate_file_pdf_accepted() {
let config = test_config();
let (mime, ext) = validate_file_content(TINY_PDF, &config).unwrap();
assert_eq!(mime, "application/pdf");
assert_eq!(ext, "pdf");
}
#[test]
fn test_validate_file_zip_accepted() {
let config = test_config();
let (mime, ext) = validate_file_content(TINY_ZIP, &config).unwrap();
assert_eq!(mime, "application/zip");
assert_eq!(ext, "zip");
}
#[test]
fn test_validate_file_plaintext_accepted_as_octet_stream() {
// Plain text has no magic bytes — infer returns None. The generic path
// accepts it as opaque binary served as a download (the common Slack
// case: .txt, .csv, .md, source code).
let config = test_config();
let (mime, ext) = validate_file_content(b"hello, this is a text file\n", &config).unwrap();
assert_eq!(mime, "application/octet-stream");
assert_eq!(ext, "bin");
}
#[test]
fn test_validate_file_html_rejected() {
// HTML is a stored-XSS carrier — blocked even though headers neutralise it.
let config = test_config();
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
let result = validate_file_content(html, &config);
assert!(
matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"),
"expected DisallowedContentType(text/html), got {result:?}"
);
}
#[test]
fn test_validate_file_too_large_rejected() {
let mut config = test_config();
config.max_file_bytes = 10;
let result = validate_file_content(TINY_PDF, &config);
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
}
#[test]
fn test_serve_inline() {
assert!(serve_inline("image/jpeg"));
assert!(serve_inline("image/png"));
assert!(serve_inline("video/mp4"));
// Generic files force download.
assert!(!serve_inline("application/pdf"));
assert!(!serve_inline("application/zip"));
assert!(!serve_inline("application/octet-stream"));
assert!(!serve_inline("audio/mpeg"));
assert!(!serve_inline("text/plain"));
}
}