Skip to main content

ocre/storage/
analyze.rs

1//! File analysis: the real type of a file from its first bytes, and the
2//! pixel size of an image from its header (Active Storage's analyzers,
3//! without reading pixels).
4
5use super::{Upload, essence};
6use crate::Validator;
7
8/// What [`analyze`] found in a file's bytes.
9///
10/// # Examples
11///
12/// ```
13/// use ocre::storage::{Analysis, analyze};
14///
15/// assert_eq!(analyze(b"%PDF-1.7\n..."), Analysis { content_type: Some("application/pdf"), width: None, height: None });
16/// assert_eq!(analyze(b"hello"), Analysis::default());
17/// ```
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
19pub struct Analysis {
20    /// The type told by the file's signature (magic bytes), or `None` for other files (text, CSV, SVG...).
21    pub content_type: Option<&'static str>,
22    /// Width in pixels, for PNG, GIF, WebP and JPEG images whose header is in the bytes.
23    pub width: Option<u32>,
24    /// Height in pixels, like `width`.
25    pub height: Option<u32>,
26}
27
28/// Types [`analyze`] recognizes, so a file declared as one of them must carry its signature.
29const SNIFFED: &[&str] = &[
30    "image/png",
31    "image/jpeg",
32    "image/gif",
33    "image/webp",
34    "image/avif",
35    "application/pdf",
36    "application/zip",
37    "video/mp4",
38    "video/quicktime",
39    "audio/mp4",
40];
41
42/// Reads a file's signature and, for images, its width and height (Active Storage's `analyze`).
43///
44/// Recognized: PNG, JPEG, GIF, WebP, AVIF, PDF, ZIP (also `.docx`, `.xlsx`,
45/// `.odt`...: they are ZIP files), MP4, M4A and QuickTime (MOV). Sizes come
46/// from PNG, GIF and WebP (VP8, VP8L, VP8X) headers and from the JPEG
47/// frame header (`SOF`), found by jumping from segment to segment. Text
48/// formats (plain text, CSV, HTML, SVG, JSON) have no signature and give
49/// `None`: telling them apart would need guesses that an attacker can steer.
50///
51/// Pure and bounded: it reads the first 32 bytes, plus a JPEG's segment
52/// headers (a few dozen jumps, never the image data): microseconds of CPU
53/// whatever the file size. Passing only the start of a file works (a few
54/// KB; up to 256 KB for JPEGs with large EXIF blocks), for example the
55/// first bytes of an R2 object from [`read_first`](crate::storage::read_first).
56///
57/// # Examples
58///
59/// ```
60/// use ocre::storage::analyze;
61///
62/// // The first 24 bytes of a 640x480 PNG.
63/// let png = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR\0\0\x02\x80\0\0\x01\xe0";
64/// let analysis = analyze(png);
65/// assert_eq!(analysis.content_type, Some("image/png"));
66/// assert_eq!((analysis.width, analysis.height), (Some(640), Some(480)));
67/// ```
68pub fn analyze(bytes: &[u8]) -> Analysis {
69    let size = |dimensions: Option<(u32, u32)>| match dimensions {
70        Some((width, height)) => (Some(width), Some(height)),
71        None => (None, None),
72    };
73    let (content_type, (width, height)) = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
74        ("image/png", size(png_size(bytes)))
75    } else if bytes.starts_with(b"\xff\xd8\xff") {
76        ("image/jpeg", size(jpeg_size(bytes)))
77    } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
78        ("image/gif", size(bytes.get(6..10).map(|screen| (le16(screen, 0), le16(screen, 2)))))
79    } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
80        ("image/webp", size(webp_size(bytes)))
81    } else if bytes.starts_with(b"%PDF-") {
82        ("application/pdf", (None, None))
83    } else if bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06") {
84        ("application/zip", (None, None))
85    } else if let Some(brand) = bytes.get(8..12).filter(|_| bytes.get(4..8) == Some(b"ftyp")) {
86        let content_type = match brand {
87            b"avif" | b"avis" => "image/avif",
88            b"qt  " => "video/quicktime",
89            b"M4A " => "audio/mp4",
90            _ => "video/mp4",
91        };
92        (content_type, (None, None))
93    } else {
94        return Analysis::default();
95    };
96    Analysis { content_type: Some(content_type), width, height }
97}
98
99impl Validator {
100    /// Checks that an upload's bytes match its declared content type (Active Storage's content-type identification).
101    ///
102    /// Adds "has content that does not match image/png" when the declared
103    /// type is one [`analyze`] recognizes but the bytes do not carry its
104    /// signature (a script renamed `.png`), or when the bytes are a
105    /// recognized type other than the declared one (a PDF sent as
106    /// `text/plain`). Text types without a signature pass. Use it after
107    /// [`Validator::file`], which limits the declared type; costs
108    /// microseconds (see [`analyze`]).
109    ///
110    /// # Examples
111    ///
112    /// ```
113    /// use ocre::{Validator, storage::Upload};
114    ///
115    /// let fake = Upload::new("cat.png", "image/png", "<script>alert(1)</script>");
116    /// let err = Validator::new().file_content("photo", &fake).finish().unwrap_err();
117    /// assert_eq!(err.to_string(), "invalid: Photo has content that does not match image/png");
118    ///
119    /// let text = Upload::new("notes.txt", "text/plain", "hello");
120    /// assert!(Validator::new().file_content("notes", &text).finish().is_ok());
121    /// ```
122    pub fn file_content(&mut self, field: &str, upload: &Upload) -> &mut Self {
123        let declared = essence(&upload.content_type);
124        let sniffed = analyze(&upload.bytes).content_type;
125        let mismatch = match sniffed {
126            Some(found) => found != declared,
127            None => SNIFFED.contains(&declared.as_str()),
128        };
129        self.check(field, mismatch, format!("has content that does not match {declared}"))
130    }
131}
132
133fn le16(bytes: &[u8], at: usize) -> u32 {
134    u32::from(bytes[at]) | u32::from(bytes[at + 1]) << 8
135}
136
137fn be16(bytes: &[u8], at: usize) -> u32 {
138    u32::from(bytes[at]) << 8 | u32::from(bytes[at + 1])
139}
140
141fn le24(bytes: &[u8], at: usize) -> u32 {
142    le16(bytes, at) | u32::from(bytes[at + 2]) << 16
143}
144
145/// Width and height of the `IHDR` chunk, which must come first.
146fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
147    let header = bytes.get(12..24).filter(|header| header.starts_with(b"IHDR"))?;
148    let word = |at: usize| u32::from_be_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]]);
149    Some((word(4), word(8)))
150}
151
152/// Size of the first chunk: `VP8 ` (lossy), `VP8L` (lossless) or `VP8X` (extended).
153fn webp_size(bytes: &[u8]) -> Option<(u32, u32)> {
154    let data = bytes.get(20..30)?;
155    match &bytes[12..16] {
156        b"VP8 " if data[3..6] == [0x9d, 0x01, 0x2a] => Some((le16(data, 6) & 0x3fff, le16(data, 8) & 0x3fff)),
157        b"VP8L" if data[0] == 0x2f => {
158            let bits = u32::from_le_bytes([data[1], data[2], data[3], data[4]]);
159            Some(((bits & 0x3fff) + 1, (bits >> 14 & 0x3fff) + 1))
160        }
161        b"VP8X" => Some((le24(data, 4) + 1, le24(data, 7) + 1)),
162        _ => None,
163    }
164}
165
166/// Height and width of the first frame header (`SOF0`..`SOF15`, except
167/// `DHT`, `JPG` and `DAC`), jumping over the other segments by their length.
168fn jpeg_size(bytes: &[u8]) -> Option<(u32, u32)> {
169    let mut at = 2;
170    loop {
171        if *bytes.get(at)? != 0xff {
172            return None;
173        }
174        let marker = *bytes.get(at + 1)?;
175        match marker {
176            // Fill byte before a marker.
177            0xff => at += 1,
178            // Markers without a segment.
179            0x01 | 0xd0..=0xd8 => at += 2,
180            // Start of scan or end of image: no frame header before the data.
181            0xd9 | 0xda => return None,
182            _ => {
183                let is_frame = (0xc0..=0xcf).contains(&marker) && !matches!(marker, 0xc4 | 0xc8 | 0xcc);
184                if is_frame {
185                    let segment = bytes.get(at + 2..at + 9)?;
186                    return Some((be16(segment, 5), be16(segment, 3)));
187                }
188                let length = be16(bytes.get(at + 2..at + 4)?, 0) as usize;
189                if length < 2 {
190                    return None;
191                }
192                at += 2 + length;
193            }
194        }
195    }
196}
197
198#[cfg(test)]
199#[path = "../../tests/storage/analyze.rs"]
200mod tests;