fraiseql_server/files/
validation.rs1use bytes::Bytes;
4
5use crate::files::{
6 config::{FileConfig, parse_size},
7 error::FileError,
8 traits::{FileValidator, ValidatedFile},
9};
10
11pub struct DefaultFileValidator;
13
14impl FileValidator for DefaultFileValidator {
15 fn validate(
16 &self,
17 data: &Bytes,
18 declared_type: &str,
19 filename: &str,
20 config: &FileConfig,
21 ) -> Result<ValidatedFile, FileError> {
22 validate_file(data, declared_type, filename, config)
23 }
24}
25
26pub fn validate_file(
28 data: &Bytes,
29 declared_type: &str,
30 filename: &str,
31 config: &FileConfig,
32) -> Result<ValidatedFile, FileError> {
33 let max_size = parse_size(&config.max_size).unwrap_or(10 * 1024 * 1024);
35
36 if data.len() > max_size {
37 return Err(FileError::TooLarge {
38 size: data.len(),
39 max: max_size,
40 });
41 }
42
43 if !config.allowed_types.iter().any(|t| t == declared_type || t == "*/*") {
45 return Err(FileError::InvalidType {
46 got: declared_type.to_string(),
47 allowed: config.allowed_types.clone(),
48 });
49 }
50
51 let sanitized = sanitize_filename(filename)?;
53
54 let detected_type = if config.validate_magic_bytes {
56 let detected = detect_content_type(data);
57 validate_magic_bytes(&detected, declared_type)?;
58 Some(detected)
59 } else {
60 None
61 };
62
63 Ok(ValidatedFile {
64 content_type: declared_type.to_string(),
65 sanitized_filename: sanitized,
66 size: data.len(),
67 detected_type,
68 })
69}
70
71pub fn detect_content_type(data: &Bytes) -> String {
73 infer::get(data)
74 .map(|t| t.mime_type().to_string())
75 .unwrap_or_else(|| "application/octet-stream".to_string())
76}
77
78fn validate_magic_bytes(detected: &str, declared: &str) -> Result<(), FileError> {
80 if !mime_types_compatible(detected, declared) {
82 return Err(FileError::MimeMismatch {
83 declared: declared.to_string(),
84 detected: detected.to_string(),
85 });
86 }
87
88 Ok(())
89}
90
91fn mime_types_compatible(detected: &str, declared: &str) -> bool {
92 if detected == declared {
94 return true;
95 }
96
97 let equivalents = [
99 ("image/jpeg", "image/jpg"),
100 ("text/plain", "application/octet-stream"),
101 ];
102
103 for (a, b) in equivalents {
104 if (detected == a && declared == b) || (detected == b && declared == a) {
105 return true;
106 }
107 }
108
109 let detected_major = detected.split('/').next().unwrap_or("");
111 let declared_major = declared.split('/').next().unwrap_or("");
112
113 if detected_major == "image" && declared_major == "image" {
115 return true;
116 }
117
118 false
119}
120
121pub fn sanitize_filename(filename: &str) -> Result<String, FileError> {
123 let filename = filename.rsplit(['/', '\\']).next().unwrap_or(filename);
125
126 if filename.is_empty() || filename == "." || filename == ".." {
128 return Err(FileError::InvalidFilename {
129 reason: "Filename cannot be empty or path component".into(),
130 });
131 }
132
133 let filename = filename.replace('\0', "");
135
136 if filename.len() > 255 {
138 return Err(FileError::InvalidFilename {
139 reason: "Filename too long (max 255 characters)".into(),
140 });
141 }
142
143 let sanitized: String = filename
145 .chars()
146 .enumerate()
147 .map(|(i, c)| {
148 match c {
149 'a'..='z' | 'A'..='Z' | '0'..='9' => c,
151 '.' if i > 0 => c,
153 '-' | '_' => c,
155 _ => '_',
157 }
158 })
159 .collect();
160
161 if sanitized.is_empty() || sanitized.chars().all(|c| c == '_') {
163 return Err(FileError::InvalidFilename {
164 reason: "Filename contains no valid characters".into(),
165 });
166 }
167
168 Ok(sanitized)
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_mime_compatibility() {
177 assert!(mime_types_compatible("image/jpeg", "image/jpeg"));
178 assert!(mime_types_compatible("image/jpeg", "image/jpg"));
179 assert!(mime_types_compatible("image/png", "image/webp")); assert!(!mime_types_compatible("image/jpeg", "application/pdf"));
181 }
182
183 #[test]
184 fn test_sanitize_filename() {
185 assert_eq!(sanitize_filename("photo.jpg").unwrap(), "photo.jpg");
186 assert_eq!(sanitize_filename("my-file_2024.pdf").unwrap(), "my-file_2024.pdf");
187
188 let result = sanitize_filename("../../../etc/passwd").unwrap();
190 assert!(!result.contains(".."));
191 assert_eq!(result, "passwd");
192
193 let result = sanitize_filename("file<>:\"|?*.jpg").unwrap();
195 assert!(!result.contains('<'));
196 assert!(!result.contains('>'));
197 assert!(!result.contains(':'));
198 }
199
200 #[test]
201 fn test_null_byte_removal() {
202 let result = sanitize_filename("image.jpg\0.exe").unwrap();
203 assert!(!result.contains('\0'));
204 }
205}