Skip to main content

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}