imagegen_bridge_artifacts/
inspect.rs1use std::io::Cursor;
4
5use image::{ImageFormat, ImageReader, Limits};
6use imagegen_bridge_core::{BridgeError, ErrorCode, OutputFormat};
7use sha2::{Digest, Sha256};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct ImageLimits {
12 pub max_encoded_bytes: u64,
14 pub max_edge: u32,
16 pub max_pixels: u64,
18 pub max_decode_alloc: u64,
20}
21
22impl Default for ImageLimits {
23 fn default() -> Self {
24 Self {
25 max_encoded_bytes: 32 * 1024 * 1024,
26 max_edge: 16_384,
27 max_pixels: 64 * 1024 * 1024,
28 max_decode_alloc: 256 * 1024 * 1024,
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ImageMetadata {
36 pub format: OutputFormat,
38 pub width: u32,
40 pub height: u32,
42 pub bytes: u64,
44 pub sha256: String,
46}
47
48pub fn inspect_image(bytes: &[u8], limits: ImageLimits) -> Result<ImageMetadata, BridgeError> {
54 let encoded_len = u64::try_from(bytes.len()).map_err(|_| image_error("image is too large"))?;
55 if bytes.is_empty() || encoded_len > limits.max_encoded_bytes {
56 return Err(image_error(
57 "encoded image exceeds the configured size limit",
58 ));
59 }
60
61 let image_type = imagesize::image_type(bytes)
62 .map_err(|_| image_error("input is not a supported PNG, JPEG, or WebP image"))?;
63 let (format, decoder_format) = match image_type {
64 imagesize::ImageType::Png => (OutputFormat::Png, ImageFormat::Png),
65 imagesize::ImageType::Jpeg => (OutputFormat::Jpeg, ImageFormat::Jpeg),
66 imagesize::ImageType::Webp => (OutputFormat::Webp, ImageFormat::WebP),
67 _ => return Err(image_error("input image format is not enabled")),
68 };
69 let dimensions =
70 imagesize::blob_size(bytes).map_err(|_| image_error("could not read image dimensions"))?;
71 let width = u32::try_from(dimensions.width)
72 .map_err(|_| image_error("image width is outside the supported range"))?;
73 let height = u32::try_from(dimensions.height)
74 .map_err(|_| image_error("image height is outside the supported range"))?;
75 let pixels = u64::from(width) * u64::from(height);
76 if width == 0
77 || height == 0
78 || width > limits.max_edge
79 || height > limits.max_edge
80 || pixels > limits.max_pixels
81 {
82 return Err(image_error("image dimensions exceed configured limits"));
83 }
84
85 let mut reader = ImageReader::with_format(Cursor::new(bytes), decoder_format);
86 let mut decode_limits = Limits::default();
87 decode_limits.max_image_width = Some(limits.max_edge);
88 decode_limits.max_image_height = Some(limits.max_edge);
89 decode_limits.max_alloc = Some(limits.max_decode_alloc);
90 reader.limits(decode_limits);
91 let decoded = reader
92 .decode()
93 .map_err(|_| image_error("image payload is malformed or incomplete"))?;
94 if decoded.width() != width || decoded.height() != height {
95 return Err(image_error("image headers and decoded dimensions disagree"));
96 }
97
98 let sha256 = base16ct::lower::encode_string(&Sha256::digest(bytes));
99 Ok(ImageMetadata {
100 format,
101 width,
102 height,
103 bytes: encoded_len,
104 sha256,
105 })
106}
107
108pub fn thumbnail_png(
110 bytes: &[u8],
111 maximum_edge: u32,
112 limits: ImageLimits,
113) -> Result<Vec<u8>, BridgeError> {
114 if !(32..=2_048).contains(&maximum_edge) {
115 return Err(image_error(
116 "thumbnail edge must be between 32 and 2048 pixels",
117 ));
118 }
119 let metadata = inspect_image(bytes, limits)?;
120 let format = match metadata.format {
121 OutputFormat::Png => ImageFormat::Png,
122 OutputFormat::Jpeg => ImageFormat::Jpeg,
123 OutputFormat::Webp => ImageFormat::WebP,
124 };
125 let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
126 let mut decode_limits = Limits::default();
127 decode_limits.max_image_width = Some(limits.max_edge);
128 decode_limits.max_image_height = Some(limits.max_edge);
129 decode_limits.max_alloc = Some(limits.max_decode_alloc);
130 reader.limits(decode_limits);
131 let thumbnail = reader
132 .decode()
133 .map_err(|_| image_error("image could not be decoded for thumbnailing"))?
134 .thumbnail(maximum_edge, maximum_edge);
135 let mut output = Cursor::new(Vec::new());
136 thumbnail
137 .write_to(&mut output, ImageFormat::Png)
138 .map_err(|_| image_error("thumbnail could not be encoded"))?;
139 let output = output.into_inner();
140 let thumbnail_limits = ImageLimits {
141 max_encoded_bytes: limits.max_encoded_bytes,
142 max_edge: maximum_edge,
143 max_pixels: u64::from(maximum_edge) * u64::from(maximum_edge),
144 max_decode_alloc: limits.max_decode_alloc,
145 };
146 inspect_image(&output, thumbnail_limits)?;
147 Ok(output)
148}
149
150fn image_error(message: &str) -> BridgeError {
151 BridgeError::new(ErrorCode::Input, message)
152}
153
154#[cfg(test)]
155pub(crate) fn test_png(width: u32, height: u32) -> Vec<u8> {
156 #![allow(clippy::unwrap_used)]
157
158 use std::io::Cursor;
159
160 let image = image::DynamicImage::new_rgba8(width, height);
161 let mut output = Cursor::new(Vec::new());
162 image.write_to(&mut output, ImageFormat::Png).unwrap();
163 output.into_inner()
164}
165
166#[cfg(test)]
167mod tests {
168 #![allow(clippy::unwrap_used)]
169
170 use super::*;
171
172 #[test]
173 fn validates_complete_png_and_checksum() {
174 let bytes = test_png(3, 2);
175 let metadata = inspect_image(&bytes, ImageLimits::default()).unwrap();
176 assert_eq!(metadata.format, OutputFormat::Png);
177 assert_eq!((metadata.width, metadata.height), (3, 2));
178 assert_eq!(metadata.sha256.len(), 64);
179 }
180
181 #[test]
182 fn rejects_truncated_image_after_header_probe() {
183 let mut bytes = test_png(3, 2);
184 bytes.truncate(bytes.len() - 8);
185 assert!(inspect_image(&bytes, ImageLimits::default()).is_err());
186 }
187
188 #[test]
189 fn enforces_dimension_limits_before_publish() {
190 let bytes = test_png(3, 2);
191 let limits = ImageLimits {
192 max_edge: 2,
193 ..ImageLimits::default()
194 };
195 assert!(inspect_image(&bytes, limits).is_err());
196 }
197
198 #[test]
199 fn creates_bounded_png_thumbnails_without_upscaling() {
200 let bytes = test_png(800, 400);
201 let thumbnail = thumbnail_png(&bytes, 128, ImageLimits::default()).unwrap();
202 let metadata = inspect_image(&thumbnail, ImageLimits::default()).unwrap();
203 assert_eq!(metadata.format, OutputFormat::Png);
204 assert_eq!((metadata.width, metadata.height), (128, 64));
205 }
206}