1use 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
20pub const MAX_UPLOAD: usize = 64 * 1024;
22
23pub const MAX_SIDE: u32 = 1024;
25
26pub const MAX_STORED: usize = 256 * 1024;
29
30pub const MIME_PNG: &str = "image/png";
32
33const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
34
35const DECODER_LIMIT: usize = 4 << 20;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Logo {
42 pub bytes: Vec<u8>,
43 pub etag: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct StoredLogo {
51 pub mime: String,
54 pub bytes: Vec<u8>,
55 pub etag: String,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Refusal {
63 NotBase64,
65 TooLarge,
67 Unsupported,
69 Dimensions,
72 Damaged,
74}
75
76pub fn from_upload(data: &str) -> Result<Logo, Refusal> {
79 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
92pub 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[test]
395 fn an_interlaced_png_comes_back_progressive_no_more() {
396 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[test]
642 fn the_upload_is_strict_standard_base64() {
643 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 #[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}