Skip to main content

mockforge_ftp/
fixtures.rs

1use anyhow::Context;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6use crate::GenerationPattern;
7
8/// FTP fixture configuration
9#[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    /// Load a YAML fixture before attaching it with `FtpServer::new_with_fixtures`.
20    ///
21    /// Returns an error identifying the file when opening or parsing fails.
22    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/// Configuration for virtual files
32#[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/// File content configuration
42#[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/// Upload rule configuration
57#[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/// File validation rules
66#[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/// Upload storage options
74#[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, // Will be calculated when rendered
102        };
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            // Check file size
123            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            // Check file extension
134            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            // Check MIME type
144            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        // Test file within size limit
285        let small_data = b"small file";
286        assert!(rule.validate_file(small_data, "test.txt").is_ok());
287
288        // Test file exceeding size limit
289        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        // Test valid extensions
313        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()); // Case insensitive
316
317        // Test invalid extension
318        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        // Test valid MIME type (text/plain for .txt)
339        let data = b"Hello world";
340        let filename = "test.txt";
341        assert!(rule.validate_file(data, filename).is_ok());
342
343        // Test invalid MIME type (image/png for .txt, but mime_guess guesses text/plain)
344        // Actually, for .txt it guesses text/plain, so let's test with a filename that guesses wrong
345        // mime_guess guesses based on extension, so for "test.png" it would guess image/png
346        let filename_invalid = "test.png";
347        assert!(rule.validate_file(data, filename_invalid).is_err());
348
349        // Test with no MIME types configured
350        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        // All validations pass
394        assert!(rule.validate_file(data, "file.txt").is_ok());
395
396        // Size validation fails
397        let large_data = vec![0u8; 2000];
398        assert!(rule.validate_file(&large_data, "file.txt").is_err());
399
400        // Extension validation fails
401        assert!(rule.validate_file(data, "file.pdf").is_err());
402
403        // MIME type validation fails
404        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}