Skip to main content

systemprompt_files/services/upload/
validator.rs

1//! Upload type and size policy.
2//!
3//! [`FileValidator`] enforces the configured size limit, rejects a blocklist of
4//! executable/script MIME types, and maps allowed types to a [`FileCategory`]
5//! used for storage layout and extension resolution. [`FileValidationError`]
6//! reports each rejection reason.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use crate::config::FileUploadConfig;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub enum FileValidationError {
16    #[error("File uploads are disabled")]
17    UploadsDisabled,
18
19    #[error("File size {size} bytes exceeds maximum allowed {max} bytes")]
20    FileTooLarge { size: u64, max: u64 },
21
22    #[error("File type '{mime_type}' is not allowed")]
23    TypeNotAllowed { mime_type: String },
24
25    #[error("File type '{mime_type}' is blocked for security reasons")]
26    TypeBlocked { mime_type: String },
27
28    #[error("File category '{category}' is disabled in configuration")]
29    CategoryDisabled { category: String },
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum FileCategory {
34    Image,
35    Document,
36    Audio,
37    Video,
38}
39
40impl FileCategory {
41    pub const fn storage_subdir(&self) -> &'static str {
42        match self {
43            Self::Image => "images",
44            Self::Document => "documents",
45            Self::Audio => "audio",
46            Self::Video => "video",
47        }
48    }
49
50    pub const fn display_name(&self) -> &'static str {
51        match self {
52            Self::Image => "image",
53            Self::Document => "document",
54            Self::Audio => "audio",
55            Self::Video => "video",
56        }
57    }
58}
59
60#[derive(Debug, Clone, Copy)]
61pub struct FileValidator {
62    config: FileUploadConfig,
63}
64
65impl FileValidator {
66    const IMAGE_TYPES: &'static [&'static str] = &[
67        "image/jpeg",
68        "image/png",
69        "image/gif",
70        "image/webp",
71        "image/svg+xml",
72        "image/bmp",
73        "image/tiff",
74        "image/x-icon",
75        "image/vnd.microsoft.icon",
76    ];
77
78    const DOCUMENT_TYPES: &'static [&'static str] = &[
79        "application/pdf",
80        "application/msword",
81        "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
82        "application/vnd.ms-excel",
83        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
84        "application/vnd.ms-powerpoint",
85        "application/vnd.openxmlformats-officedocument.presentationml.presentation",
86        "text/plain",
87        "text/csv",
88        "text/markdown",
89        "text/html",
90        "application/json",
91        "application/xml",
92        "text/xml",
93        "application/rtf",
94    ];
95
96    const AUDIO_TYPES: &'static [&'static str] = &[
97        "audio/mpeg",
98        "audio/mp3",
99        "audio/wav",
100        "audio/wave",
101        "audio/x-wav",
102        "audio/ogg",
103        "audio/webm",
104        "audio/aac",
105        "audio/flac",
106        "audio/mp4",
107        "audio/x-m4a",
108    ];
109
110    const VIDEO_TYPES: &'static [&'static str] = &[
111        "video/mp4",
112        "video/webm",
113        "video/ogg",
114        "video/quicktime",
115        "video/x-msvideo",
116        "video/x-matroska",
117    ];
118
119    const BLOCKED_TYPES: &'static [&'static str] = &[
120        "application/x-executable",
121        "application/x-msdos-program",
122        "application/x-msdownload",
123        "application/x-sh",
124        "application/x-shellscript",
125        "application/x-csh",
126        "application/x-bash",
127        "application/bat",
128        "application/x-bat",
129        "application/x-msi",
130        "application/vnd.microsoft.portable-executable",
131        "application/x-dosexec",
132        "application/x-python-code",
133        "application/javascript",
134        "text/javascript",
135        "application/x-httpd-php",
136        "application/x-php",
137        "text/x-php",
138        "application/x-perl",
139        "text/x-perl",
140        "application/x-ruby",
141        "text/x-ruby",
142        "application/java-archive",
143        "application/x-java-class",
144    ];
145
146    pub const fn new(config: FileUploadConfig) -> Self {
147        Self { config }
148    }
149
150    pub fn validate(
151        &self,
152        mime_type: &str,
153        size_bytes: u64,
154    ) -> Result<FileCategory, FileValidationError> {
155        if !self.config.enabled {
156            return Err(FileValidationError::UploadsDisabled);
157        }
158
159        if size_bytes > self.config.max_file_size_bytes {
160            return Err(FileValidationError::FileTooLarge {
161                size: size_bytes,
162                max: self.config.max_file_size_bytes,
163            });
164        }
165
166        let normalized_mime = mime_type.to_lowercase();
167
168        if Self::BLOCKED_TYPES.contains(&normalized_mime.as_str()) {
169            return Err(FileValidationError::TypeBlocked {
170                mime_type: mime_type.to_owned(),
171            });
172        }
173
174        let category = Self::categorize_mime_type(&normalized_mime)?;
175
176        if !self.is_category_allowed(&category) {
177            return Err(FileValidationError::CategoryDisabled {
178                category: category.display_name().to_owned(),
179            });
180        }
181
182        Ok(category)
183    }
184
185    fn categorize_mime_type(mime_type: &str) -> Result<FileCategory, FileValidationError> {
186        if Self::IMAGE_TYPES.contains(&mime_type) {
187            return Ok(FileCategory::Image);
188        }
189
190        if Self::DOCUMENT_TYPES.contains(&mime_type) {
191            return Ok(FileCategory::Document);
192        }
193
194        if Self::AUDIO_TYPES.contains(&mime_type) {
195            return Ok(FileCategory::Audio);
196        }
197
198        if Self::VIDEO_TYPES.contains(&mime_type) {
199            return Ok(FileCategory::Video);
200        }
201
202        Err(FileValidationError::TypeNotAllowed {
203            mime_type: mime_type.to_owned(),
204        })
205    }
206
207    const fn is_category_allowed(&self, category: &FileCategory) -> bool {
208        match category {
209            FileCategory::Image => self.config.allowed_types.images,
210            FileCategory::Document => self.config.allowed_types.documents,
211            FileCategory::Audio => self.config.allowed_types.audio,
212            FileCategory::Video => self.config.allowed_types.video,
213        }
214    }
215
216    pub fn get_extension(mime_type: &str, filename: Option<&str>) -> String {
217        if let Some(name) = filename
218            && let Some(ext) = name.rsplit('.').next()
219            && !ext.is_empty()
220            && ext.len() <= 10
221            && ext != name
222            && ext.chars().all(|c| c.is_ascii_alphanumeric())
223        {
224            return ext.to_lowercase();
225        }
226
227        let lower = mime_type.to_lowercase();
228        MIME_EXTENSION_TABLE
229            .iter()
230            .find(|(mimes, _)| mimes.contains(&lower.as_str()))
231            .map_or("bin", |(_, ext)| *ext)
232            .to_owned()
233    }
234}
235
236const MIME_EXTENSION_TABLE: &[(&[&str], &str)] = &[
237    (&["image/jpeg"], "jpg"),
238    (&["image/png"], "png"),
239    (&["image/gif"], "gif"),
240    (&["image/webp"], "webp"),
241    (&["image/svg+xml"], "svg"),
242    (&["image/bmp"], "bmp"),
243    (&["image/tiff"], "tiff"),
244    (&["image/x-icon", "image/vnd.microsoft.icon"], "ico"),
245    (&["application/pdf"], "pdf"),
246    (&["text/plain"], "txt"),
247    (&["text/csv"], "csv"),
248    (&["text/markdown"], "md"),
249    (&["text/html"], "html"),
250    (&["application/json"], "json"),
251    (&["application/xml", "text/xml"], "xml"),
252    (&["application/rtf"], "rtf"),
253    (&["application/msword"], "doc"),
254    (
255        &["application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
256        "docx",
257    ),
258    (&["application/vnd.ms-excel"], "xls"),
259    (
260        &["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
261        "xlsx",
262    ),
263    (&["application/vnd.ms-powerpoint"], "ppt"),
264    (
265        &["application/vnd.openxmlformats-officedocument.presentationml.presentation"],
266        "pptx",
267    ),
268    (&["audio/mpeg", "audio/mp3"], "mp3"),
269    (&["audio/wav", "audio/wave", "audio/x-wav"], "wav"),
270    (&["audio/ogg"], "ogg"),
271    (&["audio/webm"], "weba"),
272    (&["audio/aac"], "aac"),
273    (&["audio/flac"], "flac"),
274    (&["audio/mp4", "audio/x-m4a"], "m4a"),
275    (&["video/mp4"], "mp4"),
276    (&["video/webm"], "webm"),
277    (&["video/ogg"], "ogv"),
278    (&["video/quicktime"], "mov"),
279    (&["video/x-msvideo"], "avi"),
280    (&["video/x-matroska"], "mkv"),
281];