Skip to main content

xdg_thumbnail/
png.rs

1// SPDX-FileCopyrightText: 2026 KIM Hyunjae
2// SPDX-License-Identifier: MPL-2.0
3
4use std::collections::BTreeMap;
5use std::io::Cursor;
6
7use crate::uri::validate_absolute_uri_identity;
8use crate::{
9    PersonalOriginalIdentity, ReadablePersonalOriginalIdentity, Result, SharedOriginalMetadata,
10    SharedRelativeOriginalUri, SharedRepositoryContext, ThumbnailError, ThumbnailSize,
11    UnixMtimeSeconds,
12};
13
14const MAX_RENDERED_PIXELS: u64 = 16_777_216;
15const MAX_RENDERED_DECODE_BYTES: usize = 256 * 1024 * 1024;
16
17/// Explicit raw pixel format accepted by raw thumbnail install APIs.
18#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
19#[non_exhaustive]
20pub enum RawThumbnailPixelFormat {
21    /// Three 8-bit channels per pixel in red, green, blue order.
22    Rgb8,
23    /// Four 8-bit channels per pixel in red, green, blue, alpha order.
24    Rgba8,
25}
26
27impl RawThumbnailPixelFormat {
28    const fn channels(self) -> usize {
29        match self {
30            Self::Rgb8 => 3,
31            Self::Rgba8 => 4,
32        }
33    }
34}
35
36/// Borrowed raw rendered thumbnail pixels.
37///
38/// This type validates the caller-supplied dimensions, stride, format, and buffer length. Raw
39/// thumbnail install APIs do not infer pixel format from byte length.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub struct RawThumbnailImage<'a> {
42    width: u32,
43    height: u32,
44    stride: usize,
45    format: RawThumbnailPixelFormat,
46    pixels: &'a [u8],
47}
48
49impl<'a> RawThumbnailImage<'a> {
50    /// Creates a validated borrowed raw thumbnail image.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error when dimensions are zero or exceed resource limits, stride is too small,
55    /// the supplied buffer is too short, or required size arithmetic overflows.
56    pub fn new(
57        width: u32,
58        height: u32,
59        stride: usize,
60        format: RawThumbnailPixelFormat,
61        pixels: &'a [u8],
62    ) -> Result<Self> {
63        validate_raw_thumbnail_image(width, height, stride, format, pixels)?;
64        Ok(Self {
65            width,
66            height,
67            stride,
68            format,
69            pixels,
70        })
71    }
72
73    /// Returns the image width in pixels.
74    #[must_use]
75    pub const fn width(&self) -> u32 {
76        self.width
77    }
78
79    /// Returns the image height in pixels.
80    #[must_use]
81    pub const fn height(&self) -> u32 {
82        self.height
83    }
84
85    /// Returns the row stride in bytes.
86    #[must_use]
87    pub const fn stride(&self) -> usize {
88        self.stride
89    }
90
91    /// Returns the explicit pixel format.
92    #[must_use]
93    pub const fn format(&self) -> RawThumbnailPixelFormat {
94        self.format
95    }
96
97    /// Returns the validated pixel buffer.
98    #[must_use]
99    pub const fn pixels(&self) -> &'a [u8] {
100        self.pixels
101    }
102}
103
104/// Owned raw rendered thumbnail pixels.
105#[derive(Debug, Eq, PartialEq)]
106pub struct OwnedRawThumbnailImage {
107    width: u32,
108    height: u32,
109    stride: usize,
110    format: RawThumbnailPixelFormat,
111    pixels: Vec<u8>,
112}
113
114impl OwnedRawThumbnailImage {
115    /// Creates a validated owned raw thumbnail image.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error when dimensions are zero or exceed resource limits, stride is too small,
120    /// the supplied buffer is too short, or required size arithmetic overflows.
121    pub fn new(
122        width: u32,
123        height: u32,
124        stride: usize,
125        format: RawThumbnailPixelFormat,
126        pixels: Vec<u8>,
127    ) -> Result<Self> {
128        validate_raw_thumbnail_image(width, height, stride, format, &pixels)?;
129        Ok(Self {
130            width,
131            height,
132            stride,
133            format,
134            pixels,
135        })
136    }
137
138    /// Borrows this owned image for raw install APIs.
139    #[must_use]
140    pub fn as_borrowed(&self) -> RawThumbnailImage<'_> {
141        RawThumbnailImage {
142            width: self.width,
143            height: self.height,
144            stride: self.stride,
145            format: self.format,
146            pixels: &self.pixels,
147        }
148    }
149
150    /// Returns the image width in pixels.
151    #[must_use]
152    pub const fn width(&self) -> u32 {
153        self.width
154    }
155
156    /// Returns the image height in pixels.
157    #[must_use]
158    pub const fn height(&self) -> u32 {
159        self.height
160    }
161
162    /// Returns the row stride in bytes.
163    #[must_use]
164    pub const fn stride(&self) -> usize {
165        self.stride
166    }
167
168    /// Returns the explicit pixel format.
169    #[must_use]
170    pub const fn format(&self) -> RawThumbnailPixelFormat {
171        self.format
172    }
173
174    /// Returns the validated pixel buffer.
175    #[must_use]
176    pub fn pixels(&self) -> &[u8] {
177        &self.pixels
178    }
179
180    /// Splits this image into its validated owned parts.
181    #[must_use]
182    pub fn into_parts(self) -> OwnedRawThumbnailImageParts {
183        OwnedRawThumbnailImageParts {
184            width: self.width,
185            height: self.height,
186            stride: self.stride,
187            format: self.format,
188            pixels: self.pixels,
189        }
190    }
191}
192
193/// Owned parts of [`OwnedRawThumbnailImage`].
194#[derive(Debug, Eq, PartialEq)]
195#[non_exhaustive]
196pub struct OwnedRawThumbnailImageParts {
197    /// Image width in pixels.
198    pub width: u32,
199    /// Image height in pixels.
200    pub height: u32,
201    /// Row stride in bytes.
202    pub stride: usize,
203    /// Explicit pixel format.
204    pub format: RawThumbnailPixelFormat,
205    /// Validated pixel buffer.
206    pub pixels: Vec<u8>,
207}
208
209/// Policy-neutral problem found while validating or inspecting a cache entry.
210///
211/// This is a compact fact vocabulary for caller policy branching, not a detailed diagnostic
212/// surface that carries expected and actual values for every failure class. Metadata problems carry
213/// typed detail through [`ThumbnailMetadataProblem`]; future non-metadata detail should be exposed
214/// separately instead of changing existing variant payloads.
215#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
216#[non_exhaustive]
217pub enum CacheEntryProblem {
218    /// A standard thumbnail metadata key is missing, malformed, or does not match.
219    Metadata(ThumbnailMetadataProblem),
220    /// The cache entry itself could not be read well enough to validate.
221    UnreadableEntry,
222    /// The original cannot be verified in this validation context.
223    UnverifiableOriginal,
224    /// PNG structure could not be decoded.
225    InvalidPngStructure,
226    /// PNG encoding does not conform to the successful-thumbnail requirements.
227    NonconformingPngFormat,
228    /// PNG dimensions exceed the requested namespace.
229    DimensionsExceedNamespace,
230    /// PNG decoding would exceed configured resource limits.
231    ResourceLimitExceeded,
232    /// The cache directory entry is not a standard thumbnail filename.
233    NonstandardFilename,
234    /// The standard cache filename does not match the stored thumbnail URI identity.
235    UriFilenameMismatch,
236}
237
238/// Standard thumbnail metadata key involved in a validation or inspection problem.
239#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
240#[non_exhaustive]
241pub enum ThumbnailMetadataKey {
242    /// `Thumb::URI`.
243    Uri,
244    /// `Thumb::MTime`.
245    Mtime,
246    /// `Thumb::Size`.
247    Size,
248    /// `Thumb::Mimetype`.
249    MimeType,
250}
251
252/// Kind of problem found for a standard thumbnail metadata key.
253#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
254#[non_exhaustive]
255pub enum ThumbnailMetadataProblemKind {
256    /// Required metadata is missing for this validation context.
257    MissingRequired,
258    /// Present metadata has invalid syntax.
259    InvalidSyntax,
260    /// Metadata is well-formed but does not match the expected value.
261    ValueMismatch,
262}
263
264/// Key-specific thumbnail metadata problem.
265#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
266pub struct ThumbnailMetadataProblem {
267    key: ThumbnailMetadataKey,
268    kind: ThumbnailMetadataProblemKind,
269}
270
271impl ThumbnailMetadataProblem {
272    /// Creates a metadata problem from its key and kind.
273    #[must_use]
274    pub const fn new(key: ThumbnailMetadataKey, kind: ThumbnailMetadataProblemKind) -> Self {
275        Self { key, kind }
276    }
277
278    /// Returns the affected metadata key.
279    #[must_use]
280    pub const fn key(self) -> ThumbnailMetadataKey {
281        self.key
282    }
283
284    /// Returns the metadata problem kind.
285    #[must_use]
286    pub const fn kind(self) -> ThumbnailMetadataProblemKind {
287        self.kind
288    }
289}
290
291/// Validation confidence and validity for a personal-cache entry.
292#[derive(Clone, Debug, Eq, PartialEq)]
293#[non_exhaustive]
294pub enum PersonalValidationOutcome {
295    /// Required metadata and PNG constraints are fully verified.
296    FullyVerified,
297    /// The entry is invalid for the requested validation context.
298    Invalid(Vec<CacheEntryProblem>),
299}
300
301/// Validation confidence and validity for a shared-repository entry.
302#[derive(Clone, Debug, Eq, PartialEq)]
303#[non_exhaustive]
304pub enum SharedValidationOutcome {
305    /// Required metadata and PNG constraints are fully verified.
306    FullyVerified,
307    /// Shared thumbnail metadata is standard-allowed but incomplete.
308    MetadataIncomplete,
309    /// The entry is invalid for the requested validation context.
310    Invalid(Vec<CacheEntryProblem>),
311}
312
313/// Freedesktop thumbnail PNG text metadata.
314#[derive(Clone, Debug, Default, Eq, PartialEq)]
315pub struct ThumbnailMetadata {
316    values: BTreeMap<String, String>,
317}
318
319impl ThumbnailMetadata {
320    /// Returns a raw metadata value by key.
321    #[must_use]
322    pub fn get(&self, key: &str) -> Option<&str> {
323        self.values.get(key).map(String::as_str)
324    }
325
326    /// Iterates over raw metadata key/value pairs.
327    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> + '_ {
328        self.values
329            .iter()
330            .map(|(key, value)| (key.as_str(), value.as_str()))
331    }
332
333    /// Returns `Thumb::URI` when present.
334    #[must_use]
335    pub fn thumb_uri(&self) -> Option<&str> {
336        self.get("Thumb::URI")
337    }
338
339    /// Returns parsed `Thumb::MTime`, or `None` when it is missing or syntactically invalid.
340    #[must_use]
341    pub fn thumb_mtime_lossy(&self) -> Option<UnixMtimeSeconds> {
342        self.try_thumb_mtime().ok().flatten()
343    }
344
345    /// Returns parsed `Thumb::MTime`, distinguishing missing metadata from invalid syntax.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error when `Thumb::MTime` is present but not a non-negative whole Unix epoch
350    /// second value.
351    pub fn try_thumb_mtime(&self) -> Result<Option<UnixMtimeSeconds>> {
352        self.get("Thumb::MTime").map(parse_thumb_mtime).transpose()
353    }
354
355    pub(crate) fn thumb_mtime_result(&self) -> Result<Option<UnixMtimeSeconds>> {
356        self.try_thumb_mtime()
357    }
358
359    /// Returns parsed `Thumb::Size`, or `None` when it is missing or syntactically invalid.
360    #[must_use]
361    pub fn thumb_size_lossy(&self) -> Option<u64> {
362        self.try_thumb_size().ok().flatten()
363    }
364
365    /// Returns parsed `Thumb::Size`, distinguishing missing metadata from invalid syntax.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error when `Thumb::Size` is present but is not an unsigned integer.
370    pub fn try_thumb_size(&self) -> Result<Option<u64>> {
371        self.get("Thumb::Size").map(parse_thumb_size).transpose()
372    }
373
374    pub(crate) fn thumb_size_result(&self) -> Result<Option<u64>> {
375        self.try_thumb_size()
376    }
377
378    /// Returns `Thumb::Mimetype` when present.
379    #[must_use]
380    pub fn thumb_mime_type(&self) -> Option<&str> {
381        self.get("Thumb::Mimetype")
382    }
383}
384
385fn parse_thumb_mtime(value: &str) -> Result<UnixMtimeSeconds> {
386    value
387        .parse::<u64>()
388        .map(UnixMtimeSeconds::new)
389        .map_err(|_| ThumbnailError::invalid_metadata("invalid Thumb::MTime"))
390}
391
392fn parse_thumb_size(value: &str) -> Result<u64> {
393    value
394        .parse::<u64>()
395        .map_err(|_| ThumbnailError::invalid_metadata("invalid Thumb::Size"))
396}
397
398/// PNG sample bit depth reported by [`ParsedThumbnailPng`].
399#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
400#[non_exhaustive]
401pub enum ThumbnailPngBitDepth {
402    /// 1-bit samples.
403    One,
404    /// 2-bit samples.
405    Two,
406    /// 4-bit samples.
407    Four,
408    /// 8-bit samples.
409    Eight,
410    /// 16-bit samples.
411    Sixteen,
412}
413
414impl ThumbnailPngBitDepth {
415    fn from_png(value: png::BitDepth) -> Self {
416        match value {
417            png::BitDepth::One => Self::One,
418            png::BitDepth::Two => Self::Two,
419            png::BitDepth::Four => Self::Four,
420            png::BitDepth::Eight => Self::Eight,
421            png::BitDepth::Sixteen => Self::Sixteen,
422        }
423    }
424}
425
426/// PNG color type reported by [`ParsedThumbnailPng`].
427#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
428#[non_exhaustive]
429pub enum ThumbnailPngColorType {
430    /// Grayscale samples without alpha.
431    Grayscale,
432    /// RGB samples without alpha.
433    Rgb,
434    /// Indexed-color samples.
435    Indexed,
436    /// Grayscale samples with alpha.
437    GrayscaleAlpha,
438    /// RGBA samples.
439    Rgba,
440}
441
442impl ThumbnailPngColorType {
443    fn from_png(value: png::ColorType) -> Self {
444        match value {
445            png::ColorType::Grayscale => Self::Grayscale,
446            png::ColorType::Rgb => Self::Rgb,
447            png::ColorType::Indexed => Self::Indexed,
448            png::ColorType::GrayscaleAlpha => Self::GrayscaleAlpha,
449            png::ColorType::Rgba => Self::Rgba,
450        }
451    }
452}
453
454/// Decoded facts from a thumbnail PNG.
455#[derive(Clone, Debug, Eq, PartialEq)]
456pub struct ParsedThumbnailPng {
457    width: u32,
458    height: u32,
459    bit_depth: ThumbnailPngBitDepth,
460    color_type: ThumbnailPngColorType,
461    interlaced: bool,
462    metadata: ThumbnailMetadata,
463}
464
465impl ParsedThumbnailPng {
466    /// Decodes enough PNG data to enforce resource limits and collect Freedesktop text metadata.
467    ///
468    /// # Errors
469    ///
470    /// Returns an error when PNG decoding fails, text metadata cannot be decoded, or decoding would
471    /// exceed the crate's pixel or output-buffer resource limits.
472    pub fn parse(bytes: &[u8]) -> Result<Self> {
473        let decoder = png::Decoder::new(Cursor::new(bytes));
474        let mut reader = decoder
475            .read_info()
476            .map_err(|err| ThumbnailError::png(err.to_string()))?;
477        let Some(output_buffer_size) = reader.output_buffer_size() else {
478            return Err(ThumbnailError::png(
479                "png output buffer size is unavailable".to_owned(),
480            ));
481        };
482        let info = reader.info();
483        ensure_parsed_png_resource_limits(info.width, info.height, output_buffer_size)?;
484        let mut buffer = vec![0; output_buffer_size];
485        reader
486            .next_frame(&mut buffer)
487            .map_err(|err| ThumbnailError::png(err.to_string()))?;
488
489        let info = reader.info();
490        let mut values = BTreeMap::new();
491        for chunk in &info.uncompressed_latin1_text {
492            values.insert(chunk.keyword.clone(), chunk.text.clone());
493        }
494        for chunk in &info.compressed_latin1_text {
495            let text = chunk
496                .get_text()
497                .map_err(|err| ThumbnailError::png(err.to_string()))?;
498            values.insert(chunk.keyword.clone(), text);
499        }
500        for chunk in &info.utf8_text {
501            let text = chunk
502                .get_text()
503                .map_err(|err| ThumbnailError::png(err.to_string()))?;
504            values.insert(chunk.keyword.clone(), text);
505        }
506
507        Ok(Self {
508            width: info.width,
509            height: info.height,
510            bit_depth: ThumbnailPngBitDepth::from_png(info.bit_depth),
511            color_type: ThumbnailPngColorType::from_png(info.color_type),
512            interlaced: info.interlaced,
513            metadata: ThumbnailMetadata { values },
514        })
515    }
516
517    /// Returns the image width in pixels.
518    #[must_use]
519    pub const fn width(&self) -> u32 {
520        self.width
521    }
522
523    /// Returns the image height in pixels.
524    #[must_use]
525    pub const fn height(&self) -> u32 {
526        self.height
527    }
528
529    /// Returns the image bit depth.
530    #[must_use]
531    pub const fn bit_depth(&self) -> ThumbnailPngBitDepth {
532        self.bit_depth
533    }
534
535    /// Returns the image color type.
536    #[must_use]
537    pub const fn color_type(&self) -> ThumbnailPngColorType {
538        self.color_type
539    }
540
541    /// Returns whether the PNG is interlaced.
542    #[must_use]
543    pub const fn interlaced(&self) -> bool {
544        self.interlaced
545    }
546
547    /// Returns parsed Freedesktop thumbnail metadata.
548    #[must_use]
549    pub const fn metadata(&self) -> &ThumbnailMetadata {
550        &self.metadata
551    }
552
553    pub(crate) fn into_metadata(self) -> ThumbnailMetadata {
554        self.metadata
555    }
556
557    /// Splits this parsed PNG into its decoded owned facts.
558    #[must_use]
559    pub fn into_parts(self) -> ParsedThumbnailPngParts {
560        ParsedThumbnailPngParts {
561            width: self.width,
562            height: self.height,
563            bit_depth: self.bit_depth,
564            color_type: self.color_type,
565            interlaced: self.interlaced,
566            metadata: self.metadata,
567        }
568    }
569
570    pub(crate) fn conformance_problems(&self, size: ThumbnailSize) -> Vec<CacheEntryProblem> {
571        let mut problems = Vec::new();
572        if self.bit_depth != ThumbnailPngBitDepth::Eight
573            || self.interlaced
574            || !matches!(
575                self.color_type,
576                ThumbnailPngColorType::Rgba | ThumbnailPngColorType::GrayscaleAlpha
577            )
578        {
579            push_problem(&mut problems, CacheEntryProblem::NonconformingPngFormat);
580        }
581        if self.width > size.max_dimension() || self.height > size.max_dimension() {
582            push_problem(&mut problems, CacheEntryProblem::DimensionsExceedNamespace);
583        }
584        problems
585    }
586}
587
588/// Owned parts of [`ParsedThumbnailPng`].
589#[derive(Clone, Debug, Eq, PartialEq)]
590#[non_exhaustive]
591pub struct ParsedThumbnailPngParts {
592    /// Image width in pixels.
593    pub width: u32,
594    /// Image height in pixels.
595    pub height: u32,
596    /// PNG sample bit depth.
597    pub bit_depth: ThumbnailPngBitDepth,
598    /// PNG color type.
599    pub color_type: ThumbnailPngColorType,
600    /// Whether the PNG is interlaced.
601    pub interlaced: bool,
602    /// Parsed Freedesktop thumbnail metadata.
603    pub metadata: ThumbnailMetadata,
604}
605
606fn ensure_parsed_png_resource_limits(
607    width: u32,
608    height: u32,
609    output_buffer_size: usize,
610) -> Result<()> {
611    let pixels = u64::from(width) * u64::from(height);
612    if pixels > MAX_RENDERED_PIXELS || output_buffer_size > MAX_RENDERED_DECODE_BYTES {
613        return Err(ThumbnailError::resource_limit_exceeded(
614            "PNG decode resource limit exceeded",
615        ));
616    }
617    Ok(())
618}
619
620fn parse_thumbnail_for_validation(
621    bytes: &[u8],
622) -> std::result::Result<ParsedThumbnailPng, CacheEntryProblem> {
623    match ParsedThumbnailPng::parse(bytes) {
624        Ok(parsed) => Ok(parsed),
625        Err(ThumbnailError::ResourceLimitExceeded { .. }) => {
626            Err(CacheEntryProblem::ResourceLimitExceeded)
627        }
628        Err(_) => Err(CacheEntryProblem::InvalidPngStructure),
629    }
630}
631
632/// Validates a personal-cache successful thumbnail PNG against a readable original identity.
633#[must_use]
634pub fn validate_personal_thumbnail(
635    bytes: &[u8],
636    original: &ReadablePersonalOriginalIdentity,
637    size: ThumbnailSize,
638) -> PersonalValidationOutcome {
639    validate_personal_thumbnail_identity(bytes, original.identity(), size)
640}
641
642pub(crate) fn validate_personal_thumbnail_identity(
643    bytes: &[u8],
644    original: &PersonalOriginalIdentity,
645    size: ThumbnailSize,
646) -> PersonalValidationOutcome {
647    let parsed = match parse_thumbnail_for_validation(bytes) {
648        Ok(parsed) => parsed,
649        Err(problem) => {
650            return PersonalValidationOutcome::Invalid(vec![problem]);
651        }
652    };
653
654    let mut problems = parsed.conformance_problems(size);
655    compare_personal_metadata(&mut problems, parsed.metadata(), original);
656
657    if problems.is_empty() {
658        PersonalValidationOutcome::FullyVerified
659    } else {
660        PersonalValidationOutcome::Invalid(problems)
661    }
662}
663
664/// Validates a personal-cache failure entry PNG against a readable original identity.
665///
666/// Failure entries carry the same required personal-cache metadata as successful thumbnails, but
667/// they are not successful-thumbnail size entries and are not checked against size-class dimension
668/// limits.
669#[must_use]
670pub fn validate_personal_failure_entry(
671    bytes: &[u8],
672    original: &ReadablePersonalOriginalIdentity,
673) -> PersonalValidationOutcome {
674    validate_personal_failure_entry_identity(bytes, original.identity())
675}
676
677pub(crate) fn validate_personal_failure_entry_identity(
678    bytes: &[u8],
679    original: &PersonalOriginalIdentity,
680) -> PersonalValidationOutcome {
681    let parsed = match parse_thumbnail_for_validation(bytes) {
682        Ok(parsed) => parsed,
683        Err(problem) => {
684            return PersonalValidationOutcome::Invalid(vec![problem]);
685        }
686    };
687
688    let mut problems = Vec::new();
689    compare_personal_metadata(&mut problems, parsed.metadata(), original);
690
691    if problems.is_empty() {
692        PersonalValidationOutcome::FullyVerified
693    } else {
694        PersonalValidationOutcome::Invalid(problems)
695    }
696}
697
698/// Validates a shared-repository successful thumbnail PNG against explicit shared context and
699/// policy-neutral original metadata facts.
700#[must_use]
701pub fn validate_shared_thumbnail(
702    bytes: &[u8],
703    context: &SharedRepositoryContext,
704    original: SharedOriginalMetadata,
705    size: ThumbnailSize,
706) -> SharedValidationOutcome {
707    let parsed = match parse_thumbnail_for_validation(bytes) {
708        Ok(parsed) => parsed,
709        Err(problem) => {
710            return SharedValidationOutcome::Invalid(vec![problem]);
711        }
712    };
713
714    let mut problems = parsed.conformance_problems(size);
715    let mut incomplete = false;
716    let metadata = parsed.metadata();
717
718    match metadata.thumb_uri() {
719        Some(uri) if uri == context.shared_uri().as_str() => {}
720        Some(uri) if SharedRelativeOriginalUri::parse(uri).is_err() => {
721            push_problem(
722                &mut problems,
723                metadata_problem(
724                    ThumbnailMetadataKey::Uri,
725                    ThumbnailMetadataProblemKind::InvalidSyntax,
726                ),
727            );
728        }
729        Some(_) => push_problem(
730            &mut problems,
731            metadata_problem(
732                ThumbnailMetadataKey::Uri,
733                ThumbnailMetadataProblemKind::ValueMismatch,
734            ),
735        ),
736        None => incomplete = true,
737    }
738
739    match (metadata.thumb_mtime_result(), original.mtime()) {
740        (Ok(Some(stored)), Some(expected)) if stored == expected => {}
741        (Ok(Some(_)), Some(_)) => push_problem(
742            &mut problems,
743            metadata_problem(
744                ThumbnailMetadataKey::Mtime,
745                ThumbnailMetadataProblemKind::ValueMismatch,
746            ),
747        ),
748        (Ok(Some(_)), None) => push_problem(&mut problems, CacheEntryProblem::UnverifiableOriginal),
749        (Ok(None), _) => incomplete = true,
750        (Err(_), _) => push_problem(
751            &mut problems,
752            metadata_problem(
753                ThumbnailMetadataKey::Mtime,
754                ThumbnailMetadataProblemKind::InvalidSyntax,
755            ),
756        ),
757    }
758
759    compare_optional_size(
760        &mut problems,
761        metadata,
762        original.original_byte_size(),
763        false,
764    );
765
766    if !problems.is_empty() {
767        SharedValidationOutcome::Invalid(problems)
768    } else if incomplete {
769        SharedValidationOutcome::MetadataIncomplete
770    } else {
771        SharedValidationOutcome::FullyVerified
772    }
773}
774
775fn compare_personal_metadata(
776    problems: &mut Vec<CacheEntryProblem>,
777    metadata: &ThumbnailMetadata,
778    original: &PersonalOriginalIdentity,
779) {
780    match metadata.thumb_uri() {
781        Some(uri) if uri == original.uri().as_str() => {}
782        Some(uri) if validate_absolute_uri_identity(uri).is_err() => {
783            push_problem(
784                problems,
785                metadata_problem(
786                    ThumbnailMetadataKey::Uri,
787                    ThumbnailMetadataProblemKind::InvalidSyntax,
788                ),
789            );
790        }
791        Some(_) => push_problem(
792            problems,
793            metadata_problem(
794                ThumbnailMetadataKey::Uri,
795                ThumbnailMetadataProblemKind::ValueMismatch,
796            ),
797        ),
798        None => push_problem(
799            problems,
800            metadata_problem(
801                ThumbnailMetadataKey::Uri,
802                ThumbnailMetadataProblemKind::MissingRequired,
803            ),
804        ),
805    }
806
807    match metadata.thumb_mtime_result() {
808        Ok(Some(mtime)) if mtime == original.mtime() => {}
809        Ok(Some(_)) => push_problem(
810            problems,
811            metadata_problem(
812                ThumbnailMetadataKey::Mtime,
813                ThumbnailMetadataProblemKind::ValueMismatch,
814            ),
815        ),
816        Ok(None) => push_problem(
817            problems,
818            metadata_problem(
819                ThumbnailMetadataKey::Mtime,
820                ThumbnailMetadataProblemKind::MissingRequired,
821            ),
822        ),
823        Err(_) => push_problem(
824            problems,
825            metadata_problem(
826                ThumbnailMetadataKey::Mtime,
827                ThumbnailMetadataProblemKind::InvalidSyntax,
828            ),
829        ),
830    }
831
832    compare_optional_size(problems, metadata, original.original_byte_size(), false);
833}
834
835fn compare_optional_size(
836    problems: &mut Vec<CacheEntryProblem>,
837    metadata: &ThumbnailMetadata,
838    expected: Option<u64>,
839    missing_is_problem: bool,
840) {
841    match (metadata.thumb_size_result(), expected) {
842        (Ok(Some(stored)), Some(expected)) if stored == expected => {}
843        (Ok(Some(_)), Some(_)) => push_problem(
844            problems,
845            metadata_problem(
846                ThumbnailMetadataKey::Size,
847                ThumbnailMetadataProblemKind::ValueMismatch,
848            ),
849        ),
850        (Ok(None), Some(_)) if missing_is_problem => push_problem(
851            problems,
852            metadata_problem(
853                ThumbnailMetadataKey::Size,
854                ThumbnailMetadataProblemKind::MissingRequired,
855            ),
856        ),
857        (Ok(_), _) => {}
858        (Err(_), _) => push_problem(
859            problems,
860            metadata_problem(
861                ThumbnailMetadataKey::Size,
862                ThumbnailMetadataProblemKind::InvalidSyntax,
863            ),
864        ),
865    }
866}
867
868pub(crate) const fn metadata_problem(
869    key: ThumbnailMetadataKey,
870    kind: ThumbnailMetadataProblemKind,
871) -> CacheEntryProblem {
872    CacheEntryProblem::Metadata(ThumbnailMetadataProblem::new(key, kind))
873}
874
875pub(crate) fn push_problem(problems: &mut Vec<CacheEntryProblem>, problem: CacheEntryProblem) {
876    if !problems.contains(&problem) {
877        problems.push(problem);
878    }
879}
880
881struct RgbaImage {
882    width: u32,
883    height: u32,
884    pixels: Vec<u8>,
885}
886
887pub(crate) struct DecodedThumbnailRgba8 {
888    pub(crate) width: u32,
889    pub(crate) height: u32,
890    pub(crate) stride: usize,
891    pub(crate) pixels: Vec<u8>,
892}
893
894pub(crate) fn normalized_personal_thumbnail_png(
895    rendered_png: &[u8],
896    original: &PersonalOriginalIdentity,
897    size: ThumbnailSize,
898) -> Result<Vec<u8>> {
899    let image = decode_rendered_png_to_rgba8(rendered_png)?;
900    normalized_personal_thumbnail_rgba_png(image, original, size)
901}
902
903pub(crate) fn normalized_personal_thumbnail_raw_png(
904    image: RawThumbnailImage<'_>,
905    original: &PersonalOriginalIdentity,
906    size: ThumbnailSize,
907) -> Result<Vec<u8>> {
908    let image = raw_thumbnail_to_rgba8(image)?;
909    normalized_personal_thumbnail_rgba_png(image, original, size)
910}
911
912pub(crate) fn normalized_personal_thumbnail_from_cache_png(
913    thumbnail_png: &[u8],
914    original: &PersonalOriginalIdentity,
915    size: ThumbnailSize,
916) -> Result<Vec<u8>> {
917    let image = decode_validated_thumbnail_png_to_rgba_image(thumbnail_png)?;
918    normalized_personal_thumbnail_rgba_png(image, original, size)
919}
920
921pub(crate) fn downscaled_validated_thumbnail_png_to_rgba8(
922    thumbnail_png: &[u8],
923    size: ThumbnailSize,
924) -> Result<DecodedThumbnailRgba8> {
925    let image = decode_validated_thumbnail_png_to_rgba_image(thumbnail_png)?;
926    let image = downscale_to_namespace(image, size)?;
927    Ok(DecodedThumbnailRgba8 {
928        width: image.width,
929        height: image.height,
930        stride: usize::try_from(image.width)
931            .ok()
932            .and_then(|width| width.checked_mul(4))
933            .ok_or_else(|| {
934                ThumbnailError::resource_limit_exceeded("PNG decode resource limit exceeded")
935            })?,
936        pixels: image.pixels,
937    })
938}
939
940fn normalized_personal_thumbnail_rgba_png(
941    image: RgbaImage,
942    original: &PersonalOriginalIdentity,
943    size: ThumbnailSize,
944) -> Result<Vec<u8>> {
945    let image = downscale_to_namespace(image, size)?;
946    let metadata = thumbnail_metadata_pairs(original);
947    let png = encode_rgba_png(image.width, image.height, &image.pixels, &metadata)?;
948    match validate_personal_thumbnail_identity(&png, original, size) {
949        PersonalValidationOutcome::FullyVerified => Ok(png),
950        PersonalValidationOutcome::Invalid(problems) => {
951            Err(ThumbnailError::unsupported_rendered_thumbnail(
952                rendered_validation_error(problems.as_slice()),
953            ))
954        }
955    }
956}
957
958fn rendered_validation_error(problems: &[CacheEntryProblem]) -> &'static str {
959    if problems.contains(&CacheEntryProblem::ResourceLimitExceeded) {
960        "resource limit exceeded"
961    } else if problems.contains(&CacheEntryProblem::DimensionsExceedNamespace) {
962        "dimensions exceed namespace"
963    } else if problems.contains(&CacheEntryProblem::NonconformingPngFormat) {
964        "nonconforming final PNG"
965    } else if problems.contains(&CacheEntryProblem::InvalidPngStructure) {
966        "invalid final PNG structure"
967    } else {
968        "metadata validation failed"
969    }
970}
971
972pub(crate) fn thumbnail_metadata_pairs(
973    original: &PersonalOriginalIdentity,
974) -> Vec<(String, String)> {
975    let mut metadata = vec![
976        ("Thumb::URI".to_owned(), original.uri().as_str().to_owned()),
977        ("Thumb::MTime".to_owned(), original.mtime().to_string()),
978    ];
979    if let Some(original_byte_size) = original.original_byte_size() {
980        metadata.push(("Thumb::Size".to_owned(), original_byte_size.to_string()));
981    }
982    if let Some(mime_type) = original.mime_type() {
983        metadata.push(("Thumb::Mimetype".to_owned(), mime_type.to_owned()));
984    }
985    metadata
986}
987
988fn ensure_rendered_resource_limits(
989    width: u32,
990    height: u32,
991    output_buffer_size: usize,
992) -> Result<()> {
993    let pixels = u64::from(width) * u64::from(height);
994    if pixels > MAX_RENDERED_PIXELS || output_buffer_size > MAX_RENDERED_DECODE_BYTES {
995        return Err(ThumbnailError::unsupported_rendered_thumbnail(
996            "rendered PNG resource limit exceeded",
997        ));
998    }
999    Ok(())
1000}
1001
1002fn validate_raw_thumbnail_image(
1003    width: u32,
1004    height: u32,
1005    stride: usize,
1006    format: RawThumbnailPixelFormat,
1007    pixels: &[u8],
1008) -> Result<()> {
1009    if width == 0 || height == 0 {
1010        return Err(ThumbnailError::unsupported_rendered_thumbnail(
1011            "raw thumbnail dimensions must be non-zero",
1012        ));
1013    }
1014
1015    let row_bytes = raw_row_bytes(width, format)?;
1016    let decoded_len = rgba_buffer_len(width, height)?;
1017    ensure_raw_resource_limits(width, height, decoded_len)?;
1018
1019    if stride < row_bytes {
1020        return Err(ThumbnailError::unsupported_rendered_thumbnail(
1021            "raw thumbnail stride is too small",
1022        ));
1023    }
1024
1025    let required_len = raw_required_buffer_len(height, stride, row_bytes)?;
1026    if pixels.len() < required_len {
1027        return Err(ThumbnailError::unsupported_rendered_thumbnail(
1028            "raw thumbnail buffer is too short",
1029        ));
1030    }
1031
1032    Ok(())
1033}
1034
1035fn ensure_raw_resource_limits(width: u32, height: u32, output_buffer_size: usize) -> Result<()> {
1036    ensure_rendered_resource_limits(width, height, output_buffer_size).map_err(
1037        |error| match error {
1038            ThumbnailError::UnsupportedRenderedThumbnail { .. } => {
1039                ThumbnailError::unsupported_rendered_thumbnail(
1040                    "raw thumbnail resource limit exceeded",
1041                )
1042            }
1043            error => error,
1044        },
1045    )
1046}
1047
1048fn raw_row_bytes(width: u32, format: RawThumbnailPixelFormat) -> Result<usize> {
1049    usize::try_from(width)
1050        .map_err(|_| {
1051            ThumbnailError::unsupported_rendered_thumbnail("raw thumbnail width overflows usize")
1052        })?
1053        .checked_mul(format.channels())
1054        .ok_or_else(|| {
1055            ThumbnailError::unsupported_rendered_thumbnail(
1056                "raw thumbnail row length overflows usize",
1057            )
1058        })
1059}
1060
1061fn raw_required_buffer_len(height: u32, stride: usize, row_bytes: usize) -> Result<usize> {
1062    let height = usize::try_from(height).map_err(|_| {
1063        ThumbnailError::unsupported_rendered_thumbnail("raw thumbnail height overflows usize")
1064    })?;
1065    stride
1066        .checked_mul(height.saturating_sub(1))
1067        .and_then(|bytes_before_last_row| bytes_before_last_row.checked_add(row_bytes))
1068        .ok_or_else(|| {
1069            ThumbnailError::unsupported_rendered_thumbnail(
1070                "raw thumbnail buffer length overflows usize",
1071            )
1072        })
1073}
1074
1075fn pixel_count_len(width: u32, height: u32) -> Result<usize> {
1076    let pixels = u64::from(width) * u64::from(height);
1077    usize::try_from(pixels).map_err(|_| {
1078        ThumbnailError::unsupported_rendered_thumbnail("rendered PNG dimensions are too large")
1079    })
1080}
1081
1082fn rgba_buffer_len(width: u32, height: u32) -> Result<usize> {
1083    pixel_count_len(width, height)?
1084        .checked_mul(4)
1085        .ok_or_else(|| {
1086            ThumbnailError::unsupported_rendered_thumbnail("RGBA buffer length overflows usize")
1087        })
1088}
1089
1090fn validated_lookup_rgba_buffer_len(width: u32, height: u32) -> Result<usize> {
1091    let pixels = u64::from(width) * u64::from(height);
1092    let len = usize::try_from(pixels)
1093        .ok()
1094        .and_then(|pixels| pixels.checked_mul(4))
1095        .ok_or_else(|| {
1096            ThumbnailError::resource_limit_exceeded("PNG decode resource limit exceeded")
1097        })?;
1098    if len > MAX_RENDERED_DECODE_BYTES {
1099        return Err(ThumbnailError::resource_limit_exceeded(
1100            "PNG decode resource limit exceeded",
1101        ));
1102    }
1103    Ok(len)
1104}
1105
1106pub(crate) fn decode_validated_thumbnail_png_to_rgba8(
1107    bytes: &[u8],
1108) -> Result<DecodedThumbnailRgba8> {
1109    let image = decode_validated_thumbnail_png_to_rgba_image(bytes)?;
1110    Ok(DecodedThumbnailRgba8 {
1111        width: image.width,
1112        height: image.height,
1113        stride: usize::try_from(image.width)
1114            .ok()
1115            .and_then(|width| width.checked_mul(4))
1116            .ok_or_else(|| {
1117                ThumbnailError::resource_limit_exceeded("PNG decode resource limit exceeded")
1118            })?,
1119        pixels: image.pixels,
1120    })
1121}
1122
1123fn decode_validated_thumbnail_png_to_rgba_image(bytes: &[u8]) -> Result<RgbaImage> {
1124    let decoder = png::Decoder::new(Cursor::new(bytes));
1125    let mut reader = decoder
1126        .read_info()
1127        .map_err(|err| ThumbnailError::png(err.to_string()))?;
1128    let Some(output_buffer_size) = reader.output_buffer_size() else {
1129        return Err(ThumbnailError::png(
1130            "png output buffer size is unavailable".to_owned(),
1131        ));
1132    };
1133    let info = reader.info();
1134    ensure_parsed_png_resource_limits(info.width, info.height, output_buffer_size)?;
1135    let mut buffer = vec![0; output_buffer_size];
1136    let output = reader
1137        .next_frame(&mut buffer)
1138        .map_err(|err| ThumbnailError::png(err.to_string()))?;
1139    if output.bit_depth != png::BitDepth::Eight {
1140        return Err(ThumbnailError::png(
1141            "validated thumbnail did not decode to 8-bit samples".to_owned(),
1142        ));
1143    }
1144
1145    let expected_len = validated_lookup_rgba_buffer_len(output.width, output.height)?;
1146    let frame = &buffer[..output.buffer_size()];
1147    let pixels = match output.color_type {
1148        png::ColorType::Rgba => {
1149            if frame.len() != expected_len {
1150                return Err(ThumbnailError::png(
1151                    "decoded RGBA buffer length does not match dimensions".to_owned(),
1152                ));
1153            }
1154            frame.to_vec()
1155        }
1156        png::ColorType::GrayscaleAlpha => {
1157            let expected_gray_alpha_len = expected_len / 2;
1158            if frame.len() != expected_gray_alpha_len {
1159                return Err(ThumbnailError::png(
1160                    "decoded grayscale-alpha buffer length does not match dimensions".to_owned(),
1161                ));
1162            }
1163            let mut out = Vec::with_capacity(expected_len);
1164            for pixel in frame.chunks_exact(2) {
1165                out.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]);
1166            }
1167            out
1168        }
1169        png::ColorType::Grayscale | png::ColorType::Rgb | png::ColorType::Indexed => {
1170            return Err(ThumbnailError::png(
1171                "validated thumbnail color type is not RGBA or grayscale-alpha".to_owned(),
1172            ));
1173        }
1174    };
1175
1176    Ok(RgbaImage {
1177        width: output.width,
1178        height: output.height,
1179        pixels,
1180    })
1181}
1182
1183fn decode_rendered_png_to_rgba8(bytes: &[u8]) -> Result<RgbaImage> {
1184    let mut decoder = png::Decoder::new(Cursor::new(bytes));
1185    decoder.set_transformations(
1186        png::Transformations::EXPAND | png::Transformations::STRIP_16 | png::Transformations::ALPHA,
1187    );
1188    let mut reader = decoder
1189        .read_info()
1190        .map_err(|err| ThumbnailError::png(err.to_string()))?;
1191    let info = reader.info();
1192    if info.animation_control.is_some() {
1193        return Err(ThumbnailError::unsupported_rendered_thumbnail(
1194            "animated PNG output is unsupported",
1195        ));
1196    }
1197    let Some(output_buffer_size) = reader.output_buffer_size() else {
1198        return Err(ThumbnailError::png(
1199            "png output buffer size is unavailable".to_owned(),
1200        ));
1201    };
1202    ensure_rendered_resource_limits(info.width, info.height, output_buffer_size)?;
1203    let mut buffer = vec![0; output_buffer_size];
1204    let output = reader
1205        .next_frame(&mut buffer)
1206        .map_err(|err| ThumbnailError::png(err.to_string()))?;
1207    let frame = &buffer[..output.buffer_size()];
1208    if output.bit_depth != png::BitDepth::Eight {
1209        return Err(ThumbnailError::unsupported_rendered_thumbnail(
1210            "decoded PNG did not normalize to 8-bit samples",
1211        ));
1212    }
1213
1214    let pixels = match output.color_type {
1215        png::ColorType::Rgba => frame.to_vec(),
1216        png::ColorType::Rgb => {
1217            let mut out = Vec::with_capacity(rgba_buffer_len(output.width, output.height)?);
1218            for pixel in frame.chunks_exact(3) {
1219                out.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]);
1220            }
1221            out
1222        }
1223        png::ColorType::GrayscaleAlpha => {
1224            let mut out = Vec::with_capacity(rgba_buffer_len(output.width, output.height)?);
1225            for pixel in frame.chunks_exact(2) {
1226                out.extend_from_slice(&[pixel[0], pixel[0], pixel[0], pixel[1]]);
1227            }
1228            out
1229        }
1230        png::ColorType::Grayscale | png::ColorType::Indexed => {
1231            let mut out = Vec::with_capacity(rgba_buffer_len(output.width, output.height)?);
1232            for &gray in frame {
1233                out.extend_from_slice(&[gray, gray, gray, 255]);
1234            }
1235            out
1236        }
1237    };
1238
1239    Ok(RgbaImage {
1240        width: output.width,
1241        height: output.height,
1242        pixels,
1243    })
1244}
1245
1246fn raw_thumbnail_to_rgba8(image: RawThumbnailImage<'_>) -> Result<RgbaImage> {
1247    let row_bytes = raw_row_bytes(image.width, image.format)?;
1248    let mut pixels = Vec::with_capacity(rgba_buffer_len(image.width, image.height)?);
1249    for row_index in 0..image.height as usize {
1250        let start = row_index * image.stride;
1251        let row = &image.pixels[start..start + row_bytes];
1252        match image.format {
1253            RawThumbnailPixelFormat::Rgb8 => {
1254                for pixel in row.chunks_exact(3) {
1255                    pixels.extend_from_slice(&[pixel[0], pixel[1], pixel[2], 255]);
1256                }
1257            }
1258            RawThumbnailPixelFormat::Rgba8 => pixels.extend_from_slice(row),
1259        }
1260    }
1261    Ok(RgbaImage {
1262        width: image.width,
1263        height: image.height,
1264        pixels,
1265    })
1266}
1267
1268fn downscale_to_namespace(image: RgbaImage, size: ThumbnailSize) -> Result<RgbaImage> {
1269    let max = size.max_dimension();
1270    if image.width <= max && image.height <= max {
1271        return Ok(image);
1272    }
1273    let (width, height) = constrain_dimensions(image.width, image.height, max);
1274    let source = image
1275        .pixels
1276        .chunks_exact(4)
1277        .map(|pixel| resize::px::RGBA::new(pixel[0], pixel[1], pixel[2], pixel[3]))
1278        .collect::<Vec<_>>();
1279    let mut dest = vec![resize::px::RGBA::new(0, 0, 0, 0); pixel_count_len(width, height)?];
1280    let mut resizer = resize::new(
1281        image.width as usize,
1282        image.height as usize,
1283        width as usize,
1284        height as usize,
1285        resize::Pixel::RGBA8P,
1286        resize::Type::Lanczos3,
1287    )
1288    .map_err(|_| ThumbnailError::unsupported_rendered_thumbnail("resize setup failed"))?;
1289    resizer
1290        .resize(&source, &mut dest)
1291        .map_err(|_| ThumbnailError::unsupported_rendered_thumbnail("resize failed"))?;
1292    let mut pixels = Vec::with_capacity(dest.len() * 4);
1293    for pixel in dest {
1294        pixels.extend_from_slice(&[pixel.r, pixel.g, pixel.b, pixel.a]);
1295    }
1296    Ok(RgbaImage {
1297        width,
1298        height,
1299        pixels,
1300    })
1301}
1302
1303fn constrain_dimensions(width: u32, height: u32, max: u32) -> (u32, u32) {
1304    if width >= height {
1305        let scaled_height = (u64::from(height) * u64::from(max) / u64::from(width)).max(1) as u32;
1306        (max, scaled_height)
1307    } else {
1308        let scaled_width = (u64::from(width) * u64::from(max) / u64::from(height)).max(1) as u32;
1309        (scaled_width, max)
1310    }
1311}
1312
1313pub(crate) fn encode_rgba_png(
1314    width: u32,
1315    height: u32,
1316    pixels: &[u8],
1317    metadata: &[(String, String)],
1318) -> Result<Vec<u8>> {
1319    let expected_len = rgba_buffer_len(width, height)?;
1320    if pixels.len() != expected_len {
1321        return Err(ThumbnailError::unsupported_rendered_thumbnail(
1322            "RGBA buffer length does not match dimensions",
1323        ));
1324    }
1325    let mut output = Vec::new();
1326    {
1327        let mut encoder = png::Encoder::new(&mut output, width, height);
1328        encoder.set_color(png::ColorType::Rgba);
1329        encoder.set_depth(png::BitDepth::Eight);
1330        for (key, value) in metadata {
1331            encoder
1332                .add_text_chunk(key.clone(), value.clone())
1333                .map_err(|err| ThumbnailError::png(err.to_string()))?;
1334        }
1335        let mut writer = encoder
1336            .write_header()
1337            .map_err(|err| ThumbnailError::png(err.to_string()))?;
1338        writer
1339            .write_image_data(pixels)
1340            .map_err(|err| ThumbnailError::png(err.to_string()))?;
1341    }
1342    Ok(output)
1343}
1344
1345pub(crate) fn validate_mime_type(mime_type: &str) -> Result<()> {
1346    if mime_type.is_empty()
1347        || !mime_type.is_ascii()
1348        || mime_type.bytes().any(|byte| byte.is_ascii_control())
1349        || !mime_type.contains('/')
1350    {
1351        return Err(ThumbnailError::invalid_metadata("invalid MIME type"));
1352    }
1353    Ok(())
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358    use super::*;
1359
1360    #[test]
1361    fn rendered_resource_limit_rejects_large_dimensions() {
1362        let error = ensure_rendered_resource_limits(4097, 4097, 4097 * 4097 * 4).unwrap_err();
1363        assert!(matches!(
1364            error,
1365            ThumbnailError::UnsupportedRenderedThumbnail {
1366                reason: "rendered PNG resource limit exceeded",
1367                ..
1368            }
1369        ));
1370    }
1371
1372    #[test]
1373    fn rgba_buffer_len_rejects_overflow() {
1374        let error = rgba_buffer_len(u32::MAX, u32::MAX).unwrap_err();
1375        assert!(matches!(
1376            error,
1377            ThumbnailError::UnsupportedRenderedThumbnail {
1378                reason: "RGBA buffer length overflows usize",
1379                ..
1380            }
1381        ));
1382    }
1383
1384    #[test]
1385    fn rendered_png_rejects_apng() {
1386        let mut output = Vec::new();
1387        {
1388            let mut encoder = png::Encoder::new(&mut output, 1, 1);
1389            encoder.set_color(png::ColorType::Rgba);
1390            encoder.set_depth(png::BitDepth::Eight);
1391            encoder.set_animated(1, 0).unwrap();
1392            let mut writer = encoder.write_header().unwrap();
1393            writer.write_image_data(&[255, 0, 0, 255]).unwrap();
1394        }
1395
1396        let error = match decode_rendered_png_to_rgba8(&output) {
1397            Ok(_) => panic!("APNG rendered output was accepted"),
1398            Err(error) => error,
1399        };
1400        assert!(matches!(
1401            error,
1402            ThumbnailError::UnsupportedRenderedThumbnail {
1403                reason: "animated PNG output is unsupported",
1404                ..
1405            }
1406        ));
1407    }
1408}