1use sequoia_core::Pixels;
2
3pub const MAX_PHOTO_DIM: u32 = 8192;
4
5pub const MAX_PHOTO_BYTES: usize = 64 << 20;
6
7#[derive(Clone, Debug, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum PhotoError {
10 Io(String),
11
12 TooLarge { width: u32, height: u32 },
13
14 Decode(String),
15}
16
17impl std::fmt::Display for PhotoError {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 PhotoError::Io(path) => write!(f, "cannot read photo `{path}`"),
21 PhotoError::TooLarge { width, height } => write!(
22 f,
23 "photo past the limit ({width}x{height}, max {MAX_PHOTO_DIM}px / {} MiB)",
24 MAX_PHOTO_BYTES / (1 << 20)
25 ),
26 PhotoError::Decode(why) => write!(f, "cannot decode photo: {why}"),
27 }
28 }
29}
30
31impl std::error::Error for PhotoError {}
32
33pub fn load_pixels(path: &str) -> Result<Pixels, PhotoError> {
34 if let Ok((w, h)) = image::image_dimensions(path)
35 && !dims_allowed(w, h)
36 {
37 log::warn!(
38 "sequoia-assets: photo {path} past the limit ({w}x{h}, max {MAX_PHOTO_DIM}px / {} MiB) — refused",
39 MAX_PHOTO_BYTES / (1 << 20)
40 );
41 return Err(PhotoError::TooLarge {
42 width: w,
43 height: h,
44 });
45 }
46 let img = image::open(path)
47 .map_err(|_| PhotoError::Io(path.to_string()))?
48 .to_rgba8();
49 let (w, h) = img.dimensions();
50 if !dims_allowed(w, h) {
51 log::warn!(
52 "sequoia-assets: photo {path} after decoding past the limit ({w}x{h}) — refused"
53 );
54 return Err(PhotoError::TooLarge {
55 width: w,
56 height: h,
57 });
58 }
59 Pixels::from_rgba(w, h, img.into_raw()).ok_or_else(|| PhotoError::Decode("bad buffer".into()))
60}
61
62pub fn decode_pixels(bytes: &[u8]) -> Result<Pixels, PhotoError> {
63 precheck_bytes_dims(bytes)?;
64 let img = image::load_from_memory(bytes)
65 .map_err(|e| PhotoError::Decode(e.to_string()))?
66 .to_rgba8();
67 let (w, h) = img.dimensions();
68 if !dims_allowed(w, h) {
69 log::warn!("sequoia-assets: decoded image past the limit ({w}x{h}) — refused");
70 return Err(PhotoError::TooLarge {
71 width: w,
72 height: h,
73 });
74 }
75 Pixels::from_rgba(w, h, img.into_raw()).ok_or_else(|| PhotoError::Decode("bad buffer".into()))
76}
77
78pub fn load_pixels_thumbnail(path: &str, max_dim: u32) -> Result<Pixels, PhotoError> {
79 let img = open_checked(path)?;
80 to_thumbnail(img, max_dim)
81}
82
83pub fn decode_pixels_thumbnail(bytes: &[u8], max_dim: u32) -> Result<Pixels, PhotoError> {
84 precheck_bytes_dims(bytes)?;
85 let img = image::load_from_memory(bytes)
86 .map_err(|e| PhotoError::Decode(e.to_string()))?
87 .to_rgba8();
88 let (w, h) = img.dimensions();
89 if !dims_allowed(w, h) {
90 log::warn!("sequoia-assets: decoded image past the limit ({w}x{h}) — refused");
91 return Err(PhotoError::TooLarge {
92 width: w,
93 height: h,
94 });
95 }
96 to_thumbnail(img, max_dim)
97}
98
99fn precheck_bytes_dims(bytes: &[u8]) -> Result<(), PhotoError> {
100 let dims = image::ImageReader::new(std::io::Cursor::new(bytes))
101 .with_guessed_format()
102 .ok()
103 .and_then(|r| r.into_dimensions().ok());
104 if let Some((w, h)) = dims
105 && !dims_allowed(w, h)
106 {
107 log::warn!(
108 "sequoia-assets: image bytes past the limit ({w}x{h}, max {MAX_PHOTO_DIM}px / {} MiB) — refused",
109 MAX_PHOTO_BYTES / (1 << 20)
110 );
111 return Err(PhotoError::TooLarge {
112 width: w,
113 height: h,
114 });
115 }
116 Ok(())
117}
118
119fn open_checked(path: &str) -> Result<image::RgbaImage, PhotoError> {
120 if let Ok((w, h)) = image::image_dimensions(path)
121 && !dims_allowed(w, h)
122 {
123 log::warn!("sequoia-assets: photo {path} past the limit ({w}x{h}) — refused");
124 return Err(PhotoError::TooLarge {
125 width: w,
126 height: h,
127 });
128 }
129 let img = image::open(path)
130 .map_err(|_| PhotoError::Io(path.to_string()))?
131 .to_rgba8();
132 let (w, h) = img.dimensions();
133 if !dims_allowed(w, h) {
134 log::warn!(
135 "sequoia-assets: photo {path} after decoding past the limit ({w}x{h}) — refused"
136 );
137 return Err(PhotoError::TooLarge {
138 width: w,
139 height: h,
140 });
141 }
142 Ok(img)
143}
144
145fn to_thumbnail(img: image::RgbaImage, max_dim: u32) -> Result<Pixels, PhotoError> {
146 let (w, h) = img.dimensions();
147 if w == 0 || h == 0 {
148 return Err(PhotoError::Decode("empty image".into()));
149 }
150 let img = if max_dim > 0 && w.max(h) > max_dim {
151 let scale = max_dim as f32 / w.max(h) as f32;
152 let tw = ((w as f32 * scale).round() as u32).max(1);
153 let th = ((h as f32 * scale).round() as u32).max(1);
154 image::imageops::thumbnail(&img, tw, th)
155 } else {
156 img
157 };
158 let (w, h) = img.dimensions();
159 Pixels::from_rgba(w, h, img.into_raw()).ok_or_else(|| PhotoError::Decode("bad buffer".into()))
160}
161
162fn dims_allowed(width: u32, height: u32) -> bool {
163 if width == 0 || height == 0 || width > MAX_PHOTO_DIM || height > MAX_PHOTO_DIM {
164 return false;
165 }
166 match (width as usize)
167 .checked_mul(height as usize)
168 .and_then(|px| px.checked_mul(4))
169 {
170 Some(bytes) => bytes <= MAX_PHOTO_BYTES,
171 None => false,
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn temp_png() -> std::path::PathBuf {
180 let dir = std::env::temp_dir().join("sequoia-pixels");
181 std::fs::create_dir_all(&dir).unwrap();
182 let path = dir.join("photo.png");
183 let pixels: Vec<u8> = (0..8 * 4).flat_map(|_| [10u8, 20, 30, 255]).collect();
184 image::save_buffer(&path, &pixels, 8, 4, image::ColorType::Rgba8).unwrap();
185 path
186 }
187
188 #[test]
189 fn load_roundtrips_dimensions_and_color() {
190 let path = temp_png();
191 let px = load_pixels(path.to_str().unwrap()).expect("loads png");
192 assert_eq!((px.width(), px.height()), (8, 4));
193 let c = px.get(0, 0).unwrap();
194 assert_eq!(c.to_rgb8(), (10, 20, 30));
195 assert_eq!(px.get(0, 0).unwrap().a, 1.0);
196 }
197
198 #[test]
199 fn thumbnail_downscales_keeping_aspect() {
200 let path = temp_png();
201 let px = load_pixels_thumbnail(path.to_str().unwrap(), 4).expect("loads");
202 assert_eq!((px.width(), px.height()), (4, 2));
203 let px = load_pixels_thumbnail(path.to_str().unwrap(), 64).expect("loads");
204 assert_eq!((px.width(), px.height()), (8, 4), "small image untouched");
205 }
206
207 #[test]
208 fn decode_from_memory_works() {
209 let path = temp_png();
210 let bytes = std::fs::read(&path).unwrap();
211 let px = decode_pixels(&bytes).expect("decodes");
212 assert_eq!((px.width(), px.height()), (8, 4));
213 let thumb = decode_pixels_thumbnail(&bytes, 4).unwrap();
214 assert_eq!((thumb.width(), thumb.height()), (4, 2));
215 }
216
217 #[test]
218 fn decode_from_memory_rejects_bomb_before_alloc() {
219 fn crc32(data: &[u8]) -> u32 {
220 let mut crc = 0xFFFF_FFFFu32;
221 for &b in data {
222 crc ^= u32::from(b);
223 for _ in 0..8 {
224 let mask = (crc & 1).wrapping_neg();
225 crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
226 }
227 }
228 !crc
229 }
230 use image::ImageEncoder as _;
231 let pixels = vec![0u8; 8 * 4 * 4];
232 let mut bytes = Vec::new();
233 image::codecs::png::PngEncoder::new(&mut bytes)
234 .write_image(&pixels, 8, 4, image::ExtendedColorType::Rgba8)
235 .unwrap();
236 bytes[16..20].copy_from_slice(&100_000u32.to_be_bytes());
237 bytes[20..24].copy_from_slice(&100_000u32.to_be_bytes());
238 let crc = crc32(&bytes[12..29]);
239 bytes[29..33].copy_from_slice(&crc.to_be_bytes());
240 assert!(matches!(
241 decode_pixels(&bytes),
242 Err(PhotoError::TooLarge { .. })
243 ));
244 assert!(matches!(
245 decode_pixels_thumbnail(&bytes, 64),
246 Err(PhotoError::TooLarge { .. })
247 ));
248 }
249
250 #[test]
251 fn missing_or_garbage_is_err() {
252 assert!(load_pixels("no/such/photo.png").is_err());
253 assert!(decode_pixels(b"not an image").is_err());
254 }
255
256 #[test]
257 fn dims_guard_rejects_huge_and_empty() {
258 assert!(!dims_allowed(0, 10));
259 assert!(!dims_allowed(MAX_PHOTO_DIM + 1, 1));
260 assert!(!dims_allowed(MAX_PHOTO_DIM, MAX_PHOTO_DIM), "bytes cap");
261 assert!(dims_allowed(64, 64));
262 }
263}