Skip to main content

sequoia_assets/
photo.rs

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}