//! Avatar upload processing: validate, decode with limits, crop, resize and re-encode as PNG. //! //! Re-encoding to PNG drops all metadata (EXIF, GPS, comments) from the input. use std::io::Cursor; use image::{ImageFormat, ImageReader, Limits, imageops::FilterType}; use sha2::{Digest, Sha256}; use crate::error::{Error, Result}; /// Largest accepted upload, in bytes. pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024; /// Largest accepted width or height of the decoded source image, in pixels. const MAX_SOURCE_DIMENSION: u32 = 4096; /// Upper bound on decoder allocations (128 MiB). const MAX_ALLOC_BYTES: u64 = 128 * 1024 * 1024; /// Edge length of the stored avatar, in pixels. const AVATAR_SIZE: u32 = 256; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProcessedAvatar { pub png: Vec, pub hash: String, } /// Decode with limits (max 4096x4096, image::Limits), center-crop to a square, resize to 256x256 /// (Lanczos3), re-encode as PNG. Metadata (EXIF/GPS) is dropped by re-encoding. SVG, unknown /// formats, too-large input or a decode failure give Error::Invalid. pub fn process_avatar(bytes: &[u8]) -> Result { if bytes.len() > MAX_UPLOAD_BYTES { return Err(Error::Invalid(format!( "avatar is larger than {MAX_UPLOAD_BYTES} bytes" ))); } // Format is decided from magic bytes only; the client-supplied type is never consulted. let mut reader = ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|_| Error::Invalid("unreadable image header".into()))?; match reader.format() { Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP | ImageFormat::Gif) => {} _ => { return Err(Error::Invalid( "avatar must be a PNG, JPEG, WebP or GIF image".into(), )); } } let mut limits = Limits::default(); limits.max_image_width = Some(MAX_SOURCE_DIMENSION); limits.max_image_height = Some(MAX_SOURCE_DIMENSION); limits.max_alloc = Some(MAX_ALLOC_BYTES); reader.limits(limits); let decoded = reader .decode() .map_err(|e| Error::Invalid(format!("could not decode avatar: {e}")))?; // Center-crop to the largest square, then scale down to the avatar size. let (width, height) = (decoded.width(), decoded.height()); let side = width.min(height); let x = (width - side) / 2; let y = (height - side) / 2; let square = decoded.crop_imm(x, y, side, side); let resized = square.resize_exact(AVATAR_SIZE, AVATAR_SIZE, FilterType::Lanczos3); let mut png = Cursor::new(Vec::new()); resized .write_to(&mut png, ImageFormat::Png) .map_err(|e| Error::Invalid(format!("could not encode avatar: {e}")))?; let png = png.into_inner(); let digest = Sha256::digest(&png); let hash = hex::encode(&digest[..8]); Ok(ProcessedAvatar { png, hash }) } #[cfg(test)] mod tests { use super::*; use image::{DynamicImage, RgbImage}; const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; fn encode(img: &DynamicImage, format: ImageFormat) -> Vec { let mut out = Cursor::new(Vec::new()); img.write_to(&mut out, format).expect("encode test image"); out.into_inner() } fn sample(width: u32, height: u32) -> DynamicImage { DynamicImage::ImageRgb8(RgbImage::from_fn(width, height, |x, y| { image::Rgb([(x % 256) as u8, (y % 256) as u8, 128]) })) } fn crc32(data: &[u8]) -> u32 { let mut crc = 0xFFFF_FFFFu32; for &byte in data { crc ^= u32::from(byte); for _ in 0..8 { let mask = (crc & 1).wrapping_neg(); crc = (crc >> 1) ^ (0xEDB8_8320 & mask); } } !crc } #[test] fn png_is_cropped_and_resized_to_256() { let input = encode(&sample(300, 200), ImageFormat::Png); let out = process_avatar(&input).expect("png accepted"); assert_eq!(&out.png[..8], &PNG_MAGIC); let decoded = image::load_from_memory_with_format(&out.png, ImageFormat::Png).unwrap(); assert_eq!((decoded.width(), decoded.height()), (256, 256)); } #[test] fn jpeg_is_accepted() { let input = encode(&sample(300, 200), ImageFormat::Jpeg); let out = process_avatar(&input).expect("jpeg accepted"); assert_eq!(&out.png[..8], &PNG_MAGIC); } #[test] fn svg_is_rejected() { let svg = br#""#; assert!(matches!(process_avatar(svg), Err(Error::Invalid(_)))); } #[test] fn random_bytes_are_rejected() { let junk: Vec = (0..4096u32) .map(|i| (i.wrapping_mul(2654435761) >> 13) as u8) .collect(); assert!(matches!(process_avatar(&junk), Err(Error::Invalid(_)))); assert!(matches!(process_avatar(&[]), Err(Error::Invalid(_)))); } #[test] fn oversized_input_is_rejected() { let input = vec![0u8; MAX_UPLOAD_BYTES + 1]; assert!(matches!(process_avatar(&input), Err(Error::Invalid(_)))); } #[test] fn huge_declared_dimensions_are_rejected() { // Take a valid tiny PNG and rewrite its IHDR to claim 50000x50000, fixing the CRC so // only the dimension limit can reject it. let mut png = encode(&sample(2, 2), ImageFormat::Png); // Layout: 8-byte signature, 4-byte length, 4-byte "IHDR", then width and height. assert_eq!(&png[12..16], b"IHDR"); png[16..20].copy_from_slice(&50_000u32.to_be_bytes()); png[20..24].copy_from_slice(&50_000u32.to_be_bytes()); let crc = crc32(&png[12..29]); png[29..33].copy_from_slice(&crc.to_be_bytes()); assert!(matches!(process_avatar(&png), Err(Error::Invalid(_)))); } #[test] fn output_starts_with_png_magic() { let input = encode(&sample(64, 64), ImageFormat::Png); let out = process_avatar(&input).unwrap(); assert_eq!(&out.png[..8], &PNG_MAGIC); } #[test] fn hash_is_stable_and_well_formed() { let input = encode(&sample(120, 90), ImageFormat::Png); let a = process_avatar(&input).unwrap(); let b = process_avatar(&input).unwrap(); assert_eq!(a.hash, b.hash); assert_eq!(a.hash.len(), 16); assert!(a.hash.bytes().all(|c| c.is_ascii_hexdigit())); assert_eq!(a.png, b.png); } }