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}