tuwunel_service/media/thumbnail/sniff.rs
1//! Sniffs containers for animation.
2//!
3//! A picture names its own format in its first bytes and carries its frame
4//! sequence, if it has one, at a place each format fixes. Reading those is what
5//! lets a request for a still be honored against a file whose declared content
6//! type says otherwise, or says nothing useful, as `image/png` does for an
7//! APNG.
8//!
9//! A walk answers one of three states, because the two questions asked of it
10//! take opposite defaults. Whether a picture may be served fails closed, so an
11//! unreadable one is withheld and a wrong answer costs a needless re-encode
12//! rather than a violation; what a picture is names nothing the walk did not
13//! settle, since a guess there would be written down as fact.
14
15use tuwunel_core::{implement, utils::math::checked_ops};
16
17use super::animate::{APNG, GIF, WEBP};
18
19// Leading signatures of the three animating containers.
20const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n";
21const RIFF_MAGIC: &[u8] = b"RIFF";
22const WEBP_MAGIC: &[u8] = b"WEBP";
23const GIF_MAGIC: [&[u8]; 2] = [b"GIF87a", b"GIF89a"];
24
25// Chunk names that settle a PNG.
26//
27// The animation control is required to precede the pixel data, so meeting
28// the data first settles the question without reading any of it.
29const PNG_ANIMATION: &[u8] = b"acTL";
30const PNG_DATA: &[u8] = b"IDAT";
31
32// Chunk names a WebP can open with, and the animation flag's own bit.
33//
34// Only the extended form can hold a frame sequence, so a file opening with a
35// plain lossy or lossless chunk is a still without reading further.
36const WEBP_EXTENDED: &[u8] = b"VP8X";
37const WEBP_LOSSY: &[u8] = b"VP8 ";
38const WEBP_LOSSLESS: &[u8] = b"VP8L";
39const WEBP_ANIMATION: u8 = 0x02;
40
41// Leading byte of each block in a GIF body.
42//
43// A body is a flat sequence of these, ending at the trailer, and animation
44// is the presence of a second image rather than anything the format states.
45const GIF_EXTENSION: u8 = 0x21;
46const GIF_IMAGE: u8 = 0x2C;
47const GIF_TRAILER: u8 = 0x3B;
48
49// GIF colour-table flag and size bits.
50//
51// The flag says whether one follows and the size field holds the exponent of
52// its entry count, each entry being a three byte colour.
53const GIF_COLOR_TABLE: u8 = 0x80;
54const GIF_TABLE_SIZE: u8 = 0x07;
55
56/// Bytes the signature and the logical screen descriptor occupy together.
57///
58/// A global colour table, when one is announced, follows them, and the block
59/// sequence follows that.
60const GIF_HEADER_LEN: usize = 13;
61
62/// What a container's header settles about a frame sequence.
63///
64/// The two questions a caller asks of it want opposite defaults, so an
65/// unsettled walk is its own answer rather than being folded into either.
66#[derive(Clone, Copy)]
67pub(in super::super) enum Sequence {
68 /// This picture holds no sequence, either because its container cannot or
69 /// because the walk reached the end of one.
70 Absent,
71
72 /// The walk found a sequence, in a container of this type.
73 Present(&'static str),
74
75 /// The walk ran out of picture, or met a structure it does not know.
76 Unsettled,
77}
78
79/// Whether these bytes may carry more than one frame.
80///
81/// Walks the container first, for a caller holding no walk of its own.
82#[inline]
83pub(in super::super) fn animates(bytes: &[u8]) -> bool { sequence(bytes).animates() }
84
85/// Whether the walked picture may carry more than one frame.
86///
87/// Only a settled walk answers false, so a truncated or unrecognized picture is
88/// withheld from a request that forbade animation rather than served to it.
89#[implement(Sequence)]
90#[inline]
91pub(in super::super) fn animates(self) -> bool { !matches!(self, Self::Absent) }
92
93/// Whether the walk settled on an animation rather than merely allowing one.
94///
95/// This is the narrower half of [`Self::animates`], which withholds an
96/// unsettled walk too: naming a picture takes proof where refusing to serve one
97/// does not.
98#[cfg(feature = "media_thumbnail")]
99#[implement(Sequence)]
100#[inline]
101pub(in super::super) fn names_animation(self) -> bool { self.animated_type().is_some() }
102
103/// The container type a picture's own bytes name.
104///
105/// Walks the container first, for a caller holding no walk of its own.
106#[inline]
107pub(in super::super) fn animated_type(bytes: &[u8]) -> Option<&'static str> {
108 sequence(bytes).animated_type()
109}
110
111/// The content type the walked picture ought to be stored under.
112///
113/// A settled walk names the container itself, and the declared type stands
114/// where the walk settled nothing. The label is what a lookup goes on wherever
115/// the picture is not in hand: choosing between the rows at a size walks keys
116/// alone, and a redirect hands the object over without reading it.
117#[implement(Sequence)]
118#[inline]
119pub(in super::super) fn stored_type(self, declared: Option<&str>) -> Option<&str> {
120 self.animated_type().or(declared)
121}
122
123/// The container type the walked picture's own bytes name.
124///
125/// Only a settled walk answers `Some`, since this names what a picture is
126/// rather than deciding what may be served, and a guess would be recorded as
127/// fact.
128#[implement(Sequence)]
129#[inline]
130fn animated_type(self) -> Option<&'static str> {
131 match self {
132 | Self::Present(content_type) => Some(content_type),
133 | Self::Absent | Self::Unsettled => None,
134 }
135}
136
137/// Walks the container its first bytes name.
138///
139/// A container that cannot hold a sequence answers `Absent`, and so does one
140/// whose walk reaches the end of its blocks without finding a second frame. A
141/// walk that runs out of picture, or meets a structure it does not know,
142/// answers `Unsettled` instead of guessing either way.
143pub(in super::super) fn sequence(bytes: &[u8]) -> Sequence {
144 let is_gif = GIF_MAGIC
145 .iter()
146 .any(|magic| bytes.starts_with(magic));
147
148 let (content_type, settled) = match bytes {
149 | _ if bytes.starts_with(PNG_MAGIC) => (APNG, png_sequence(bytes)),
150 | _ if bytes.starts_with(RIFF_MAGIC) && bytes.get(8..12) == Some(WEBP_MAGIC) =>
151 (WEBP, webp_sequence(bytes)),
152 | _ if is_gif => (GIF, gif_sequence(bytes)),
153 | _ => return Sequence::Absent,
154 };
155
156 match settled {
157 | Some(true) => Sequence::Present(content_type),
158 | Some(false) => Sequence::Absent,
159 | None => Sequence::Unsettled,
160 }
161}
162
163/// Whether a PNG holds the control chunk that makes it an APNG.
164///
165/// Each hop clears a whole chunk, so the walk advances by at least its twelve
166/// byte frame every time and ends when a hop lands past the picture.
167fn png_sequence(bytes: &[u8]) -> Option<bool> {
168 let mut rest = bytes.get(PNG_MAGIC.len()..)?;
169
170 loop {
171 let kind = rest.get(4..8)?;
172
173 if kind == PNG_ANIMATION {
174 return Some(true);
175 }
176
177 if kind == PNG_DATA {
178 return Some(false);
179 }
180
181 // the length counts the data alone, which follows a four byte length
182 // and a four byte type and precedes a four byte checksum
183 rest = rest
184 .get(..4)
185 .and_then(|field| field.try_into().ok())
186 .map(u32::from_be_bytes)
187 .and_then(|length| usize::try_from(length).ok())
188 .and_then(|length| length.checked_add(12))
189 .and_then(|skip| rest.get(skip..))?;
190 }
191}
192
193/// Whether a WebP announces animation in its opening chunk.
194///
195/// The chunk name and the flags sit at fixed offsets, so this reads two fields
196/// and never walks. Only the two plain still forms answer as a still, since a
197/// chunk name neither they nor the extended header claim is a structure this
198/// does not recognize.
199fn webp_sequence(bytes: &[u8]) -> Option<bool> {
200 let chunk = bytes.get(12..16)?;
201
202 match chunk {
203 | _ if chunk == WEBP_LOSSY || chunk == WEBP_LOSSLESS => Some(false),
204 | _ if chunk == WEBP_EXTENDED => bytes
205 .get(20)
206 .map(|flags| flags & WEBP_ANIMATION != 0),
207 | _ => None,
208 }
209}
210
211/// Whether a GIF holds more than one image descriptor.
212///
213/// Frame count is written nowhere in the format, so the blocks are walked
214/// until a second image is found or the trailer ends them. Every block clears
215/// at least its own introducer and a terminating sub-block, so the offset
216/// rises on every pass and a walk that runs off the picture ends.
217fn gif_sequence(bytes: &[u8]) -> Option<bool> {
218 let &screen = bytes.get(10)?;
219 let global_table = (screen & GIF_COLOR_TABLE != 0)
220 .then(|| color_table_len(screen))
221 .unwrap_or_default();
222
223 let mut at = global_table.checked_add(GIF_HEADER_LEN)?;
224 let mut seen = false;
225
226 loop {
227 let &block = bytes.get(at)?;
228 let image = block == GIF_IMAGE;
229
230 if image && seen {
231 return Some(true);
232 }
233
234 seen |= image;
235
236 let next = match block {
237 | GIF_TRAILER => return Some(false),
238 | GIF_EXTENSION => at.checked_add(2),
239 | GIF_IMAGE => image_data_start(bytes, at),
240 | _ => return None,
241 };
242
243 at = next.and_then(|next| skip_sub_blocks(bytes, next))?;
244 }
245}
246
247/// Size in bytes of the colour table a descriptor announces.
248///
249/// The size field is an exponent rather than a count, and each entry is a
250/// three byte colour.
251fn color_table_len(packed: u8) -> usize {
252 let exponent = u32::from(packed & GIF_TABLE_SIZE).saturating_add(1);
253
254 2_usize.saturating_pow(exponent).saturating_mul(3)
255}
256
257/// Offset of an image descriptor's sub-block chain.
258///
259/// The descriptor is a fixed nine bytes carrying the flag for a colour table
260/// of its own, and one further byte holds the code size the data opens with.
261fn image_data_start(bytes: &[u8], at: usize) -> Option<usize> {
262 let descriptor = at.checked_add(1)?;
263 let packed = *bytes.get(descriptor.checked_add(8)?)?;
264 let local_table = (packed & GIF_COLOR_TABLE != 0)
265 .then(|| color_table_len(packed))
266 .unwrap_or_default();
267
268 checked_ops!(descriptor + 9 + local_table + 1)
269}
270
271/// Offset past the sub-block chain starting here, when it ends.
272///
273/// Each block announces its own length and a zero length closes the chain, so
274/// every pass clears at least the length byte and a chain that neither closes
275/// nor runs out of picture cannot occur.
276fn skip_sub_blocks(bytes: &[u8], mut at: usize) -> Option<usize> {
277 loop {
278 let size = usize::from(*bytes.get(at)?);
279
280 at = checked_ops!(at + 1 + size)?;
281
282 if size == 0 {
283 return Some(at);
284 }
285 }
286}