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 needed
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//!
20//! Phase 2 brought the ambiguity that comment anticipated, and it turned out not to be the kind
21//! a magic table solves. `.docx`, `.xlsx`, `.pptx`, and every `OpenDocument` file share one magic
22//! number, because they are all ZIP archives. Telling them apart means opening the container and
23//! reading the content type the package declares for its own main part — which no magic-number
24//! crate does either, and which the ZIP layer this crate already owns does directly
25//! (ADR-0027, ADR-0028).
26//!
27//! # Why detection opens the container
28//!
29//! It would be cheaper to search the raw bytes for `word/document.xml` and be done. That is
30//! also how a file gets routed to the wrong handler: the string appears verbatim in any archive
31//! that merely *contains* a Word document, and an attacker can put it in a comment. Reading the
32//! declared content type is the format's own answer to "what is this", and it costs one central
33//! directory walk and one small inflate.
34
35use crate::bytes::Reader;
36use crate::error::{Result, StryptError, UnsupportedKind};
37use crate::formats::{jxl, ogg};
38
39/// A format strypt has a handler for.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
41#[non_exhaustive]
42pub enum Format {
43 /// JPEG, in a JFIF or EXIF container.
44 Jpeg,
45 /// PNG.
46 Png,
47 /// WebP, which is a RIFF container.
48 Webp,
49 /// PDF.
50 Pdf,
51 /// TIFF, including the multi-page files scanners produce.
52 Tiff,
53 /// GIF, in either the 87a or the 89a spelling.
54 Gif,
55 /// HEIF — `.heic` and `.heif`, the format an iPhone photograph arrives in.
56 Heif,
57 /// AVIF: the same container as HEIF, carrying AV1 rather than HEVC.
58 Avif,
59 /// A `WordprocessingML` document — `.docx`.
60 Docx,
61 /// A `SpreadsheetML` workbook — `.xlsx`.
62 Xlsx,
63 /// A `PresentationML` presentation — `.pptx`.
64 Pptx,
65 /// An `OpenDocument` text document — `.odt`.
66 Odt,
67 /// An `OpenDocument` spreadsheet — `.ods`.
68 Ods,
69 /// An `OpenDocument` presentation — `.odp`.
70 Odp,
71 /// SVG, which is XML text rather than a container of encoded pixels.
72 Svg,
73 /// JPEG XL, in either of its two spellings: a bare codestream or a BMFF container.
74 Jxl,
75 /// FLAC, in its native spelling — a `fLaC` marker and a list of metadata blocks.
76 Flac,
77 /// WAV: a RIFF container of form type `WAVE`. RF64 and BW64 are a different container and
78 /// are named separately in `detect_unsupported`.
79 Wav,
80 /// MP3: MPEG-1 Audio Layer III frames, with or without the tags glued to either end of them.
81 Mp3,
82 /// Ogg Vorbis. One handler serves all three Ogg spellings; they are separate formats here
83 /// because they are separate mappings with separate header packets (ADR-0041).
84 Ogg,
85 /// Opus, in its Ogg encapsulation (RFC 7845). The only encapsulation strypt handles.
86 Opus,
87 /// FLAC carried in Ogg pages rather than in its native container.
88 OggFlac,
89 /// MP4: the ISO base media file format carrying tracks. `.mp4` and `.m4v`.
90 Mp4,
91 /// M4A: the same container carrying audio only. `.m4a` and `.m4b`.
92 M4a,
93}
94
95impl Format {
96 /// The stable lowercase identifier used in reports and JSON output.
97 ///
98 /// Stable across releases: front-ends and downstream scripts key on it.
99 #[must_use]
100 pub const fn id(self) -> &'static str {
101 match self {
102 Self::Jpeg => "jpeg",
103 Self::Png => "png",
104 Self::Webp => "webp",
105 Self::Pdf => "pdf",
106 Self::Tiff => "tiff",
107 Self::Gif => "gif",
108 Self::Heif => "heif",
109 Self::Avif => "avif",
110 Self::Docx => "docx",
111 Self::Xlsx => "xlsx",
112 Self::Pptx => "pptx",
113 Self::Odt => "odt",
114 Self::Ods => "ods",
115 Self::Odp => "odp",
116 Self::Svg => "svg",
117 Self::Jxl => "jxl",
118 Self::Flac => "flac",
119 Self::Wav => "wav",
120 Self::Mp3 => "mp3",
121 Self::Ogg => "ogg",
122 Self::Opus => "opus",
123 Self::OggFlac => "ogg-flac",
124 Self::Mp4 => "mp4",
125 Self::M4a => "m4a",
126 }
127 }
128
129 /// The conventional extension for this format, without a leading dot.
130 ///
131 /// Used when deriving a default output filename. Never used to *detect* anything.
132 #[must_use]
133 pub const fn conventional_extension(self) -> &'static str {
134 match self {
135 Self::Jpeg => "jpg",
136 Self::Png => "png",
137 Self::Webp => "webp",
138 Self::Pdf => "pdf",
139 Self::Tiff => "tiff",
140 Self::Gif => "gif",
141 // `.heic` rather than `.heif`: it is what cameras write and what users see.
142 Self::Heif => "heic",
143 Self::Avif => "avif",
144 Self::Docx => "docx",
145 Self::Xlsx => "xlsx",
146 Self::Pptx => "pptx",
147 Self::Odt => "odt",
148 Self::Ods => "ods",
149 Self::Odp => "odp",
150 Self::Svg => "svg",
151 Self::Jxl => "jxl",
152 Self::Flac => "flac",
153 Self::Wav => "wav",
154 Self::Mp3 => "mp3",
155 Self::Ogg => "ogg",
156 Self::Opus => "opus",
157 // `.oga` rather than `.ogg`: the Xiph naming note reserves `.ogg` for Vorbis.
158 Self::OggFlac => "oga",
159 Self::Mp4 => "mp4",
160 Self::M4a => "m4a",
161 }
162 }
163}
164
165impl std::fmt::Display for Format {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 f.write_str(match self {
168 Self::Jpeg => "JPEG",
169 Self::Png => "PNG",
170 Self::Webp => "WebP",
171 Self::Pdf => "PDF",
172 Self::Tiff => "TIFF",
173 Self::Gif => "GIF",
174 Self::Heif => "HEIF",
175 Self::Avif => "AVIF",
176 Self::Docx => "DOCX",
177 Self::Xlsx => "XLSX",
178 Self::Pptx => "PPTX",
179 Self::Odt => "ODT",
180 Self::Ods => "ODS",
181 Self::Odp => "ODP",
182 Self::Svg => "SVG",
183 Self::Jxl => "JPEG XL",
184 Self::Flac => "FLAC",
185 Self::Wav => "WAV",
186 Self::Mp3 => "MP3",
187 Self::Ogg => "Ogg Vorbis",
188 Self::Opus => "Opus",
189 Self::OggFlac => "Ogg FLAC",
190 Self::Mp4 => "MP4",
191 Self::M4a => "M4A",
192 })
193 }
194}
195
196/// How far into a file the PDF header is allowed to appear.
197///
198/// ISO 32000-1 requires `%PDF-` at the start, but Adobe's own implementation notes have long
199/// tolerated leading bytes, and real-world files — particularly ones that have been through
200/// an email gateway or a broken CGI script — routinely carry a preamble. Readers accept it,
201/// so a file with a preamble *is* a PDF in every way that matters to the user, and refusing
202/// to recognise it would leave that user believing they had an exotic file rather than a
203/// slightly damaged ordinary one. The window is bounded because scanning an arbitrary
204/// distance into an arbitrary file is how a detector becomes a denial-of-service target.
205const PDF_HEADER_SEARCH_WINDOW: usize = 1024;
206
207/// Identify `data` by content.
208///
209/// # Errors
210///
211/// Returns [`StryptError::UnsupportedFormat`] when the content is recognised but has no
212/// handler in this release, and [`StryptError::UnrecognisedFormat`] when it matches nothing.
213/// Both are reported to the user; neither is ever treated as "pass the file through
214/// unchanged", which is the failure this whole module exists to prevent.
215pub fn detect(data: &[u8]) -> Result<Format> {
216 if let Some(format) = detect_supported(data) {
217 return Ok(format);
218 }
219 if let Some(kind) = detect_unsupported(data) {
220 return Err(StryptError::UnsupportedFormat { format: kind });
221 }
222 Err(StryptError::UnrecognisedFormat)
223}
224
225/// Match the formats this release handles. Exact magic numbers are checked before the PDF
226/// header scan, so that a `%PDF-` string sitting inside a JPEG's EXIF block cannot cause a
227/// mis-dispatch.
228fn detect_supported(data: &[u8]) -> Option<Format> {
229 // JPEG: SOI (FFD8) immediately followed by the first marker's FF prefix. Checking the
230 // third byte rejects a bare FFD8 that starts some other file by coincidence.
231 if starts_with(data, &[0xFF, 0xD8, 0xFF]) {
232 return Some(Format::Jpeg);
233 }
234 // PNG signature, ISO/IEC 15948 §5.2. The CR-LF-EOF-LF tail exists to detect exactly the
235 // kind of transfer corruption that would otherwise silently truncate a file.
236 if starts_with(data, &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
237 return Some(Format::Png);
238 }
239 if is_riff_with_form(data, *b"WEBP") {
240 return Some(Format::Webp);
241 }
242 if is_riff_with_form(data, *b"WAVE") {
243 return Some(Format::Wav);
244 }
245 // TIFF byte-order mark followed by the magic number 42, in that byte order. BigTIFF
246 // spells 43 here and is refused by the handler by name rather than matched as TIFF.
247 if starts_with(data, &[b'I', b'I', 0x2A, 0x00]) || starts_with(data, &[b'M', b'M', 0x00, 0x2A])
248 {
249 return Some(Format::Tiff);
250 }
251 // GIF89a and its predecessor. §17 fixes the signature and the version as six bytes together,
252 // and the handler treats both spellings alike — an `87a` file carrying extensions is common,
253 // and no decoder enforces the version string either.
254 if starts_with(data, b"GIF87a") || starts_with(data, b"GIF89a") {
255 return Some(Format::Gif);
256 }
257 // JPEG XL, both spellings (ISO/IEC 18181-2 §5.2, ISO/IEC 18181-1 §9.1). The container's
258 // signature box is checked before the `ftyp` sniffs below, and the bare codestream's `FF 0A`
259 // is checked before the MPEG audio frame sync in `detect_unsupported`, which matches `FF`
260 // followed by three set bits and would otherwise call a `.jxl` an MP3.
261 if starts_with(data, &jxl::SIGNATURE_BOX) || starts_with(data, &jxl::CODESTREAM_MAGIC) {
262 return Some(Format::Jxl);
263 }
264 // The stream marker (RFC 9639 §8). A FLAC carrying a prepended ID3v2 tag does not start with
265 // it, and used to be refused by name here. It is routed to the handler now: the MP3 tranche
266 // put an ID3 reader in the tree, so the tag is read and removed rather than left in front of
267 // blocks strypt had cleaned (ADR-0040 lifts ADR-0038 decision 7).
268 if starts_with(data, b"fLaC") || id3_precedes(data, b"fLaC") {
269 return Some(Format::Flac);
270 }
271 // MPEG audio: an ID3v2 tag with frames behind it, or the frames on their own. It has to come
272 // after JPEG XL's bare `FF 0A` codestream, which a frame sync matches, and after the FLAC
273 // check above, which claims the other thing an ID3v2 tag gets stuck in front of.
274 if starts_with(data, b"ID3") || crate::formats::mp3::frame_header(data).is_some() {
275 return Some(Format::Mp3);
276 }
277 // Ogg carries somebody else's codec, so the container's magic is not the answer: the first
278 // page's packet is. A mapping with no handler is named in `detect_unsupported` (ADR-0041).
279 if let Some(ogg::Sniff::Supported(format)) = ogg::sniff(data) {
280 return Some(format);
281 }
282 if let Some(format) = iso_base_media_still(data) {
283 return Some(format);
284 }
285 // Tracks rather than a picture, and the brand list is again the whole of the answer: the same
286 // `ftyp` introduces a photograph, a film, a fragmented stream and an encrypted one (ADR-0042).
287 if let Some(IsoClass::Movie(format)) = iso_base_media_movie(data) {
288 return Some(format);
289 }
290 if find_pdf_header(data).is_some() {
291 return Some(Format::Pdf);
292 }
293 if let Some(Package::Ooxml(format) | Package::OpenDocument(format)) = zip_package(data) {
294 return Some(format);
295 }
296 // Last, because it is the only sniff here that reads text rather than a magic number. SVG
297 // has no signature at all: the format's own answer to "what is this" is its root element,
298 // and finding it means stepping over an XML declaration, comments, and a doctype first.
299 if is_svg(data) {
300 return Some(Format::Svg);
301 }
302 None
303}
304
305/// Name formats we can identify but do not yet handle, so a refusal can say something useful.
306fn detect_unsupported(data: &[u8]) -> Option<UnsupportedKind> {
307 // ZIP local-file, end-of-central-directory, and spanned-archive signatures. All three
308 // reach the same place for us: OOXML, ODF, and plain archives are Phase 2.
309 if starts_with(data, b"PK\x03\x04")
310 || starts_with(data, b"PK\x05\x06")
311 || starts_with(data, b"PK\x07\x08")
312 {
313 // A macro-enabled document is named specifically, because the advice differs. It is not
314 // "Phase 2 will get to this": its `vbaProject.bin` is an OLE compound file strypt cannot
315 // read, and a document reported clean while a container inside it went unexamined is the
316 // failure in `docs/THREAT_MODEL.md` §5.4 (ADR-0029).
317 return Some(match zip_package(data) {
318 Some(Package::MacroEnabled) => UnsupportedKind::MacroEnabledOffice,
319 // A drawing, a formula, a chart, or any `-template` variant: understood, named, and
320 // declined. `docs/ROADMAP.md` Phase 2 group 2 is the three document types, and
321 // widening it is a superseding ADR rather than a judgement call (ADR-0027).
322 Some(Package::OtherOpenDocument) => UnsupportedKind::OtherOpenDocument,
323 _ => UnsupportedKind::ZipContainer,
324 });
325 }
326 // BigTIFF: the same byte-order marks, but spelling 43. Named separately from the TIFF the
327 // handler accepts, because its eight-byte offsets are a different layout (ADR-0033).
328 if starts_with(data, &[b'I', b'I', 0x2B, 0x00]) || starts_with(data, &[b'M', b'M', 0x00, 0x2B])
329 {
330 return Some(UnsupportedKind::BigTiff);
331 }
332 // Any remaining ISO base-media file. Still images, progressive MP4 and M4A were matched above,
333 // so what is left is a shape strypt refuses by name — fragmented, encrypted, QuickTime, 3GPP —
334 // or a motion HEIF, which reaches the generic refusal.
335 if data.get(4..8) == Some(b"ftyp") {
336 return Some(match iso_base_media_movie(data) {
337 Some(IsoClass::Refused(kind)) => kind,
338 _ => UnsupportedKind::IsoBaseMedia,
339 });
340 }
341 // Theora, Speex, Skeleton, anything unrecognised, and any file carrying more than one logical
342 // bitstream. Named rather than left unrecognised, for a file every player calls an Ogg.
343 if let Some(ogg::Sniff::Refused(kind)) = ogg::sniff(data) {
344 return Some(kind);
345 }
346 if starts_with(data, b"OggS") {
347 return Some(UnsupportedKind::OtherOggCodec);
348 }
349 // RF64 (EBU Tech 3306) and BW64 (ITU-R BS.2088) spell the >4 GB case with their own magic and
350 // a `ds64` chunk holding the real sizes, so a WAV handler would walk the wrong extent. Named
351 // rather than left unrecognised, because "unrecognised" is untrue for a file most users would
352 // call a WAV (ADR-0039).
353 if starts_with(data, b"RF64") || starts_with(data, b"BW64") {
354 return Some(UnsupportedKind::Rf64);
355 }
356 // Any other RIFF payload: AVI, and friends.
357 if starts_with(data, b"RIFF") {
358 return Some(UnsupportedKind::OtherRiff);
359 }
360 // A gzip stream, which for this project means `.svgz` far more often than anything else.
361 // Named rather than left unrecognised so the message can say "decompress it first", because
362 // "the content does not match any format strypt recognises" is untrue and unhelpful for a
363 // common spelling of a format that *is* handled (ADR-0035).
364 if starts_with(data, &[0x1F, 0x8B]) {
365 return Some(UnsupportedKind::Gzip);
366 }
367 if looks_like_xml(data) {
368 return Some(UnsupportedKind::Xml);
369 }
370 None
371}
372
373/// How far into a file the SVG root element is allowed to appear.
374///
375/// An XML declaration, a generator comment, and the SVG 1.1 doctype together run to a few
376/// hundred bytes in real files, and Adobe's export writes all three. The window is generous
377/// enough for them and bounded for the reason [`PDF_HEADER_SEARCH_WINDOW`] is: detection sees
378/// every byte of every file offered to it, including ones no handler will accept.
379const SVG_ROOT_SEARCH_WINDOW: usize = 8192;
380
381/// True when the first element of `data` is `<svg`.
382///
383/// **The root element, not the presence of the string.** `<svg` appears inside any HTML page that
384/// embeds a drawing, and inside an XML document that merely describes one; routing either to this
385/// handler would be the mis-dispatch this module exists to prevent. The scan therefore steps over
386/// exactly what may legally precede a root element — an XML declaration, comments, processing
387/// instructions, and a doctype — and then requires what follows to be the element itself.
388fn is_svg(data: &[u8]) -> bool {
389 let window = data.get(..SVG_ROOT_SEARCH_WINDOW).unwrap_or(data);
390 let Ok(text) = std::str::from_utf8(window) else {
391 // Not UTF-8 within the window. A UTF-16 SVG is legal XML and is refused by the handler
392 // rather than misread here (ADR-0035), and a truncated multi-byte character at the window
393 // edge is not worth a second decode attempt for a sniff.
394 return false;
395 };
396 let mut rest = text.trim_start_matches('\u{feff}').trim_start();
397
398 // Bounded: a file of nothing but comments must not spin.
399 for _ in 0..64 {
400 let terminator = if rest.starts_with("<!--") {
401 "-->"
402 } else if rest.starts_with("<?") {
403 "?>"
404 } else if rest.starts_with("<!") {
405 // A doctype, whose internal subset may itself contain `>`. Stopping at the first one
406 // is good enough for a sniff: the handler refuses an internal subset outright.
407 ">"
408 } else {
409 // A prefixed root — `<svg:svg>`, which very old Inkscape releases wrote — is
410 // deliberately *not* claimed. The handler removes prefixed elements on an
411 // allow-list (ADR-0035), so claiming it would mean removing the document. It
412 // falls through to the generic XML refusal instead, which is fail-closed.
413 return rest.starts_with("<svg")
414 && rest
415 .get(4..5)
416 .is_none_or(|c| c.starts_with([' ', '\t', '\r', '\n', '>', '/']));
417 };
418 let Some(end) = rest.find(terminator) else {
419 return false;
420 };
421 rest = rest
422 .get(end.saturating_add(terminator.len())..)
423 .unwrap_or_default()
424 .trim_start();
425 }
426 false
427}
428
429/// What a ZIP package turned out to be.
430enum Package {
431 /// An Office Open XML package this release handles.
432 Ooxml(Format),
433 /// A macro-enabled Office document, refused rather than handled.
434 MacroEnabled,
435 /// An `OpenDocument` package this release handles.
436 OpenDocument(Format),
437 /// An `OpenDocument` package of a type this release does not handle — a drawing, a formula,
438 /// a chart, a database, or any of the `-template` variants.
439 OtherOpenDocument,
440}
441
442/// The main-part content type each supported format declares for itself.
443///
444/// ECMA-376 Part 2: `[Content_Types].xml` is the package's own statement of what it holds, and
445/// it is the only place in the file that answers the question authoritatively.
446const OOXML_MAIN_TYPES: [(&str, Format); 3] = [
447 ("wordprocessingml.document.main+xml", Format::Docx),
448 ("spreadsheetml.sheet.main+xml", Format::Xlsx),
449 ("presentationml.presentation.main+xml", Format::Pptx),
450];
451
452/// The macro-enabled counterparts, matched only so the refusal can name them.
453const OOXML_MACRO_TYPES: [&str; 3] = [
454 "wordprocessingml.document.macroEnabled.main+xml",
455 "spreadsheetml.sheet.macroEnabled.main+xml",
456 "presentationml.presentation.macroEnabled.main+xml",
457];
458
459/// How much of an index part is inflated to answer the question.
460///
461/// `[Content_Types].xml` and `META-INF/manifest.xml` are each a few kilobytes in any real
462/// document, and `mimetype` is one line. Bounding the inflate keeps detection — which sees every
463/// byte of every file the user offers, including files no handler will accept — from becoming
464/// somewhere an attacker can make strypt do work.
465const INDEX_PART_BUDGET: u64 = 4 * 1024 * 1024;
466
467/// Identify a ZIP package by what it declares about itself.
468///
469/// Every failure returns [`None`], which routes the file to the generic ZIP refusal. Detection
470/// is not the place to explain why an archive is malformed; the handler that gets a real
471/// package is.
472fn zip_package(data: &[u8]) -> Option<Package> {
473 let entries =
474 crate::container::zip::read(data, &crate::formats::ParseLimits::default()).ok()?;
475 // OpenDocument is checked first because its answer is cheaper and unambiguous: a one-line
476 // `mimetype` entry, which no OOXML package has.
477 opendocument_package(&entries).or_else(|| ooxml_package(&entries))
478}
479
480/// The contents of `name`, when the package has such an entry and it is text.
481fn index_part(entries: &[crate::container::zip::Entry<'_>], name: &str) -> Option<String> {
482 let entry = entries
483 .iter()
484 .find(|entry| entry.name_str() == Some(name))?;
485 let bytes = entry.contents(INDEX_PART_BUDGET).ok()?;
486 std::str::from_utf8(bytes.as_ref()).ok().map(str::to_owned)
487}
488
489/// Identify an `OpenDocument` package by the media type it declares for itself.
490///
491/// ODF 1.3 Part 2 §3.3 puts that media type in a `mimetype` entry, and §4.3 puts it again on the
492/// manifest's root file-entry. Both are read, because the first is optional in older packages
493/// and the second is what remains when a producer omitted it.
494fn opendocument_package(entries: &[crate::container::zip::Entry<'_>]) -> Option<Package> {
495 let declared = index_part(entries, "mimetype")
496 .map(|text| text.trim().to_owned())
497 .filter(|text| text.starts_with(crate::formats::odf::MEDIA_TYPE_PREFIX))
498 .or_else(|| {
499 let manifest = index_part(entries, "META-INF/manifest.xml")?;
500 crate::formats::odf::root_media_type_of(&manifest)
501 })?;
502
503 if !declared.starts_with(crate::formats::odf::MEDIA_TYPE_PREFIX) {
504 return None;
505 }
506 Some(
507 crate::formats::odf::format_for_media_type(&declared)
508 .map_or(Package::OtherOpenDocument, Package::OpenDocument),
509 )
510}
511
512/// Identify an Office Open XML package by the content type it declares for its main part.
513///
514/// ECMA-376 Part 2: `[Content_Types].xml` is the package's own statement of what it holds, and
515/// it is the only place in the file that answers the question authoritatively.
516fn ooxml_package(entries: &[crate::container::zip::Entry<'_>]) -> Option<Package> {
517 let text = index_part(entries, "[Content_Types].xml")?;
518
519 // Macro-enabled is checked first: its content type contains the plain one's spelling as a
520 // substring in some producers' output, so matching the other way round would silently accept
521 // a document whose VBA project nobody looked at.
522 if OOXML_MACRO_TYPES
523 .iter()
524 .any(|candidate| text.contains(candidate))
525 {
526 return Some(Package::MacroEnabled);
527 }
528 OOXML_MAIN_TYPES
529 .iter()
530 .find(|(candidate, _)| text.contains(candidate))
531 .map(|(_, format)| Package::Ooxml(*format))
532}
533
534/// Brands that make an ISO base-media file a still HEIF, and the format each routes to.
535///
536/// ISO/IEC 23008-12 §10.2 and the AVIF specification §4 both work this way: the container is the
537/// same one MP4 uses, and the `ftyp` brands are what say which of them a file is. Matching on
538/// `ftyp` alone — which is all this module did before the handler landed — cannot tell a
539/// photograph from a video.
540const STILL_BRANDS: [(&[u8; 4], Format); 7] = [
541 (b"avif", Format::Avif),
542 (b"avio", Format::Avif),
543 (b"heic", Format::Heif),
544 (b"heix", Format::Heif),
545 (b"heim", Format::Heif),
546 (b"heis", Format::Heif),
547 // The generic HEIF image brand. Listed last so that a file declaring both `mif1` and a
548 // specific brand is named by the specific one.
549 (b"mif1", Format::Heif),
550];
551
552/// Brands that declare an image *sequence* rather than a still.
553///
554/// Matched so that such a file is **not** claimed by the still handler. It falls through to the
555/// generic ISO base-media refusal, and the handler refuses the same shape again from the inside
556/// when a `moov` box is present (ADR-0034). Two checks rather than one because a file may carry a
557/// sequence brand without a `moov`, or a `moov` without the brand.
558const SEQUENCE_BRANDS: [&[u8; 4]; 3] = [b"msf1", b"avis", b"hevc"];
559
560/// How many bytes of `ftyp` are scanned for brands.
561const BRAND_WINDOW: usize = 256;
562
563/// Identify a still HEIF or AVIF by the brands its `ftyp` declares.
564///
565/// Returns [`None`] for every other ISO base-media file, including video, which then reaches
566/// [`detect_unsupported`] and is refused by name. Routing an MP4 to the HEIF handler would be a
567/// mis-dispatch of exactly the kind this module exists to prevent.
568fn iso_base_media_still(data: &[u8]) -> Option<Format> {
569 if data.get(4..8) != Some(b"ftyp") {
570 return None;
571 }
572 // The declared box size is deliberately not trusted: a truncated or lying size is common, and
573 // detection's job is to route the file to a handler that polices its own structure. The brand
574 // list is read from what is actually present, bounded by a window rather than by the field.
575 let window = data.get(..BRAND_WINDOW).unwrap_or(data);
576 // Major brand at offset 8, minor version at 12, then compatible brands to the end.
577 let major = window.get(8..12);
578 let compatible = window.get(16..).unwrap_or_default();
579
580 let brands = major
581 .into_iter()
582 .chain(compatible.chunks_exact(4))
583 .collect::<Vec<_>>();
584
585 // A sequence brand anywhere disqualifies the file, even alongside a still brand: an Apple Live
586 // Photo declares `heic` and carries a video track, and claiming it here would mean the handler
587 // had to refuse a file detection had already called a photograph.
588 if brands
589 .iter()
590 .any(|b| SEQUENCE_BRANDS.iter().any(|s| b == &&s[..]))
591 {
592 return None;
593 }
594 for (brand, format) in STILL_BRANDS {
595 if brands.iter().any(|b| b == &&brand[..]) {
596 return Some(format);
597 }
598 }
599 None
600}
601
602/// What an ISO base-media file's brands turned out to declare.
603enum IsoClass {
604 /// A container of tracks this release handles.
605 Movie(Format),
606 /// A shape refused by name.
607 Refused(UnsupportedKind),
608}
609
610/// Classify an ISO base-media file by the brands its `ftyp` declares.
611///
612/// Refusals are matched before acceptances, because a protected or fragmented file declares the
613/// ordinary brands as well: an encrypted `.m4p` carries `M4A ` and `mp42` beside `M4P `, and
614/// claiming it on the first match would route it to a handler that must then refuse it anyway.
615fn iso_base_media_movie(data: &[u8]) -> Option<IsoClass> {
616 use crate::formats::mp4::boxes as mp4;
617
618 if data.get(4..8) != Some(b"ftyp") {
619 return None;
620 }
621 // As `iso_base_media_still`: the declared box size is not trusted, and the brand list is read
622 // from a bounded window of what is actually present.
623 let window = data.get(..BRAND_WINDOW).unwrap_or(data);
624 let brands: Vec<&[u8]> = window
625 .get(8..12)
626 .into_iter()
627 .chain(window.get(16..).unwrap_or_default().chunks_exact(4))
628 .collect();
629 let has = |list: &[[u8; 4]]| {
630 brands
631 .iter()
632 .any(|b| list.iter().any(|candidate| *b == &candidate[..]))
633 };
634
635 if has(&mp4::FRAGMENT_BRANDS) {
636 return Some(IsoClass::Refused(UnsupportedKind::FragmentedMp4));
637 }
638 if has(&[mp4::PROTECTED_BRAND]) {
639 return Some(IsoClass::Refused(UnsupportedKind::ProtectedMedia));
640 }
641 if has(&[mp4::QUICKTIME_BRAND]) {
642 return Some(IsoClass::Refused(UnsupportedKind::QuickTimeMovie));
643 }
644 if brands
645 .iter()
646 .any(|b| b.get(..3) == Some(b"3gp") || b.get(..3) == Some(b"3g2"))
647 {
648 return Some(IsoClass::Refused(
649 UnsupportedKind::ThirdGenerationPartnership,
650 ));
651 }
652 // Audio first: an `.m4a` declares `mp42` and `isom` alongside `M4A `, so the more specific
653 // brand has to win or every M4A would be reported as an MP4.
654 if has(&mp4::M4A_BRANDS) {
655 return Some(IsoClass::Movie(Format::M4a));
656 }
657 if has(&mp4::MP4_BRANDS) || has(&mp4::MP4_BRANDS_VIDEO) {
658 return Some(IsoClass::Movie(Format::Mp4));
659 }
660 None
661}
662
663/// True when `data` begins with `prefix`.
664fn starts_with(data: &[u8], prefix: &[u8]) -> bool {
665 data.get(0..prefix.len()) == Some(prefix)
666}
667
668/// True when `data` is a RIFF container whose form type matches `form`.
669///
670/// The declared RIFF size is deliberately *not* trusted here — a lying size field is common
671/// in truncated files, and detection's job is to route the file to a handler that will police
672/// its own structure, not to validate it.
673fn is_riff_with_form(data: &[u8], form: [u8; 4]) -> bool {
674 let mut r = Reader::new(data);
675 if r.peek(4) != Some(b"RIFF") {
676 return false;
677 }
678 // Skip "RIFF" and the 32-bit little-endian size that follows it.
679 if r.skip(8).is_none() {
680 return false;
681 }
682 r.peek(4) == Some(form.as_slice())
683}
684
685/// True when `data` opens with an `ID3v2` tag that is followed immediately by `marker`.
686///
687/// The tag's own header is all this reads: three bytes of identifier, a version, a flags byte, and
688/// a four-byte size whose bytes carry seven bits each (ID3v2.4 §3.1). No frame is parsed — the
689/// question is only what kind of file the tag was stuck on the front of.
690fn id3_precedes(data: &[u8], marker: &[u8]) -> bool {
691 let mut r = Reader::new(data);
692 if r.skip(5).is_none() {
693 return false;
694 }
695 let Some(flags) = r.u8() else {
696 return false;
697 };
698 let Some(size) = r.take(4) else {
699 return false;
700 };
701 let mut total: usize = 0;
702 for byte in size {
703 total = total
704 .saturating_mul(128)
705 .saturating_add(usize::from(byte & 0x7F));
706 }
707 // The size counts neither the ten-byte header it sits in nor the optional footer.
708 let mut at = total.saturating_add(10);
709 if flags & 0x10 != 0 {
710 at = at.saturating_add(10);
711 }
712 data.get(at..at.saturating_add(marker.len())) == Some(marker)
713}
714
715/// Find the `%PDF-` header within the bounded search window, returning its offset.
716fn find_pdf_header(data: &[u8]) -> Option<usize> {
717 const HEADER: &[u8] = b"%PDF-";
718 let window = data.get(0..PDF_HEADER_SEARCH_WINDOW).unwrap_or(data);
719 window
720 .windows(HEADER.len())
721 .position(|candidate| candidate == HEADER)
722}
723
724/// Crude XML sniff: skip a UTF-8 BOM and leading whitespace, then look for a tag opener.
725///
726/// Only used to *name* an unsupported format, so a false positive costs the user a slightly
727/// wrong noun in a refusal message, never a mis-dispatch to a handler.
728fn looks_like_xml(data: &[u8]) -> bool {
729 let mut r = Reader::new(data);
730 if r.peek(3) == Some(&[0xEF, 0xBB, 0xBF]) && r.skip(3).is_none() {
731 return false;
732 }
733 // Bounded: a file of nothing but whitespace must not spin.
734 for _ in 0..64 {
735 match r.peek(1) {
736 Some([b' ' | b'\t' | b'\r' | b'\n']) => {
737 if r.skip(1).is_none() {
738 return false;
739 }
740 }
741 _ => break,
742 }
743 }
744 r.peek(5) == Some(b"<?xml") || r.peek(4) == Some(b"<svg") || r.peek(9) == Some(b"<!DOCTYPE")
745}
746
747#[cfg(test)]
748mod tests {
749 // Test code is never reachable from untrusted bytes, which is the boundary the
750 // panic-freedom lints exist to police (ADR-0006). A test that cannot say `.unwrap()` says
751 // everything twice instead, and the noise hides the assertion that matters.
752 #![allow(clippy::unwrap_used)]
753
754 use super::*;
755
756 #[test]
757 fn the_four_supported_formats_are_recognised() {
758 assert_eq!(detect(&[0xFF, 0xD8, 0xFF, 0xE0]).unwrap(), Format::Jpeg);
759 assert_eq!(
760 detect(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]).unwrap(),
761 Format::Png
762 );
763 assert_eq!(
764 detect(b"RIFF\x00\x00\x00\x00WEBPVP8 ").unwrap(),
765 Format::Webp
766 );
767 assert_eq!(detect(b"%PDF-1.7\n").unwrap(), Format::Pdf);
768 }
769
770 #[test]
771 fn both_gif_spellings_route_to_the_handler() {
772 // `87a` predates extension blocks, but files spelling it while carrying them are common
773 // and no decoder enforces the version string. Both reach the same handler.
774 assert_eq!(
775 detect(b"GIF87a\x01\x00\x01\x00\x00\x00\x00").unwrap(),
776 Format::Gif
777 );
778 assert_eq!(
779 detect(b"GIF89a\x01\x00\x01\x00\x00\x00\x00").unwrap(),
780 Format::Gif
781 );
782 }
783
784 #[test]
785 fn a_pdf_named_jpg_is_still_a_pdf() {
786 // The mis-dispatch guard from docs/ARCHITECTURE.md §1. Detection never sees the name,
787 // which is precisely why this cannot go wrong.
788 assert_eq!(
789 detect(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n").unwrap(),
790 Format::Pdf
791 );
792 }
793
794 #[test]
795 fn a_pdf_header_inside_a_jpeg_does_not_win() {
796 // A hostile file could embed "%PDF-" in an EXIF comment to try to steer dispatch.
797 // Exact magic numbers are matched first, so the JPEG handler keeps the file.
798 let mut data = vec![0xFF, 0xD8, 0xFF, 0xE1];
799 data.extend_from_slice(b"junk %PDF-1.7 junk");
800 assert_eq!(detect(&data).unwrap(), Format::Jpeg);
801 }
802
803 #[test]
804 fn a_pdf_with_a_leading_preamble_is_recognised() {
805 // Real-world files mangled by gateways carry junk before the header, and readers
806 // accept them — so a user with one has an ordinary PDF, not an exotic file.
807 let mut data = b"\r\n<!-- inserted by a broken proxy -->\r\n".to_vec();
808 data.extend_from_slice(b"%PDF-1.5\n");
809 assert_eq!(detect(&data).unwrap(), Format::Pdf);
810 }
811
812 #[test]
813 fn a_pdf_header_beyond_the_search_window_is_not_scanned_for() {
814 // Bounding the scan is what stops detection becoming a denial-of-service target on
815 // large files that mention "%PDF-" somewhere in the middle.
816 let mut data = vec![b'x'; PDF_HEADER_SEARCH_WINDOW];
817 data.extend_from_slice(b"%PDF-1.7\n");
818 assert!(matches!(
819 detect(&data),
820 Err(StryptError::UnrecognisedFormat)
821 ));
822 }
823
824 #[test]
825 fn a_riff_is_routed_by_its_form_type_rather_than_by_its_magic() {
826 // WAV and WebP share a container, so the form type is what tells them apart. Anything
827 // else RIFF is named rather than called "unrecognised", which would be unhelpful.
828 assert_eq!(
829 detect(b"RIFF\x00\x00\x00\x00WAVEfmt ").unwrap(),
830 Format::Wav
831 );
832 assert!(matches!(
833 detect(b"RIFF\x00\x00\x00\x00AVI LIST").unwrap_err(),
834 StryptError::UnsupportedFormat {
835 format: UnsupportedKind::OtherRiff
836 }
837 ));
838 // RF64 and BW64 are a different container, not a large WAV.
839 for magic in [
840 &b"RF64\x00\x00\x00\x00WAVEds64"[..],
841 &b"BW64\x00\x00\x00\x00WAVEds64"[..],
842 ] {
843 assert!(matches!(
844 detect(magic).unwrap_err(),
845 StryptError::UnsupportedFormat {
846 format: UnsupportedKind::Rf64
847 }
848 ));
849 }
850 }
851
852 #[test]
853 fn mpeg_audio_is_claimed_by_its_tag_or_by_a_frame_header() {
854 // A bare frame sync is only four bytes, so the reserved values in the version, layer,
855 // bitrate and sampling-frequency fields are what keep `FF Ex` in an unrelated binary from
856 // being read as audio (ADR-0040).
857 for bytes in [
858 &b"ID3\x04\x00\x00\x00\x00\x00\x00"[..],
859 &b"ID3\x03\x00\x00\x00\x00\x00\x00"[..],
860 // MPEG-1 Layer III, 128 kbps, 44.1 kHz.
861 &b"\xFF\xFB\x90\xC0"[..],
862 ] {
863 assert_eq!(detect(bytes).unwrap(), Format::Mp3, "{bytes:?}");
864 }
865 for bytes in [
866 // Reserved version, reserved layer, forbidden bitrate, forbidden sample rate.
867 &b"\xFF\xEB\x90\xC0"[..],
868 &b"\xFF\xF9\x90\xC0"[..],
869 &b"\xFF\xFB\xF0\xC0"[..],
870 &b"\xFF\xFB\x9C\xC0"[..],
871 ] {
872 assert!(detect(bytes).is_err(), "{bytes:?}");
873 }
874 }
875
876 #[test]
877 fn an_id3_prefixed_flac_is_routed_to_the_flac_handler_not_to_mp3() {
878 // Both formats get an ID3v2 tag stuck in front of them, so the order of the two sniffs is
879 // what tells them apart (ADR-0040).
880 let mut input = b"ID3\x04\x00\x00".to_vec();
881 input.extend_from_slice(&[0, 0, 0, 4]);
882 input.extend_from_slice(&[0u8; 4]);
883 input.extend_from_slice(b"fLaC");
884 assert_eq!(detect(&input).unwrap(), Format::Flac);
885 }
886
887 #[test]
888 fn phase_two_formats_are_named_in_the_refusal() {
889 for (bytes, expected) in [
890 (&b"PK\x03\x04"[..], UnsupportedKind::ZipContainer),
891 (&b"II\x2B\x00"[..], UnsupportedKind::BigTiff),
892 (&b"OggS"[..], UnsupportedKind::OtherOggCodec),
893 // MP4 shares HEIF's container, so the brand is what routes it — and what refuses it.
894 (
895 &b"\x00\x00\x00\x18ftypdash\x00\x00\x02\x00iso6dash"[..],
896 UnsupportedKind::FragmentedMp4,
897 ),
898 (
899 &b"\x00\x00\x00\x18ftypM4P \x00\x00\x02\x00M4A mp42"[..],
900 UnsupportedKind::ProtectedMedia,
901 ),
902 (
903 &b"\x00\x00\x00\x18ftypqt \x00\x00\x02\x00qt qt "[..],
904 UnsupportedKind::QuickTimeMovie,
905 ),
906 (
907 &b"\x00\x00\x00\x18ftyp3gp4\x00\x00\x02\x003gp4isom"[..],
908 UnsupportedKind::ThirdGenerationPartnership,
909 ),
910 // An ISO base-media file whose brands name nothing at all.
911 (
912 &b"\x00\x00\x00\x18ftypzzzz\x00\x00\x02\x00zzzzyyyy"[..],
913 UnsupportedKind::IsoBaseMedia,
914 ),
915 // XML that is not SVG. The SVG spelling of this is now *supported*, so what is left
916 // here is a document strypt identifies as markup and declines.
917 (&b"<?xml version=\"1.0\"?><rss/>"[..], UnsupportedKind::Xml),
918 // A gzip stream, which for this project means `.svgz` far more often than not.
919 (&b"\x1f\x8b\x08\x00"[..], UnsupportedKind::Gzip),
920 ] {
921 let got = detect(bytes).unwrap_err();
922 assert!(
923 matches!(got, StryptError::UnsupportedFormat { format } if format == expected),
924 "detecting {expected:?} gave {got:?}"
925 );
926 }
927 }
928
929 #[test]
930 fn still_image_brands_route_to_the_handler_and_video_does_not() {
931 // The whole of this format's detection is the brand list: `ftyp` alone cannot tell a
932 // photograph from a film, and routing a video to the still handler would mean reporting
933 // a stripped photograph for a file that is neither.
934 for (brand, expected) in [
935 (&b"avif"[..], Format::Avif),
936 (&b"heic"[..], Format::Heif),
937 (&b"mif1"[..], Format::Heif),
938 ] {
939 let mut data = vec![0, 0, 0, 0x14];
940 data.extend_from_slice(b"ftyp");
941 data.extend_from_slice(brand);
942 data.extend_from_slice(&[0, 0, 0, 0]);
943 data.extend_from_slice(brand);
944 assert_eq!(detect(&data).unwrap(), expected, "brand {brand:?}");
945 }
946 }
947
948 #[test]
949 fn movie_brands_route_to_the_mp4_handler_and_audio_wins_over_the_generic_one() {
950 // An `.m4a` declares `M4A `, `mp42` and `isom` together, so the order of the two lists is
951 // what keeps it from being reported as a video (ADR-0042).
952 for (major, compatible, expected) in [
953 (&b"isom"[..], &b"isomiso2mp41"[..], Format::Mp4),
954 (&b"mp42"[..], &b"mp42isom"[..], Format::Mp4),
955 (&b"M4V "[..], &b"M4V mp42"[..], Format::Mp4),
956 (&b"M4A "[..], &b"M4A mp42isom"[..], Format::M4a),
957 (&b"M4B "[..], &b"M4B mp42"[..], Format::M4a),
958 ] {
959 let mut data = vec![0, 0, 0, 0x18];
960 data.extend_from_slice(b"ftyp");
961 data.extend_from_slice(major);
962 data.extend_from_slice(&[0, 0, 2, 0]);
963 data.extend_from_slice(compatible);
964 assert_eq!(detect(&data).unwrap(), expected, "brand {major:?}");
965 }
966 }
967
968 #[test]
969 fn a_sequence_brand_is_not_claimed_as_a_still_image() {
970 // An Apple Live Photo declares a still brand *and* a sequence one. Claiming it here would
971 // mean detection calling it a photograph and the handler then having to refuse it.
972 let mut data = vec![0, 0, 0, 0x18];
973 data.extend_from_slice(b"ftypheic\x00\x00\x00\x00heicmsf1");
974 assert!(matches!(
975 detect(&data),
976 Err(StryptError::UnsupportedFormat {
977 format: UnsupportedKind::IsoBaseMedia
978 })
979 ));
980 }
981
982 #[test]
983 fn an_svg_is_recognised_by_its_root_element_and_nothing_else() {
984 // SVG has no magic number at all: the format's own answer to "what is this" is its root
985 // element, reached past an XML declaration, comments, and a doctype.
986 for bytes in [
987 &b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>"[..],
988 b"\xef\xbb\xbf<svg/>",
989 b"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg width=\"1\"/>",
990 b"<!-- Generator: Adobe Illustrator --><svg>x</svg>",
991 b"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"svg11.dtd\">\n<svg/>",
992 ] {
993 assert_eq!(detect(bytes).unwrap(), Format::Svg, "{bytes:?}");
994 }
995 }
996
997 #[test]
998 fn markup_that_merely_mentions_svg_is_not_claimed_as_one() {
999 // The mis-dispatch guard for a format sniffed from text rather than from a signature.
1000 // `<svg` appears inside any HTML page that embeds a drawing, and routing one here would
1001 // mean the handler editing a document it has no rules for.
1002 for bytes in [
1003 &b"<html><body><svg><rect/></svg></body></html>"[..],
1004 b"<?xml version=\"1.0\"?><gallery><svg/></gallery>",
1005 b"<svgeny/>",
1006 // A prefixed root, which very old Inkscape releases wrote. Not claimed, because the
1007 // handler removes prefixed elements on an allow-list and would remove the document.
1008 b"<svg:svg xmlns:svg=\"http://www.w3.org/2000/svg\"/>",
1009 ] {
1010 assert!(
1011 !matches!(detect(bytes), Ok(Format::Svg)),
1012 "wrongly claimed {bytes:?}"
1013 );
1014 }
1015 }
1016
1017 #[test]
1018 fn a_root_element_beyond_the_search_window_is_not_scanned_for() {
1019 let mut data = b"<!--".to_vec();
1020 data.resize(SVG_ROOT_SEARCH_WINDOW, b'x');
1021 data.extend_from_slice(b"--><svg/>");
1022 assert!(!matches!(detect(&data), Ok(Format::Svg)));
1023 }
1024
1025 #[test]
1026 fn nothing_recognisable_is_an_error_never_a_silent_pass() {
1027 // The single most dangerous outcome this tool can produce is "success" on a file it
1028 // did not process (docs/THREAT_MODEL.md §5.4). There is no Ok path here.
1029 assert!(matches!(detect(b""), Err(StryptError::UnrecognisedFormat)));
1030 assert!(matches!(
1031 detect(b"hello world"),
1032 Err(StryptError::UnrecognisedFormat)
1033 ));
1034 }
1035
1036 #[test]
1037 fn truncated_magic_numbers_do_not_panic() {
1038 // Every prefix of every signature, including the empty one.
1039 let signatures: [&[u8]; 4] = [
1040 &[0xFF, 0xD8, 0xFF],
1041 &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
1042 b"RIFF\x00\x00\x00\x00WEBP",
1043 b"%PDF-1.7",
1044 ];
1045 for sig in signatures {
1046 for n in 0..=sig.len() {
1047 let prefix = sig.get(0..n).unwrap_or_default();
1048 let _ = detect(prefix);
1049 }
1050 }
1051 }
1052}