Skip to main content

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}