strypt_core/detect.rs
1//! Format detection by content sniffing.
2//!
3//! **The file extension is a hint and is never consulted here.** A `.jpg` that is really a
4//! PDF must be handled as a PDF or refused outright; handing it to the JPEG handler would
5//! produce a confident success message about a file that was never touched
6//! (`docs/ARCHITECTURE.md` §1, `docs/THREAT_MODEL.md` §5.4). Detection therefore takes bytes
7//! and nothing else — there is deliberately no way to pass it a path.
8//!
9//! Detection is itself a hostile-input parser: it is the one piece of code that sees every
10//! byte of every file the user feeds in, including files no handler will ever accept. It
11//! reads through [`crate::bytes::Reader`] for the same reason the handlers do.
12//!
13//! # Why this is hand-written rather than a dependency
14//!
15//! `file-format` and `infer` were both evaluated (`docs/ARCHITECTURE.md` §4). Phase 1 needs
16//! to discriminate exactly four supported formats plus a short list of formats worth *naming*
17//! in a refusal, which is under a hundred lines of magic-number matching. Taking a crate with
18//! broad magic tables for that would add supply-chain surface (ADR-0008) to save very little.
19//! Revisit in Phase 2, when the supported-format count grows and the container types get
20//! genuinely ambiguous.
21
22use crate::bytes::Reader;
23use crate::error::{Result, StryptError, UnsupportedKind};
24
25/// A format strypt has a handler for.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
27#[non_exhaustive]
28pub enum Format {
29 /// JPEG, in a JFIF or EXIF container.
30 Jpeg,
31 /// PNG.
32 Png,
33 /// WebP, which is a RIFF container.
34 Webp,
35 /// PDF.
36 Pdf,
37}
38
39impl Format {
40 /// The stable lowercase identifier used in reports and JSON output.
41 ///
42 /// Stable across releases: front-ends and downstream scripts key on it.
43 #[must_use]
44 pub const fn id(self) -> &'static str {
45 match self {
46 Self::Jpeg => "jpeg",
47 Self::Png => "png",
48 Self::Webp => "webp",
49 Self::Pdf => "pdf",
50 }
51 }
52
53 /// The conventional extension for this format, without a leading dot.
54 ///
55 /// Used when deriving a default output filename. Never used to *detect* anything.
56 #[must_use]
57 pub const fn conventional_extension(self) -> &'static str {
58 match self {
59 Self::Jpeg => "jpg",
60 Self::Png => "png",
61 Self::Webp => "webp",
62 Self::Pdf => "pdf",
63 }
64 }
65}
66
67impl std::fmt::Display for Format {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.write_str(match self {
70 Self::Jpeg => "JPEG",
71 Self::Png => "PNG",
72 Self::Webp => "WebP",
73 Self::Pdf => "PDF",
74 })
75 }
76}
77
78/// How far into a file the PDF header is allowed to appear.
79///
80/// ISO 32000-1 requires `%PDF-` at the start, but Adobe's own implementation notes have long
81/// tolerated leading bytes, and real-world files — particularly ones that have been through
82/// an email gateway or a broken CGI script — routinely carry a preamble. Readers accept it,
83/// so a file with a preamble *is* a PDF in every way that matters to the user, and refusing
84/// to recognise it would leave that user believing they had an exotic file rather than a
85/// slightly damaged ordinary one. The window is bounded because scanning an arbitrary
86/// distance into an arbitrary file is how a detector becomes a denial-of-service target.
87const PDF_HEADER_SEARCH_WINDOW: usize = 1024;
88
89/// Identify `data` by content.
90///
91/// # Errors
92///
93/// Returns [`StryptError::UnsupportedFormat`] when the content is recognised but has no
94/// handler in this release, and [`StryptError::UnrecognisedFormat`] when it matches nothing.
95/// Both are reported to the user; neither is ever treated as "pass the file through
96/// unchanged", which is the failure this whole module exists to prevent.
97pub fn detect(data: &[u8]) -> Result<Format> {
98 if let Some(format) = detect_supported(data) {
99 return Ok(format);
100 }
101 if let Some(kind) = detect_unsupported(data) {
102 return Err(StryptError::UnsupportedFormat { format: kind });
103 }
104 Err(StryptError::UnrecognisedFormat)
105}
106
107/// Match the four formats Phase 1 handles. Exact magic numbers are checked before the PDF
108/// header scan, so that a `%PDF-` string sitting inside a JPEG's EXIF block cannot cause a
109/// mis-dispatch.
110fn detect_supported(data: &[u8]) -> Option<Format> {
111 // JPEG: SOI (FFD8) immediately followed by the first marker's FF prefix. Checking the
112 // third byte rejects a bare FFD8 that starts some other file by coincidence.
113 if starts_with(data, &[0xFF, 0xD8, 0xFF]) {
114 return Some(Format::Jpeg);
115 }
116 // PNG signature, ISO/IEC 15948 §5.2. The CR-LF-EOF-LF tail exists to detect exactly the
117 // kind of transfer corruption that would otherwise silently truncate a file.
118 if starts_with(data, &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
119 return Some(Format::Png);
120 }
121 if is_riff_with_form(data, *b"WEBP") {
122 return Some(Format::Webp);
123 }
124 if find_pdf_header(data).is_some() {
125 return Some(Format::Pdf);
126 }
127 None
128}
129
130/// Name formats we can identify but do not yet handle, so a refusal can say something useful.
131fn detect_unsupported(data: &[u8]) -> Option<UnsupportedKind> {
132 // ZIP local-file, end-of-central-directory, and spanned-archive signatures. All three
133 // reach the same place for us: OOXML, ODF, and plain archives are Phase 2.
134 if starts_with(data, b"PK\x03\x04")
135 || starts_with(data, b"PK\x05\x06")
136 || starts_with(data, b"PK\x07\x08")
137 {
138 return Some(UnsupportedKind::ZipContainer);
139 }
140 if starts_with(data, b"GIF87a") || starts_with(data, b"GIF89a") {
141 return Some(UnsupportedKind::Gif);
142 }
143 // TIFF byte-order marks: "II" little-endian, "MM" big-endian, each followed by 42.
144 if starts_with(data, &[b'I', b'I', 0x2A, 0x00]) || starts_with(data, &[b'M', b'M', 0x00, 0x2A])
145 {
146 return Some(UnsupportedKind::Tiff);
147 }
148 // ISO base media (MP4/M4A/HEIF/AVIF): a box whose type at offset 4 is "ftyp".
149 if data.get(4..8) == Some(b"ftyp") {
150 return Some(UnsupportedKind::IsoBaseMedia);
151 }
152 if starts_with(data, b"OggS") {
153 return Some(UnsupportedKind::Ogg);
154 }
155 if starts_with(data, b"fLaC") {
156 return Some(UnsupportedKind::Flac);
157 }
158 // ID3v2-tagged MP3, or a bare MPEG audio frame sync (11 set bits).
159 if starts_with(data, b"ID3") {
160 return Some(UnsupportedKind::Mp3);
161 }
162 if let (Some(&0xFF), Some(&second)) = (data.first(), data.get(1))
163 && (second & 0xE0) == 0xE0
164 {
165 return Some(UnsupportedKind::Mp3);
166 }
167 // Any other RIFF payload: WAV, AVI, and friends.
168 if starts_with(data, b"RIFF") {
169 return Some(UnsupportedKind::OtherRiff);
170 }
171 if looks_like_xml(data) {
172 return Some(UnsupportedKind::Xml);
173 }
174 None
175}
176
177/// True when `data` begins with `prefix`.
178fn starts_with(data: &[u8], prefix: &[u8]) -> bool {
179 data.get(0..prefix.len()) == Some(prefix)
180}
181
182/// True when `data` is a RIFF container whose form type matches `form`.
183///
184/// The declared RIFF size is deliberately *not* trusted here — a lying size field is common
185/// in truncated files, and detection's job is to route the file to a handler that will police
186/// its own structure, not to validate it.
187fn is_riff_with_form(data: &[u8], form: [u8; 4]) -> bool {
188 let mut r = Reader::new(data);
189 if r.peek(4) != Some(b"RIFF") {
190 return false;
191 }
192 // Skip "RIFF" and the 32-bit little-endian size that follows it.
193 if r.skip(8).is_none() {
194 return false;
195 }
196 r.peek(4) == Some(form.as_slice())
197}
198
199/// Find the `%PDF-` header within the bounded search window, returning its offset.
200fn find_pdf_header(data: &[u8]) -> Option<usize> {
201 const HEADER: &[u8] = b"%PDF-";
202 let window = data.get(0..PDF_HEADER_SEARCH_WINDOW).unwrap_or(data);
203 window
204 .windows(HEADER.len())
205 .position(|candidate| candidate == HEADER)
206}
207
208/// Crude XML sniff: skip a UTF-8 BOM and leading whitespace, then look for a tag opener.
209///
210/// Only used to *name* an unsupported format, so a false positive costs the user a slightly
211/// wrong noun in a refusal message, never a mis-dispatch to a handler.
212fn looks_like_xml(data: &[u8]) -> bool {
213 let mut r = Reader::new(data);
214 if r.peek(3) == Some(&[0xEF, 0xBB, 0xBF]) && r.skip(3).is_none() {
215 return false;
216 }
217 // Bounded: a file of nothing but whitespace must not spin.
218 for _ in 0..64 {
219 match r.peek(1) {
220 Some([b' ' | b'\t' | b'\r' | b'\n']) => {
221 if r.skip(1).is_none() {
222 return false;
223 }
224 }
225 _ => break,
226 }
227 }
228 r.peek(5) == Some(b"<?xml") || r.peek(4) == Some(b"<svg") || r.peek(9) == Some(b"<!DOCTYPE")
229}
230
231#[cfg(test)]
232mod tests {
233 // Test code is never reachable from untrusted bytes, which is the boundary the
234 // panic-freedom lints exist to police (ADR-0006). A test that cannot say `.unwrap()` says
235 // everything twice instead, and the noise hides the assertion that matters.
236 #![allow(clippy::unwrap_used)]
237
238 use super::*;
239
240 #[test]
241 fn the_four_supported_formats_are_recognised() {
242 assert_eq!(detect(&[0xFF, 0xD8, 0xFF, 0xE0]).unwrap(), Format::Jpeg);
243 assert_eq!(
244 detect(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]).unwrap(),
245 Format::Png
246 );
247 assert_eq!(
248 detect(b"RIFF\x00\x00\x00\x00WEBPVP8 ").unwrap(),
249 Format::Webp
250 );
251 assert_eq!(detect(b"%PDF-1.7\n").unwrap(), Format::Pdf);
252 }
253
254 #[test]
255 fn a_pdf_named_jpg_is_still_a_pdf() {
256 // The mis-dispatch guard from docs/ARCHITECTURE.md §1. Detection never sees the name,
257 // which is precisely why this cannot go wrong.
258 assert_eq!(
259 detect(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n").unwrap(),
260 Format::Pdf
261 );
262 }
263
264 #[test]
265 fn a_pdf_header_inside_a_jpeg_does_not_win() {
266 // A hostile file could embed "%PDF-" in an EXIF comment to try to steer dispatch.
267 // Exact magic numbers are matched first, so the JPEG handler keeps the file.
268 let mut data = vec![0xFF, 0xD8, 0xFF, 0xE1];
269 data.extend_from_slice(b"junk %PDF-1.7 junk");
270 assert_eq!(detect(&data).unwrap(), Format::Jpeg);
271 }
272
273 #[test]
274 fn a_pdf_with_a_leading_preamble_is_recognised() {
275 // Real-world files mangled by gateways carry junk before the header, and readers
276 // accept them — so a user with one has an ordinary PDF, not an exotic file.
277 let mut data = b"\r\n<!-- inserted by a broken proxy -->\r\n".to_vec();
278 data.extend_from_slice(b"%PDF-1.5\n");
279 assert_eq!(detect(&data).unwrap(), Format::Pdf);
280 }
281
282 #[test]
283 fn a_pdf_header_beyond_the_search_window_is_not_scanned_for() {
284 // Bounding the scan is what stops detection becoming a denial-of-service target on
285 // large files that mention "%PDF-" somewhere in the middle.
286 let mut data = vec![b'x'; PDF_HEADER_SEARCH_WINDOW];
287 data.extend_from_slice(b"%PDF-1.7\n");
288 assert!(matches!(
289 detect(&data),
290 Err(StryptError::UnrecognisedFormat)
291 ));
292 }
293
294 #[test]
295 fn a_non_webp_riff_is_named_rather_than_mishandled() {
296 // WAV shares WebP's container. Routing it to the WebP handler would be a
297 // mis-dispatch; calling it "unrecognised" would be unhelpful. Name it.
298 let e = detect(b"RIFF\x00\x00\x00\x00WAVEfmt ").unwrap_err();
299 assert!(matches!(
300 e,
301 StryptError::UnsupportedFormat {
302 format: UnsupportedKind::OtherRiff
303 }
304 ));
305 }
306
307 #[test]
308 fn phase_two_formats_are_named_in_the_refusal() {
309 for (bytes, expected) in [
310 (&b"PK\x03\x04"[..], UnsupportedKind::ZipContainer),
311 (&b"GIF89a"[..], UnsupportedKind::Gif),
312 (&b"II\x2A\x00"[..], UnsupportedKind::Tiff),
313 (&b"OggS"[..], UnsupportedKind::Ogg),
314 (&b"fLaC"[..], UnsupportedKind::Flac),
315 (&b"ID3\x04"[..], UnsupportedKind::Mp3),
316 (
317 &b"\x00\x00\x00\x18ftypavif"[..],
318 UnsupportedKind::IsoBaseMedia,
319 ),
320 (&b"<?xml version=\"1.0\"?><svg/>"[..], UnsupportedKind::Xml),
321 ] {
322 let got = detect(bytes).unwrap_err();
323 assert!(
324 matches!(got, StryptError::UnsupportedFormat { format } if format == expected),
325 "detecting {expected:?} gave {got:?}"
326 );
327 }
328 }
329
330 #[test]
331 fn nothing_recognisable_is_an_error_never_a_silent_pass() {
332 // The single most dangerous outcome this tool can produce is "success" on a file it
333 // did not process (docs/THREAT_MODEL.md §5.4). There is no Ok path here.
334 assert!(matches!(detect(b""), Err(StryptError::UnrecognisedFormat)));
335 assert!(matches!(
336 detect(b"hello world"),
337 Err(StryptError::UnrecognisedFormat)
338 ));
339 }
340
341 #[test]
342 fn truncated_magic_numbers_do_not_panic() {
343 // Every prefix of every signature, including the empty one.
344 let signatures: [&[u8]; 4] = [
345 &[0xFF, 0xD8, 0xFF],
346 &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
347 b"RIFF\x00\x00\x00\x00WEBP",
348 b"%PDF-1.7",
349 ];
350 for sig in signatures {
351 for n in 0..=sig.len() {
352 let prefix = sig.get(0..n).unwrap_or_default();
353 let _ = detect(prefix);
354 }
355 }
356 }
357}