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/// Largest container the analysis will attempt, in pixels.
34///
35/// A ceiling on memory rather than on capacity. Analysing a container costs
36/// about sixteen bytes per pixel at its peak — the decoded samples, three
37/// `f32` planes of the cost model live at once, and the flood fill's visit
38/// map — so the working set is a straight multiple of the pixel count and
39/// nothing else. At 128 megapixels that peak is about two gibibytes, which is
40/// what a desktop can be expected to spare.
41///
42/// Without this the failure mode is not an error but a machine that stops
43/// responding: a 32767x32767 PNG is a legal file, and analysing one asks for
44/// some sixteen gibibytes, which on any ordinary machine means paging to disk
45/// for as long as the user is willing to wait. A refusal that arrives
46/// immediately is strictly better than a correct answer that never does.
47///
48/// The limit is far above any camera: a 100-megapixel medium-format back
49/// produces a quarter of it, and the largest consumer sensor a tenth.
50const MAX_PIXELS: u64 = 128 * 1024 * 1024;
51
52/// Number of leading bytes required before any format probing can be trusted.
53const MIN_HEADER_LEN: usize = 12;
54
55/// Bytes of a PNG file spanned by the signature and the whole `IHDR` chunk.
56///
57/// The format requires `IHDR` to be the first chunk, so a container's geometry
58/// is always readable from this prefix and never depends on how the rest of the
59/// file is laid out.
60const PNG_HEADER_LEN: usize = 33;
61
62/// Offset of the big-endian `u32` width inside a PNG file.
63///
64/// Eight bytes of signature, four of chunk length and four of chunk type.
65const IHDR_WIDTH_OFFSET: usize = 16;
66
67/// Payload length the `IHDR` chunk always declares.
68///
69/// Fixed by the format: width and height as `u32`, then one byte each of bit
70/// depth, colour type, compression, filter and interlace.
71const IHDR_CHUNK_LENGTH: u32 = 13;
72
73/// The eight-byte PNG signature.
74const PNG_MAGIC: [u8; 8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
75
76/// Every way the validation pipeline can reject a candidate container image.
77#[derive(Debug)]
78pub enum ValidationError {
79    /// The file could not be read from disk.
80    IoError(std::io::Error),
81    /// The file is a JPEG. Lossy containers destroy embedded payloads.
82    JpegDetected,
83    /// The file is a WebP. Lossy containers destroy embedded payloads.
84    WebpDetected,
85    /// The file is not a PNG, and not a format we can name specifically.
86    NotPng,
87    /// The PNG decodes to a pixel layout the embedder cannot use.
88    UnsupportedColorSpace {
89        /// Debug representation of the layout reported by the decoder.
90        found: String,
91    },
92    /// The image is smaller than the minimum accepted size.
93    ImageTooSmall {
94        /// Width reported by the decoder, in pixels.
95        width: u32,
96        /// Height reported by the decoder, in pixels.
97        height: u32,
98        /// Minimum accepted side length, in pixels.
99        min: u32,
100    },
101    /// The image has more pixels than the analysis will attempt.
102    ///
103    /// Reported from the header, before anything is decoded; see
104    /// [`MAX_PIXELS`] for why a limit exists at all.
105    ImageTooLarge {
106        /// Width reported by the header, in pixels.
107        width: u32,
108        /// Height reported by the header, in pixels.
109        height: u32,
110        /// Pixels the image contains.
111        pixels: u64,
112        /// Pixels the analysis will attempt.
113        max: u64,
114    },
115    /// The PNG stream is malformed or truncated.
116    DecodingError(String),
117    /// The image is a lossless re-encoding of previously JPEG-compressed data.
118    JpegArtifactsDetected {
119        /// Blocking ratio measured over the sampled 8x8 blocks. Around `1.0`
120        /// for a clean image; the higher it is, the stronger the JPEG grid. See
121        /// [`crate::image_io::jpeg_detect::detect_jpeg_artifacts`].
122        ratio: f32,
123    },
124}
125
126impl fmt::Display for ValidationError {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        match self {
129            ValidationError::IoError(err) => write!(f, "failed to read the image file: {err}"),
130            ValidationError::JpegDetected => {
131                write!(
132                    f,
133                    "the file is a JPEG; only lossless PNG containers are supported"
134                )
135            }
136            ValidationError::WebpDetected => {
137                write!(
138                    f,
139                    "the file is a WebP; only lossless PNG containers are supported"
140                )
141            }
142            ValidationError::NotPng => write!(f, "the file is not a PNG image"),
143            ValidationError::UnsupportedColorSpace { found } => {
144                write!(f, "unsupported pixel layout: {found}")
145            }
146            ValidationError::ImageTooSmall { width, height, min } => write!(
147                f,
148                "image is {width}x{height}; both sides must be at least {min} pixels"
149            ),
150            ValidationError::ImageTooLarge {
151                width,
152                height,
153                pixels,
154                max,
155            } => write!(
156                f,
157                "image is {width}x{height}, which is {} megapixels; analysing it would need more \
158                 memory than this limit allows, so it is refused rather than attempted. The \
159                 maximum is {} megapixels",
160                pixels / (1024 * 1024),
161                max / (1024 * 1024)
162            ),
163            ValidationError::DecodingError(message) => {
164                write!(f, "failed to decode the PNG stream: {message}")
165            }
166            ValidationError::JpegArtifactsDetected { ratio } => write!(
167                f,
168                "image shows an 8x8 JPEG block structure (blocking ratio {ratio:.2}, a clean \
169                 image scores about 1.00); use a container that was never JPEG-compressed"
170            ),
171        }
172    }
173}
174
175impl std::error::Error for ValidationError {
176    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
177        match self {
178            ValidationError::IoError(err) => Some(err),
179            _ => None,
180        }
181    }
182}
183
184impl From<std::io::Error> for ValidationError {
185    fn from(err: std::io::Error) -> Self {
186        ValidationError::IoError(err)
187    }
188}
189
190/// State 1 — bytes read from disk, of unknown format.
191struct RawBytes(Vec<u8>);
192
193/// State 2 — bytes whose magic number identifies them as a PNG file.
194struct VerifiedPngFile(Vec<u8>);
195
196/// State 3 — decoded samples with a layout the embedder understands.
197struct DecodedPng {
198    pixels: Vec<u8>,
199    width: u32,
200    height: u32,
201    color_space: ColorSpace,
202}
203
204/// Transition 1 — identifies the container format from its magic number.
205///
206/// JPEG and WebP get dedicated errors because they are the two formats a user
207/// is most likely to hand over by mistake, and a precise message saves them a
208/// round of guessing.
209fn validate_magic_bytes(raw: RawBytes) -> Result<VerifiedPngFile, ValidationError> {
210    let bytes = raw.0;
211
212    if bytes.len() < MIN_HEADER_LEN {
213        return Err(ValidationError::NotPng);
214    }
215
216    if bytes[0..3] == [0xFF, 0xD8, 0xFF] {
217        return Err(ValidationError::JpegDetected);
218    }
219
220    if &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
221        return Err(ValidationError::WebpDetected);
222    }
223
224    if bytes[0..8] != PNG_MAGIC {
225        return Err(ValidationError::NotPng);
226    }
227
228    Ok(VerifiedPngFile(bytes))
229}
230
231/// Geometry of a candidate container, read from its header alone.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub struct ImageGeometry {
234    /// Width, in pixels.
235    pub width: u32,
236    /// Height, in pixels.
237    pub height: u32,
238}
239
240impl ImageGeometry {
241    /// Pixels the image contains.
242    ///
243    /// Widened to `u64` before multiplying: two `u32` sides multiply to as much
244    /// as `2^64`, and a product that wrapped would turn the largest images into
245    /// the ones that look smallest.
246    pub fn pixel_count(&self) -> u64 {
247        u64::from(self.width) * u64::from(self.height)
248    }
249}
250
251/// Applies the two size gates to a geometry.
252///
253/// Shared by the header probe and the decode path so that the two can never
254/// disagree about which containers are the right size.
255fn check_dimensions(geometry: ImageGeometry) -> Result<ImageGeometry, ValidationError> {
256    let ImageGeometry { width, height } = geometry;
257
258    if width < MIN_DIMENSION || height < MIN_DIMENSION {
259        return Err(ValidationError::ImageTooSmall {
260            width,
261            height,
262            min: MIN_DIMENSION,
263        });
264    }
265
266    let pixels = geometry.pixel_count();
267    if pixels > MAX_PIXELS {
268        return Err(ValidationError::ImageTooLarge {
269            width,
270            height,
271            pixels,
272            max: MAX_PIXELS,
273        });
274    }
275
276    Ok(geometry)
277}
278
279/// Reads the geometry of a container without decoding a single pixel.
280///
281/// Opens the file, reads its first [`PNG_HEADER_LEN`] bytes and applies the
282/// format and size gates to them. Nothing else is read, so the cost does not
283/// depend on how large the file is.
284///
285/// # What this is for
286///
287/// Deciding whether a file is worth decoding. [`load_and_validate`] reads the
288/// whole file into memory before it can answer the same question, which is
289/// wasted work for every image that was never the right size — and on a folder
290/// of photographs most of them are not. It is also the only way to refuse an
291/// image that is too large to analyse *before* allocating anything for it.
292///
293/// A geometry this accepts is not a usable container. It has passed two gates
294/// of five; the pixels still have to be decoded, screened for a JPEG grid,
295/// hashed and measured for texture.
296///
297/// # Errors
298///
299/// Returns [`ValidationError::IoError`] if the file cannot be read,
300/// [`ValidationError::JpegDetected`], [`ValidationError::WebpDetected`] or
301/// [`ValidationError::NotPng`] if it is not a PNG, and
302/// [`ValidationError::ImageTooSmall`] or [`ValidationError::ImageTooLarge`] if
303/// its geometry is outside the accepted range.
304pub fn probe_geometry(path: &Path) -> Result<ImageGeometry, ValidationError> {
305    use std::io::Read;
306
307    let mut file = std::fs::File::open(path)?;
308    let mut header = [0u8; PNG_HEADER_LEN];
309
310    // A short read is not an error here: a file of twenty bytes is simply not a
311    // PNG, and `validate_magic_bytes` is what says so.
312    let mut filled = 0usize;
313    loop {
314        match file.read(&mut header[filled..]) {
315            Ok(0) => break,
316            Ok(read) => filled += read,
317            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
318            Err(err) => return Err(ValidationError::IoError(err)),
319        }
320
321        if filled == PNG_HEADER_LEN {
322            break;
323        }
324    }
325
326    // The format gate runs on the same bytes and the same rules as the full
327    // path, so a file the probe calls a JPEG is one the loader would too.
328    validate_magic_bytes(RawBytes(header[..filled].to_vec()))?;
329
330    if filled < PNG_HEADER_LEN {
331        return Err(ValidationError::NotPng);
332    }
333
334    // The chunk header is checked before the two integers behind it are read.
335    // Without this the probe would take whatever bytes happen to sit at that
336    // offset as a geometry, and a file with an honest signature over a corrupt
337    // stream would be reported as an absurdly large image rather than as the
338    // broken file it is. The decoder is the authority on a malformed stream, so
339    // anything that does not look like `IHDR` is handed straight to it.
340    let declares_ihdr = header.get(8..16).is_some_and(|chunk| {
341        chunk[..4] == IHDR_CHUNK_LENGTH.to_be_bytes() && &chunk[4..] == b"IHDR"
342    });
343    if !declares_ihdr {
344        return Err(ValidationError::DecodingError(
345            "the file begins with a PNG signature but no IHDR chunk follows it".to_owned(),
346        ));
347    }
348
349    let Some(fields) = header.get(IHDR_WIDTH_OFFSET..IHDR_WIDTH_OFFSET + 8) else {
350        return Err(ValidationError::NotPng);
351    };
352
353    // Both are big-endian `u32`, as every integer in a PNG chunk is. The slice
354    // is exactly eight bytes by the check above, so neither conversion fails.
355    let (width_bytes, height_bytes) = fields.split_at(4);
356    let width = u32::from_be_bytes(width_bytes.try_into().unwrap_or([0; 4]));
357    let height = u32::from_be_bytes(height_bytes.try_into().unwrap_or([0; 4]));
358
359    check_dimensions(ImageGeometry { width, height })
360}
361
362/// Transition 2 — decodes the PNG and normalises its samples.
363///
364/// Dimensions and colour type are read from the decoder header before the
365/// pixel data is expanded, so an image of the wrong size or with an unusable
366/// layout is rejected without paying for a full decode.
367fn decode_png(file: VerifiedPngFile) -> Result<DecodedPng, ValidationError> {
368    let decoder = PngDecoder::new(Cursor::new(file.0))
369        .map_err(|err| ValidationError::DecodingError(err.to_string()))?;
370
371    let (width, height) = decoder.dimensions();
372    check_dimensions(ImageGeometry { width, height })?;
373
374    let color_type = decoder.color_type();
375    let color_space = match color_type {
376        ColorType::Rgb8 => ColorSpace::Rgb8,
377        ColorType::Rgb16 => ColorSpace::Rgb16,
378        ColorType::Rgba8 => ColorSpace::Rgba8,
379        ColorType::L8 => ColorSpace::Luma8,
380        other => {
381            return Err(ValidationError::UnsupportedColorSpace {
382                found: format!("{other:?}"),
383            });
384        }
385    };
386
387    let decoded = DynamicImage::from_decoder(decoder)
388        .map_err(|err| ValidationError::DecodingError(err.to_string()))?;
389
390    // The decoder is asked for the exact layout its header advertised, so none
391    // of these conversions resamples anything.
392    let pixels = match color_space {
393        ColorSpace::Rgb8 => decoded.into_rgb8().into_raw(),
394        ColorSpace::Rgba8 => decoded.into_rgba8().into_raw(),
395        ColorSpace::Luma8 => decoded.into_luma8().into_raw(),
396        // `image` hands 16-bit samples over as native-endian `u16`. Storing
397        // them as explicit little-endian pairs keeps the buffer layout, and
398        // therefore every offset computed by the embedder, identical on
399        // big-endian hosts.
400        ColorSpace::Rgb16 => decoded
401            .into_rgb16()
402            .into_raw()
403            .into_iter()
404            .flat_map(u16::to_le_bytes)
405            .collect(),
406    };
407
408    Ok(DecodedPng {
409        pixels,
410        width,
411        height,
412        color_space,
413    })
414}
415
416/// Transition 3 — the final gate, rejecting laundered JPEG content.
417///
418/// A PNG that was produced by re-encoding a JPEG carries blocking artifacts
419/// whose statistics are a well-known steganalysis lead, so such images must
420/// never be used as containers.
421fn validate_no_jpeg_artifacts(decoded: DecodedPng) -> Result<ImageBuffer, ValidationError> {
422    let DecodedPng {
423        pixels,
424        width,
425        height,
426        color_space,
427    } = decoded;
428
429    match jpeg_detect::detect_jpeg_artifacts(&pixels, width, height, color_space) {
430        Some(ratio) => Err(ValidationError::JpegArtifactsDetected { ratio }),
431        None => Ok(ImageBuffer::new(pixels, width, height, color_space)),
432    }
433}
434
435/// Loads a container image from disk and runs it through every validation gate.
436///
437/// This is the only public entry point of the type-state, and the only way for
438/// any caller to obtain an [`ImageBuffer`].
439///
440/// # Errors
441///
442/// Returns a [`ValidationError`] if the file cannot be read, is not a PNG,
443/// decodes to an unsupported pixel layout, is smaller than 2000x2000, or shows
444/// traces of previous JPEG compression.
445pub fn load_and_validate(path: &Path) -> Result<ImageBuffer, ValidationError> {
446    // The header first, on its own. Reading the file to decide whether it was
447    // worth reading is the wrong order twice over: it is wasted work for a file
448    // that was never the right size, and for one that is far too large it means
449    // allocating gigabytes for an image that is about to be refused for being
450    // that large. Twenty-four bytes answer both questions.
451    probe_geometry(path)?;
452
453    let raw = RawBytes(std::fs::read(path)?);
454    let verified = validate_magic_bytes(raw)?;
455    let decoded = decode_png(verified)?;
456    validate_no_jpeg_artifacts(decoded)
457}
458
459#[cfg(test)]
460mod tests {
461    // The crate-wide bans on panicking helpers reach into `cfg(test)` code as
462    // well. A test that cannot panic cannot fail, so they are lifted here and
463    // only here.
464    #![allow(clippy::expect_used)]
465    #![allow(clippy::panic)]
466
467    use super::*;
468
469    use image::{GrayAlphaImage, GrayImage, ImageFormat, Rgb, RgbImage, RgbaImage};
470    use tempfile::NamedTempFile;
471
472    use crate::image_io::buffer::CoverSource;
473
474    /// Side length of the throwaway containers built below.
475    ///
476    /// Exactly the minimum the size gate accepts, so a layout test never fails
477    /// for the wrong reason.
478    const SIDE: u32 = MIN_DIMENSION;
479
480    /// Twelve bytes, so that [`validate_magic_bytes`] gets past its length
481    /// guard and has to decide on the signature itself.
482    fn header(prefix: &[u8]) -> RawBytes {
483        let mut bytes = prefix.to_vec();
484        bytes.resize(MIN_HEADER_LEN.max(prefix.len()), 0);
485
486        RawBytes(bytes)
487    }
488
489    /// Writes a flat PNG of the given layout and hands back the file holding it.
490    ///
491    /// Flat rather than textured on purpose: this module's gates are about
492    /// format, geometry and block structure, none of which need content, and a
493    /// uniform image compresses to a few kilobytes instead of the tens of
494    /// megabytes a noise field of this size would cost. It scores zero on the
495    /// block detector, so it passes the final gate as well.
496    fn flat_png(color_space: ColorSpace) -> NamedTempFile {
497        let file = NamedTempFile::new().expect("temporary png file");
498
499        let saved = match color_space {
500            ColorSpace::Rgb8 => RgbImage::from_pixel(SIDE, SIDE, Rgb([90, 110, 130]))
501                .save_with_format(file.path(), ImageFormat::Png),
502            ColorSpace::Rgba8 => {
503                RgbaImage::from_pixel(SIDE, SIDE, image::Rgba([90, 110, 130, 255]))
504                    .save_with_format(file.path(), ImageFormat::Png)
505            }
506            ColorSpace::Luma8 => GrayImage::from_pixel(SIDE, SIDE, image::Luma([110]))
507                .save_with_format(file.path(), ImageFormat::Png),
508            ColorSpace::Rgb16 => image::ImageBuffer::<Rgb<u16>, Vec<u16>>::from_pixel(
509                SIDE,
510                SIDE,
511                Rgb([23_000, 28_000, 33_000]),
512            )
513            .save_with_format(file.path(), ImageFormat::Png),
514        };
515        saved.expect("a flat png must be writable");
516
517        file
518    }
519
520    /// A header too short to identify anything is not a PNG.
521    #[test]
522    fn a_truncated_header_is_not_a_png() {
523        let error = validate_magic_bytes(RawBytes(vec![0x89, 0x50, 0x4E]))
524            .map(|_| ())
525            .expect_err("three bytes cannot identify a format");
526
527        assert!(matches!(error, ValidationError::NotPng), "got: {error:?}");
528    }
529
530    /// The three formats the first gate names, and the one it accepts.
531    #[test]
532    fn the_magic_number_decides_the_format() {
533        let jpeg = validate_magic_bytes(header(&[0xFF, 0xD8, 0xFF]))
534            .map(|_| ())
535            .expect_err("a jpeg must be named as such");
536        assert!(
537            matches!(jpeg, ValidationError::JpegDetected),
538            "got: {jpeg:?}"
539        );
540
541        let mut webp = b"RIFF".to_vec();
542        webp.extend_from_slice(&[0, 0, 0, 0]);
543        webp.extend_from_slice(b"WEBP");
544        let webp = validate_magic_bytes(RawBytes(webp))
545            .map(|_| ())
546            .expect_err("a webp must be named as such");
547        assert!(
548            matches!(webp, ValidationError::WebpDetected),
549            "got: {webp:?}"
550        );
551
552        let unknown = validate_magic_bytes(header(b"GIF89a"))
553            .map(|_| ())
554            .expect_err("an unknown format must be refused");
555        assert!(
556            matches!(unknown, ValidationError::NotPng),
557            "got: {unknown:?}"
558        );
559
560        assert!(validate_magic_bytes(header(&PNG_MAGIC)).is_ok());
561    }
562
563    /// A file that is not there is an I/O failure, and the cause is preserved.
564    #[test]
565    fn a_missing_file_is_an_io_error() {
566        let error = load_and_validate(Path::new("no-such-container-image.png"))
567            .map(|_| ())
568            .expect_err("a path that does not exist must be refused");
569
570        assert!(
571            matches!(error, ValidationError::IoError(_)),
572            "got: {error:?}"
573        );
574
575        // The `source` chain is what lets a front-end print why the read
576        // failed without this layer having to flatten it into a string.
577        assert!(std::error::Error::source(&error).is_some());
578    }
579
580    /// The header probe reads a geometry without decoding anything.
581    #[test]
582    fn the_probe_reads_the_geometry_from_the_header() {
583        let file = flat_png(ColorSpace::Rgb8);
584
585        match probe_geometry(file.path()) {
586            Ok(geometry) => {
587                assert_eq!(geometry.width, SIDE);
588                assert_eq!(geometry.height, SIDE);
589                assert_eq!(geometry.pixel_count(), u64::from(SIDE) * u64::from(SIDE));
590            }
591            Err(error) => panic!("a flat container must probe: {error}"),
592        }
593    }
594
595    /// The probe applies the same format gate as the full path.
596    #[test]
597    fn the_probe_refuses_what_the_loader_refuses() {
598        let scratch = NamedTempFile::new().expect("temporary file");
599
600        std::fs::write(
601            scratch.path(),
602            [0xFF, 0xD8, 0xFF, 0, 0, 0, 0, 0, 0, 0, 0, 0],
603        )
604        .expect("a jpeg header must be writable");
605        assert!(matches!(
606            probe_geometry(scratch.path()),
607            Err(ValidationError::JpegDetected)
608        ));
609
610        std::fs::write(scratch.path(), b"not an image at all").expect("writable");
611        assert!(matches!(
612            probe_geometry(scratch.path()),
613            Err(ValidationError::NotPng)
614        ));
615
616        // A file that stops before the geometry does. The signature is honest
617        // and there is nothing behind it, which is not a PNG either.
618        std::fs::write(scratch.path(), PNG_MAGIC).expect("writable");
619        assert!(matches!(
620            probe_geometry(scratch.path()),
621            Err(ValidationError::NotPng)
622        ));
623
624        assert!(matches!(
625            probe_geometry(Path::new("no-such-container.png")),
626            Err(ValidationError::IoError(_))
627        ));
628    }
629
630    /// Both size gates, applied to a geometry rather than to a file.
631    ///
632    /// The upper one cannot be reached through a real file in a test: the
633    /// smallest PNG that would trip it is 128 megapixels, which costs more to
634    /// write than the whole suite costs to run. The gate is a pure function of
635    /// two integers, so it is exercised as one.
636    #[test]
637    fn the_size_gates_bound_the_geometry_from_both_ends() {
638        let accepted = ImageGeometry {
639            width: MIN_DIMENSION,
640            height: MIN_DIMENSION,
641        };
642        assert!(check_dimensions(accepted).is_ok());
643
644        assert!(matches!(
645            check_dimensions(ImageGeometry {
646                width: MIN_DIMENSION - 1,
647                height: MIN_DIMENSION,
648            }),
649            Err(ValidationError::ImageTooSmall { .. })
650        ));
651
652        // 32767x32767, the geometry that made this gate necessary: a legal PNG
653        // whose analysis asks for some sixteen gibibytes.
654        let enormous = ImageGeometry {
655            width: 32_767,
656            height: 32_767,
657        };
658        match check_dimensions(enormous) {
659            Err(ValidationError::ImageTooLarge { pixels, max, .. }) => {
660                assert_eq!(pixels, 32_767 * 32_767);
661                assert_eq!(max, MAX_PIXELS);
662                assert!(pixels > max);
663            }
664            other => panic!("a billion-pixel container must be refused, got: {other:?}"),
665        }
666
667        // The exact boundary, from both sides. The limit is a pixel count and
668        // not a side length, so the geometry that lands on it exactly is a
669        // rectangle rather than a square.
670        let (width, height) = (16_384u32, 8_192u32);
671        assert_eq!(u64::from(width) * u64::from(height), MAX_PIXELS);
672        assert!(check_dimensions(ImageGeometry { width, height }).is_ok());
673        assert!(matches!(
674            check_dimensions(ImageGeometry {
675                width,
676                height: height + 1,
677            }),
678            Err(ValidationError::ImageTooLarge { .. })
679        ));
680    }
681
682    /// The pixel count is computed wide enough not to wrap.
683    ///
684    /// Two `u32` sides multiply to as much as `2^64`, and a product taken in
685    /// `u32` would turn the largest images into the ones that look smallest —
686    /// which would let exactly the file this limit exists for walk straight
687    /// through it.
688    #[test]
689    fn the_pixel_count_does_not_wrap() {
690        let geometry = ImageGeometry {
691            width: u32::MAX,
692            height: u32::MAX,
693        };
694
695        assert_eq!(
696            geometry.pixel_count(),
697            u64::from(u32::MAX) * u64::from(u32::MAX)
698        );
699        assert!(geometry.pixel_count() > MAX_PIXELS);
700        assert!(matches!(
701            check_dimensions(geometry),
702            Err(ValidationError::ImageTooLarge { .. })
703        ));
704    }
705
706    /// An honest PNG signature over bytes that are not a PNG stream.
707    ///
708    /// The complement of the magic-byte gate: the first transition passes and
709    /// the decoder is the one that has to refuse.
710    #[test]
711    fn a_corrupt_png_stream_is_a_decoding_error() {
712        let mut bytes = PNG_MAGIC.to_vec();
713        bytes.extend_from_slice(&[0x13; 64]);
714
715        let file = NamedTempFile::new().expect("temporary png file");
716        std::fs::write(file.path(), &bytes).expect("the corrupt file must be writable");
717
718        let error = load_and_validate(file.path())
719            .map(|_| ())
720            .expect_err("a malformed png stream must be refused");
721
722        assert!(
723            matches!(error, ValidationError::DecodingError(_)),
724            "got: {error:?}"
725        );
726    }
727
728    /// The size gate names the offending dimensions.
729    #[test]
730    fn an_undersized_container_is_refused() {
731        let file = NamedTempFile::new().expect("temporary png file");
732        RgbImage::from_pixel(100, 100, Rgb([10, 20, 30]))
733            .save_with_format(file.path(), ImageFormat::Png)
734            .expect("a small png must be writable");
735
736        let error = load_and_validate(file.path())
737            .map(|_| ())
738            .expect_err("a 100x100 container must be refused");
739
740        assert!(
741            matches!(
742                error,
743                ValidationError::ImageTooSmall {
744                    width: 100,
745                    height: 100,
746                    min: MIN_DIMENSION,
747                }
748            ),
749            "got: {error:?}"
750        );
751    }
752
753    /// Grayscale with an alpha channel is a layout the embedder cannot use.
754    #[test]
755    fn an_unsupported_layout_is_refused_by_name() {
756        let file = NamedTempFile::new().expect("temporary png file");
757        GrayAlphaImage::from_pixel(SIDE, SIDE, image::LumaA([110, 255]))
758            .save_with_format(file.path(), ImageFormat::Png)
759            .expect("a grayscale-alpha png must be writable");
760
761        let error = load_and_validate(file.path())
762            .map(|_| ())
763            .expect_err("grayscale with alpha must be refused");
764
765        match error {
766            ValidationError::UnsupportedColorSpace { found } => {
767                assert!(found.contains("La8"), "the layout must be named: {found}");
768            }
769            other => panic!("expected an unsupported layout, got: {other:?}"),
770        }
771    }
772
773    /// Each of the four accepted layouts decodes to the buffer length its own
774    /// `bytes_per_pixel` announces.
775    ///
776    /// The contract every layer above relies on: [`CoverSource::pixels`] must
777    /// hold exactly `pixel_count * bytes_per_pixel` bytes, and the 16-bit path
778    /// is the one where that is not obvious, because the decoder hands over
779    /// native `u16` samples that this module re-lays as byte pairs.
780    #[test]
781    fn every_supported_layout_decodes_to_its_own_stride() {
782        for expected in [
783            ColorSpace::Rgb8,
784            ColorSpace::Rgba8,
785            ColorSpace::Luma8,
786            ColorSpace::Rgb16,
787        ] {
788            let file = flat_png(expected);
789            let image = match load_and_validate(file.path()) {
790                Ok(image) => image,
791                Err(error) => panic!("a flat {expected:?} container must load: {error}"),
792            };
793
794            assert_eq!(image.color_space(), expected);
795            assert_eq!(image.dimensions(), (SIDE, SIDE));
796            assert_eq!(
797                image.pixels().len(),
798                image.pixel_count() * expected.bytes_per_pixel()
799            );
800        }
801    }
802
803    /// Every rejection says what is wrong in words a user can act on.
804    #[test]
805    fn every_rejection_explains_itself() {
806        let messages = [
807            ValidationError::IoError(std::io::Error::other("disk on fire")).to_string(),
808            ValidationError::JpegDetected.to_string(),
809            ValidationError::WebpDetected.to_string(),
810            ValidationError::NotPng.to_string(),
811            ValidationError::UnsupportedColorSpace {
812                found: "Rgba16".to_owned(),
813            }
814            .to_string(),
815            ValidationError::ImageTooSmall {
816                width: 10,
817                height: 20,
818                min: MIN_DIMENSION,
819            }
820            .to_string(),
821            ValidationError::ImageTooLarge {
822                width: 32_767,
823                height: 32_767,
824                pixels: 32_767 * 32_767,
825                max: MAX_PIXELS,
826            }
827            .to_string(),
828            ValidationError::DecodingError("truncated".to_owned()).to_string(),
829            ValidationError::JpegArtifactsDetected { ratio: 3.25 }.to_string(),
830        ];
831
832        for message in &messages {
833            assert!(!message.is_empty());
834        }
835
836        assert!(messages[1].contains("JPEG"));
837        assert!(messages[2].contains("WebP"));
838        assert!(messages[4].contains("Rgba16"));
839        assert!(messages[5].contains("10x20"));
840        // Megapixels rather than a raw count: "1024 megapixels" is a size a
841        // person can compare against their own camera, "1073676289" is not.
842        assert!(messages[6].contains("32767x32767"));
843        // 32767 squared is one pixel short of 1024 megapixels, and the figure
844        // is truncated rather than rounded — a limit that reported itself as
845        // larger than it is would be the wrong way to be imprecise.
846        assert!(messages[6].contains("1023 megapixels"));
847        assert!(messages[6].contains("128 megapixels"));
848        assert!(messages[8].contains("3.25"));
849
850        // Only the I/O variant has an underlying cause to chain to.
851        assert!(std::error::Error::source(&ValidationError::NotPng).is_none());
852    }
853
854    /// The `From` shortcut the loader relies on to use the `?` operator.
855    #[test]
856    fn an_io_error_converts_into_a_validation_error() {
857        let converted =
858            ValidationError::from(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
859
860        assert!(matches!(converted, ValidationError::IoError(_)));
861    }
862}