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;