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