stenoxide_core/image_io/
validate.rs1use std::fmt;
19use std::io::Cursor;
20use std::path::Path;
21
22use image::{codecs::png::PngDecoder, ColorType, DynamicImage, ImageDecoder};
23
24use crate::image_io::buffer::{ColorSpace, ImageBuffer};
25use crate::image_io::jpeg_detect;
26
27const MIN_DIMENSION: u32 = 2000;
32
33const MIN_HEADER_LEN: usize = 12;
35
36const PNG_MAGIC: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
38
39#[derive(Debug)]
41pub enum ValidationError {
42 IoError(std::io::Error),
44 JpegDetected,
46 WebpDetected,
48 NotPng,
50 UnsupportedColorSpace {
52 found: String,
54 },
55 ImageTooSmall {
57 width: u32,
59 height: u32,
61 min: u32,
63 },
64 DecodingError(String),
66 JpegArtifactsDetected {
68 ratio: f32,
72 },
73}
74
75impl fmt::Display for ValidationError {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 match self {
78 ValidationError::IoError(err) => write!(f, "failed to read the image file: {err}"),
79 ValidationError::JpegDetected => {
80 write!(
81 f,
82 "the file is a JPEG; only lossless PNG containers are supported"
83 )
84 }
85 ValidationError::WebpDetected => {
86 write!(
87 f,
88 "the file is a WebP; only lossless PNG containers are supported"
89 )
90 }
91 ValidationError::NotPng => write!(f, "the file is not a PNG image"),
92 ValidationError::UnsupportedColorSpace { found } => {
93 write!(f, "unsupported pixel layout: {found}")
94 }
95 ValidationError::ImageTooSmall { width, height, min } => write!(
96 f,
97 "image is {width}x{height}; both sides must be at least {min} pixels"
98 ),
99 ValidationError::DecodingError(message) => {
100 write!(f, "failed to decode the PNG stream: {message}")
101 }
102 ValidationError::JpegArtifactsDetected { ratio } => write!(
103 f,
104 "image shows an 8x8 JPEG block structure (blocking ratio {ratio:.2}, a clean \
105 image scores about 1.00); use a container that was never JPEG-compressed"
106 ),
107 }
108 }
109}
110
111impl std::error::Error for ValidationError {
112 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
113 match self {
114 ValidationError::IoError(err) => Some(err),
115 _ => None,
116 }
117 }
118}
119
120impl From<std::io::Error> for ValidationError {
121 fn from(err: std::io::Error) -> Self {
122 ValidationError::IoError(err)
123 }
124}
125
126struct RawBytes(Vec<u8>);
128
129struct VerifiedPngFile(Vec<u8>);
131
132struct DecodedPng {
134 pixels: Vec<u8>,
135 width: u32,
136 height: u32,
137 color_space: ColorSpace,
138}
139
140fn validate_magic_bytes(raw: RawBytes) -> Result<VerifiedPngFile, ValidationError> {
146 let bytes = raw.0;
147
148 if bytes.len() < MIN_HEADER_LEN {
149 return Err(ValidationError::NotPng);
150 }
151
152 if bytes[0..3] == [0xFF, 0xD8, 0xFF] {
153 return Err(ValidationError::JpegDetected);
154 }
155
156 if &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
157 return Err(ValidationError::WebpDetected);
158 }
159
160 if bytes[0..8] != PNG_MAGIC {
161 return Err(ValidationError::NotPng);
162 }
163
164 Ok(VerifiedPngFile(bytes))
165}
166
167fn decode_png(file: VerifiedPngFile) -> Result<DecodedPng, ValidationError> {
173 let decoder = PngDecoder::new(Cursor::new(file.0))
174 .map_err(|err| ValidationError::DecodingError(err.to_string()))?;
175
176 let (width, height) = decoder.dimensions();
177 if width < MIN_DIMENSION || height < MIN_DIMENSION {
178 return Err(ValidationError::ImageTooSmall {
179 width,
180 height,
181 min: MIN_DIMENSION,
182 });
183 }
184
185 let color_type = decoder.color_type();
186 let color_space = match color_type {
187 ColorType::Rgb8 => ColorSpace::Rgb8,
188 ColorType::Rgb16 => ColorSpace::Rgb16,
189 ColorType::Rgba8 => ColorSpace::Rgba8,
190 ColorType::L8 => ColorSpace::Luma8,
191 other => {
192 return Err(ValidationError::UnsupportedColorSpace {
193 found: format!("{other:?}"),
194 });
195 }
196 };
197
198 let decoded = DynamicImage::from_decoder(decoder)
199 .map_err(|err| ValidationError::DecodingError(err.to_string()))?;
200
201 let pixels = match color_space {
204 ColorSpace::Rgb8 => decoded.into_rgb8().into_raw(),
205 ColorSpace::Rgba8 => decoded.into_rgba8().into_raw(),
206 ColorSpace::Luma8 => decoded.into_luma8().into_raw(),
207 ColorSpace::Rgb16 => decoded
212 .into_rgb16()
213 .into_raw()
214 .into_iter()
215 .flat_map(u16::to_le_bytes)
216 .collect(),
217 };
218
219 Ok(DecodedPng {
220 pixels,
221 width,
222 height,
223 color_space,
224 })
225}
226
227fn validate_no_jpeg_artifacts(decoded: DecodedPng) -> Result<ImageBuffer, ValidationError> {
233 let DecodedPng {
234 pixels,
235 width,
236 height,
237 color_space,
238 } = decoded;
239
240 match jpeg_detect::detect_jpeg_artifacts(&pixels, width, height, color_space) {
241 Some(ratio) => Err(ValidationError::JpegArtifactsDetected { ratio }),
242 None => Ok(ImageBuffer::new(pixels, width, height, color_space)),
243 }
244}
245
246pub fn load_and_validate(path: &Path) -> Result<ImageBuffer, ValidationError> {
257 let raw = RawBytes(std::fs::read(path)?);
258 let verified = validate_magic_bytes(raw)?;
259 let decoded = decode_png(verified)?;
260 validate_no_jpeg_artifacts(decoded)
261}