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