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 {
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 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
59pub 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
95pub fn mb(n: usize) -> usize {
97 n * 1024 * 1024
98}
99
100pub fn kb(n: usize) -> usize {
102 n * 1024
103}
104
105pub 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 #[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 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 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}