Skip to main content

hennery_kernel/
logo.rs

1//! Hat logos (kernel spec §5.1; plan 4d-B2). A logo is uploaded as a PNG,
2//! at most 64 KiB, and stored and served only as this module re-encodes it:
3//! decoded to plain 8-bit pixels, then written afresh as `IHDR`, `IDAT` and
4//! `IEND`, so no text, metadata, profile, animation or trailing bytes of the
5//! upload survive (the security review's ruling B: no SVG, no WebP). The
6//! browser turns any other format into a PNG before it uploads.
7//!
8//! Memory is bounded before anything is allocated by the image's size: the
9//! header alone is read first, and an image over `MAX_SIDE` pixels either way
10//! is refused there (the review's A2).
11
12use crate::hats::HatChange;
13use crate::hosts::Hosts;
14use anyhow::Result;
15use base64::Engine;
16use rusqlite::{OptionalExtension, params};
17use sha2::{Digest, Sha256};
18use std::io::Cursor;
19
20/// The largest upload, decoded (kernel spec §5.1).
21pub const MAX_UPLOAD: usize = 64 * 1024;
22
23/// The widest and tallest logo, in pixels.
24pub const MAX_SIDE: u32 = 1024;
25
26/// The largest logo stored, once re-encoded: a small upload can decode to
27/// pixels that compress worse than it did.
28pub const MAX_STORED: usize = 256 * 1024;
29
30/// The one kind a logo is stored and served as.
31pub const MIME_PNG: &str = "image/png";
32
33const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
34
35/// What the decoder may allocate besides the frame: its row buffers. The
36/// frame itself is bounded by `MAX_SIDE`, checked from the header first.
37const DECODER_LIMIT: usize = 4 << 20;
38
39/// A logo as it is stored: always a PNG of ours.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Logo {
42    pub bytes: Vec<u8>,
43    /// What `GET /api/hats/{id}/logo` answers as its `ETag` (without the
44    /// quotes): the first 128 bits of the bytes' SHA-256, in hex.
45    pub etag: String,
46}
47
48/// A stored logo, as the logo route serves it.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct StoredLogo {
51    /// Always `MIME_PNG` (the schema's check); the route still answers
52    /// only a kind it knows.
53    pub mime: String,
54    pub bytes: Vec<u8>,
55    pub etag: String,
56}
57
58/// Why an upload is not a logo. Each has its own status and fixed message
59/// at the route; none carries the decoder's words or the upload's bytes
60/// (the review's A8).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Refusal {
63    /// Not strict standard base64 (padded, no whitespace, no `data:`).
64    NotBase64,
65    /// Over `MAX_UPLOAD` decoded, or over the stored cap once re-encoded.
66    TooLarge,
67    /// Not a PNG by its signature: SVG, WebP, JPEG, GIF, anything else.
68    Unsupported,
69    /// Over `MAX_SIDE` either way (a side of 0 is refused by the decoder
70    /// first, as `Damaged`).
71    Dimensions,
72    /// The decoder refused it.
73    Damaged,
74}
75
76/// The upload `data` as a logo: decoded from base64, then re-encoded
77/// (`reencode`) under `MAX_STORED`.
78pub fn from_upload(data: &str) -> Result<Logo, Refusal> {
79    // Checked from the length before anything is decoded (the review's O2).
80    if data.len() > MAX_UPLOAD.div_ceil(3) * 4 {
81        return Err(Refusal::TooLarge);
82    }
83    let bytes = base64::engine::general_purpose::STANDARD
84        .decode(data)
85        .map_err(|_| Refusal::NotBase64)?;
86    if bytes.len() > MAX_UPLOAD {
87        return Err(Refusal::TooLarge);
88    }
89    reencode(&bytes, MAX_STORED)
90}
91
92/// `input`, a PNG, decoded and written afresh: 8-bit, the colour type its
93/// pixels have once palettes and transparency are expanded, only `IHDR`,
94/// `IDAT` and `IEND`. An animated PNG keeps its first frame. Refused when
95/// it is not a PNG, a side is 0 or over `MAX_SIDE`, the decoder fails, or the
96/// result is over `max_stored` bytes (a parameter, so a test can reach it:
97/// the review's A4).
98pub fn reencode(input: &[u8], max_stored: usize) -> Result<Logo, Refusal> {
99    if !input.starts_with(SIGNATURE) {
100        return Err(Refusal::Unsupported);
101    }
102    let mut decoder = png::Decoder::new_with_limits(Cursor::new(input), png::Limits { bytes: DECODER_LIMIT });
103    // The header alone first, so nothing is sized by it before it is
104    // checked (the review's A2).
105    let header = decoder.read_header_info().map_err(|_| Refusal::Damaged)?;
106    if !(1..=MAX_SIDE).contains(&header.width) || !(1..=MAX_SIDE).contains(&header.height) {
107        return Err(Refusal::Dimensions);
108    }
109    decoder.set_transformations(png::Transformations::normalize_to_color8());
110    decoder.set_ignore_text_chunk(true);
111    decoder.set_ignore_iccp_chunk(true);
112    let mut reader = decoder.read_info().map_err(|_| Refusal::Damaged)?;
113    let size = reader.output_buffer_size().ok_or(Refusal::Damaged)?;
114    let mut pixels = vec![0; size];
115    // The first frame; the encoder refuses pixels of any other length
116    // than the frame it is given (the review's A3).
117    let frame = reader.next_frame(&mut pixels).map_err(|_| Refusal::Damaged)?;
118    let mut bytes = Vec::new();
119    let mut encoder = png::Encoder::new(&mut bytes, frame.width, frame.height);
120    encoder.set_color(frame.color_type);
121    encoder.set_depth(png::BitDepth::Eight);
122    let mut writer = encoder.write_header().map_err(|_| Refusal::Damaged)?;
123    writer.write_image_data(&pixels).map_err(|_| Refusal::Damaged)?;
124    writer.finish().map_err(|_| Refusal::Damaged)?;
125    if bytes.len() > max_stored {
126        return Err(Refusal::TooLarge);
127    }
128    let etag = hex::encode(&Sha256::digest(&bytes)[..16]);
129    Ok(Logo { bytes, etag })
130}
131
132impl Hosts {
133    /// `hat_id`'s logo, if the owner has that hat and it has one.
134    pub fn hat_logo(&self, hat_id: &str) -> Result<Option<StoredLogo>> {
135        Ok(self
136            .conn()
137            .query_row(
138                "SELECT logo_mime, logo_bytes, logo_etag FROM hats
139                 WHERE id = ?1 AND owner_id = ?2 AND logo_bytes IS NOT NULL",
140                [hat_id, self.owner_id()],
141                |r| {
142                    Ok(StoredLogo {
143                        mime: r.get(0)?,
144                        bytes: r.get(1)?,
145                        etag: r.get(2)?,
146                    })
147                },
148            )
149            .optional()?)
150    }
151
152    /// Give `hat_id` `logo`, replacing any it had: `Done` with the hat as it
153    /// is now; `NotFound`; or `Purging` for a hat frozen for its purge,
154    /// which keeps the logo it had. The freeze is checked in the statement
155    /// that would set it (plan 9c decision 10c).
156    pub fn set_hat_logo(&self, hat_id: &str, logo: &Logo) -> Result<HatChange> {
157        let owner = self.owner_id();
158        let mut conn = self.conn();
159        let tx = conn.transaction()?;
160        let set = tx.execute(
161            "UPDATE hats SET logo_mime = ?3, logo_bytes = ?4, logo_etag = ?5
162             WHERE id = ?1 AND owner_id = ?2
163                 AND NOT EXISTS (SELECT 1 FROM purged_hats p WHERE p.hat_id = hats.id AND p.owner_id = ?2)",
164            params![hat_id, owner, MIME_PNG, logo.bytes, logo.etag],
165        )?;
166        let hat = crate::hats::hat_in(&tx, owner, hat_id)?;
167        tx.commit()?;
168        Ok(match hat {
169            Some(hat) if set == 1 => HatChange::Done(hat),
170            Some(_) => HatChange::Purging,
171            None => HatChange::NotFound,
172        })
173    }
174
175    /// Take `hat_id`'s logo away, if it has one: `Done` with the hat as it
176    /// is now, or `NotFound`. A frozen hat's logo goes too: that is the
177    /// purge's own direction (the review's A6).
178    pub fn clear_hat_logo(&self, hat_id: &str) -> Result<HatChange> {
179        let owner = self.owner_id();
180        let mut conn = self.conn();
181        let tx = conn.transaction()?;
182        tx.execute(
183            "UPDATE hats SET logo_mime = NULL, logo_bytes = NULL, logo_etag = NULL
184             WHERE id = ?1 AND owner_id = ?2",
185            [hat_id, owner],
186        )?;
187        let hat = crate::hats::hat_in(&tx, owner, hat_id)?;
188        tx.commit()?;
189        Ok(hat.map_or(HatChange::NotFound, HatChange::Done))
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use png::{BitDepth, ColorType};
197
198    /// PNG's CRC-32 (ISO 3309), bit by bit: test fixtures only.
199    fn crc32(bytes: &[u8]) -> u32 {
200        let mut crc = !0u32;
201        for &byte in bytes {
202            crc ^= u32::from(byte);
203            for _ in 0..8 {
204                crc = if crc & 1 == 1 {
205                    (crc >> 1) ^ 0xedb8_8320
206                } else {
207                    crc >> 1
208                };
209            }
210        }
211        !crc
212    }
213
214    fn chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
215        let mut out = (data.len() as u32).to_be_bytes().to_vec();
216        out.extend_from_slice(kind);
217        out.extend_from_slice(data);
218        let mut crc = kind.to_vec();
219        crc.extend_from_slice(data);
220        out.extend_from_slice(&crc32(&crc).to_be_bytes());
221        out
222    }
223
224    /// `data` as a zlib stream of stored (uncompressed) blocks.
225    fn zlib_stored(data: &[u8]) -> Vec<u8> {
226        let mut out = vec![0x78, 0x01];
227        let blocks: Vec<&[u8]> = if data.is_empty() {
228            vec![&[]]
229        } else {
230            data.chunks(65535).collect()
231        };
232        for (n, block) in blocks.iter().enumerate() {
233            out.push(u8::from(n + 1 == blocks.len()));
234            let len = block.len() as u16;
235            out.extend_from_slice(&len.to_le_bytes());
236            out.extend_from_slice(&(!len).to_le_bytes());
237            out.extend_from_slice(block);
238        }
239        let (mut a, mut b) = (1u32, 0u32);
240        for &byte in data {
241            a = (a + u32::from(byte)) % 65521;
242            b = (b + a) % 65521;
243        }
244        out.extend_from_slice(&((b << 16) | a).to_be_bytes());
245        out
246    }
247
248    fn ihdr(width: u32, height: u32, depth: u8, colour: u8, interlace: u8) -> Vec<u8> {
249        let mut data = width.to_be_bytes().to_vec();
250        data.extend_from_slice(&height.to_be_bytes());
251        data.extend_from_slice(&[depth, colour, 0, 0, interlace]);
252        chunk(b"IHDR", &data)
253    }
254
255    fn file(chunks: &[Vec<u8>]) -> Vec<u8> {
256        let mut out = SIGNATURE.to_vec();
257        for chunk in chunks {
258            out.extend_from_slice(chunk);
259        }
260        out
261    }
262
263    /// A PNG of `pixels` as the `png` crate writes it.
264    fn encode(width: u32, height: u32, colour: ColorType, depth: BitDepth, pixels: &[u8]) -> Vec<u8> {
265        let mut out = Vec::new();
266        let mut encoder = png::Encoder::new(&mut out, width, height);
267        encoder.set_color(colour);
268        encoder.set_depth(depth);
269        let mut writer = encoder.write_header().unwrap();
270        writer.write_image_data(pixels).unwrap();
271        writer.finish().unwrap();
272        out
273    }
274
275    /// The chunks of `png`, in order, by type; and the bytes after `IEND`.
276    fn chunks_of(png: &[u8]) -> (Vec<String>, usize) {
277        assert!(png.starts_with(SIGNATURE));
278        let mut at = SIGNATURE.len();
279        let mut kinds = Vec::new();
280        while at + 8 <= png.len() {
281            let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
282            let kind = String::from_utf8_lossy(&png[at + 4..at + 8]).into_owned();
283            at += 12 + len;
284            let end = kind == "IEND";
285            kinds.push(kind);
286            if end {
287                break;
288            }
289        }
290        (kinds, png.len() - at)
291    }
292
293    /// The output holds `IHDR`, then `IDAT`s, then `IEND`, and nothing
294    /// after it.
295    fn assert_clean(logo: &Logo) {
296        let (kinds, trailing) = chunks_of(&logo.bytes);
297        assert_eq!(kinds.first().map(String::as_str), Some("IHDR"), "{kinds:?}");
298        assert_eq!(kinds.last().map(String::as_str), Some("IEND"), "{kinds:?}");
299        assert!(kinds[1..kinds.len() - 1].iter().all(|k| k == "IDAT"), "{kinds:?}");
300        assert!(kinds.len() >= 3, "{kinds:?}");
301        assert_eq!(trailing, 0);
302        assert_eq!(logo.etag, hex::encode(&Sha256::digest(&logo.bytes)[..16]));
303    }
304
305    /// `png` decoded as the browser would see it: 8-bit, palettes expanded.
306    fn pixels(png: &[u8]) -> (u32, u32, ColorType, Vec<u8>) {
307        let mut decoder = png::Decoder::new(Cursor::new(png));
308        decoder.set_transformations(png::Transformations::normalize_to_color8());
309        let mut reader = decoder.read_info().unwrap();
310        let mut buf = vec![0; reader.output_buffer_size().unwrap()];
311        let frame = reader.next_frame(&mut buf).unwrap();
312        buf.truncate(frame.buffer_size());
313        (frame.width, frame.height, frame.color_type, buf)
314    }
315
316    fn reencoded(input: &[u8]) -> Logo {
317        let logo = reencode(input, MAX_STORED).unwrap();
318        assert_clean(&logo);
319        logo
320    }
321
322    /// The chunks of a clean `png`, with `before` inserted before its first
323    /// `IDAT`, `after` before its `IEND`, and `trailing` after `IEND`.
324    fn with_extras(png: &[u8], before: &[Vec<u8>], after: &[Vec<u8>], trailing: &[u8]) -> Vec<u8> {
325        let mut at = SIGNATURE.len();
326        let mut out = SIGNATURE.to_vec();
327        let mut seen_idat = false;
328        while at < png.len() {
329            let len = u32::from_be_bytes(png[at..at + 4].try_into().unwrap()) as usize;
330            let kind = &png[at + 4..at + 8];
331            if kind == b"IDAT" && !seen_idat {
332                seen_idat = true;
333                before.iter().for_each(|c| out.extend_from_slice(c));
334            }
335            if kind == b"IEND" {
336                after.iter().for_each(|c| out.extend_from_slice(c));
337            }
338            out.extend_from_slice(&png[at..at + 12 + len]);
339            at += 12 + len;
340        }
341        out.extend_from_slice(trailing);
342        out
343    }
344
345    /// Every 8-bit colour type comes back as its pixels, in its own colour
346    /// type, written afresh.
347    #[test]
348    fn each_colour_type_comes_back_as_its_pixels() {
349        let cases = [
350            (ColorType::Grayscale, 1),
351            (ColorType::GrayscaleAlpha, 2),
352            (ColorType::Rgb, 3),
353            (ColorType::Rgba, 4),
354        ];
355        for (colour, channels) in cases {
356            let data: Vec<u8> = (0..3 * 2 * channels).map(|n| (n * 37 % 251) as u8).collect();
357            let input = encode(3, 2, colour, BitDepth::Eight, &data);
358            let logo = reencoded(&input);
359            assert_eq!(pixels(&logo.bytes), (3, 2, colour, data), "{colour:?}");
360        }
361    }
362
363    /// A palette with transparency becomes RGBA, and 16-bit samples are cut
364    /// to 8: the pixels the browser would have shown.
365    #[test]
366    fn palettes_and_16_bit_samples_become_plain_8_bit_pixels() {
367        let palette = file(&[
368            ihdr(2, 1, 8, 3, 0),
369            chunk(b"PLTE", &[255, 0, 0, 0, 0, 255]),
370            chunk(b"tRNS", &[128]),
371            chunk(b"IDAT", &zlib_stored(&[0, 0, 1])),
372            chunk(b"IEND", &[]),
373        ]);
374        let logo = reencoded(&palette);
375        assert_eq!(
376            pixels(&logo.bytes),
377            (2, 1, ColorType::Rgba, vec![255, 0, 0, 128, 0, 0, 255, 255])
378        );
379        let wide = encode(
380            1,
381            1,
382            ColorType::Rgb,
383            BitDepth::Sixteen,
384            &[0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc],
385        );
386        assert_eq!(chunks_of(&wide).0[0], "IHDR");
387        let logo = reencoded(&wide);
388        assert_eq!(pixels(&logo.bytes), (1, 1, ColorType::Rgb, vec![0x12, 0x56, 0x9a]));
389        assert_eq!(logo.bytes[24], 8, "the bit depth in IHDR");
390    }
391
392    /// An interlaced PNG (Adam7) comes back as the same pixels, not
393    /// interlaced.
394    #[test]
395    fn an_interlaced_png_comes_back_progressive_no_more() {
396        // 3 × 3 grey, pixel (x, y) = 10 * y + x, in Adam7's seven passes.
397        let (w, h) = (3usize, 3usize);
398        let passes = [
399            (0, 0, 8, 8),
400            (4, 0, 8, 8),
401            (0, 4, 4, 8),
402            (2, 0, 4, 4),
403            (0, 2, 2, 4),
404            (1, 0, 2, 2),
405            (0, 1, 1, 2),
406        ];
407        let mut data = Vec::new();
408        for (x0, y0, dx, dy) in passes {
409            for y in (y0..h).step_by(dy) {
410                let row: Vec<u8> = (x0..w).step_by(dx).map(|x| (10 * y + x) as u8).collect();
411                if !row.is_empty() {
412                    data.push(0);
413                    data.extend(row);
414                }
415            }
416        }
417        let input = file(&[
418            ihdr(3, 3, 8, 0, 1),
419            chunk(b"IDAT", &zlib_stored(&data)),
420            chunk(b"IEND", &[]),
421        ]);
422        let logo = reencoded(&input);
423        assert_eq!(logo.bytes[28], 0, "not interlaced");
424        let expected: Vec<u8> = (0..h).flat_map(|y| (0..w).map(move |x| (10 * y + x) as u8)).collect();
425        assert_eq!(pixels(&logo.bytes), (3, 3, ColorType::Grayscale, expected));
426    }
427
428    /// Nothing of the upload but its pixels survives: text, compressed and
429    /// international text, EXIF, an ICC profile, physical size, gamma, a
430    /// private chunk, before and after the image data, and a document
431    /// hidden after `IEND` (a polyglot).
432    #[test]
433    fn metadata_private_chunks_and_trailing_documents_do_not_survive() {
434        let clean = encode(2, 2, ColorType::Rgb, BitDepth::Eight, &[9; 12]);
435        let mut itxt = b"Comment\0\0\0en\0Kommentar\0".to_vec();
436        itxt.extend_from_slice(b"<script>alert(1)</script>");
437        let before = [
438            chunk(b"tEXt", b"Comment\0<svg onload=alert(1)>"),
439            chunk(
440                b"zTXt",
441                &[b"Comment\0\0".as_slice(), &zlib_stored(b"<script>x</script>")].concat(),
442            ),
443            chunk(b"iTXt", &itxt),
444            chunk(b"eXIf", b"MM\0*\0\0\0\x08\0\0secret-camera"),
445            chunk(
446                b"iCCP",
447                &[b"profile\0\0".as_slice(), &zlib_stored(b"not a profile")].concat(),
448            ),
449            chunk(b"pHYs", &[0, 0, 0x0b, 0x13, 0, 0, 0x0b, 0x13, 1]),
450            chunk(b"gAMA", &45455u32.to_be_bytes()),
451            chunk(b"prVt", b"private <iframe src=x>"),
452        ];
453        let after = [
454            chunk(b"tEXt", b"Late\0<html><script>alert(2)</script>"),
455            chunk(b"prVt", b"after the pixels"),
456        ];
457        let trailing = b"<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(3)\"/><script>alert(4)</script>";
458        let input = with_extras(&clean, &before, &after, trailing);
459        assert_eq!(chunks_of(&input).1, trailing.len(), "the fixture hides a document");
460        let logo = reencoded(&input);
461        assert_eq!(pixels(&logo.bytes), pixels(&clean));
462        for needle in [
463            "script", "svg", "secret", "profile", "private", "iframe", "html", "Comment", "alert",
464        ] {
465            assert!(
466                !logo.bytes.windows(needle.len()).any(|w| w == needle.as_bytes()),
467                "{needle} survived"
468            );
469        }
470    }
471
472    /// An animated PNG keeps its first frame alone: no animation chunks.
473    #[test]
474    fn an_animated_png_keeps_its_first_frame() {
475        let mut input = Vec::new();
476        {
477            let mut encoder = png::Encoder::new(&mut input, 2, 1);
478            encoder.set_color(ColorType::Grayscale);
479            encoder.set_depth(BitDepth::Eight);
480            encoder.set_animated(2, 0).unwrap();
481            let mut writer = encoder.write_header().unwrap();
482            writer.write_image_data(&[1, 2]).unwrap();
483            writer.write_image_data(&[200, 201]).unwrap();
484            writer.finish().unwrap();
485        }
486        let kinds = chunks_of(&input).0;
487        assert!(
488            kinds.contains(&"acTL".to_string()) && kinds.contains(&"fdAT".to_string()),
489            "{kinds:?}"
490        );
491        let logo = reencoded(&input);
492        assert_eq!(pixels(&logo.bytes), (2, 1, ColorType::Grayscale, vec![1, 2]));
493    }
494
495    /// Only a PNG, by its signature: an SVG, XML, WebP, JPEG, GIF, an
496    /// empty upload and a near miss of the signature are each refused
497    /// before any decoder reads them (the review's A1).
498    #[test]
499    fn anything_but_a_png_is_unsupported() {
500        let cases: [(&str, &[u8]); 9] = [
501            (
502                "svg",
503                b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>",
504            ),
505            (
506                "xml",
507                b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"/>",
508            ),
509            ("bom svg", b"\xef\xbb\xbf<svg/>"),
510            (
511                "webp",
512                b"RIFF\x1a\0\0\0WEBPVP8L\x0d\0\0\0\x2f\0\0\0\x10\x07\x10\x11\x11\x88\x88\xfe\x07\0",
513            ),
514            (
515                "jpeg",
516                b"\xff\xd8\xff\xe0\0\x10JFIF\0\x01\x01\0\0\x01\0\x01\0\0\xff\xd9",
517            ),
518            ("gif", b"GIF89a\x01\0\x01\0\0\0\0;"),
519            ("empty", b""),
520            ("short", &SIGNATURE[..7]),
521            ("near miss", b"\x89PNG\r\n\x1a\x0b"),
522        ];
523        for (name, input) in cases {
524            assert_eq!(reencode(input, MAX_STORED), Err(Refusal::Unsupported), "{name}");
525        }
526    }
527
528    /// The size is checked from the header alone, before anything else is
529    /// read (the review's A2): a header claiming 2³¹ pixels, with nothing
530    /// after it, is `Dimensions`, not `Damaged`. 1024 either way is taken,
531    /// grey or RGBA as a canvas exports it.
532    #[test]
533    fn a_side_over_1024_is_refused_from_the_header_alone() {
534        for (w, h) in [(1025, 1), (1, 1025), (0x7fff_ffff, 0x7fff_ffff), (1 << 20, 16)] {
535            let header_only = file(&[ihdr(w, h, 8, 6, 0)]);
536            assert_eq!(reencode(&header_only, MAX_STORED), Err(Refusal::Dimensions), "{w}×{h}");
537        }
538        let largest = encode(1024, 1024, ColorType::Grayscale, BitDepth::Eight, &vec![7; 1024 * 1024]);
539        assert!(largest.len() < MAX_UPLOAD, "{}", largest.len());
540        let logo = reencoded(&largest);
541        let (w, h, colour, data) = pixels(&logo.bytes);
542        assert_eq!((w, h, colour), (1024, 1024, ColorType::Grayscale));
543        assert!(data.iter().all(|&p| p == 7));
544
545        let rgba = encode(
546            1024,
547            1024,
548            ColorType::Rgba,
549            BitDepth::Eight,
550            &[1, 2, 3, 4].repeat(1024 * 1024),
551        );
552        assert!(rgba.len() < MAX_UPLOAD, "{}", rgba.len());
553        let logo = reencoded(&rgba);
554        let (w, h, colour, data) = pixels(&logo.bytes);
555        assert_eq!((w, h, colour), (1024, 1024, ColorType::Rgba));
556        assert!(data.chunks(4).all(|p| p == [1, 2, 3, 4]));
557    }
558
559    /// What the decoder refuses is `Damaged`: a zero side, a truncated
560    /// image, a broken checksum, an Apple `CgBI` file, no image data.
561    #[test]
562    fn a_damaged_png_is_refused() {
563        let good = encode(4, 4, ColorType::Rgb, BitDepth::Eight, &[3; 48]);
564        let mut bad_crc = good.clone();
565        bad_crc[29] ^= 0xff;
566        let cases: [(&str, Vec<u8>); 5] = [
567            ("zero width", file(&[ihdr(0, 1, 8, 0, 0)])),
568            ("truncated", good[..good.len() - 20].to_vec()),
569            ("header crc", bad_crc),
570            (
571                "CgBI",
572                file(&[
573                    chunk(b"CgBI", &[0x50, 0, 0x20, 0x06]),
574                    ihdr(1, 1, 8, 6, 0),
575                    chunk(b"IDAT", &[0x63, 0, 0, 0, 0, 0]),
576                    chunk(b"IEND", &[]),
577                ]),
578            ),
579            ("no image data", file(&[ihdr(1, 1, 8, 0, 0), chunk(b"IEND", &[])])),
580        ];
581        for (name, input) in cases {
582            assert_eq!(reencode(&input, MAX_STORED), Err(Refusal::Damaged), "{name}");
583        }
584    }
585
586    /// Image data that inflates past its rows gives the rows and no more.
587    #[test]
588    fn image_data_past_its_rows_is_not_kept() {
589        let mut rows = vec![0, 5, 6];
590        rows.extend(std::iter::repeat_n(0xaa, 60_000));
591        let input = file(&[
592            ihdr(2, 1, 8, 0, 0),
593            chunk(b"IDAT", &zlib_stored(&rows)),
594            chunk(b"IEND", &[]),
595        ]);
596        let logo = reencoded(&input);
597        assert_eq!(pixels(&logo.bytes), (2, 1, ColorType::Grayscale, vec![5, 6]));
598        assert!(logo.bytes.len() < 100, "{}", logo.bytes.len());
599    }
600
601    /// The stored cap is checked after the re-encoding (the review's A4):
602    /// at the size it is taken, a byte under it is refused.
603    #[test]
604    fn a_logo_over_the_stored_cap_is_too_large() {
605        let input = encode(8, 8, ColorType::Rgba, BitDepth::Eight, &(0..=255).collect::<Vec<u8>>());
606        let size = reencoded(&input).bytes.len();
607        assert!(reencode(&input, size).is_ok());
608        assert_eq!(reencode(&input, size - 1), Err(Refusal::TooLarge));
609    }
610
611    fn b64(bytes: &[u8]) -> String {
612        base64::engine::general_purpose::STANDARD.encode(bytes)
613    }
614
615    /// A PNG of exactly `len` bytes: a small image padded with a private
616    /// chunk.
617    fn png_of_len(len: usize) -> Vec<u8> {
618        let small = encode(1, 1, ColorType::Grayscale, BitDepth::Eight, &[1]);
619        let pad = len - small.len() - 12;
620        let out = with_extras(&small, &[chunk(b"prVt", &vec![b'p'; pad])], &[], b"");
621        assert_eq!(out.len(), len);
622        out
623    }
624
625    /// 64 KiB decoded is taken, a byte more is not; and an upload whose
626    /// length alone is over is refused before it is decoded (the review's
627    /// O2): `TooLarge` even when it is not base64 at all.
628    #[test]
629    fn the_upload_is_at_most_64_kib() {
630        let logo = from_upload(&b64(&png_of_len(MAX_UPLOAD))).unwrap();
631        assert_clean(&logo);
632        assert_eq!(from_upload(&b64(&png_of_len(MAX_UPLOAD + 1))), Err(Refusal::TooLarge));
633        let longest = MAX_UPLOAD.div_ceil(3) * 4;
634        assert_eq!(b64(&png_of_len(MAX_UPLOAD)).len(), longest);
635        assert_eq!(from_upload(&"!".repeat(longest)), Err(Refusal::NotBase64));
636        assert_eq!(from_upload(&"!".repeat(longest + 1)), Err(Refusal::TooLarge));
637    }
638
639    /// Strict standard base64 only: no whitespace, no line breaks, no
640    /// `data:` URL, no URL-safe alphabet, no missing padding.
641    #[test]
642    fn the_upload_is_strict_standard_base64() {
643        // Bytes chosen so the encoding holds `+` and `/`.
644        let mut input = encode(1, 1, ColorType::Rgb, BitDepth::Eight, &[0xfb, 0xff, 0xbf]);
645        input = with_extras(&input, &[chunk(b"prVt", &[0xfb, 0xef, 0xbe, 0xff, 0xfb])], &[], b"");
646        let good = b64(&input);
647        assert!(
648            good.contains('+') && good.contains('/') && good.ends_with('='),
649            "{good}"
650        );
651        assert!(from_upload(&good).is_ok());
652        let cases = [
653            ("space", format!("{} {}", &good[..8], &good[8..])),
654            ("newline", format!("{}\n{}", &good[..76], &good[76..])),
655            ("trailing newline", format!("{good}\n")),
656            ("data url", format!("data:image/png;base64,{good}")),
657            ("url-safe", good.replace('+', "-").replace('/', "_")),
658            ("unpadded", good.trim_end_matches('=').to_string()),
659        ];
660        for (name, data) in cases {
661            assert_eq!(from_upload(&data), Err(Refusal::NotBase64), "{name}");
662        }
663    }
664
665    /// The same image gives the same logo and `ETag`; another, another.
666    #[test]
667    fn the_etag_follows_the_bytes() {
668        let a = reencoded(&encode(1, 1, ColorType::Grayscale, BitDepth::Eight, &[1]));
669        let again = reencoded(&encode(1, 1, ColorType::Grayscale, BitDepth::Eight, &[1]));
670        let b = reencoded(&encode(1, 1, ColorType::Grayscale, BitDepth::Eight, &[2]));
671        assert_eq!(a, again);
672        assert_ne!(a.etag, b.etag);
673        assert_eq!(a.etag.len(), 32);
674    }
675}