Skip to main content

system2_shared/image/
header.rs

1// the readers are `const fn`, so `image!` validates a file by the rules of `Image::new`, and a
2// `const fn` cannot take a range of a slice, so they take offsets
3
4pub(crate) const MIN_SIDE: u32 = 280;
5pub(crate) const MAX_SIDE: u32 = 1568;
6const MAX_BYTES: usize = 16 * 1024 * 1024;
7
8/// The first eight bytes of each PNG file.
9pub const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
10
11// the signature, then the length, the type, the 13 bytes of data, and the crc of the header chunk
12const PNG_HEADER_LENGTH: u32 = 13;
13const PNG_HEADER_END: usize = 33;
14const PNG_CHUNK_START: usize = 8;
15const PNG_CRC: usize = 4;
16const JPEG_START: &[u8] = &[0xFF, 0xD8];
17const EXIF_START: &[u8] = b"Exif\0\0";
18const ORIENTATION_TAG: u16 = 0x0112;
19const UPRIGHT: u16 = 1;
20const TIFF_ENTRY: usize = 12;
21
22/// The size of an image, in pixels.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct Header {
25    /// The width, in pixels.
26    pub width: u32,
27    /// The height, in pixels.
28    pub height: u32,
29}
30
31/// An image that the API cannot take as it is.
32// `Copy`, so a constant can drop it
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HeaderError {
35    /// The image is not PNG or JPEG.
36    Format,
37    /// The PNG or JPEG header is not complete.
38    Header,
39    /// A side of the image is less than 280 px or more than 1568 px.
40    Size {
41        /// The width, in pixels.
42        width: u32,
43        /// The height, in pixels.
44        height: u32,
45    },
46    /// The image has more than 16 MiB.
47    Bytes {
48        /// The size of the image, in bytes.
49        size: usize,
50    },
51    /// The EXIF orientation turns or mirrors the image.
52    Orientation {
53        /// The value of the EXIF orientation, from 2 to 8.
54        orientation: u16,
55    },
56}
57
58// with the feature, `image!` converts a file, but it cannot read the value of a constant
59#[cfg(not(feature = "image"))]
60macro_rules! fix {
61    ($action:literal) => {
62        concat!("turn on the `image` feature of system2 to ", $action)
63    };
64}
65
66#[cfg(feature = "image")]
67macro_rules! fix {
68    ($action:literal) => {
69        concat!(
70            "pass a path to `image!`, or call `Image::new`, to ",
71            $action
72        )
73    };
74}
75
76impl HeaderError {
77    /// Returns the message of the error, without the values of the image.
78    // a panic in a constant takes no numbers, so the message of `image!` has none
79    #[must_use]
80    pub const fn message(self) -> &'static str {
81        match self {
82            HeaderError::Format => concat!("the image must be PNG or JPEG: ", fix!("convert it")),
83            HeaderError::Header => "the image must have a complete PNG or JPEG header",
84            HeaderError::Size { .. } => concat!(
85                "each side of the image must be from 280 to 1568 px: ",
86                fix!("scale it")
87            ),
88            HeaderError::Bytes { .. } => concat!(
89                "the image must have at most 16 MiB: ",
90                fix!("make it smaller")
91            ),
92            HeaderError::Orientation { .. } => concat!(
93                "the image must be upright, but its EXIF orientation turns or mirrors it, and the \
94                 API ignores the orientation: ",
95                fix!("turn its pixels")
96            ),
97        }
98    }
99}
100
101/// Reads the size of a PNG or JPEG image, and validates the image.
102///
103/// The rules are those of `Image::new` without the `image` feature.
104///
105/// # Errors
106///
107/// Returns the [`HeaderError`] of the first rule that the image breaks.
108pub const fn read_header(bytes: &[u8]) -> Result<Header, HeaderError> {
109    if bytes.len() > MAX_BYTES {
110        let size = bytes.len();
111        return Err(HeaderError::Bytes { size });
112    }
113    let read = if same_at(bytes, 0, PNG_SIGNATURE) {
114        read_png(bytes)
115    } else if same_at(bytes, 0, JPEG_START) {
116        read_jpeg(bytes)
117    } else {
118        return Err(HeaderError::Format);
119    };
120    let (header, orientation) = match read {
121        Ok(read) => read,
122        Err(error) => return Err(error),
123    };
124    let Header { width, height } = header;
125    if !fits(width) || !fits(height) {
126        return Err(HeaderError::Size { width, height });
127    }
128
129    // 2 to 8 turn or mirror the pixels, and other values mean nothing
130    match orientation {
131        2..=8 => Err(HeaderError::Orientation { orientation }),
132        _ => Ok(header),
133    }
134}
135
136pub(crate) const fn fits(side: u32) -> bool {
137    side >= MIN_SIDE && side <= MAX_SIDE
138}
139
140// the header and the exif orientation, upright if the image has none
141type Read = Result<(Header, u16), HeaderError>;
142
143// the header chunk comes first, and an exif chunk counts only before the image data
144const fn read_png(bytes: &[u8]) -> Read {
145    if bytes.len() < PNG_HEADER_END
146        || u32_at(bytes, 8, true) != PNG_HEADER_LENGTH
147        || !same_at(bytes, 12, b"IHDR")
148    {
149        return Err(HeaderError::Header);
150    }
151    let width = u32_at(bytes, 16, true);
152    let height = u32_at(bytes, 20, true);
153    let mut orientation = UPRIGHT;
154    let mut chunk = PNG_SIGNATURE.len();
155    while chunk + PNG_CHUNK_START <= bytes.len() {
156        let data = chunk + PNG_CHUNK_START;
157        let end = data.saturating_add(u32_at(bytes, chunk, true) as usize);
158        if same_at(bytes, chunk + 4, b"IDAT") || end > bytes.len() {
159            break;
160        }
161        if same_at(bytes, chunk + 4, b"eXIf") {
162            orientation = exif_orientation(bytes, data, end);
163        }
164        chunk = end + PNG_CRC;
165    }
166    Ok((Header { width, height }, orientation))
167}
168
169// walks the segments to the first frame header, and reads exif from an app1 segment on the way
170const fn read_jpeg(bytes: &[u8]) -> Read {
171    let mut orientation = UPRIGHT;
172    let mut segment = JPEG_START.len();
173    while segment + 4 <= bytes.len() {
174        if bytes[segment] != 0xFF {
175            return Err(HeaderError::Header);
176        }
177        let marker = bytes[segment + 1];
178        let data = segment + 4;
179        let end = segment + 2 + u16_at(bytes, segment + 2, true) as usize;
180        if end > bytes.len() {
181            return Err(HeaderError::Header);
182        }
183        if is_frame(marker) {
184            if end < data + 5 {
185                return Err(HeaderError::Header);
186            }
187            let height = u16_at(bytes, data + 1, true) as u32;
188            let width = u16_at(bytes, data + 3, true) as u32;
189            return Ok((Header { width, height }, orientation));
190        }
191        if marker == 0xE1 && same_at(bytes, data, EXIF_START) {
192            orientation = exif_orientation(bytes, data + EXIF_START.len(), end);
193        }
194        segment = end;
195    }
196    Err(HeaderError::Header)
197}
198
199// sof0 to sof15, except dht, jpg, and dac, which share the range
200const fn is_frame(marker: u8) -> bool {
201    matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC)
202}
203
204// the orientation in the tiff data from `start` to `end`. the api ignores exif, so exif that
205// cannot be read counts as upright, and does not refuse a photo that the api takes
206const fn exif_orientation(bytes: &[u8], start: usize, end: usize) -> u16 {
207    if start + 8 > end {
208        return UPRIGHT;
209    }
210    let big_endian = if same_at(bytes, start, b"MM") {
211        true
212    } else if same_at(bytes, start, b"II") {
213        false
214    } else {
215        return UPRIGHT;
216    };
217    let directory = start.saturating_add(u32_at(bytes, start + 4, big_endian) as usize);
218    if directory.saturating_add(2) > end {
219        return UPRIGHT;
220    }
221    let entry_count = u16_at(bytes, directory, big_endian) as usize;
222    let mut entry_index = 0;
223    while entry_index < entry_count {
224        let entry = directory + 2 + entry_index * TIFF_ENTRY;
225        if entry + TIFF_ENTRY > end {
226            return UPRIGHT;
227        }
228        if u16_at(bytes, entry, big_endian) == ORIENTATION_TAG {
229            return u16_at(bytes, entry + 8, big_endian);
230        }
231        entry_index += 1;
232    }
233    UPRIGHT
234}
235
236// `==` on slices is not const
237const fn same_at(bytes: &[u8], at: usize, expected: &[u8]) -> bool {
238    if at + expected.len() > bytes.len() {
239        return false;
240    }
241    let mut index = 0;
242    while index < expected.len() {
243        if bytes[at + index] != expected[index] {
244            return false;
245        }
246        index += 1;
247    }
248    true
249}
250
251const fn u16_at(bytes: &[u8], at: usize, big_endian: bool) -> u16 {
252    let pair = [bytes[at], bytes[at + 1]];
253    if big_endian {
254        u16::from_be_bytes(pair)
255    } else {
256        u16::from_le_bytes(pair)
257    }
258}
259
260const fn u32_at(bytes: &[u8], at: usize, big_endian: bool) -> u32 {
261    let quad = [bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]];
262    if big_endian {
263        u32::from_be_bytes(quad)
264    } else {
265        u32::from_le_bytes(quad)
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::{Header, HeaderError, read_header};
272
273    fn png(width: u32, height: u32) -> Vec<u8> {
274        let mut header = [width.to_be_bytes(), height.to_be_bytes()].concat();
275        header.extend([8, 2, 0, 0, 0]);
276        [&b"\x89PNG\r\n\x1a\n"[..], &png_chunk(b"IHDR", &header)].concat()
277    }
278
279    fn png_chunk(chunk_type: &[u8], data: &[u8]) -> Vec<u8> {
280        let length = u32::try_from(data.len()).unwrap().to_be_bytes();
281        [&length[..], chunk_type, data, &[0; 4]].concat()
282    }
283
284    fn jpeg_segment(marker: u8, data: &[u8]) -> Vec<u8> {
285        let length = u16::try_from(data.len() + 2).unwrap().to_be_bytes();
286        [&[0xFF, marker][..], &length, data].concat()
287    }
288
289    // a jpeg with this frame marker, and an app1 segment before the frame if one is given
290    fn jpeg_with(width: u16, height: u16, marker: u8, app1: Option<&[u8]>) -> Vec<u8> {
291        let mut frame = vec![8];
292        frame.extend(height.to_be_bytes());
293        frame.extend(width.to_be_bytes());
294        frame.extend([1, 1, 0x11, 0]);
295        let app1 = app1
296            .map(|data| jpeg_segment(0xE1, data))
297            .unwrap_or_default();
298        [
299            &[0xFF, 0xD8][..],
300            &app1,
301            &jpeg_segment(marker, &frame),
302            &[0xFF, 0xD9],
303        ]
304        .concat()
305    }
306
307    fn jpeg(width: u16, height: u16) -> Vec<u8> {
308        jpeg_with(width, height, 0xC0, None)
309    }
310
311    // tiff data with one directory entry, the orientation
312    fn tiff(orientation: u16, big_endian: bool) -> Vec<u8> {
313        let u16_bytes = |value: u16| {
314            if big_endian {
315                value.to_be_bytes()
316            } else {
317                value.to_le_bytes()
318            }
319        };
320        let u32_bytes = |value: u32| {
321            if big_endian {
322                value.to_be_bytes()
323            } else {
324                value.to_le_bytes()
325            }
326        };
327        let order: &[u8] = if big_endian { b"MM" } else { b"II" };
328        [
329            order,
330            &u16_bytes(42),
331            &u32_bytes(8),
332            &u16_bytes(1),
333            &u16_bytes(0x0112),
334            &u16_bytes(3),
335            &u32_bytes(1),
336            &u16_bytes(orientation),
337            &[0, 0],
338        ]
339        .concat()
340    }
341
342    fn exif(tiff: &[u8]) -> Vec<u8> {
343        [&b"Exif\0\0"[..], tiff].concat()
344    }
345
346    fn header(width: u32, height: u32) -> Header {
347        Header { width, height }
348    }
349
350    #[test]
351    fn read_header_reads_the_size_of_a_png_and_a_jpeg() {
352        let progressive = jpeg_with(500, 700, 0xC2, None);
353        let after_xmp = jpeg_with(
354            640,
355            480,
356            0xC0,
357            Some(b"http://ns.adobe.com/xap/1.0/\0<x:xmpmeta/>"),
358        );
359
360        // a huffman table shares the range of the frame markers
361        let after_table = [
362            &[0xFF, 0xD8][..],
363            &jpeg_segment(0xC4, &[0; 20]),
364            &jpeg(640, 480)[2..],
365        ]
366        .concat();
367        let cases = [
368            (png(400, 300), Ok(header(400, 300))),
369            (jpeg(640, 480), Ok(header(640, 480))),
370            (progressive, Ok(header(500, 700))),
371            (after_xmp, Ok(header(640, 480))),
372            (after_table, Ok(header(640, 480))),
373        ];
374        for (bytes, header) in cases {
375            assert_eq!(read_header(&bytes), header, "{bytes:?}");
376        }
377    }
378
379    #[test]
380    fn read_header_takes_each_side_from_280_to_1568_px() {
381        let cases = [
382            (280, 280, true),
383            (1568, 1568, true),
384            (279, 500, false),
385            (500, 279, false),
386            (1569, 500, false),
387            (500, 1569, false),
388        ];
389        for (width, height, valid) in cases {
390            let expected = if valid {
391                Ok(header(width.into(), height.into()))
392            } else {
393                Err(HeaderError::Size {
394                    width: width.into(),
395                    height: height.into(),
396                })
397            };
398
399            assert_eq!(read_header(&png(width.into(), height.into())), expected);
400            assert_eq!(read_header(&jpeg(width, height)), expected);
401        }
402    }
403
404    #[test]
405    fn read_header_takes_at_most_16_mib() {
406        let mut bytes = png(300, 300);
407        bytes.resize(16 * 1024 * 1024, 0);
408        assert_eq!(read_header(&bytes), Ok(header(300, 300)));
409
410        bytes.push(0);
411        assert_eq!(
412            read_header(&bytes),
413            Err(HeaderError::Bytes { size: 16_777_217 })
414        );
415    }
416
417    #[test]
418    fn read_header_refuses_other_formats() {
419        let others: [&[u8]; 4] = [
420            b"",
421            b"GIF89a\x01\x00\x01\x00",
422            b"RIFF\0\0\0\0WEBPVP8 ",
423            b"\xFF",
424        ];
425        for bytes in others {
426            assert_eq!(read_header(bytes), Err(HeaderError::Format), "{bytes:?}");
427        }
428    }
429
430    #[test]
431    fn read_header_refuses_a_broken_header() {
432        let signature = b"\x89PNG\r\n\x1a\n".to_vec();
433        let short_png = png(300, 300)[..20].to_vec();
434        let no_crc = png(300, 300)[..32].to_vec();
435        let mut long_header = png(300, 300);
436        long_header[11] = 14;
437        let data_first = [&signature[..], &png_chunk(b"IDAT", &[0; 13])].concat();
438        let no_frame = vec![0xFF, 0xD8, 0xFF, 0xD9];
439        let past_the_end = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x10, 0x00, 0x00];
440        let short_frame = [
441            &[0xFF, 0xD8][..],
442            &jpeg_segment(0xC0, &[8, 1, 0, 2]),
443            &[0xFF, 0xD9],
444        ]
445        .concat();
446        let not_a_marker = vec![0xFF, 0xD8, 0x00, 0xC0, 0x00, 0x11];
447        let cases = [
448            signature,
449            short_png,
450            no_crc,
451            long_header,
452            data_first,
453            no_frame,
454            past_the_end,
455            short_frame,
456            not_a_marker,
457        ];
458        for bytes in cases {
459            assert_eq!(read_header(&bytes), Err(HeaderError::Header), "{bytes:?}");
460        }
461    }
462
463    #[test]
464    fn read_header_refuses_an_image_that_its_exif_turns() {
465        let png_exif = [png(400, 300), png_chunk(b"eXIf", &tiff(3, true))].concat();
466        let cases = [
467            (jpeg_with(640, 480, 0xC0, Some(&exif(&tiff(6, false)))), 6),
468            (jpeg_with(640, 480, 0xC0, Some(&exif(&tiff(8, true)))), 8),
469            (jpeg_with(640, 480, 0xC0, Some(&exif(&tiff(2, false)))), 2),
470            (png_exif, 3),
471        ];
472        for (bytes, orientation) in cases {
473            assert_eq!(
474                read_header(&bytes),
475                Err(HeaderError::Orientation { orientation })
476            );
477        }
478    }
479
480    #[test]
481    fn read_header_takes_an_upright_image() {
482        let upright = jpeg_with(640, 480, 0xC0, Some(&exif(&tiff(1, false))));
483        let unknown = jpeg_with(640, 480, 0xC0, Some(&exif(&tiff(9, false))));
484        let png_upright = [png(400, 300), png_chunk(b"eXIf", &tiff(1, true))].concat();
485
486        // png puts exif before the image data, so exif after it does not count
487        let after_data = [
488            png(400, 300),
489            png_chunk(b"IDAT", &[0; 4]),
490            png_chunk(b"eXIf", &tiff(6, true)),
491        ]
492        .concat();
493        let chunk_past_the_end = [&png(400, 300)[..], &100u32.to_be_bytes(), b"eXIf"].concat();
494        for bytes in [
495            upright,
496            unknown,
497            png_upright,
498            after_data,
499            chunk_past_the_end,
500        ] {
501            assert!(read_header(&bytes).is_ok(), "{bytes:?}");
502        }
503    }
504
505    #[test]
506    fn read_header_ignores_exif_that_it_cannot_read() {
507        let mut other_tag = tiff(6, false);
508        other_tag[10..12].copy_from_slice(&0x0110u16.to_le_bytes());
509        let other_order = [&b"XX"[..], &tiff(6, false)[2..]].concat();
510        let short_entry = tiff(6, false)[..21].to_vec();
511        let far_directory = [&b"II\x2a\x00"[..], &500u32.to_le_bytes()].concat();
512        let cases = [
513            other_tag,
514            other_order,
515            short_entry,
516            far_directory,
517            Vec::new(),
518        ];
519        for tiff in cases {
520            let bytes = jpeg_with(640, 480, 0xC0, Some(&exif(&tiff)));
521
522            assert_eq!(read_header(&bytes), Ok(header(640, 480)), "{tiff:?}");
523        }
524    }
525
526    #[test]
527    fn message_tells_the_rule() {
528        let errors = [
529            HeaderError::Format,
530            HeaderError::Header,
531            HeaderError::Size {
532                width: 1,
533                height: 1,
534            },
535            HeaderError::Bytes { size: 1 },
536            HeaderError::Orientation { orientation: 6 },
537        ];
538        #[cfg(not(feature = "image"))]
539        let fix = "turn on the `image` feature of system2 to";
540        #[cfg(feature = "image")]
541        let fix = "pass a path to `image!`, or call `Image::new`, to";
542        let messages = [
543            format!("the image must be PNG or JPEG: {fix} convert it"),
544            "the image must have a complete PNG or JPEG header".to_owned(),
545            format!("each side of the image must be from 280 to 1568 px: {fix} scale it"),
546            format!("the image must have at most 16 MiB: {fix} make it smaller"),
547            format!(
548                "the image must be upright, but its EXIF orientation turns or mirrors it, and the \
549                 API ignores the orientation: {fix} turn its pixels"
550            ),
551        ];
552        assert_eq!(errors.map(HeaderError::message), messages);
553    }
554}