Files
buzz/crates/buzz-media/src/validation.rs
Will Pfleger f7ff4294fa feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288)
Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters,
and desktop token minting into compliance with [NIP-FI
§kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md).

## What changed

**`buzz-media/src/auth.rs`**
- Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict`
applies full NIP-FI rules (active modes); `Permissive` preserves
pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15].
- Rewrite `verify_blossom_auth_event_for_verb` with count-based
cardinality: exactly one `t`/`expiration`/`server` in `Strict`;
duplicate `x` also rejected. `Permissive` keeps main's boolean semantics
exactly: any valued `t` other than the verb (including `""`) rejects,
valueless `t` is ignored.
- `Strict`: mandatory `server` tag on all proofs (upload + read); absent
or mismatched → `evidence_rejected` (`ServerMismatch`).
- `Strict`: 60-second proof window (`now - created_at <= 60s`,
`expiration <= created_at + 60s`). `Permissive`: 3600s / optional
server.
- **[F1]** Permissive server validation uses any-match semantics: a
proof with multiple `server` tags is accepted if any tag matches the
bound host. This restores origin/main base behavior. Strict
duplicate-rejection unchanged.
- **[F1 compat]** Permissive server gate uses `server_values.is_empty()`
(valued tags only) rather than `server_count > 0` (includes valueless
tags). A lone valueless `["server"]` tag is treated as absent in
Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15].
- **[F4]** Valueless `x` tags in Strict get reads are tracked before
`filter_map` so they cannot become absent scope (host-wide
authorization). Any `x` tag present in Strict must contain the exact
requested sha256.
- **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they
call `verify_blossom_upload_auth` and `verify_upload_hash_only`
directly. They do not enter the upload pipeline
(`process_upload`/`process_video_upload`). Retained as helper evidence.

**`buzz-media/src/error.rs`**
- Add `DuplicateTag(&'static str)` variant.
- Add `blossom_denial_kind()` method: classifies each auth error as
`MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`.
Non-auth errors return `None`.
- `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15].

**`buzz-relay/src/api/media.rs`**
- Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to
byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy
JSON in `Permissive`.
- `blossom_strictness_from_state()` selects `Strict` when
`state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness
is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on
upload and read, and through the post-admission X-SHA-256, hash-binding,
and membership denials.
- `extract_blossom_auth` keeps first-value `Authorization` extraction;
Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality
gate, and Off keeps legacy behavior [FI-INV-15].
- Both membership-denial call sites use
`media_denial(RelayMembershipRequired, strictness)` so Strict mode
produces the correct fixed-text response.
- `upload_blob` serving-write fence conversion (`:406`) uses
`map_err(|e| media_denial(e, strictness))` — a community fence committed
between auth admission and lease acquisition no longer resets Strict to
Permissive.
- Mode-driven regressions in `mod postgres_tests` (selected by the
nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore
= "requires Postgres"]`), run through the full relay router with a real
Enforce verifier and assertion: Enforce rejects Permissive-only proofs
(300s lifetime, missing `server` tag) on both upload routes and reads
with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI
responses; DenyProtected answers 503 before any proof check; membership
denials are `authorization denied` text/plain in Enforce and exact
legacy JSON 403 in Off, with a member positive control. GET and HEAD are
each discriminated (HEAD with a suppressed body). Both upload routes pin
the handler's `X-SHA-256` missing/malformed/unsigned and post-body
hash-mismatch denials in both modes. Off rejects an empty-valued `t`
beside a valid one with legacy 401 JSON in either order, and ignores a
valueless one. Exact status, Content-Type, challenge, and body bytes are
pinned.

**`buzz-media/src/upload.rs`** (`mod minio_tests`)
- **[F3 pipeline]** Four tests exercising the actual post-body call
sites (`process_upload` `:85`, `process_video_upload` `:413`) with real
MinIO I/O: buffered/streaming × expired-proof-accept +
mismatched-hash-reject. The positive cases use
`fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`,
lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s
sleep so the proof is expired when the upload completes.
`nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio
time does not advance it. The negative cases use a pre-expired proof
with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`;
no CI lane runs them automatically.

**`buzz-media/src/validation.rs`**
- `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a
618-byte pre-computed H.264/avc1 fast-start MP4 available to all
`buzz-media` test modules; smoke-tested by
`test_minimal_valid_mp4_passes_validate_video_file`.

**`desktop/src-tauri/src/commands/media.rs`**
- `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s
(was 3600s/300s).
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s).

**`desktop/src-tauri/src/media_proxy.rs`**
- Single re-mint+retry on 401/403 for range requests; non-auth failures
do not retry.

**`crates/buzz-cli/src/client.rs`**
- Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag
mandatory on upload.

**`crates/buzz-dev-mcp/src/view_image.rs`**
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated.

**`mobile/lib/shared/relay/media_auth.dart`**
- `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated.

**`mobile/lib/shared/relay/media_upload.dart`**
- `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory.

**`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`**
- **[F2]** Remove Android-specific `VideoPlayerController.networkUrl`
streaming path. `video_player_android` 2.9.5 freezes headers into static
`DefaultHttpDataSource` request properties — a 60s proof minted at
controller creation time becomes stale on seeks. All platforms use the
authenticated local-file download path.
- **[F2 transport]** `unawaited(request.sink.close())` before
`client.send()`. `AbortableStreamedRequest` carries no request body;
without closing the sink, `IOClient.send()` awaits
`stream.pipe(ioRequest)` which blocks until the sink ends — every video
download hung in loading indefinitely on every platform.
- **[F2 pending-ctrl]** `pendingController` ref set before the first
`await (initialize())`, cleared in all exit paths. Effect cleanup
disposes `pendingController.value` so a close-during-init releases the
native player even if the initialized event never arrives.
- **[F2r(a)]** On native init failure (post-create,
pre-initialized-event), the inner catch starts disposal detached via
`unawaited(localController.dispose().catchError(...))` — the
`.catchError` handler absorbs any disposal exception so it does not
reach the test error zone — then rethrows the original load error.
`video_player` 2.11.1 completes the init future with an error without
disposing the native player.
- **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()`
(subscribe+cancel) instead of `drain()`. `drain()` waits for the
upstream to close; a stalled server body blocked `initializeVideo`
indefinitely.
- **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in
the catch — not `await`. Two failure mechanisms exist: (1) when
`createWithOptions()` throws, `_creatingCompleter` is never completed
and `await dispose()` deadlocks at `:682-683`, blocking the outer catch
from setting error state. (2) when `createWithOptions()` succeeds but
initialization fails, `dispose()` runs but its thrown exception becomes
an unhandled async error reaching the test zone. Both are fixed:
`unawaited` lets the outer catch run immediately and set `error.value`;
`.catchError(...)` on the detached disposal future absorbs any disposal
exception.


**`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`**
- `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test
without the sink fix times out because the drain never completes,
validating the transport contract.
- F2r(a): `forceInitError=true` fake emits `PlatformException` on
subscribe; `disposeCallCount >= 1` verifies disposal even on init
failure.
- F2r(b): `stalledBody` `StreamController` with `onCancel` Completer
(bounded completion signal, not a sleep). Asserts: (1)
`bodyStreamCancelled=true` while body stream is still open (impossible
with `drain()`); (2) error UI visible while body remains open. Restoring
`drain()` breaks both.
- F2r(c): `neverInitialize=true` fake never sends initialized event;
unmount asserts `disposeCallCount >= 1` via `pendingController`.
Distinct from the event-error case.
- F2r(d): `forceCreateError=true` fake throws `PlatformException` from
`createWithOptions()` itself (before any player ID is returned). Asserts
error UI visible within a bounded deadline. With the old `await
dispose()` the outer catch is blocked — `error.value` is never set and
the error UI assertion fails at the deadline; with
`unawaited(dispose())` the error text appears immediately.


**`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`**
(separate file)
- Real-IO loopback probes using `flutter_test` (not `package:test`) to
avoid `depend_on_referenced_packages` lint. No
`TestWidgetsFlutterBinding` — no HttpClient override interfering with
`dart:io`.
- Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request
body before replying. Without `sink.close()`, `req.drain()` hangs and
the test times out.
- Probe 2: server signals request arrival via a Completer (not a sleep);
abort fires after confirmed arrival; `client.send()` must throw
`RequestAbortedException` (typed assertion) within a bounded 5s
deadline.

**`scripts/test-video-upload.sh`**
- Proof minted with `+60` expiry and mandatory `server` tag.

**`buzz-test-client/tests/e2e_media*.rs`** +
**`buzz-relay/src/api/media.rs` test fixtures**
- All fixtures updated to 55s expiry + `server` tag.

**`RELEASING.md`**
- New "NIP-FI upload proof freshness (relay floor)" section documenting
that 60s proofs require the first relay release carrying this PR's
post-body hash-only check (the first tag > `v0.2.1` — update once cut)
for reliable large-file uploads. Operators on older self-hosted relays
(≤ `v0.2.1`) must upgrade before receiving these clients.

## Finding 3: 60s upload proofs vs old-relay compatibility

Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full
expiry verification after the body is transferred. A valid upload whose
body transfer takes >60s will fail on an old relay.

**Resolution (relay floor prerequisite):** `RELEASING.md` requires that
verified supported deployments (relay releases confirmed to carry the
post-body hash-only check, commit `75e9bef748`) be in place before
distributing 60s-proof client builds. Unknown or unverified self-hosted
relay deployments are excluded from distribution until they upgrade.
This makes the verified-deployment status a prerequisite for release
distribution, not a post-distribution advisory. The concrete tag (`>
v0.2.1`) is to be recorded once cut.

## Sequencing note

Builds on #7264 (merged): Blossom strictness is derived from its
`config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce`
activates the strict verifier with no further wiring.

## GIF compatibility exception

An unauthenticated Off-mode request on a configured tenant with no GIF
provider changes from 404 to 401 (base `gifs.rs:269-275` checked an
unconfigured provider first; the current code authenticates first). All
other Off-mode Blossom responses are byte-identical to pre-NIP-FI
behavior.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 14:46:56 -04:00

2802 lines
112 KiB
Rust
Raw 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.
///
/// 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.
///
/// HTML is intentionally *not* blocked: it is accepted as an inert download
/// (`serve_inline` returns false for `text/html`, so it is served with
/// `Content-Disposition: attachment` + `nosniff` + `CSP: default-src 'none'`,
/// and the desktop renderer never navigates a webview to a generic
/// attachment). The old sniff-based block only caught the well-formed HTML
/// `infer` recognises anyway — HTML that evades the sniff already uploaded as
/// `application/octet-stream` and served as a download, so blocking canonical
/// HTML was inconsistent rather than a real control. `application/xhtml+xml`
/// stays listed as dormant defence in depth: `infer` has no XHTML matcher, so
/// it is unreachable through sniffing, but the entry costs nothing and guards
/// against a future detector that does classify it.
const BLOCKED_FILE_MIME_TYPES: &[&str] = &[
// Active web content — stored-XSS vectors.
"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/html" => "html",
"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: short edge ≤ 2160 and long edge ≤ 3840 (portrait or landscape)
/// - 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. Apply the 2160×3840 envelope independent
// of orientation so equivalent portrait and landscape videos
// receive the same treatment.
let width = track.width() as u32;
let height = track.height() as u32;
let short_edge = width.min(height);
let long_edge = width.max(height);
if short_edge > 2160 || long_edge > 3840 {
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",
}
}
/// Return a minimal structurally-valid H.264/avc1 MP4 body (618 bytes).
///
/// Fast-start layout (ftyp + moov + mdat), single video track, 1 s, 320×240,
/// no audio. Generated from the same builder as the unit tests and verified to
/// pass `validate_video_file` with the default `MediaConfig`.
///
/// Exposed only under `#[cfg(test)]` so it has no runtime cost; placed at
/// module scope (not inside `mod tests`) so other test modules in this crate
/// can import it via `use crate::validation::minimal_valid_mp4;`.
#[cfg(test)]
pub(crate) fn minimal_valid_mp4() -> Vec<u8> {
// ftyp(isom) + moov + mdat — pre-computed; identical to
// build_mp4_bytes(true, b"avc1", 1_000, 320, 240, false).
vec![
0x00, 0x00, 0x00, 0x14, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x00,
0x00, 0x69, 0x73, 0x6f, 0x6d, 0x00, 0x00, 0x02, 0x4e, 0x6d, 0x6f, 0x6f, 0x76, 0x00, 0x00,
0x00, 0x6c, 0x6d, 0x76, 0x68, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x01, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x02, 0x00, 0x00, 0x01, 0xda, 0x74, 0x72, 0x61, 0x6b, 0x00, 0x00, 0x00, 0x5c, 0x74, 0x6b,
0x68, 0x64, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40,
0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x01, 0x76,
0x6d, 0x64, 0x69, 0x61, 0x00, 0x00, 0x00, 0x20, 0x6d, 0x64, 0x68, 0x64, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00,
0x03, 0xe8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d, 0x68, 0x64, 0x6c, 0x72, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x76, 0x69, 0x64, 0x65, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x48, 0x61,
0x6e, 0x64, 0x6c, 0x65, 0x72, 0x00, 0x00, 0x00, 0x01, 0x21, 0x6d, 0x69, 0x6e, 0x66, 0x00,
0x00, 0x00, 0x14, 0x76, 0x6d, 0x68, 0x64, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0x64, 0x69, 0x6e, 0x66, 0x00, 0x00, 0x00,
0x1c, 0x64, 0x72, 0x65, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
0x00, 0x0c, 0x75, 0x72, 0x6c, 0x20, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xe1, 0x73,
0x74, 0x62, 0x6c, 0x00, 0x00, 0x00, 0x79, 0x73, 0x74, 0x73, 0x64, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x69, 0x61, 0x76, 0x63, 0x31, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x40, 0x00, 0xf0, 0x00, 0x48, 0x00, 0x00, 0x00,
0x48, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0xff, 0xff,
0x00, 0x00, 0x00, 0x13, 0x61, 0x76, 0x63, 0x43, 0x01, 0x42, 0x00, 0x1e, 0xff, 0xe1, 0x00,
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x73, 0x74, 0x74, 0x73, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x03, 0xe8, 0x00, 0x00,
0x00, 0x1c, 0x73, 0x74, 0x73, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x18,
0x73, 0x74, 0x73, 0x7a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x73, 0x74, 0x63, 0x6f, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x08, 0x6d,
0x64, 0x61, 0x74,
]
}
#[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<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:?}"),
}
}
/// Smoke-test: `minimal_valid_mp4()` (the bytes embedded for streaming upload
/// tests) must round-trip through `validate_video_file`. If this fails, the
/// embedded byte sequence needs to be re-generated.
#[test]
fn test_minimal_valid_mp4_passes_validate_video_file() {
let mp4_bytes = minimal_valid_mp4();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &test_config());
assert!(
result.is_ok(),
"minimal_valid_mp4() must pass validate_video_file; got: {result:?}"
);
}
#[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_accepts_portrait_resolution() {
let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2160, 3840, false);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let meta = validate_video_file(tmp.path(), &test_config())
.expect("portrait video within the 2160x3840 envelope should be accepted");
assert_eq!((meta.width, meta.height), (2160, 3840));
}
#[test]
fn test_validate_video_rejects_resolution_above_short_edge_limit() {
let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2161, 3840, false);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &test_config());
assert!(
matches!(result, Err(MediaError::ResolutionTooHigh)),
"expected ResolutionTooHigh, got {result:?}"
);
}
#[test]
fn test_validate_video_rejects_resolution_above_long_edge_limit() {
let mp4_bytes = build_mp4_bytes(true, b"avc1", 1_000, 2160, 3841, false);
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), &mp4_bytes).unwrap();
let result = validate_video_file(tmp.path(), &test_config());
assert!(
matches!(result, Err(MediaError::ResolutionTooHigh)),
"expected ResolutionTooHigh, 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_accepted_as_inert_download() {
// HTML is accepted on the generic file path as an inert attachment.
// `infer` recognises canonical HTML as `text/html`; it must map to the
// `html` extension and, crucially, NOT be served inline — the serve
// layer relies on `serve_inline("text/html") == false` to attach a
// `Content-Disposition: attachment` + `nosniff` + restrictive CSP,
// which is what keeps the payload from ever executing.
let config = test_config();
let html = b"<!DOCTYPE html><html><body><script>alert(1)</script></body></html>";
// Sanity: this fixture is exactly the shape `infer` classifies as HTML.
assert_eq!(infer::get(html).map(|k| k.mime_type()), Some("text/html"));
let (mime, ext) = validate_file_content(html, &config).unwrap();
assert_eq!(mime, "text/html");
assert_eq!(ext, "html");
assert!(
!serve_inline(&mime),
"text/html must never be served inline — it must force download"
);
}
#[test]
fn test_validate_file_executable_still_rejected() {
// Removing HTML from the deny-list must not weaken the executable
// block. `infer` classifies an ELF header as `application/x-executable`,
// which the generic path must still reject via the deny-list.
let config = test_config();
// `infer`'s ELF matcher requires the magic plus >52 bytes of header.
let mut elf = b"\x7fELF".to_vec();
elf.extend_from_slice(&[0u8; 60]);
assert_eq!(
infer::get(&elf).map(|k| k.mime_type()),
Some("application/x-executable")
);
assert!(
matches!(validate_file_content(&elf, &config), Err(MediaError::DisallowedContentType(ref m)) if m == "application/x-executable"),
"ELF executable must still be rejected by the generic file path"
);
}
#[test]
fn test_generic_deny_list_keeps_active_content_and_executables() {
// Static guard on the deny-list itself: HTML is intentionally gone, but
// SVG, JavaScript, XHTML, and the native-executable types remain. These
// are the entries that keep the inert-download boundary honest even if a
// future `infer` upgrade starts classifying more of them by content.
assert!(!BLOCKED_FILE_MIME_TYPES.contains(&"text/html"));
for kept in [
"image/svg+xml",
"application/xhtml+xml",
"application/javascript",
"text/javascript",
"application/x-msdownload",
"application/x-executable",
"application/vnd.microsoft.portable-executable",
"application/x-mach-binary",
"application/x-msi",
"application/x-apple-diskimage",
] {
assert!(
BLOCKED_FILE_MIME_TYPES.contains(&kept),
"{kept} must remain in the generic-file deny-list"
);
}
}
#[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"));
}
}