Skip to main content

imagegen_bridge_artifacts/
inspect.rs

1//! Image type, integrity, dimension, and checksum inspection.
2
3use std::io::Cursor;
4
5use image::{ImageFormat, ImageReader, Limits};
6use imagegen_bridge_core::{BridgeError, ErrorCode, OutputFormat};
7use sha2::{Digest, Sha256};
8
9/// Limits enforced while inspecting and decoding an image.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct ImageLimits {
12    /// Maximum encoded image bytes.
13    pub max_encoded_bytes: u64,
14    /// Maximum width or height.
15    pub max_edge: u32,
16    /// Maximum decoded pixel count.
17    pub max_pixels: u64,
18    /// Maximum memory that the decoder may allocate.
19    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/// Verified metadata for encoded image bytes.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct ImageMetadata {
36    /// Detected encoded format.
37    pub format: OutputFormat,
38    /// Decoded width in pixels.
39    pub width: u32,
40    /// Decoded height in pixels.
41    pub height: u32,
42    /// Encoded byte length.
43    pub bytes: u64,
44    /// Lowercase hexadecimal SHA-256 digest.
45    pub sha256: String,
46}
47
48/// Fully verifies an encoded PNG, JPEG, or WebP image under bounded limits.
49///
50/// This checks magic bytes, probes dimensions without allocating a pixel
51/// buffer, applies dimension/pixel/allocation limits, then performs a complete
52/// decode so truncated payloads do not pass as valid artifacts.
53pub 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
108/// Produces a bounded PNG thumbnail after complete source verification.
109pub 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}