Skip to main content

webp_anim/
inspect.rs

1use std::{error::Error, fmt};
2
3use libwebp_sys::WebPGetInfo;
4
5use crate::{
6    codec::decode::{AnimationDecoder, DecodeError, DecodeLimits},
7    model::{CanvasSize, StaticWebpInfo},
8};
9
10const RIFF_HEADER_LEN: usize = 12;
11const RIFF_CHUNK_HEADER_LEN: usize = 8;
12const WEBP_ANIMATION_FLAG: u8 = 0x02;
13
14/// Resource limits used while classifying a WebP container and reading its
15/// animation metadata.
16///
17/// The [`Default`] values mirror [`DecodeLimits::default`]. Metadata inspection
18/// does not decode the complete frame sequence, but animated inputs are still
19/// checked against the canvas, frame-count, and per-frame RGBA limits.
20#[derive(Clone, Debug)]
21pub struct InspectLimits {
22    /// Maximum number of input bytes accepted by [`inspect`].
23    pub max_input_bytes: usize,
24    /// Maximum number of pixels in the image canvas.
25    pub max_canvas_pixels: u64,
26    /// Maximum number of frames reported for an animated input.
27    pub max_frame_count: u32,
28    /// Maximum number of bytes in one full-canvas RGBA frame.
29    pub max_frame_rgba_bytes: usize,
30}
31
32impl Default for InspectLimits {
33    fn default() -> Self {
34        let decode = DecodeLimits::default();
35        Self {
36            max_input_bytes: decode.max_input_bytes,
37            max_canvas_pixels: decode.max_canvas_pixels,
38            max_frame_count: decode.max_frame_count,
39            max_frame_rgba_bytes: decode.max_frame_rgba_bytes,
40        }
41    }
42}
43
44impl InspectLimits {
45    /// Relaxes crate-level metadata inspection limits for trusted input.
46    ///
47    /// This does not bypass libwebp or platform allocation limits.
48    pub const fn for_trusted_input() -> Self {
49        Self {
50            max_input_bytes: usize::MAX,
51            max_canvas_pixels: u64::MAX,
52            max_frame_count: u32::MAX,
53            max_frame_rgba_bytes: usize::MAX,
54        }
55    }
56}
57
58/// The kind and metadata of a valid WebP image.
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum WebpKind {
61    /// A non-animated WebP image and its canvas dimensions.
62    Static(StaticWebpInfo),
63    /// One stored animated WebP sequence and its metadata.
64    Animated(crate::model::AnimationInfo),
65}
66
67/// Failure while classifying a WebP container or reading its metadata.
68#[derive(Clone, Debug, Eq, PartialEq)]
69pub enum InspectError {
70    /// The input is larger than the configured byte limit.
71    InputTooLarge {
72        /// Actual input length in bytes.
73        actual: usize,
74        /// Configured maximum input length in bytes.
75        maximum: usize,
76    },
77    /// The input does not begin with a RIFF/WebP container header.
78    InvalidContainer,
79    /// The RIFF/WebP container ends before its declared chunks do.
80    TruncatedContainer,
81    /// libwebp rejected the image payload as invalid WebP data.
82    InvalidWebp,
83    /// A configured inspection limit was exceeded.
84    LimitExceeded {
85        /// Name of the exceeded limit.
86        limit: &'static str,
87        /// Observed value.
88        actual: u64,
89        /// Configured maximum value.
90        maximum: u64,
91    },
92    /// Animated metadata inspection failed while constructing a decoder.
93    Animation(DecodeError),
94}
95
96impl fmt::Display for InspectError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            Self::InputTooLarge { actual, maximum } => {
100                write!(
101                    f,
102                    "input is {actual} bytes, exceeding the {maximum}-byte limit"
103                )
104            }
105            Self::InvalidContainer => f.write_str("input is not a RIFF/WebP container"),
106            Self::TruncatedContainer => {
107                f.write_str("WebP RIFF container is truncated or malformed")
108            }
109            Self::InvalidWebp => f.write_str("input is not a valid WebP image"),
110            Self::LimitExceeded {
111                limit,
112                actual,
113                maximum,
114            } => {
115                write!(f, "{limit} is {actual}, exceeding the {maximum} limit")
116            }
117            Self::Animation(error) => write!(f, "failed to inspect animated WebP: {error}"),
118        }
119    }
120}
121
122impl Error for InspectError {}
123
124/// Inspects container kind and metadata without decoding its complete frame sequence.
125pub fn inspect(input: &[u8], limits: InspectLimits) -> Result<WebpKind, InspectError> {
126    if input.len() > limits.max_input_bytes {
127        return Err(InspectError::InputTooLarge {
128            actual: input.len(),
129            maximum: limits.max_input_bytes,
130        });
131    }
132    let animated = animation_flag(input)?;
133
134    let mut width = 0_i32;
135    let mut height = 0_i32;
136    // SAFETY: `input` stays live for the call and the width/height pointers are writable.
137    if unsafe { WebPGetInfo(input.as_ptr(), input.len(), &mut width, &mut height) } == 0 {
138        return Err(InspectError::InvalidWebp);
139    }
140    let canvas = CanvasSize {
141        width: u32::try_from(width).map_err(|_| InspectError::InvalidWebp)?,
142        height: u32::try_from(height).map_err(|_| InspectError::InvalidWebp)?,
143    };
144    enforce_canvas_limit(canvas, limits.max_canvas_pixels)?;
145
146    if !animated {
147        return Ok(WebpKind::Static(StaticWebpInfo { canvas }));
148    }
149
150    let decode_limits = DecodeLimits {
151        max_input_bytes: limits.max_input_bytes,
152        max_canvas_pixels: limits.max_canvas_pixels,
153        max_frame_count: limits.max_frame_count,
154        max_frame_rgba_bytes: limits.max_frame_rgba_bytes,
155        ..DecodeLimits::default()
156    };
157    let decoder = AnimationDecoder::new(input, decode_limits).map_err(InspectError::Animation)?;
158    Ok(WebpKind::Animated(*decoder.info()))
159}
160
161/// Cheap boolean classification for routing. Use [`inspect`] when malformed input must be reported.
162pub fn is_animated_webp_fast(input: &[u8]) -> bool {
163    animation_flag(input).unwrap_or(false)
164}
165
166fn animation_flag(input: &[u8]) -> Result<bool, InspectError> {
167    if input.len() < RIFF_HEADER_LEN {
168        return Err(InspectError::TruncatedContainer);
169    }
170    if &input[..4] != b"RIFF" || &input[8..RIFF_HEADER_LEN] != b"WEBP" {
171        return Err(InspectError::InvalidContainer);
172    }
173    let riff_size =
174        usize::try_from(u32::from_le_bytes(input[4..8].try_into().unwrap())).unwrap_or(usize::MAX);
175    let container_end = riff_size
176        .checked_add(8)
177        .ok_or(InspectError::TruncatedContainer)?;
178    if container_end > input.len() {
179        return Err(InspectError::TruncatedContainer);
180    }
181
182    let mut offset = RIFF_HEADER_LEN;
183    while offset < container_end {
184        let header_end = offset
185            .checked_add(RIFF_CHUNK_HEADER_LEN)
186            .ok_or(InspectError::TruncatedContainer)?;
187        if header_end > container_end {
188            return Err(InspectError::TruncatedContainer);
189        }
190        let chunk = &input[offset..offset + 4];
191        let length = usize::try_from(u32::from_le_bytes(
192            input[offset + 4..header_end].try_into().unwrap(),
193        ))
194        .unwrap_or(usize::MAX);
195        let payload = header_end;
196        let padded_length = length
197            .checked_add(length & 1)
198            .ok_or(InspectError::TruncatedContainer)?;
199        let next = payload
200            .checked_add(padded_length)
201            .ok_or(InspectError::TruncatedContainer)?;
202        if next > container_end {
203            return Err(InspectError::TruncatedContainer);
204        }
205        if chunk == b"VP8X" {
206            if length < 10 {
207                return Err(InspectError::TruncatedContainer);
208            }
209            return Ok(input[payload] & WEBP_ANIMATION_FLAG != 0);
210        }
211        offset = next;
212    }
213    Ok(false)
214}
215
216fn enforce_canvas_limit(canvas: CanvasSize, maximum: u64) -> Result<(), InspectError> {
217    let actual = canvas.pixel_count().ok_or(InspectError::InvalidWebp)?;
218    if actual > maximum {
219        return Err(InspectError::LimitExceeded {
220            limit: "canvas pixels",
221            actual,
222            maximum,
223        });
224    }
225    Ok(())
226}