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    pub fn accept(mut self, pattern: &str) -> Self {
37        if !mime_matches(self.file.content_type(), pattern) {
38            self.errors.push(format!("File type must match {pattern}"));
39        }
40        self
41    }
42
43    /// Finish validation.
44    ///
45    /// Returns `Ok(())` when all rules pass, or a validation error whose
46    /// `details` map the field name to the collected error messages.
47    pub fn check(self) -> Result<(), modo::Error> {
48        if self.errors.is_empty() {
49            Ok(())
50        } else {
51            Err(modo::validate::validation_error(vec![(
52                self.file.name(),
53                self.errors,
54            )]))
55        }
56    }
57}
58
59/// Check if a content type matches a pattern (e.g. `"image/*"` matches `"image/png"`).
60///
61/// Parameters after `;` in the content type are stripped before matching.
62/// The pattern `"*/*"` matches any type.
63pub fn mime_matches(content_type: &str, pattern: &str) -> bool {
64    let content_type = content_type
65        .split(';')
66        .next()
67        .unwrap_or(content_type)
68        .trim();
69    if pattern == "*/*" {
70        return true;
71    }
72    if let Some(prefix) = pattern.strip_suffix("/*") {
73        content_type.starts_with(prefix)
74            && content_type
75                .as_bytes()
76                .get(prefix.len())
77                .is_some_and(|&b| b == b'/')
78    } else {
79        content_type == pattern
80    }
81}
82
83fn format_size(bytes: usize) -> String {
84    if bytes >= 1024 * 1024 * 1024 && bytes.is_multiple_of(1024 * 1024 * 1024) {
85        format!("{}GB", bytes / (1024 * 1024 * 1024))
86    } else if bytes >= 1024 * 1024 && bytes.is_multiple_of(1024 * 1024) {
87        format!("{}MB", bytes / (1024 * 1024))
88    } else if bytes >= 1024 && bytes.is_multiple_of(1024) {
89        format!("{}KB", bytes / 1024)
90    } else {
91        format!("{bytes}B")
92    }
93}
94
95/// Convert megabytes to bytes.
96pub fn mb(n: usize) -> usize {
97    n * 1024 * 1024
98}
99
100/// Convert kilobytes to bytes.
101pub fn kb(n: usize) -> usize {
102    n * 1024
103}
104
105/// Convert gigabytes to bytes.
106pub fn gb(n: usize) -> usize {
107    n * 1024 * 1024 * 1024
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
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_exact() {
134        assert!(mime_matches("image/png; charset=utf-8", "image/png"));
135        assert!(!mime_matches("image/jpeg; charset=utf-8", "image/png"));
136    }
137
138    #[test]
139    fn mime_with_params_wildcard() {
140        assert!(mime_matches("image/png; charset=utf-8", "image/*"));
141        assert!(!mime_matches("text/plain; charset=utf-8", "image/*"));
142    }
143
144    #[test]
145    fn mime_empty_content_type() {
146        assert!(!mime_matches("", "image/png"));
147        assert!(!mime_matches("image/png", ""));
148    }
149
150    #[test]
151    fn size_helpers() {
152        assert_eq!(kb(1), 1024);
153        assert_eq!(mb(1), 1024 * 1024);
154        assert_eq!(gb(1), 1024 * 1024 * 1024);
155        assert_eq!(mb(5), 5 * 1024 * 1024);
156    }
157
158    #[test]
159    fn format_size_display() {
160        assert_eq!(format_size(500), "500B");
161        assert_eq!(format_size(1024), "1KB");
162        assert_eq!(format_size(5 * 1024 * 1024), "5MB");
163        assert_eq!(format_size(2 * 1024 * 1024 * 1024), "2GB");
164    }
165
166    #[test]
167    fn format_size_non_aligned_falls_back_to_bytes() {
168        assert_eq!(format_size(2047), "2047B");
169        assert_eq!(format_size(1025), "1025B");
170        assert_eq!(format_size(1024 * 1024 + 1), "1048577B");
171    }
172
173    // -- UploadValidator --
174
175    #[test]
176    fn validator_max_size_pass() {
177        let f = UploadedFile::__test_new("f", "a.bin", "application/octet-stream", &[0u8; 5]);
178        f.validate().max_size(10).check().unwrap();
179    }
180
181    #[test]
182    fn validator_max_size_fail() {
183        let f = UploadedFile::__test_new("f", "a.bin", "application/octet-stream", &[0u8; 20]);
184        assert!(f.validate().max_size(10).check().is_err());
185    }
186
187    #[test]
188    fn validator_max_size_exact_boundary() {
189        let f = UploadedFile::__test_new("f", "a.bin", "application/octet-stream", &[0u8; 10]);
190        // size == max should pass (not >)
191        f.validate().max_size(10).check().unwrap();
192    }
193
194    #[test]
195    fn validator_accept_pass() {
196        let f = UploadedFile::__test_new("f", "img.png", "image/png", b"img");
197        f.validate().accept("image/*").check().unwrap();
198    }
199
200    #[test]
201    fn validator_accept_fail() {
202        let f = UploadedFile::__test_new("f", "doc.txt", "text/plain", b"text");
203        assert!(f.validate().accept("image/*").check().is_err());
204    }
205
206    #[test]
207    fn validator_chain_both_fail() {
208        let f = UploadedFile::__test_new("f", "doc.txt", "text/plain", &[0u8; 20]);
209        let err = f
210            .validate()
211            .max_size(10)
212            .accept("image/*")
213            .check()
214            .unwrap_err();
215        // Both errors should be collected
216        let details = err.details();
217        let messages = details
218            .get("f")
219            .expect("expected details for field 'f'")
220            .as_array()
221            .expect("expected JSON array");
222        assert_eq!(
223            messages.len(),
224            2,
225            "expected 2 validation messages, got: {messages:?}"
226        );
227    }
228
229    #[test]
230    fn validator_chain_both_pass() {
231        let f = UploadedFile::__test_new("f", "img.png", "image/png", &[0u8; 5]);
232        f.validate().max_size(10).accept("image/*").check().unwrap();
233    }
234
235    #[test]
236    fn mime_semicolon_no_params() {
237        assert!(mime_matches("image/png;", "image/png"));
238    }
239
240    #[test]
241    fn mime_case_sensitive() {
242        assert!(!mime_matches("Image/PNG", "image/png"));
243    }
244
245    #[test]
246    fn mime_wildcard_invalid_form() {
247        assert!(!mime_matches("image/png", "*/image"));
248    }
249
250    #[test]
251    fn mime_leading_trailing_whitespace() {
252        assert!(mime_matches(" image/png ", "image/png"));
253    }
254
255    #[test]
256    fn mime_wildcard_partial_type_rejected() {
257        assert!(!mime_matches("imageX/png", "image/*"));
258    }
259
260    #[test]
261    fn format_size_zero() {
262        assert_eq!(format_size(0), "0B");
263    }
264
265    #[test]
266    fn format_size_boundary_1023() {
267        assert_eq!(format_size(1023), "1023B");
268    }
269
270    #[test]
271    fn format_size_boundary_below_mb() {
272        assert_eq!(format_size(1024 * 1024 - 1), "1048575B");
273    }
274}