Files
buzz/crates/buzz-media/src/error.rs
Jude Edwards 196d62f97c fix(media): accept portrait video resolutions (#6058)
## Summary
- apply the existing 2160×3840 video resolution envelope independent of
orientation
- accept portrait recordings whose short/long edges fit that envelope
- retain rejection coverage for either edge exceeding its limit
- make the relay error describe the orientation-independent limits

## Why
The iOS simulator produces portrait H.264 recordings such as 1206×2622.
The relay previously checked `width <= 3840 && height <= 2160`, so the
equivalent portrait dimensions were rejected while landscape dimensions
were accepted.

## Validation
- `bin/just ci`
- `cargo test -p buzz-media` (120 passed; one MinIO integration test
ignored by its existing live-service guard)
- `cargo clippy -p buzz-media --all-targets -- -D warnings`
- exact blocked artifact
`/Users/judeedwards/.buzz/.scratch/pr5874-mongo-evidence/native/head-20260816T050317Z/video-bitexact.mp4`
validated through `validate_video_file`: 1206×2622 accepted

Signed-off-by: Carl <5f365698229751c0461f57bb03a4e93134e6e936bd7039ebe7b737282a43c754@buzz.block.builderlab.xyz>
Co-authored-by: Carl <5f365698229751c0461f57bb03a4e93134e6e936bd7039ebe7b737282a43c754@buzz.block.builderlab.xyz>
2026-08-19 18:01:47 +00:00

223 lines
7.5 KiB
Rust

//! Media error types.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
/// Errors from media operations.
#[derive(Debug, thiserror::Error)]
pub enum MediaError {
#[error("unknown content type")]
UnknownContentType,
#[error("disallowed content type: {0}")]
DisallowedContentType(String),
#[error("file too large: {size} bytes (max {max})")]
FileTooLarge { size: u64, max: u64 },
#[error("image dimensions too large")]
ImageTooLarge,
#[error("invalid image data")]
InvalidImage,
#[error("media contains metadata or a non-canonical metadata channel")]
MetadataForbidden,
#[error("invalid signature")]
InvalidSignature,
#[error("invalid auth event kind")]
InvalidAuthKind,
#[error("invalid auth verb")]
InvalidAuthVerb,
#[error("missing required tag: {0}")]
MissingTag(&'static str),
#[error("hash mismatch")]
HashMismatch,
#[error("server mismatch")]
ServerMismatch,
#[error("token expired")]
TokenExpired,
#[error("timestamp out of window")]
TimestampOutOfWindow,
#[error("storage error: {0}")]
StorageError(String),
#[error("internal error")]
Internal,
#[error("not found")]
NotFound,
#[error("missing authorization header")]
MissingAuth,
#[error("invalid authorization scheme")]
InvalidAuthScheme,
#[error("invalid base64 encoding")]
InvalidBase64,
#[error("invalid auth event")]
InvalidAuthEvent,
#[error("unauthorized")]
Unauthorized,
#[error("insufficient scope")]
InsufficientScope,
#[error("relay membership required")]
RelayMembershipRequired,
#[error("community writes are fenced")]
CommunityWriteFenced,
#[error("media service temporarily unavailable")]
ServiceUnavailable,
#[error("token revoked")]
TokenRevoked,
#[error("pubkey mismatch")]
PubkeyMismatch,
#[error("upload rate limit exceeded")]
UploadRateLimitExceeded,
#[error("upload concurrency limit reached")]
UploadConcurrencyLimitReached,
/// A video/audio track does not use the canonical H.264/AAC codecs.
#[error("unsupported media codec: only H.264 video and AAC audio are accepted")]
WrongCodec,
/// Video duration exceeds the 600-second limit.
#[error("video too long: duration exceeds 600 seconds")]
DurationTooLong,
/// Video resolution exceeds the 2160 short-edge / 3840 long-edge envelope.
#[error(
"video resolution too high: maximum is 2160 on the short edge and 3840 on the long edge"
)]
ResolutionTooHigh,
/// MP4 moov atom appears after mdat — not fast-start.
#[error("moov atom not at front of file (not fast-start)")]
MoovNotAtFront,
/// Container is not MP4 (e.g. MOV, MKV).
#[error("unsupported container: only MP4 is accepted")]
UnsupportedContainer,
/// MP4 metadata could not be parsed.
#[error("invalid video data")]
InvalidVideo,
/// I/O error during streaming upload.
#[error("io error: {0}")]
Io(String),
}
impl From<image::ImageError> for MediaError {
fn from(_: image::ImageError) -> Self {
Self::InvalidImage
}
}
impl From<s3::error::S3Error> for MediaError {
fn from(e: s3::error::S3Error) -> Self {
Self::StorageError(e.to_string())
}
}
impl From<serde_json::Error> for MediaError {
fn from(e: serde_json::Error) -> Self {
Self::StorageError(e.to_string())
}
}
impl IntoResponse for MediaError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
Self::DisallowedContentType(_) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
}
Self::FileTooLarge { .. } | Self::ImageTooLarge => {
(StatusCode::PAYLOAD_TOO_LARGE, self.to_string())
}
// All authentication failures return the same generic 401 to prevent oracle enumeration.
// InsufficientScope is intentionally 403 — it's an authorization (not authentication)
// failure and is safe to distinguish since it requires a valid identity first.
Self::MissingAuth
| Self::InvalidAuthScheme
| Self::InvalidBase64
| Self::InvalidAuthEvent
| Self::InvalidSignature
| Self::InvalidAuthKind
| Self::InvalidAuthVerb
| Self::TokenExpired
| Self::TimestampOutOfWindow
| Self::Unauthorized
| Self::TokenRevoked
| Self::PubkeyMismatch
| Self::HashMismatch
| Self::ServerMismatch
| Self::MissingTag(_) => {
tracing::warn!(error = %self, "authentication failed");
(
StatusCode::UNAUTHORIZED,
"authentication failed".to_string(),
)
}
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
Self::RelayMembershipRequired | Self::CommunityWriteFenced => {
(StatusCode::FORBIDDEN, self.to_string())
}
Self::ServiceUnavailable => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
Self::UploadRateLimitExceeded | Self::UploadConcurrencyLimitReached => {
(StatusCode::TOO_MANY_REQUESTS, self.to_string())
}
Self::UnknownContentType | Self::UnsupportedContainer | Self::WrongCodec => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
}
Self::DurationTooLong
| Self::ResolutionTooHigh
| Self::MoovNotAtFront
| Self::InvalidVideo
| Self::InvalidImage
| Self::MetadataForbidden => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Io(_) | Self::StorageError(_) | Self::Internal => {
tracing::error!(error = %self, "media storage error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, axum::Json(serde_json::json!({"error": msg}))).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serving_backend_failures_map_to_5xx_but_fences_remain_403() {
for error in [
MediaError::ServiceUnavailable,
MediaError::Internal,
MediaError::StorageError("backend".to_string()),
] {
assert!(error.into_response().status().is_server_error());
}
assert_eq!(
MediaError::CommunityWriteFenced.into_response().status(),
StatusCode::FORBIDDEN
);
}
#[test]
fn unsupported_media_maps_to_415() {
for error in [
MediaError::UnknownContentType,
MediaError::DisallowedContentType("audio/mpeg".to_string()),
MediaError::UnsupportedContainer,
MediaError::WrongCodec,
] {
assert_eq!(
error.into_response().status(),
StatusCode::UNSUPPORTED_MEDIA_TYPE
);
}
}
#[test]
fn invalid_or_noncanonical_media_maps_to_422() {
for error in [
MediaError::InvalidImage,
MediaError::InvalidVideo,
MediaError::MetadataForbidden,
MediaError::MoovNotAtFront,
MediaError::DurationTooLong,
MediaError::ResolutionTooHigh,
] {
assert_eq!(
error.into_response().status(),
StatusCode::UNPROCESSABLE_ENTITY
);
}
}
}