1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::GenerationPattern;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct FtpFixture {
10 pub identifier: String,
11 pub name: String,
12 pub description: Option<String>,
13 pub virtual_files: Vec<VirtualFileConfig>,
14 pub upload_rules: Vec<UploadRule>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct VirtualFileConfig {
20 pub path: PathBuf,
21 pub content: FileContentConfig,
22 pub permissions: String,
23 pub owner: String,
24 pub group: String,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type")]
30pub enum FileContentConfig {
31 #[serde(rename = "static")]
32 Static { content: String },
33 #[serde(rename = "template")]
34 Template { template: String },
35 #[serde(rename = "generated")]
36 Generated {
37 size: usize,
38 pattern: GenerationPattern,
39 },
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct UploadRule {
45 pub path_pattern: String,
46 pub auto_accept: bool,
47 pub validation: Option<FileValidation>,
48 pub storage: UploadStorage,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct FileValidation {
54 pub max_size_bytes: Option<u64>,
55 pub allowed_extensions: Option<Vec<String>>,
56 pub mime_types: Option<Vec<String>>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(tag = "type")]
62pub enum UploadStorage {
63 #[serde(rename = "discard")]
64 Discard,
65 #[serde(rename = "memory")]
66 Memory,
67 #[serde(rename = "file")]
68 File { path: PathBuf },
69}
70
71impl VirtualFileConfig {
72 pub fn to_file_fixture(self) -> crate::vfs::FileFixture {
73 let content = match self.content {
74 FileContentConfig::Static { content } => {
75 crate::vfs::FileContent::Static(content.into_bytes())
76 }
77 FileContentConfig::Template { template } => crate::vfs::FileContent::Template(template),
78 FileContentConfig::Generated { size, pattern } => {
79 crate::vfs::FileContent::Generated { size, pattern }
80 }
81 };
82
83 let metadata = crate::vfs::FileMetadata {
84 permissions: self.permissions,
85 owner: self.owner,
86 group: self.group,
87 size: 0, };
89
90 crate::vfs::FileFixture {
91 path: self.path,
92 content,
93 metadata,
94 }
95 }
96}
97
98impl UploadRule {
99 pub fn matches_path(&self, path: &str) -> bool {
100 match Regex::new(&self.path_pattern) {
101 Ok(regex) => regex.is_match(path),
102 Err(_) => false,
103 }
104 }
105
106 pub fn validate_file(&self, data: &[u8], filename: &str) -> Result<(), String> {
107 if let Some(validation) = &self.validation {
108 if let Some(max_size) = validation.max_size_bytes {
110 if data.len() as u64 > max_size {
111 return Err(format!(
112 "File too large: {} bytes (max: {})",
113 data.len(),
114 max_size
115 ));
116 }
117 }
118
119 if let Some(extensions) = &validation.allowed_extensions {
121 let has_valid_ext = extensions.iter().any(|ext| {
122 filename.to_lowercase().ends_with(&format!(".{}", ext.to_lowercase()))
123 });
124 if !extensions.is_empty() && !has_valid_ext {
125 return Err(format!("Invalid file extension. Allowed: {:?}", extensions));
126 }
127 }
128
129 if let Some(mime_types) = &validation.mime_types {
131 let guessed = mime_guess::from_path(filename).first_or_octet_stream();
132 let guessed_str = guessed.as_ref();
133 if !mime_types.iter().any(|allowed| allowed == guessed_str) {
134 return Err(format!(
135 "Invalid MIME type: {} (allowed: {:?})",
136 guessed_str, mime_types
137 ));
138 }
139 }
140 }
141
142 Ok(())
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn test_ftp_fixture_debug() {
152 let fixture = FtpFixture {
153 identifier: "test-id".to_string(),
154 name: "Test Fixture".to_string(),
155 description: Some("A test fixture".to_string()),
156 virtual_files: vec![],
157 upload_rules: vec![],
158 };
159
160 let debug = format!("{:?}", fixture);
161 assert!(debug.contains("test-id"));
162 }
163
164 #[test]
165 fn test_ftp_fixture_clone() {
166 let fixture = FtpFixture {
167 identifier: "test".to_string(),
168 name: "Test".to_string(),
169 description: None,
170 virtual_files: vec![],
171 upload_rules: vec![],
172 };
173
174 let cloned = fixture.clone();
175 assert_eq!(fixture.identifier, cloned.identifier);
176 }
177
178 #[test]
179 fn test_virtual_file_config_to_file_fixture_static() {
180 let config = VirtualFileConfig {
181 path: std::path::PathBuf::from("/test.txt"),
182 content: FileContentConfig::Static {
183 content: "Hello World".to_string(),
184 },
185 permissions: "644".to_string(),
186 owner: "user".to_string(),
187 group: "group".to_string(),
188 };
189
190 let fixture = config.to_file_fixture();
191 assert_eq!(fixture.path, std::path::PathBuf::from("/test.txt"));
192 assert_eq!(fixture.metadata.permissions, "644");
193 }
194
195 #[test]
196 fn test_virtual_file_config_to_file_fixture_template() {
197 let config = VirtualFileConfig {
198 path: std::path::PathBuf::from("/template.txt"),
199 content: FileContentConfig::Template {
200 template: "Hello {{name}}".to_string(),
201 },
202 permissions: "755".to_string(),
203 owner: "root".to_string(),
204 group: "admin".to_string(),
205 };
206
207 let fixture = config.to_file_fixture();
208 assert_eq!(fixture.metadata.owner, "root");
209 }
210
211 #[test]
212 fn test_virtual_file_config_to_file_fixture_generated() {
213 let config = VirtualFileConfig {
214 path: std::path::PathBuf::from("/generated.bin"),
215 content: FileContentConfig::Generated {
216 size: 1024,
217 pattern: GenerationPattern::Random,
218 },
219 permissions: "600".to_string(),
220 owner: "user".to_string(),
221 group: "user".to_string(),
222 };
223
224 let fixture = config.to_file_fixture();
225 assert_eq!(fixture.metadata.permissions, "600");
226 }
227
228 #[test]
229 fn test_upload_rule_matches_path() {
230 let rule = UploadRule {
231 path_pattern: r"^/uploads/.*\.txt$".to_string(),
232 auto_accept: true,
233 validation: None,
234 storage: UploadStorage::Memory,
235 };
236
237 assert!(rule.matches_path("/uploads/file.txt"));
238 assert!(rule.matches_path("/uploads/test.txt"));
239 assert!(!rule.matches_path("/uploads/file.pdf"));
240 assert!(!rule.matches_path("/other/file.txt"));
241 }
242
243 #[test]
244 fn test_upload_rule_matches_path_invalid_regex() {
245 let rule = UploadRule {
246 path_pattern: "[invalid regex(".to_string(),
247 auto_accept: true,
248 validation: None,
249 storage: UploadStorage::Memory,
250 };
251
252 assert!(!rule.matches_path("/any/path"));
253 }
254
255 #[test]
256 fn test_validate_file_max_size() {
257 let validation = FileValidation {
258 max_size_bytes: Some(100),
259 allowed_extensions: None,
260 mime_types: None,
261 };
262
263 let rule = UploadRule {
264 path_pattern: ".*".to_string(),
265 auto_accept: true,
266 validation: Some(validation),
267 storage: UploadStorage::Discard,
268 };
269
270 let small_data = b"small file";
272 assert!(rule.validate_file(small_data, "test.txt").is_ok());
273
274 let large_data = vec![0u8; 200];
276 let result = rule.validate_file(&large_data, "test.txt");
277 assert!(result.is_err());
278 assert!(result.unwrap_err().contains("too large"));
279 }
280
281 #[test]
282 fn test_validate_file_extensions() {
283 let validation = FileValidation {
284 max_size_bytes: None,
285 allowed_extensions: Some(vec!["txt".to_string(), "pdf".to_string()]),
286 mime_types: None,
287 };
288
289 let rule = UploadRule {
290 path_pattern: ".*".to_string(),
291 auto_accept: true,
292 validation: Some(validation),
293 storage: UploadStorage::Discard,
294 };
295
296 let data = b"test content";
297
298 assert!(rule.validate_file(data, "file.txt").is_ok());
300 assert!(rule.validate_file(data, "document.pdf").is_ok());
301 assert!(rule.validate_file(data, "FILE.TXT").is_ok()); let result = rule.validate_file(data, "image.png");
305 assert!(result.is_err());
306 assert!(result.unwrap_err().contains("Invalid file extension"));
307 }
308
309 #[test]
310 fn test_validate_file_mime_type() {
311 let validation = FileValidation {
312 max_size_bytes: None,
313 allowed_extensions: None,
314 mime_types: Some(vec!["text/plain".to_string(), "application/json".to_string()]),
315 };
316
317 let rule = UploadRule {
318 path_pattern: ".*".to_string(),
319 auto_accept: true,
320 validation: Some(validation),
321 storage: UploadStorage::Discard,
322 };
323
324 let data = b"Hello world";
326 let filename = "test.txt";
327 assert!(rule.validate_file(data, filename).is_ok());
328
329 let filename_invalid = "test.png";
333 assert!(rule.validate_file(data, filename_invalid).is_err());
334
335 let rule_no_mime = UploadRule {
337 path_pattern: ".*".to_string(),
338 auto_accept: true,
339 validation: Some(FileValidation {
340 max_size_bytes: None,
341 allowed_extensions: None,
342 mime_types: None,
343 }),
344 storage: UploadStorage::Discard,
345 };
346 assert!(rule_no_mime.validate_file(data, filename).is_ok());
347 }
348
349 #[test]
350 fn test_validate_file_no_validation() {
351 let rule = UploadRule {
352 path_pattern: ".*".to_string(),
353 auto_accept: true,
354 validation: None,
355 storage: UploadStorage::Discard,
356 };
357
358 let data = vec![0u8; 10000];
359 assert!(rule.validate_file(&data, "any.file").is_ok());
360 }
361
362 #[test]
363 fn test_validate_file_combined_validations() {
364 let validation = FileValidation {
365 max_size_bytes: Some(1000),
366 allowed_extensions: Some(vec!["txt".to_string()]),
367 mime_types: Some(vec!["text/plain".to_string()]),
368 };
369
370 let rule = UploadRule {
371 path_pattern: ".*".to_string(),
372 auto_accept: true,
373 validation: Some(validation),
374 storage: UploadStorage::Discard,
375 };
376
377 let data = b"valid content";
378
379 assert!(rule.validate_file(data, "file.txt").is_ok());
381
382 let large_data = vec![0u8; 2000];
384 assert!(rule.validate_file(&large_data, "file.txt").is_err());
385
386 assert!(rule.validate_file(data, "file.pdf").is_err());
388
389 assert!(rule.validate_file(data, "file.png").is_err());
391 }
392
393 #[test]
394 fn test_file_validation_debug() {
395 let validation = FileValidation {
396 max_size_bytes: Some(100),
397 allowed_extensions: Some(vec!["txt".to_string()]),
398 mime_types: Some(vec!["text/plain".to_string()]),
399 };
400
401 let debug = format!("{:?}", validation);
402 assert!(debug.contains("FileValidation"));
403 }
404
405 #[test]
406 fn test_upload_storage_variants() {
407 let discard = UploadStorage::Discard;
408 let memory = UploadStorage::Memory;
409 let file = UploadStorage::File {
410 path: std::path::PathBuf::from("/tmp/uploads"),
411 };
412
413 let _ = format!("{:?}", discard);
414 let _ = format!("{:?}", memory);
415 let _ = format!("{:?}", file);
416 }
417
418 #[test]
419 fn test_file_content_config_variants() {
420 let static_content = FileContentConfig::Static {
421 content: "test".to_string(),
422 };
423 let template = FileContentConfig::Template {
424 template: "Hello {{name}}".to_string(),
425 };
426 let generated = FileContentConfig::Generated {
427 size: 100,
428 pattern: GenerationPattern::Random,
429 };
430
431 let _ = format!("{:?}", static_content);
432 let _ = format!("{:?}", template);
433 let _ = format!("{:?}", generated);
434 }
435}