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 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}