Skip to main content

stenoxide_core/image_io/
validate.rs

1//! Validation gates of the image type-state pipeline.
2//!
3//! The loader is a three-state automaton. Each state is a distinct private
4//! type, and the only way to move between them is the transition function that
5//! consumes the previous state by value:
6//!
7//! ```text
8//! RawBytes --validate_magic_bytes--> VerifiedPngFile
9//!          --decode_png-------------> DecodedPng
10//!          --validate_no_jpeg_artifacts--> ImageBuffer
11//! ```
12//!
13//! Because the intermediate states are private to this module and
14//! `ImageBuffer::new` is `pub(crate)`, no caller can
15//! fabricate a validated image or skip a gate: the ordering is enforced by the
16//! type system rather than by convention.
17
18use 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
27/// Minimum accepted side length, in pixels.
28///
29/// Smaller containers do not offer enough embeddable samples for the STC
30/// encoder to stay below the `max_bpp` limit while carrying a useful payload.
31const MIN_DIMENSION: u32 = 2000;
32
33/// Number of leading bytes required before any format probing can be trusted.
34const MIN_HEADER_LEN: usize = 12;
35
36/// The eight-byte PNG signature.
37const PNG_MAGIC: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
38
39/// Every way the validation pipeline can reject a candidate container image.
40#[derive(Debug)]
41pub enum ValidationError {
42    /// The file could not be read from disk.
43    IoError(std::io::Error),
44    /// The file is a JPEG. Lossy containers destroy embedded payloads.
45    JpegDetected,
46    /// The file is a WebP. Lossy containers destroy embedded payloads.
47    WebpDetected,
48    /// The file is not a PNG, and not a format we can name specifically.
49    NotPng,
50    /// The PNG decodes to a pixel layout the embedder cannot use.
51    UnsupportedColorSpace {
52        /// Debug representation of the layout reported by the decoder.
53        found: String,
54    },
55    /// The image is smaller than the minimum accepted size.
56    ImageTooSmall {
57        /// Width reported by the decoder, in pixels.
58        width: u32,
59        /// Height reported by the decoder, in pixels.
60        height: u32,
61        /// Minimum accepted side length, in pixels.
62        min: u32,
63    },
64    /// The PNG stream is malformed or truncated.
65    DecodingError(String),
66    /// The image is a lossless re-encoding of previously JPEG-compressed data.
67    JpegArtifactsDetected {
68        /// Blocking ratio measured over the sampled 8x8 blocks. Around `1.0`
69        /// for a clean image; the higher it is, the stronger the JPEG grid. See
70        /// [`crate::image_io::jpeg_detect::detect_jpeg_artifacts`].
71        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
126/// State 1 — bytes read from disk, of unknown format.
127struct RawBytes(Vec<u8>);
128
129/// State 2 — bytes whose magic number identifies them as a PNG file.
130struct VerifiedPngFile(Vec<u8>);
131
132/// State 3 — decoded samples with a layout the embedder understands.
133struct DecodedPng {
134    pixels: Vec<u8>,
135    width: u32,
136    height: u32,
137    color_space: ColorSpace,
138}
139
140/// Transition 1 — identifies the container format from its magic number.
141///
142/// JPEG and WebP get dedicated errors because they are the two formats a user
143/// is most likely to hand over by mistake, and a precise message saves them a
144/// round of guessing.
145fn 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
167/// Transition 2 — decodes the PNG and normalises its samples.
168///
169/// Dimensions and colour type are read from the decoder header before the
170/// pixel data is expanded, so an oversized image with an unusable layout is
171/// rejected without paying for a full decode.
172fn 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    // The decoder is asked for the exact layout its header advertised, so none
202    // of these conversions resamples anything.
203    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        // `image` hands 16-bit samples over as native-endian `u16`. Storing
208        // them as explicit little-endian pairs keeps the buffer layout, and
209        // therefore every offset computed by the embedder, identical on
210        // big-endian hosts.
211        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
227/// Transition 3 — the final gate, rejecting laundered JPEG content.
228///
229/// A PNG that was produced by re-encoding a JPEG carries blocking artifacts
230/// whose statistics are a well-known steganalysis lead, so such images must
231/// never be used as containers.
232fn 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
246/// Loads a container image from disk and runs it through every validation gate.
247///
248/// This is the only public entry point of the type-state, and the only way for
249/// any caller to obtain an [`ImageBuffer`].
250///
251/// # Errors
252///
253/// Returns a [`ValidationError`] if the file cannot be read, is not a PNG,
254/// decodes to an unsupported pixel layout, is smaller than 2000x2000, or shows
255/// traces of previous JPEG compression.
256pub 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}