stet_graphics/image_limits.rs
1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Bounds on file-declared image dimensions.
6//!
7//! `/Width`, `/Height`, and `/BitsPerComponent` are arbitrary integers taken
8//! from the input, and every buffer size and loop bound downstream is derived
9//! from them. Two things go wrong without a ceiling: the products overflow —
10//! silently in release, where a wrapped size yields a buffer smaller than the
11//! loops that fill it — and even where the arithmetic survives, a declared
12//! size far larger than the data behind it is a denial of service. A 60-byte
13//! PostScript file requesting a 2000000000 x 2000000000 image asks for a
14//! 4 x 10^18 byte allocation, which aborts the process rather than failing.
15//!
16//! These live in `stet-graphics` because both input paths need them and
17//! neither can see the other: the PostScript operators are in `stet-ops`, the
18//! PDF image handler is in `stet-pdf-reader`, and `stet-pdf-reader`
19//! deliberately does not depend on the interpreter. Duplicating the constants
20//! would let two prepress-calibrated numbers drift apart.
21//!
22//! # Calibration
23//!
24//! **Sized for prepress, not for the sample corpus.** The corpus maximum is
25//! 151M pixels, but that is a sample of ordinary documents and is the wrong
26//! yardstick for a RIP. The sizes that matter:
27//!
28//! | Case | Pixels |
29//! |---|---|
30//! | 40x28 inch press sheet @ 600 dpi | 403M |
31//! | A0 poster (33x47 in) @ 600 dpi | 558M |
32//! | 60x40 inch grand format @ 600 dpi | 864M |
33//! | 60x40 inch grand format @ 1200 dpi | 3.46G |
34//!
35//! An earlier 400M ceiling, calibrated from the corpus, rejected all four.
36
37/// Largest accepted value for an image's width or height, in samples.
38///
39/// No prepress case comes close — the largest above is 72000 — while the
40/// bound keeps every dimension-derived product finite.
41pub const MAX_IMAGE_DIMENSION: i64 = 100_000;
42
43/// Largest accepted pixel count (`width * height`) for a single image.
44///
45/// 4e9 rather than something larger because it must stay under `2^32`: a
46/// number of sites compute `width * height` in `u32`, and that product is
47/// only exact while it fits. Anything multiplying further by a component
48/// count must use checked or saturating arithmetic instead.
49pub const MAX_IMAGE_PIXELS: u64 = 4_000_000_000;
50
51/// Largest accepted bits-per-component.
52///
53/// PDF 32000-1 permits 1, 2, 4, 8, and 16; PostScript adds 12. The value
54/// reaches shift expressions such as `1u32 << bpc`, which panic in debug
55/// builds at 32 or more.
56pub const MAX_BITS_PER_COMPONENT: i64 = 16;
57
58/// Validate one file-supplied image dimension.
59///
60/// Returns `None` for a missing, non-positive, or out-of-range value, so
61/// callers reject the image rather than truncating it with an `as u32` cast —
62/// a width of 4294967297 otherwise silently becomes a 1-pixel image.
63pub fn validate_image_dimension(value: Option<i64>) -> Option<u32> {
64 match value {
65 Some(n) if n > 0 && n <= MAX_IMAGE_DIMENSION => Some(n as u32),
66 _ => None,
67 }
68}
69
70/// Validate a `width`/`height` pair and return the pixel count.
71///
72/// Guarantees `width * height` fits in `u32` and in `usize` — including the
73/// 32-bit `usize` of the wasm32 target — so downstream sites may multiply the
74/// two directly.
75pub fn validate_image_size(width: u32, height: u32) -> Option<usize> {
76 let pixels = u64::from(width).checked_mul(u64::from(height))?;
77 if pixels == 0 || pixels > MAX_IMAGE_PIXELS {
78 return None;
79 }
80 usize::try_from(pixels).ok()
81}
82
83/// Validate a file-supplied bits-per-component, falling back to 8 when absent.
84pub fn validate_bits_per_component(value: Option<i64>) -> Option<u32> {
85 match value {
86 None => Some(8),
87 Some(n) if n > 0 && n <= MAX_BITS_PER_COMPONENT => Some(n as u32),
88 _ => None,
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn prepress_sizes_are_accepted() {
98 // The four cases from the table above.
99 for (label, w, h) in [
100 ("40x28in press sheet @ 600dpi", 24_000, 16_800),
101 ("A0 poster @ 600dpi", 19_800, 28_200),
102 ("60x40in grand format @ 600dpi", 36_000, 24_000),
103 ("60x40in grand format @ 1200dpi", 72_000, 48_000),
104 ] {
105 assert!(
106 validate_image_size(w, h).is_some(),
107 "{label} ({w}x{h}) must be accepted"
108 );
109 }
110 }
111
112 #[test]
113 fn overflowing_and_degenerate_sizes_are_rejected() {
114 // 65537 * 65536 overflows u32 to 65536 rather than wrapping to zero.
115 assert!(validate_image_size(65_537, 65_536).is_none());
116 assert!(validate_image_size(0, 100).is_none());
117 assert!(validate_image_dimension(Some(0)).is_none());
118 assert!(validate_image_dimension(Some(-1)).is_none());
119 assert!(validate_image_dimension(Some(4_294_967_297)).is_none());
120 assert!(validate_image_dimension(None).is_none());
121 }
122
123 #[test]
124 fn pixel_ceiling_stays_within_u32() {
125 assert!(
126 MAX_IMAGE_PIXELS < u64::from(u32::MAX),
127 "sites computing width * height in u32 depend on this"
128 );
129 }
130
131 #[test]
132 fn bits_per_component_is_bounded() {
133 assert_eq!(validate_bits_per_component(None), Some(8));
134 assert_eq!(validate_bits_per_component(Some(8)), Some(8));
135 assert!(validate_bits_per_component(Some(99)).is_none());
136 assert!(validate_bits_per_component(Some(0)).is_none());
137 }
138}