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