Skip to main content

modo_upload/
validate.rs

1use crate::file::UploadedFile;
2
3/// Fluent validator for uploaded files.
4///
5/// Obtained by calling [`UploadedFile::validate()`].  Chain `.max_size()` and
6/// `.accept()` calls, then call `.check()` to finalize.  All constraint
7/// violations are collected before returning, so a single `.check()` call
8/// reports every failing rule at once.
9pub struct UploadValidator<'a> {
10    file: &'a UploadedFile,
11    errors: Vec<String>,
12}
13
14impl<'a> UploadValidator<'a> {
15    pub(crate) fn new(file: &'a UploadedFile) -> Self {
16        Self {
17            file,
18            errors: Vec::new(),
19        }
20    }
21
22    /// Reject if the file exceeds `max` bytes.
23    pub fn max_size(mut self, max: usize) -> Self {
24        if self.file.size() > max {
25            self.errors
26                .push(format!("file exceeds maximum size of {}", format_size(max)));
27        }
28        self
29    }
30
31    /// Reject if the content type doesn't match `pattern`.
32    ///
33    /// Supports exact types (`"image/png"`), wildcard subtypes (`"image/*"`),
34    /// and the catch-all `"*/*"`.  Parameters after `;` in the content type
35    /// are stripped before matching.
36    ///
37    /// When the MIME header check passes, the file's actual bytes are
38    /// inspected via magic-number detection.  If the detected type does not
39    /// match the claimed content type, the file is rejected (422).  Files
40    /// whose type cannot be detected from bytes (e.g. plain text, JSON)
41    /// are allowed through — magic-bytes validation only applies when
42    /// detection succeeds.
43    pub fn accept(mut self, pattern: &str) -> Self {
44        if !mime_matches(self.file.content_type(), pattern) {
45            self.errors.push(format!("file type must match {pattern}"));
46            return self;
47        }
48        // Skip magic-bytes validation for catch-all or empty files
49        if pattern == "*/*" || self.file.is_empty() {
50            return self;
51        }
52        if let Some(err) = validate_magic_bytes(self.file.content_type(), self.file.data()) {
53            self.errors.push(err);
54        }
55        self
56    }
57
58    /// Finish validation.
59    ///
60    /// Returns `Ok(())` when all rules pass, or a validation error whose
61    /// `details` map the field name to the collected error messages.
62    pub fn check(self) -> Result<(), modo::Error> {
63        if self.errors.is_empty() {
64            Ok(())
65        } else {
66            Err(modo::validate::validation_error(vec![(
67                self.file.name(),
68                self.errors,
69            )]))
70        }
71    }
72}
73
74/// Validate that the file's actual bytes match its claimed content type.
75///
76/// Returns `Some(error_message)` when the detected type does not match,
77/// or `None` when validation passes (including when the type cannot be
78/// detected from bytes).
79fn validate_magic_bytes(claimed_content_type: &str, data: &[u8]) -> Option<String> {
80    let detected = infer::get(data)?; // can't detect — allow through
81    let claimed = claimed_content_type
82        .split(';')
83        .next()
84        .unwrap_or(claimed_content_type)
85        .trim();
86    if detected.mime_type() != claimed {
87        Some(format!(
88            "file content does not match claimed type {claimed} (detected {})",
89            detected.mime_type()
90        ))
91    } else {
92        None
93    }
94}
95
96/// Check if a content type matches a pattern (e.g. `"image/*"` matches `"image/png"`).
97///
98/// Parameters after `;` in the content type are stripped before matching.
99/// The pattern `"*/*"` matches any type.
100pub fn mime_matches(content_type: &str, pattern: &str) -> bool {
101    let content_type = content_type
102        .split(';')
103        .next()
104        .unwrap_or(content_type)
105        .trim();
106    if pattern == "*/*" {
107        return true;
108    }
109    if let Some(prefix) = pattern.strip_suffix("/*") {
110        content_type.starts_with(prefix)
111            && content_type
112                .as_bytes()
113                .get(prefix.len())
114                .is_some_and(|&b| b == b'/')
115    } else {
116        content_type == pattern
117    }
118}
119
120fn format_size(bytes: usize) -> String {
121    if bytes >= 1024 * 1024 * 1024 && bytes.is_multiple_of(1024 * 1024 * 1024) {
122        format!("{}GB", bytes / (1024 * 1024 * 1024))
123    } else if bytes >= 1024 * 1024 && bytes.is_multiple_of(1024 * 1024) {
124        format!("{}MB", bytes / (1024 * 1024))
125    } else if bytes >= 1024 && bytes.is_multiple_of(1024) {
126        format!("{}KB", bytes / 1024)
127    } else {
128        format!("{bytes}B")
129    }
130}
131
132/// Convert megabytes to bytes.
133pub fn mb(n: usize) -> usize {
134    n * 1024 * 1024
135}
136
137/// Convert kilobytes to bytes.
138pub fn kb(n: usize) -> usize {
139    n * 1024
140}
141
142/// Convert gigabytes to bytes.
143pub fn gb(n: usize) -> usize {
144    n * 1024 * 1024 * 1024
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn mime_exact_match() {
153        assert!(mime_matches("image/png", "image/png"));
154        assert!(!mime_matches("image/jpeg", "image/png"));
155    }
156
157    #[test]
158    fn mime_wildcard_match() {
159        assert!(mime_matches("image/png", "image/*"));
160        assert!(mime_matches("image/jpeg", "image/*"));
161        assert!(!mime_matches("text/plain", "image/*"));
162    }
163
164    #[test]
165    fn mime_any_match() {
166        assert!(mime_matches("anything/here", "*/*"));
167    }
168
169    #[test]
170    fn mime_with_params_exact() {
171        assert!(mime_matches("image/png; charset=utf-8", "image/png"));
172        assert!(!mime_matches("image/jpeg; charset=utf-8", "image/png"));
173    }
174
175    #[test]
176    fn mime_with_params_wildcard() {
177        assert!(mime_matches("image/png; charset=utf-8", "image/*"));
178        assert!(!mime_matches("text/plain; charset=utf-8", "image/*"));
179    }
180
181    #[test]
182    fn mime_empty_content_type() {
183        assert!(!mime_matches("", "image/png"));
184        assert!(!mime_matches("image/png", ""));
185    }
186
187    #[test]
188    fn size_helpers() {
189        assert_eq!(kb(1), 1024);
190        assert_eq!(mb(1), 1024 * 1024);
191        assert_eq!(gb(1), 1024 * 1024 * 1024);
192        assert_eq!(mb(5), 5 * 1024 * 1024);
193    }
194
195    #[test]
196    fn format_size_display() {
197        assert_eq!(format_size(500), "500B");
198        assert_eq!(format_size(1024), "1KB");
199        assert_eq!(format_size(5 * 1024 * 1024), "5MB");
200        assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2GB");
201    }
202
203    #[test]
204    fn format_size_non_aligned_falls_back_to_bytes() {
205        assert_eq!(format_size(2047), "2047B");
206        assert_eq!(format_size(1025), "1025B");
207        assert_eq!(format_size(1024 * 1024 + 1), "1048577B");
208    }
209
210    // -- UploadValidator --
211
212    #[test]
213    fn validator_max_size_pass() {
214        let f = UploadedFile::__test_new("f", "a.bin", "application/octet-stream", &[0u8; 5]);
215        f.validate().max_size(10).check().unwrap();
216    }
217
218    #[test]
219    fn validator_max_size_fail() {
220        let f = UploadedFile::__test_new("f", "a.bin", "application/octet-stream", &[0u8; 20]);
221        assert!(f.validate().max_size(10).check().is_err());
222    }
223
224    #[test]
225    fn validator_max_size_exact_boundary() {
226        let f = UploadedFile::__test_new("f", "a.bin", "application/octet-stream", &[0u8; 10]);
227        // size == max should pass (not >)
228        f.validate().max_size(10).check().unwrap();
229    }
230
231    #[test]
232    fn accept_passes_when_bytes_undetectable() {
233        // b"img" can't be identified by `infer`, so magic-bytes validation
234        // is skipped and only the MIME header check applies.
235        let f = UploadedFile::__test_new("f", "img.png", "image/png", b"img");
236        f.validate().accept("image/*").check().unwrap();
237    }
238
239    #[test]
240    fn validator_accept_fail() {
241        let f = UploadedFile::__test_new("f", "doc.txt", "text/plain", b"text");
242        assert!(f.validate().accept("image/*").check().is_err());
243    }
244
245    #[test]
246    fn validator_chain_both_fail() {
247        let f = UploadedFile::__test_new("f", "doc.txt", "text/plain", &[0u8; 20]);
248        let err = f
249            .validate()
250            .max_size(10)
251            .accept("image/*")
252            .check()
253            .unwrap_err();
254        // Both errors should be collected
255        let details = err.details();
256        let messages = details
257            .get("f")
258            .expect("expected details for field 'f'")
259            .as_array()
260            .expect("expected JSON array");
261        assert_eq!(
262            messages.len(),
263            2,
264            "expected 2 validation messages, got: {messages:?}"
265        );
266    }
267
268    #[test]
269    fn validator_chain_both_pass() {
270        let f = UploadedFile::__test_new("f", "img.png", "image/png", &[0u8; 5]);
271        f.validate().max_size(10).accept("image/*").check().unwrap();
272    }
273
274    #[test]
275    fn mime_semicolon_no_params() {
276        assert!(mime_matches("image/png;", "image/png"));
277    }
278
279    #[test]
280    fn mime_case_sensitive() {
281        assert!(!mime_matches("Image/PNG", "image/png"));
282    }
283
284    #[test]
285    fn mime_wildcard_invalid_form() {
286        assert!(!mime_matches("image/png", "*/image"));
287    }
288
289    #[test]
290    fn mime_leading_trailing_whitespace() {
291        assert!(mime_matches(" image/png ", "image/png"));
292    }
293
294    #[test]
295    fn mime_wildcard_partial_type_rejected() {
296        assert!(!mime_matches("imageX/png", "image/*"));
297    }
298
299    #[test]
300    fn format_size_zero() {
301        assert_eq!(format_size(0), "0B");
302    }
303
304    #[test]
305    fn format_size_boundary_1023() {
306        assert_eq!(format_size(1023), "1023B");
307    }
308
309    #[test]
310    fn format_size_boundary_below_mb() {
311        assert_eq!(format_size(1024 * 1024 - 1), "1048575B");
312    }
313
314    #[test]
315    fn accept_rejects_mismatched_magic_bytes() {
316        // PNG magic bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
317        let png_bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00];
318        // Claim it's JPEG but bytes are PNG
319        let f = UploadedFile::__test_new("f", "photo.jpg", "image/jpeg", png_bytes);
320        let err = f.validate().accept("image/jpeg").check();
321        assert!(
322            err.is_err(),
323            "should reject: claimed JPEG but bytes are PNG"
324        );
325    }
326
327    #[test]
328    fn accept_passes_matching_magic_bytes() {
329        let png_bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00];
330        let f = UploadedFile::__test_new("f", "photo.png", "image/png", png_bytes);
331        f.validate().accept("image/png").check().unwrap();
332    }
333
334    #[test]
335    fn accept_passes_matching_wildcard_with_valid_bytes() {
336        let png_bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00];
337        let f = UploadedFile::__test_new("f", "photo.png", "image/png", png_bytes);
338        f.validate().accept("image/*").check().unwrap();
339    }
340
341    #[test]
342    fn accept_rejects_wildcard_when_bytes_mismatch_category() {
343        // PNG bytes but claiming text/plain
344        let png_bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00];
345        let f = UploadedFile::__test_new("f", "data.txt", "text/plain", png_bytes);
346        // text/plain doesn't match image/* pattern
347        let err = f.validate().accept("text/*").check();
348        assert!(err.is_err(), "should reject: bytes are PNG, not text");
349    }
350
351    #[test]
352    fn accept_skips_magic_bytes_for_unknown_types() {
353        // For types infer can't detect (e.g. application/json), skip byte validation
354        let json_bytes = b"{\"key\": \"value\"}";
355        let f = UploadedFile::__test_new("f", "data.json", "application/json", json_bytes);
356        f.validate().accept("application/json").check().unwrap();
357    }
358
359    #[test]
360    fn accept_skips_magic_bytes_for_empty_files() {
361        let f = UploadedFile::__test_new("f", "empty.png", "image/png", &[]);
362        // Empty file — no bytes to sniff, MIME header matches, should pass
363        f.validate().accept("image/png").check().unwrap();
364    }
365
366    #[test]
367    fn accept_star_star_skips_magic_bytes() {
368        // Wildcard */* should accept anything regardless of bytes
369        let png_bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00];
370        let f = UploadedFile::__test_new("f", "photo.jpg", "image/jpeg", png_bytes);
371        f.validate().accept("*/*").check().unwrap();
372    }
373}