Skip to main content

modo/extractor/
upload_validator.rs

1use crate::extractor::multipart::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.
8pub struct UploadValidator<'a> {
9    file: &'a UploadedFile,
10    errors: Vec<String>,
11}
12
13impl<'a> UploadValidator<'a> {
14    pub(crate) fn new(file: &'a UploadedFile) -> Self {
15        Self {
16            file,
17            errors: Vec::new(),
18        }
19    }
20
21    /// Reject if the file exceeds `max` bytes.
22    pub fn max_size(mut self, max: usize) -> Self {
23        if self.file.size > max {
24            self.errors
25                .push(format!("file exceeds maximum size of {}", format_size(max)));
26        }
27        self
28    }
29
30    /// Reject if the content type doesn't match `pattern`.
31    ///
32    /// Supports exact types (`"image/png"`), wildcard subtypes (`"image/*"`),
33    /// and the catch-all `"*/*"`. Parameters after `;` in the content type
34    /// are stripped before matching.
35    pub fn accept(mut self, pattern: &str) -> Self {
36        if !mime_matches(&self.file.content_type, pattern) {
37            self.errors.push(format!("file type must match {pattern}"));
38        }
39        self
40    }
41
42    /// Finish validation.
43    ///
44    /// # Errors
45    ///
46    /// Returns a `422 Unprocessable Entity` error with all collected violation
47    /// messages in the `details` payload if any rule failed.
48    pub fn check(self) -> crate::error::Result<()> {
49        if self.errors.is_empty() {
50            Ok(())
51        } else {
52            let details = serde_json::json!({
53                self.file.name.clone(): self.errors,
54            });
55            Err(
56                crate::error::Error::unprocessable_entity("upload validation failed")
57                    .with_details(details),
58            )
59        }
60    }
61}
62
63/// Check if a content type matches a pattern.
64///
65/// Parameters after `;` in the content type are stripped before matching.
66/// The pattern `"*/*"` matches any type.
67fn mime_matches(content_type: &str, pattern: &str) -> bool {
68    let content_type = content_type
69        .split(';')
70        .next()
71        .unwrap_or(content_type)
72        .trim();
73    if pattern == "*/*" {
74        return true;
75    }
76    if let Some(prefix) = pattern.strip_suffix("/*") {
77        content_type.starts_with(prefix)
78            && content_type
79                .as_bytes()
80                .get(prefix.len())
81                .is_some_and(|&b| b == b'/')
82    } else {
83        content_type == pattern
84    }
85}
86
87fn format_size(bytes: usize) -> String {
88    if bytes >= 1024 * 1024 * 1024 && bytes.is_multiple_of(1024 * 1024 * 1024) {
89        format!("{}GB", bytes / (1024 * 1024 * 1024))
90    } else if bytes >= 1024 * 1024 && bytes.is_multiple_of(1024 * 1024) {
91        format!("{}MB", bytes / (1024 * 1024))
92    } else if bytes >= 1024 && bytes.is_multiple_of(1024) {
93        format!("{}KB", bytes / 1024)
94    } else {
95        format!("{bytes}B")
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn test_file(name: &str, content_type: &str, size: usize) -> UploadedFile {
104        UploadedFile {
105            name: name.to_string(),
106            content_type: content_type.to_string(),
107            size,
108            data: bytes::Bytes::from(vec![0u8; size]),
109        }
110    }
111
112    // -- mime_matches --
113
114    #[test]
115    fn mime_exact_match() {
116        assert!(mime_matches("image/png", "image/png"));
117        assert!(!mime_matches("image/jpeg", "image/png"));
118    }
119
120    #[test]
121    fn mime_wildcard_match() {
122        assert!(mime_matches("image/png", "image/*"));
123        assert!(mime_matches("image/jpeg", "image/*"));
124        assert!(!mime_matches("text/plain", "image/*"));
125    }
126
127    #[test]
128    fn mime_any_match() {
129        assert!(mime_matches("anything/here", "*/*"));
130    }
131
132    #[test]
133    fn mime_with_params() {
134        assert!(mime_matches("image/png; charset=utf-8", "image/png"));
135    }
136
137    #[test]
138    fn mime_wildcard_partial_type_rejected() {
139        assert!(!mime_matches("imageX/png", "image/*"));
140    }
141
142    // -- UploadValidator --
143
144    #[test]
145    fn validator_max_size_pass() {
146        let f = test_file("f", "application/octet-stream", 5);
147        f.validate().max_size(10).check().unwrap();
148    }
149
150    #[test]
151    fn validator_max_size_fail() {
152        let f = test_file("f", "application/octet-stream", 20);
153        assert!(f.validate().max_size(10).check().is_err());
154    }
155
156    #[test]
157    fn validator_max_size_exact_boundary() {
158        let f = test_file("f", "application/octet-stream", 10);
159        f.validate().max_size(10).check().unwrap();
160    }
161
162    #[test]
163    fn validator_accept_pass() {
164        let f = test_file("f", "image/png", 5);
165        f.validate().accept("image/*").check().unwrap();
166    }
167
168    #[test]
169    fn validator_accept_fail() {
170        let f = test_file("f", "text/plain", 5);
171        assert!(f.validate().accept("image/*").check().is_err());
172    }
173
174    #[test]
175    fn validator_chain_both_fail() {
176        let f = test_file("f", "text/plain", 20);
177        let err = f
178            .validate()
179            .max_size(10)
180            .accept("image/*")
181            .check()
182            .unwrap_err();
183        let details = err.details().expect("expected details");
184        let messages = details["f"].as_array().expect("expected array");
185        assert_eq!(messages.len(), 2);
186    }
187
188    #[test]
189    fn validator_chain_both_pass() {
190        let f = test_file("f", "image/png", 5);
191        f.validate().max_size(10).accept("image/*").check().unwrap();
192    }
193}