use std::io::Cursor;
use blurhash::encode;
use exif::{In, Reader, Tag};
use fast_thumbhash::rgba_to_thumb_hash_b91;
use image::codecs::jpeg::JpegEncoder;
use image::codecs::png::PngEncoder;
use image::{ImageEncoder, ImageReader};
use crate::media_processing::types::{ImageMetadata, MediaProcessingError, MediaProcessingOptions};
use crate::media_processing::validation::validate_image_dimensions;
pub(crate) fn extract_metadata_from_encoded_image(
data: &[u8],
options: &MediaProcessingOptions,
generate_blurhash_flag: bool,
generate_thumbhash_flag: bool,
) -> Result<ImageMetadata, MediaProcessingError> {
let img_reader = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to read image: {}", e),
})?;
let (width, height) = img_reader.into_dimensions().map_err(|e| {
MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to get image dimensions: {}", e),
}
})?;
validate_image_dimensions(width, height, options)?;
let mut metadata = ImageMetadata {
dimensions: Some((width, height)),
blurhash: None,
thumbhash: None,
};
if generate_blurhash_flag || generate_thumbhash_flag {
let img_reader = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to read image for preview hash: {}", e),
})?;
let img =
img_reader
.decode()
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to decode image for preview hash: {}", e),
})?;
if generate_blurhash_flag {
metadata.blurhash = generate_blurhash(&img);
}
if generate_thumbhash_flag {
metadata.thumbhash = Some(generate_thumbhash(&img));
}
}
Ok(metadata)
}
pub(crate) fn extract_metadata_from_decoded_image(
img: &image::DynamicImage,
options: &MediaProcessingOptions,
generate_blurhash_flag: bool,
generate_thumbhash_flag: bool,
) -> Result<ImageMetadata, MediaProcessingError> {
let width = img.width();
let height = img.height();
validate_image_dimensions(width, height, options)?;
let mut metadata = ImageMetadata {
dimensions: Some((width, height)),
blurhash: None,
thumbhash: None,
};
if generate_blurhash_flag {
metadata.blurhash = generate_blurhash(img);
}
if generate_thumbhash_flag {
metadata.thumbhash = Some(generate_thumbhash(img));
}
Ok(metadata)
}
pub(crate) fn generate_blurhash(img: &image::DynamicImage) -> Option<String> {
let small_img = img.resize(32, 32, image::imageops::FilterType::Lanczos3);
let rgba_img = small_img.to_rgba8();
encode(4, 3, rgba_img.width(), rgba_img.height(), rgba_img.as_raw()).ok()
}
pub(crate) fn generate_thumbhash(img: &image::DynamicImage) -> String {
let small_img = img.resize(100, 100, image::imageops::FilterType::Triangle);
let rgba_img = small_img.to_rgba8();
rgba_to_thumb_hash_b91(
rgba_img.width() as usize,
rgba_img.height() as usize,
rgba_img.as_raw(),
)
}
pub(crate) fn is_safe_raster_format(mime_type: &str) -> bool {
matches!(mime_type, "image/jpeg" | "image/png")
}
pub(crate) fn preflight_dimension_check(
data: &[u8],
options: &MediaProcessingOptions,
) -> Result<(), MediaProcessingError> {
let img_reader = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to read image header during preflight: {}", e),
})?;
let (width, height) = img_reader.into_dimensions().map_err(|e| {
MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to get image dimensions during preflight: {}", e),
}
})?;
validate_image_dimensions(width, height, options)?;
Ok(())
}
pub(crate) fn strip_exif_and_return_image(
data: &[u8],
mime_type: &str,
) -> Result<(Vec<u8>, image::DynamicImage), MediaProcessingError> {
let img_reader = ImageReader::new(Cursor::new(data))
.with_guessed_format()
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to read image for EXIF stripping: {}", e),
})?;
let mut img =
img_reader
.decode()
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to decode image for EXIF stripping: {}", e),
})?;
img = apply_exif_orientation(data, img)?;
let mut output = Cursor::new(Vec::new());
match mime_type {
"image/jpeg" => {
let mut encoder = JpegEncoder::new_with_quality(&mut output, 100);
encoder
.encode(
img.as_bytes(),
img.width(),
img.height(),
img.color().into(),
)
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to re-encode JPEG: {}", e),
})?;
}
"image/png" => {
let encoder = PngEncoder::new(&mut output);
encoder
.write_image(
img.as_bytes(),
img.width(),
img.height(),
img.color().into(),
)
.map_err(|e| MediaProcessingError::MetadataExtractionFailed {
reason: format!("Failed to re-encode PNG: {}", e),
})?;
}
_ => {
return Err(MediaProcessingError::MetadataExtractionFailed {
reason: format!("Unsupported image format for EXIF stripping: {}", mime_type),
});
}
}
Ok((output.into_inner(), img))
}
fn apply_exif_orientation(
data: &[u8],
img: image::DynamicImage,
) -> Result<image::DynamicImage, MediaProcessingError> {
let exif_reader = match Reader::new().read_from_container(&mut Cursor::new(data)) {
Ok(exif) => exif,
Err(_) => return Ok(img), };
let orientation = match exif_reader.get_field(Tag::Orientation, In::PRIMARY) {
Some(field) => match field.value.get_uint(0) {
Some(val) => val,
None => return Ok(img), },
None => return Ok(img), };
let transformed = match orientation {
1 => img, 2 => img.fliph(), 3 => img.rotate180(), 4 => img.flipv(), 5 => img.rotate270().fliph(), 6 => img.rotate90(), 7 => img.rotate90().fliph(), 8 => img.rotate270(), _ => img, };
Ok(transformed)
}
#[cfg(test)]
mod tests {
use image::{DynamicImage, ImageBuffer, Rgb, RgbImage};
use super::*;
fn create_test_png(width: u32, height: u32) -> Vec<u8> {
let img = ImageBuffer::from_fn(width, height, |_, _| Rgb([255u8, 0u8, 0u8]));
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
png_data
}
fn create_test_jpeg(width: u32, height: u32) -> Vec<u8> {
let img = ImageBuffer::from_fn(width, height, |_, _| Rgb([255u8, 0u8, 0u8]));
let mut jpeg_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut jpeg_data),
image::ImageFormat::Jpeg,
)
.unwrap();
jpeg_data
}
#[test]
fn test_extract_metadata_from_encoded_image() {
let img = ImageBuffer::from_fn(10, 10, |_, _| Rgb([255u8, 0u8, 0u8]));
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let options = MediaProcessingOptions::validation_only();
let result = extract_metadata_from_encoded_image(&png_data, &options, false, false);
assert!(result.is_ok(), "Failed to extract metadata: {:?}", result);
let metadata = result.unwrap();
assert_eq!(metadata.dimensions, Some((10, 10)));
assert!(metadata.blurhash.is_none());
assert!(metadata.thumbhash.is_none());
let result = extract_metadata_from_encoded_image(&png_data, &options, false, true);
assert!(
result.is_ok(),
"Failed to extract metadata with thumbhash: {:?}",
result
);
let metadata = result.unwrap();
assert_eq!(metadata.dimensions, Some((10, 10)));
assert!(metadata.blurhash.is_none());
assert!(metadata.thumbhash.is_some());
}
#[test]
fn test_extract_metadata_dimension_validation() {
let png_data = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x01, 0x01, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x00,
0x01, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let strict_options = MediaProcessingOptions {
sanitize_exif: false,
generate_blurhash: false,
generate_thumbhash: false,
max_dimension: Some(0), ..Default::default()
};
let result = extract_metadata_from_encoded_image(&png_data, &strict_options, false, false);
assert!(result.is_err());
if let Err(MediaProcessingError::ImageDimensionsTooLarge {
width,
height,
max_dimension,
}) = result
{
assert_eq!(width, 1);
assert_eq!(height, 1);
assert_eq!(max_dimension, 0);
} else {
panic!("Expected DimensionsTooLarge error");
}
}
#[test]
fn test_safe_raster_format_detection() {
assert!(is_safe_raster_format("image/jpeg"));
assert!(is_safe_raster_format("image/png"));
assert!(!is_safe_raster_format("image/gif"));
assert!(!is_safe_raster_format("image/webp"));
assert!(!is_safe_raster_format("image/svg+xml"));
assert!(!is_safe_raster_format("image/bmp"));
assert!(!is_safe_raster_format("application/pdf"));
assert!(!is_safe_raster_format(""));
}
#[test]
fn test_preflight_rejects_oversized_image() {
let png_data = create_test_png(100, 100);
let strict_options = MediaProcessingOptions {
max_dimension: Some(50), ..Default::default()
};
let result = preflight_dimension_check(&png_data, &strict_options);
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::ImageDimensionsTooLarge { .. })
));
}
#[test]
fn test_preflight_accepts_valid_image() {
let png_data = create_test_png(50, 50);
let options = MediaProcessingOptions::default();
let result = preflight_dimension_check(&png_data, &options);
assert!(result.is_ok());
}
#[test]
fn test_preflight_invalid_data() {
let invalid_data = vec![0x00, 0x01, 0x02, 0x03];
let options = MediaProcessingOptions::default();
let result = preflight_dimension_check(&invalid_data, &options);
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::MetadataExtractionFailed { .. })
));
}
#[test]
fn test_extract_metadata_from_decoded_image() {
let img: RgbImage = ImageBuffer::from_fn(100, 50, |_, _| Rgb([255u8, 0u8, 0u8]));
let dynamic_img = DynamicImage::ImageRgb8(img);
let options = MediaProcessingOptions::default();
let result = extract_metadata_from_decoded_image(&dynamic_img, &options, false, false);
assert!(result.is_ok());
let metadata = result.unwrap();
assert_eq!(metadata.dimensions, Some((100, 50)));
assert!(metadata.blurhash.is_none());
assert!(metadata.thumbhash.is_none());
let result = extract_metadata_from_decoded_image(&dynamic_img, &options, true, true);
assert!(result.is_ok());
let metadata = result.unwrap();
assert_eq!(metadata.dimensions, Some((100, 50)));
assert!(metadata.blurhash.is_some());
assert!(metadata.thumbhash.is_some());
}
#[test]
fn test_extract_metadata_from_decoded_image_dimension_validation() {
let img: RgbImage = ImageBuffer::from_fn(100, 100, |_, _| Rgb([255u8, 0u8, 0u8]));
let dynamic_img = DynamicImage::ImageRgb8(img);
let strict_options = MediaProcessingOptions {
max_dimension: Some(50),
..Default::default()
};
let result =
extract_metadata_from_decoded_image(&dynamic_img, &strict_options, false, false);
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::ImageDimensionsTooLarge { .. })
));
}
#[test]
fn test_generate_blurhash_produces_valid_hash() {
let img: RgbImage = ImageBuffer::from_fn(32, 32, |x, y| {
Rgb([((x * 8) % 256) as u8, ((y * 8) % 256) as u8, 128u8])
});
let dynamic_img = DynamicImage::ImageRgb8(img);
let result = generate_blurhash(&dynamic_img);
assert!(result.is_some());
let hash = result.unwrap();
assert!(!hash.is_empty());
assert!(hash.len() > 4);
}
#[test]
fn test_generate_thumbhash_produces_valid_hash() {
let img: RgbImage = ImageBuffer::from_fn(32, 32, |x, y| {
Rgb([((x * 8) % 256) as u8, ((y * 8) % 256) as u8, 128u8])
});
let dynamic_img = DynamicImage::ImageRgb8(img);
let hash = generate_thumbhash(&dynamic_img);
assert!(!hash.is_empty());
assert!(hash.len() > 4);
assert!(
!hash.contains('"'),
"thumbhash must not contain double quote"
);
assert!(!hash.contains('\\'), "thumbhash must not contain backslash");
assert!(hash.is_ascii(), "thumbhash must be ASCII");
}
#[test]
fn test_strip_exif_jpeg() {
let jpeg_data = create_test_jpeg(50, 50);
let result = strip_exif_and_return_image(&jpeg_data, "image/jpeg");
assert!(result.is_ok());
let (cleaned_data, img) = result.unwrap();
assert!(!cleaned_data.is_empty());
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 50);
}
#[test]
fn test_strip_exif_png() {
let png_data = create_test_png(50, 50);
let result = strip_exif_and_return_image(&png_data, "image/png");
assert!(result.is_ok());
let (cleaned_data, img) = result.unwrap();
assert!(!cleaned_data.is_empty());
assert_eq!(img.width(), 50);
assert_eq!(img.height(), 50);
}
#[test]
fn test_strip_exif_unsupported_format() {
let png_data = create_test_png(50, 50);
let result = strip_exif_and_return_image(&png_data, "image/webp");
assert!(result.is_err());
assert!(matches!(
result,
Err(MediaProcessingError::MetadataExtractionFailed { .. })
));
}
#[test]
fn test_strip_exif_invalid_data() {
let invalid_data = vec![0x00, 0x01, 0x02, 0x03];
let result = strip_exif_and_return_image(&invalid_data, "image/jpeg");
assert!(result.is_err());
}
#[test]
fn test_animated_format_fallback() {
assert!(!is_safe_raster_format("image/gif"));
assert!(!is_safe_raster_format("image/webp"));
}
#[test]
fn test_animated_format_without_sanitize() {
let options = MediaProcessingOptions {
sanitize_exif: false,
..Default::default()
};
assert!(is_safe_raster_format("image/png"));
assert!(is_safe_raster_format("image/jpeg"));
assert!(!options.sanitize_exif);
}
#[test]
fn test_svg_passthrough_with_sanitize_requested() {
assert!(!is_safe_raster_format("image/svg+xml"));
}
#[test]
fn test_extract_metadata_with_thumbhash_generation() {
let png_data = create_test_png(32, 32);
let options = MediaProcessingOptions::default();
let result = extract_metadata_from_encoded_image(&png_data, &options, false, true);
assert!(result.is_ok());
let metadata = result.unwrap();
assert_eq!(metadata.dimensions, Some((32, 32)));
assert!(metadata.blurhash.is_none());
assert!(metadata.thumbhash.is_some());
}
#[test]
fn test_extract_metadata_with_blurhash_generation() {
let png_data = create_test_png(32, 32);
let options = MediaProcessingOptions::default();
let result = extract_metadata_from_encoded_image(&png_data, &options, true, false);
assert!(result.is_ok());
let metadata = result.unwrap();
assert_eq!(metadata.dimensions, Some((32, 32)));
assert!(metadata.blurhash.is_some());
assert!(metadata.thumbhash.is_none());
}
}