use std::io::Cursor;
use image::ImageReader;
use crate::media_processing::types::{
MAX_FILE_SIZE, MAX_IMAGE_MEMORY_MB, MAX_IMAGE_PIXELS, MediaProcessingError,
MediaProcessingOptions,
};
#[cfg(feature = "mip04")]
use crate::media_processing::types::MAX_FILENAME_LENGTH;
#[cfg(feature = "mip04")]
pub(crate) const SUPPORTED_MIME_TYPES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"image/x-icon",
"image/tiff",
"image/x-farbfeld",
"image/avif",
"image/qoi",
"video/mp4",
"video/quicktime",
"video/x-matroska",
"video/webm",
"video/x-msvideo",
"video/ogg",
"audio/ogg",
"audio/flac",
"audio/x-flac",
"audio/aac",
"audio/mp4",
"audio/webm",
"audio/mpeg",
"audio/wav",
"audio/x-matroska",
"application/pdf",
"text/plain",
];
#[cfg(feature = "mip04")]
pub(crate) const ESCAPE_HATCH_MIME_TYPE: &str = "application/octet-stream";
pub(crate) const GROUP_IMAGE_MIME_TYPES: &[&str] = &[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"image/x-icon",
"image/tiff",
"image/x-farbfeld",
"image/avif",
"image/qoi",
];
pub(crate) fn validate_file_size(
data: &[u8],
options: &MediaProcessingOptions,
) -> Result<(), MediaProcessingError> {
let max_size = options.max_file_size.unwrap_or(MAX_FILE_SIZE);
if data.len() > max_size {
return Err(MediaProcessingError::FileTooLarge {
size: data.len(),
max_size,
});
}
Ok(())
}
#[cfg(feature = "mip04")]
pub(crate) fn validate_mime_type(mime_type: &str) -> Result<String, MediaProcessingError> {
let normalized = mime_type.trim().to_ascii_lowercase();
let canonical = normalized.split(';').next().unwrap_or(&normalized).trim();
if !canonical.contains('/') || canonical.len() > 100 {
return Err(MediaProcessingError::InvalidMimeType {
mime_type: mime_type.to_string(),
});
}
if canonical == ESCAPE_HATCH_MIME_TYPE {
return Ok(canonical.to_string());
}
if !SUPPORTED_MIME_TYPES.contains(&canonical) {
return Err(MediaProcessingError::InvalidMimeType {
mime_type: canonical.to_string(),
});
}
Ok(canonical.to_string())
}
pub(crate) fn validate_group_image_mime_type(
mime_type: &str,
) -> Result<String, MediaProcessingError> {
let normalized = mime_type.trim().to_ascii_lowercase();
let canonical = normalized.split(';').next().unwrap_or(&normalized).trim();
if !canonical.contains('/') || canonical.len() > 100 {
return Err(MediaProcessingError::InvalidMimeType {
mime_type: mime_type.to_string(),
});
}
if !GROUP_IMAGE_MIME_TYPES.contains(&canonical) {
return Err(MediaProcessingError::InvalidMimeType {
mime_type: canonical.to_string(),
});
}
Ok(canonical.to_string())
}
fn detect_mime_type_from_data(data: &[u8]) -> Result<String, MediaProcessingError> {
let img_reader = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| MediaProcessingError::InvalidMimeType {
mime_type: format!("Could not detect image format: {}", e),
})?;
let format = img_reader
.format()
.ok_or_else(|| MediaProcessingError::InvalidMimeType {
mime_type: "Could not determine image format".to_string(),
})?;
let mime_type = match format {
image::ImageFormat::Png => "image/png",
image::ImageFormat::Jpeg => "image/jpeg",
image::ImageFormat::Gif => "image/gif",
image::ImageFormat::WebP => "image/webp",
image::ImageFormat::Bmp => "image/bmp",
image::ImageFormat::Ico => "image/x-icon",
image::ImageFormat::Tiff => "image/tiff",
image::ImageFormat::Tga => "image/x-tga",
image::ImageFormat::Dds => "image/vnd-ms.dds",
image::ImageFormat::Hdr => "image/vnd.radiance",
image::ImageFormat::OpenExr => "image/x-exr",
image::ImageFormat::Pnm => "image/x-portable-anymap",
image::ImageFormat::Farbfeld => "image/x-farbfeld",
image::ImageFormat::Avif => "image/avif",
image::ImageFormat::Qoi => "image/qoi",
_ => {
return Err(MediaProcessingError::InvalidMimeType {
mime_type: format!("Unsupported image format: {:?}", format),
});
}
};
Ok(mime_type.to_string())
}
#[cfg(feature = "mip04")]
pub(crate) fn validate_mime_type_matches_data(
data: &[u8],
claimed_mime_type: &str,
) -> Result<String, MediaProcessingError> {
let canonical_claimed = validate_mime_type(claimed_mime_type)?;
if canonical_claimed == ESCAPE_HATCH_MIME_TYPE {
return Ok(canonical_claimed);
}
let detected_mime_type = detect_mime_type_from_data(data)?;
if canonical_claimed != detected_mime_type {
return Err(MediaProcessingError::MimeTypeMismatch {
claimed: canonical_claimed,
detected: detected_mime_type,
});
}
Ok(canonical_claimed)
}
pub(crate) fn validate_group_image_mime_type_matches_data(
data: &[u8],
claimed_mime_type: &str,
) -> Result<String, MediaProcessingError> {
let canonical_claimed = validate_group_image_mime_type(claimed_mime_type)?;
let detected_mime_type = detect_mime_type_from_data(data)?;
if canonical_claimed != detected_mime_type {
return Err(MediaProcessingError::MimeTypeMismatch {
claimed: canonical_claimed,
detected: detected_mime_type,
});
}
Ok(canonical_claimed)
}
#[cfg(feature = "mip04")]
pub(crate) fn validate_filename(filename: &str) -> Result<(), MediaProcessingError> {
if filename.is_empty() {
return Err(MediaProcessingError::EmptyFilename);
}
if filename.len() > MAX_FILENAME_LENGTH {
return Err(MediaProcessingError::FilenameTooLong {
length: filename.len(),
max_length: MAX_FILENAME_LENGTH,
});
}
if filename.contains('/') || filename.contains('\\') || filename.chars().any(|c| c.is_control())
{
return Err(MediaProcessingError::InvalidFilename);
}
Ok(())
}
pub(crate) fn validate_image_dimensions(
width: u32,
height: u32,
options: &MediaProcessingOptions,
) -> Result<(), MediaProcessingError> {
if let Some(max_dim) = options.max_dimension
&& (width > max_dim || height > max_dim)
{
return Err(MediaProcessingError::ImageDimensionsTooLarge {
width,
height,
max_dimension: max_dim,
});
}
let total_pixels = width as u64 * height as u64;
if total_pixels > MAX_IMAGE_PIXELS {
return Err(MediaProcessingError::ImageTooManyPixels {
total_pixels,
max_pixels: MAX_IMAGE_PIXELS,
});
}
let bytes_per_pixel = 4u64; let total_bytes = total_pixels * bytes_per_pixel;
let estimated_mb = total_bytes.div_ceil(1024 * 1024);
if estimated_mb > MAX_IMAGE_MEMORY_MB {
return Err(MediaProcessingError::ImageMemoryTooLarge {
estimated_mb,
max_mb: MAX_IMAGE_MEMORY_MB,
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use image::{ImageBuffer, Rgb};
use super::*;
#[test]
fn test_validate_file_size() {
let options = MediaProcessingOptions::validation_only();
let valid_data = vec![0u8; 1000];
assert!(validate_file_size(&valid_data, &options).is_ok());
let large_data = vec![0u8; MAX_FILE_SIZE + 1];
let result = validate_file_size(&large_data, &options);
assert!(matches!(
result,
Err(MediaProcessingError::FileTooLarge { .. })
));
let custom_options = MediaProcessingOptions {
sanitize_exif: false,
generate_blurhash: false,
generate_thumbhash: false,
max_file_size: Some(500),
..Default::default()
};
let result = validate_file_size(&valid_data, &custom_options);
assert!(matches!(
result,
Err(MediaProcessingError::FileTooLarge { .. })
));
}
#[test]
#[cfg(feature = "mip04")]
fn test_validate_mime_type() {
assert_eq!(validate_mime_type("image/jpeg").unwrap(), "image/jpeg");
assert_eq!(validate_mime_type("video/mp4").unwrap(), "video/mp4");
assert_eq!(validate_mime_type("audio/wav").unwrap(), "audio/wav");
assert_eq!(validate_mime_type("Image/JPEG").unwrap(), "image/jpeg");
assert_eq!(validate_mime_type("VIDEO/MP4").unwrap(), "video/mp4");
assert_eq!(validate_mime_type(" image/jpeg ").unwrap(), "image/jpeg");
assert_eq!(validate_mime_type("\timage/png\n").unwrap(), "image/png");
assert_eq!(validate_mime_type(" Image/WEBP ").unwrap(), "image/webp");
assert_eq!(
validate_mime_type("image/png; charset=utf-8").unwrap(),
"image/png"
);
assert_eq!(
validate_mime_type("image/jpeg; charset=utf-8; quality=90").unwrap(),
"image/jpeg"
);
assert_eq!(
validate_mime_type(" image/png ; charset=utf-8 ").unwrap(),
"image/png"
);
assert_eq!(
validate_mime_type("video/mp4; codecs=\"avc1.42E01E\"").unwrap(),
"video/mp4"
);
let result = validate_mime_type("invalid");
assert!(matches!(
result,
Err(MediaProcessingError::InvalidMimeType { .. })
));
let long_mime = "a".repeat(101);
let result = validate_mime_type(&long_mime);
assert!(matches!(
result,
Err(MediaProcessingError::InvalidMimeType { .. })
));
}
#[test]
#[cfg(feature = "mip04")]
fn test_validate_filename() {
assert!(validate_filename("test.jpg").is_ok());
assert!(validate_filename("my-photo.png").is_ok());
let result = validate_filename("");
assert!(matches!(result, Err(MediaProcessingError::EmptyFilename)));
let long_filename = "a".repeat(MAX_FILENAME_LENGTH + 1);
let result = validate_filename(&long_filename);
assert!(matches!(
result,
Err(MediaProcessingError::FilenameTooLong { .. })
));
let max_filename = "a".repeat(MAX_FILENAME_LENGTH);
assert!(validate_filename(&max_filename).is_ok());
assert!(matches!(
validate_filename("path/to/file.jpg"),
Err(MediaProcessingError::InvalidFilename)
));
assert!(matches!(
validate_filename("path\\to\\file.jpg"),
Err(MediaProcessingError::InvalidFilename)
));
}
#[test]
fn test_validate_image_dimensions() {
let options = MediaProcessingOptions::validation_only();
assert!(validate_image_dimensions(1920, 1080, &options).is_ok());
assert!(validate_image_dimensions(800, 600, &options).is_ok());
let result = validate_image_dimensions(20000, 15000, &options);
assert!(matches!(
result,
Err(MediaProcessingError::ImageDimensionsTooLarge { .. })
));
let no_limit_options = MediaProcessingOptions {
sanitize_exif: false,
generate_blurhash: false,
generate_thumbhash: false,
max_dimension: None,
..Default::default()
};
let result = validate_image_dimensions(50000, 40000, &no_limit_options);
assert!(matches!(
result,
Err(MediaProcessingError::ImageTooManyPixels { .. })
));
assert!(validate_image_dimensions(12000, 4000, &no_limit_options).is_ok());
let custom_options = MediaProcessingOptions {
sanitize_exif: false,
generate_blurhash: false,
generate_thumbhash: false,
max_dimension: Some(1024),
..Default::default()
};
let result = validate_image_dimensions(2048, 1536, &custom_options);
assert!(matches!(
result,
Err(MediaProcessingError::ImageDimensionsTooLarge { .. })
));
}
#[test]
fn test_validate_image_dimensions_decompression_bomb_protection() {
let options = MediaProcessingOptions {
sanitize_exif: false,
generate_blurhash: false,
generate_thumbhash: false,
max_dimension: None, ..Default::default()
};
assert!(validate_image_dimensions(7071, 7071, &options).is_ok());
let result = validate_image_dimensions(7100, 7100, &options);
assert!(matches!(
result,
Err(MediaProcessingError::ImageTooManyPixels { .. })
));
let result = validate_image_dimensions(16384, 16384, &options);
assert!(matches!(
result,
Err(MediaProcessingError::ImageTooManyPixels { .. })
));
assert!(validate_image_dimensions(10000, 4000, &options).is_ok());
assert!(validate_image_dimensions(4000, 10000, &options).is_ok());
}
#[test]
#[cfg(feature = "mip04")]
fn test_validate_mime_type_matches_data() {
let img = ImageBuffer::from_fn(8, 8, |x, y| {
Rgb([(x * 32) as u8, (y * 32) as u8, ((x + y) * 16) as u8])
});
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let result = validate_mime_type_matches_data(&png_data, "image/png");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let result = validate_mime_type_matches_data(&png_data, "Image/PNG");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let result = validate_mime_type_matches_data(&png_data, " image/png ");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let result = validate_mime_type_matches_data(&png_data, "image/jpeg");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::MimeTypeMismatch { .. })
));
let result = validate_mime_type_matches_data(&png_data, "image/webp");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::MimeTypeMismatch { .. })
));
let mut jpeg_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut jpeg_data),
image::ImageFormat::Jpeg,
)
.unwrap();
let result = validate_mime_type_matches_data(&jpeg_data, "image/jpeg");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/jpeg");
let result = validate_mime_type_matches_data(&jpeg_data, "image/png");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::MimeTypeMismatch { .. })
));
}
#[test]
fn test_detect_mime_type_from_data() {
let img = ImageBuffer::from_fn(8, 8, |x, y| {
Rgb([(x * 32) as u8, (y * 32) as u8, ((x + y) * 16) as u8])
});
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let result = detect_mime_type_from_data(&png_data);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let mut jpeg_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut jpeg_data),
image::ImageFormat::Jpeg,
)
.unwrap();
let result = detect_mime_type_from_data(&jpeg_data);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/jpeg");
let invalid_data = vec![0u8; 100];
let result = detect_mime_type_from_data(&invalid_data);
assert!(result.is_err());
}
#[test]
#[cfg(feature = "mip04")]
fn test_validate_mime_type_parameter_stripping() {
assert_eq!(
validate_mime_type("image/png; charset=utf-8").unwrap(),
"image/png"
);
assert_eq!(
validate_mime_type("image/jpeg; charset=utf-8; quality=90").unwrap(),
"image/jpeg"
);
assert_eq!(
validate_mime_type("video/mp4; codecs=\"avc1.42E01E\"").unwrap(),
"video/mp4"
);
assert_eq!(
validate_mime_type(" image/png ; charset=utf-8 ").unwrap(),
"image/png"
);
let img = ImageBuffer::from_fn(8, 8, |x, y| {
Rgb([(x * 32) as u8, (y * 32) as u8, ((x + y) * 16) as u8])
});
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let result = validate_mime_type_matches_data(&png_data, "image/png; charset=utf-8");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
}
#[test]
#[cfg(feature = "mip04")]
fn test_validate_mime_type_allowlist_enforcement() {
assert!(validate_mime_type("image/png").is_ok());
assert!(validate_mime_type("image/jpeg").is_ok());
assert!(validate_mime_type("video/mp4").is_ok());
assert!(validate_mime_type("audio/mpeg").is_ok());
assert!(validate_mime_type("application/pdf").is_ok());
assert!(validate_mime_type("text/plain").is_ok());
assert!(validate_mime_type("application/x-executable").is_err());
assert!(validate_mime_type("text/html").is_err());
assert!(validate_mime_type("application/javascript").is_err());
assert!(validate_mime_type("image/svg+xml").is_err());
assert_eq!(
validate_mime_type("application/octet-stream").unwrap(),
"application/octet-stream"
);
assert_eq!(
validate_mime_type("application/octet-stream; charset=binary").unwrap(),
"application/octet-stream"
);
}
#[test]
#[cfg(feature = "mip04")]
fn test_validate_mime_type_escape_hatch_bypasses_byte_validation() {
let img = ImageBuffer::from_fn(8, 8, |x, y| {
Rgb([(x * 32) as u8, (y * 32) as u8, ((x + y) * 16) as u8])
});
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let result = validate_mime_type_matches_data(&png_data, "application/octet-stream");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "application/octet-stream");
let result = validate_mime_type_matches_data(&png_data, "image/png");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let result = validate_mime_type_matches_data(&png_data, "image/jpeg");
assert!(result.is_err());
}
#[test]
fn test_validate_group_image_mime_type_strict() {
assert_eq!(
validate_group_image_mime_type("image/png").unwrap(),
"image/png"
);
assert_eq!(
validate_group_image_mime_type("image/jpeg").unwrap(),
"image/jpeg"
);
assert_eq!(
validate_group_image_mime_type("image/webp").unwrap(),
"image/webp"
);
assert!(validate_group_image_mime_type("video/mp4").is_err());
assert!(validate_group_image_mime_type("audio/mpeg").is_err());
assert!(validate_group_image_mime_type("application/pdf").is_err());
assert!(validate_group_image_mime_type("text/plain").is_err());
assert!(validate_group_image_mime_type("application/octet-stream").is_err());
assert!(validate_group_image_mime_type("image/svg+xml").is_err());
}
#[test]
fn test_validate_group_image_mime_type_matches_data() {
let img = ImageBuffer::from_fn(8, 8, |x, y| {
Rgb([(x * 32) as u8, (y * 32) as u8, ((x + y) * 16) as u8])
});
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let result = validate_group_image_mime_type_matches_data(&png_data, "image/png");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let result = validate_group_image_mime_type_matches_data(&png_data, "image/jpeg");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::MimeTypeMismatch { .. })
));
let result = validate_group_image_mime_type_matches_data(&png_data, "video/mp4");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::InvalidMimeType { .. })
));
}
#[test]
#[cfg(feature = "mip04")]
fn test_validate_mime_type_with_byte_validation() {
let img = ImageBuffer::from_fn(8, 8, |x, y| {
Rgb([(x * 32) as u8, (y * 32) as u8, ((x + y) * 16) as u8])
});
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let canonical = validate_mime_type("image/png").unwrap();
assert_eq!(canonical, "image/png");
let result = validate_mime_type_matches_data(&png_data, &canonical);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let canonical = validate_mime_type("image/png; charset=utf-8").unwrap();
assert_eq!(canonical, "image/png");
let result = validate_mime_type_matches_data(&png_data, &canonical);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
let canonical = validate_mime_type("image/jpeg").unwrap();
assert_eq!(canonical, "image/jpeg");
let result = validate_mime_type_matches_data(&png_data, &canonical);
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::MimeTypeMismatch { .. })
));
let result = validate_mime_type("image/svg+xml");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::InvalidMimeType { .. })
));
let canonical = validate_mime_type("video/mp4").unwrap();
assert_eq!(canonical, "video/mp4");
let canonical = validate_mime_type("video/mp4; codecs=\"avc1\"").unwrap();
assert_eq!(canonical, "video/mp4");
let result = validate_mime_type("application/x-executable");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::InvalidMimeType { .. })
));
}
}