//! 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 { // 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 { // --- 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 = 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"sdtp", *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(), s3_addressing_style: crate::config::S3AddressingStyle::Path, 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 { 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 { 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 = (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#" "#; 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 { 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""; 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 { 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 { 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 { build_mp4_bytes(true, b"hev1", 1_000, 320, 240, false) } /// Build an MP4 with duration > 600s. fn build_mp4_too_long() -> Vec { build_mp4_bytes(true, b"avc1", 601_000, 320, 240, false) } /// Build an MP4 with resolution > 3840x2160. fn build_mp4_too_large() -> Vec { build_mp4_bytes(true, b"avc1", 1_000, 3841, 2161, false) } /// Build an MP4 with audio track. fn build_mp4_with_audio() -> Vec { 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, child: &[u8]) -> Vec { 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 { // ftyp box: size(4) + 'ftyp'(4) + major_brand(4) + minor_ver(4) + compat(4) let ftyp: Vec = { 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 = { 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 { 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 { 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 = { 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(×cale.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 { // tkhd (version 0, flags=3 = enabled+in-movie) let tkhd_payload: Vec = { 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 { // mdhd let mdhd_payload: Vec = { 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(×cale.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 { 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 { // 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 { // 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 { let tkhd_payload: Vec = { 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 { let mdhd_payload: Vec = { 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(×cale.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 { // 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, 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_accepts_standard_sample_dependency_table() { let bytes = [ box_wrap(b"ftyp", b"isom\0\0\0\0isom"), box_wrap( b"moov", &box_wrap( b"trak", &box_wrap( b"mdia", &box_wrap( b"minf", &box_wrap(b"stbl", &box_wrap(b"sdtp", &[0x20, 0x10])), ), ), ), ), 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""; 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")); } }