modo/extractor/
upload_validator.rs1use crate::extractor::multipart::UploadedFile;
2
3pub 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 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 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 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
63fn 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 #[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 #[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}