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}