1use crate::file::UploadedFile;
2
3pub 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 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 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 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 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
74fn validate_magic_bytes(claimed_content_type: &str, data: &[u8]) -> Option<String> {
80 let detected = infer::get(data)?; 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
96pub 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
132pub fn mb(n: usize) -> usize {
134 n * 1024 * 1024
135}
136
137pub fn kb(n: usize) -> usize {
139 n * 1024
140}
141
142pub 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 #[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 f.validate().max_size(10).check().unwrap();
229 }
230
231 #[test]
232 fn accept_passes_when_bytes_undetectable() {
233 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 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 let png_bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00];
318 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 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 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 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 f.validate().accept("image/png").check().unwrap();
364 }
365
366 #[test]
367 fn accept_star_star_skips_magic_bytes() {
368 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}